diff --git a/README.md b/README.md index 744682d9..26ea6e34 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,8 @@ node shared/plugins/scripts/verify-docs.mjs # 契约 $id 与文档 node shared/plugins/scripts/sdk-e2e.mjs # 三个 SDK 对当前 Core 的端到端测试 ``` -修改 `shared/plugins/contract/v1/`、`rust/crates/core/src/plugins/capabilities.rs` 或 -`rust/crates/core/src/lsp/interface/plugin_function.rs` 的 PR,要在同一个 PR 里更新 +修改 `shared/plugins/contract/v1/`、`rust/crates/plugin-model/src/capabilities.rs` 或 +`rust/crates/plugin-rpc/src/` 的 PR,要在同一个 PR 里更新 [协议更新记录](shared/plugins/reference/protocol-changelog.md)。 --- @@ -43,4 +43,4 @@ Lithe's cross-platform application core, and the single source of the plugin con **Plugin authors** start at [`shared/plugins/README.md`](shared/plugins/README.md) and never need to clone the Lithe application repository. -A pull request that changes `shared/plugins/contract/v1/`, `rust/crates/core/src/plugins/capabilities.rs` or `rust/crates/core/src/lsp/interface/plugin_function.rs` updates the [protocol changelog](shared/plugins/reference/protocol-changelog.md) in the same pull request. +A pull request that changes `shared/plugins/contract/v1/`, `rust/crates/plugin-model/src/capabilities.rs` or `rust/crates/plugin-rpc/src/` updates the [protocol changelog](shared/plugins/reference/protocol-changelog.md) in the same pull request. diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 04aa1aa0..aa0063a1 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -874,37 +874,75 @@ dependencies = [ "url", ] +[[package]] +name = "lithe-build" +version = "0.1.0" +dependencies = [ + "base64", + "lithe-debug", + "lithe-execution", + "lithe-plugin-model", + "lithe-protocol", + "lithe-remote", + "lithe-workspace", + "quick-xml", + "regex", + "serde", + "serde_json", + "sha2", + "toml", + "url", +] + [[package]] name = "lithe-core" version = "0.1.0" dependencies = [ - "ammonia", "base64", - "chrono", - "comrak", - "encoding_rs", - "ignore", "lithe-ai", + "lithe-build", "lithe-core-rules", + "lithe-debug", + "lithe-diagnostics", "lithe-diff", + "lithe-document-uri", + "lithe-editor", + "lithe-execution", + "lithe-git", "lithe-git-host", "lithe-github", "lithe-ide-host", + "lithe-lsp-engine", + "lithe-lsp-policy", + "lithe-lsp-transport", "lithe-network", + "lithe-plugin-content-providers", + "lithe-plugin-editor", + "lithe-plugin-function-relay", + "lithe-plugin-keymap-import", + "lithe-plugin-language-servers", + "lithe-plugin-manifest", + "lithe-plugin-marketplace", + "lithe-plugin-model", + "lithe-plugin-native-ui", + "lithe-plugin-output-links", + "lithe-plugin-rpc", + "lithe-plugin-web-content", "lithe-process", + "lithe-project", + "lithe-project-generators", "lithe-protocol", "lithe-remote", + "lithe-request-context", "lithe-terminal", - "quick-xml", - "rand", - "regex", + "lithe-text", + "lithe-toolchain", + "lithe-vcs", + "lithe-workspace", "serde", "serde_json", - "serde_yaml_ng", "sha2", - "toml", "url", - "zip", ] [[package]] @@ -916,6 +954,31 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "lithe-debug" +version = "0.1.0" +dependencies = [ + "base64", + "lithe-diagnostics", + "lithe-plugin-model", + "lithe-protocol", + "serde", + "serde_json", + "sha2", +] + +[[package]] +name = "lithe-diagnostics" +version = "0.1.0" +dependencies = [ + "chrono", + "lithe-protocol", + "regex", + "serde", + "serde_json", + "sha2", +] + [[package]] name = "lithe-diff" version = "0.1.0" @@ -926,6 +989,66 @@ dependencies = [ "similar", ] +[[package]] +name = "lithe-document-uri" +version = "0.1.0" +dependencies = [ + "lithe-protocol", + "serde", + "serde_json", + "url", +] + +[[package]] +name = "lithe-editor" +version = "0.1.0" +dependencies = [ + "lithe-protocol", + "lithe-text", + "regex", + "serde", + "serde_json", +] + +[[package]] +name = "lithe-execution" +version = "0.1.0" +dependencies = [ + "lithe-diagnostics", + "lithe-plugin-model", + "lithe-protocol", + "lithe-toolchain", + "lithe-workspace", + "regex", + "serde", + "serde_json", + "serde_yaml_ng", + "sha2", + "toml", +] + +[[package]] +name = "lithe-git" +version = "0.1.0" +dependencies = [ + "chrono", + "lithe-ai", + "lithe-core-rules", + "lithe-diagnostics", + "lithe-diff", + "lithe-git-host", + "lithe-github", + "lithe-protocol", + "lithe-remote", + "lithe-request-context", + "lithe-workspace", + "rand", + "regex", + "serde", + "serde_json", + "sha2", +] + [[package]] name = "lithe-git-host" version = "0.1.0" @@ -961,6 +1084,73 @@ dependencies = [ "uuid", ] +[[package]] +name = "lithe-lsp-client" +version = "0.1.0" +dependencies = [ + "lithe-diagnostics", + "lithe-document-uri", + "lithe-plugin-editor", + "lithe-plugin-keymap-import", + "lithe-plugin-model", + "lithe-plugin-rpc", + "lithe-protocol", + "lithe-text", + "lithe-vcs", + "serde", + "serde_json", + "url", +] + +[[package]] +name = "lithe-lsp-engine" +version = "0.1.0" +dependencies = [ + "lithe-diagnostics", + "lithe-document-uri", + "lithe-git", + "lithe-lsp-client", + "lithe-lsp-transport", + "lithe-plugin-editor", + "lithe-plugin-function-relay", + "lithe-plugin-language-servers", + "lithe-plugin-rpc", + "lithe-plugin-server-policy", + "lithe-project", + "lithe-protocol", + "lithe-text", + "lithe-vcs", + "lithe-workspace", + "serde", + "serde_json", + "url", +] + +[[package]] +name = "lithe-lsp-policy" +version = "0.1.0" +dependencies = [ + "lithe-core-rules", + "lithe-document-uri", + "lithe-protocol", + "serde", + "serde_json", + "url", +] + +[[package]] +name = "lithe-lsp-transport" +version = "0.1.0" +dependencies = [ + "lithe-diagnostics", + "lithe-git-host", + "lithe-protocol", + "lithe-remote", + "lithe-workspace", + "serde", + "serde_json", +] + [[package]] name = "lithe-network" version = "0.1.0" @@ -969,6 +1159,186 @@ dependencies = [ "serde_json", ] +[[package]] +name = "lithe-plugin-backend" +version = "0.1.0" +dependencies = [ + "lithe-plugin-model", + "serde", + "serde_json", +] + +[[package]] +name = "lithe-plugin-content-providers" +version = "0.1.0" +dependencies = [ + "lithe-plugin-model", + "lithe-protocol", + "serde", + "serde_json", +] + +[[package]] +name = "lithe-plugin-editor" +version = "0.1.0" +dependencies = [ + "lithe-plugin-model", + "lithe-protocol", + "serde", + "serde_json", +] + +[[package]] +name = "lithe-plugin-function-relay" +version = "0.1.0" +dependencies = [ + "lithe-plugin-functions", + "lithe-plugin-model", + "lithe-plugin-rpc", + "serde", + "serde_json", +] + +[[package]] +name = "lithe-plugin-functions" +version = "0.1.0" +dependencies = [ + "lithe-plugin-model", + "serde", +] + +[[package]] +name = "lithe-plugin-grammar" +version = "0.1.0" +dependencies = [ + "lithe-plugin-model", + "serde", + "serde_json", +] + +[[package]] +name = "lithe-plugin-keymap-import" +version = "0.1.0" +dependencies = [ + "lithe-plugin-model", + "lithe-protocol", + "serde", + "serde_json", +] + +[[package]] +name = "lithe-plugin-language-servers" +version = "0.1.0" +dependencies = [ + "lithe-plugin-model", + "lithe-protocol", + "serde", + "serde_json", +] + +[[package]] +name = "lithe-plugin-manifest" +version = "0.1.0" +dependencies = [ + "lithe-build", + "lithe-debug", + "lithe-plugin-backend", + "lithe-plugin-functions", + "lithe-plugin-grammar", + "lithe-plugin-keymap-import", + "lithe-plugin-language-servers", + "lithe-plugin-model", + "lithe-plugin-native-ui", + "lithe-plugin-output-links", + "lithe-plugin-rpc", + "lithe-project-generators", + "lithe-protocol", + "lithe-toolchain", + "lithe-vcs", + "serde", + "serde_json", +] + +[[package]] +name = "lithe-plugin-marketplace" +version = "0.1.0" +dependencies = [ + "lithe-plugin-manifest", + "lithe-plugin-model", + "lithe-protocol", + "serde", + "serde_json", + "url", +] + +[[package]] +name = "lithe-plugin-model" +version = "0.1.0" +dependencies = [ + "lithe-protocol", + "regex", + "serde", + "serde_json", +] + +[[package]] +name = "lithe-plugin-native-ui" +version = "0.1.0" +dependencies = [ + "lithe-plugin-model", + "lithe-protocol", + "serde", + "serde_json", +] + +[[package]] +name = "lithe-plugin-output-links" +version = "0.1.0" +dependencies = [ + "lithe-document-uri", + "lithe-plugin-model", + "lithe-protocol", + "lithe-toolchain", + "regex", + "serde", + "serde_json", +] + +[[package]] +name = "lithe-plugin-rpc" +version = "0.1.0" +dependencies = [ + "lithe-plugin-keymap-import", + "lithe-plugin-language-servers", + "lithe-plugin-model", + "lithe-protocol", + "serde", + "serde_json", +] + +[[package]] +name = "lithe-plugin-server-policy" +version = "0.1.0" +dependencies = [ + "lithe-document-uri", + "lithe-plugin-language-servers", + "lithe-plugin-rpc", + "lithe-protocol", + "serde", + "serde_json", +] + +[[package]] +name = "lithe-plugin-web-content" +version = "0.1.0" +dependencies = [ + "lithe-plugin-manifest", + "lithe-plugin-model", + "lithe-protocol", + "serde", + "serde_json", +] + [[package]] name = "lithe-process" version = "0.1.0" @@ -977,6 +1347,41 @@ dependencies = [ "serde_json", ] +[[package]] +name = "lithe-project" +version = "0.1.0" +dependencies = [ + "ammonia", + "base64", + "comrak", + "encoding_rs", + "lithe-core-rules", + "lithe-diagnostics", + "lithe-git", + "lithe-protocol", + "lithe-request-context", + "lithe-workspace", + "regex", + "serde", + "serde_json", + "sha2", + "url", +] + +[[package]] +name = "lithe-project-generators" +version = "0.1.0" +dependencies = [ + "lithe-plugin-model", + "lithe-protocol", + "rand", + "regex", + "serde", + "serde_json", + "url", + "zip", +] + [[package]] name = "lithe-protocol" version = "0.1.0" @@ -997,6 +1402,16 @@ dependencies = [ "trash", ] +[[package]] +name = "lithe-request-context" +version = "0.1.0" +dependencies = [ + "lithe-diagnostics", + "regex", + "serde", + "serde_json", +] + [[package]] name = "lithe-terminal" version = "0.1.0" @@ -1005,6 +1420,52 @@ dependencies = [ "serde", ] +[[package]] +name = "lithe-text" +version = "0.1.0" +dependencies = [ + "lithe-protocol", + "serde", + "serde_json", +] + +[[package]] +name = "lithe-toolchain" +version = "0.1.0" +dependencies = [ + "ignore", + "lithe-plugin-model", + "lithe-protocol", + "regex", + "serde", + "serde_json", +] + +[[package]] +name = "lithe-vcs" +version = "0.1.0" +dependencies = [ + "lithe-plugin-model", + "lithe-protocol", + "serde", + "serde_json", +] + +[[package]] +name = "lithe-workspace" +version = "0.1.0" +dependencies = [ + "lithe-diagnostics", + "lithe-git-host", + "lithe-protocol", + "lithe-remote", + "lithe-request-context", + "rand", + "serde", + "serde_json", + "url", +] + [[package]] name = "lock_api" version = "0.4.14" diff --git a/rust/contracts/application-boundary.md b/rust/contracts/application-boundary.md index 9b4fdc90..2db7a5c1 100644 --- a/rust/contracts/application-boundary.md +++ b/rust/contracts/application-boundary.md @@ -1,7 +1,7 @@ # Application Boundary Contract -The application boundary describes product behavior that a SwiftUI/AppKit or -React/Tauri Windows UI can consume. It does not describe widgets, threads, processes, +The application boundary describes product behavior that the desktop clients +consume through the C ABI. It does not describe widgets, threads, processes, or operating-system APIs. It defines the cross-platform contract; current product scope and setup are documented in [`README.md`](https://github.com/1lck/Lithe-IDEA/blob/preview/README.md); the verification scripts are the executable source of boundary checks. @@ -181,77 +181,6 @@ browser reads the saved file; this action does not save buffers or start a web server. File URLs preserve Unicode and reserved characters. Browser discovery and launch belong to platform adapters, and launch failures are shown to the user. -## Module Lifecycle Contract - -The macOS reference product implements the built-in manifest in -`rust/fixtures/modules/built-in-v1.json`. Module IDs and manifest fields are -platform-neutral compatibility surfaces. A future Windows implementation may -adopt the contract independently without sharing Swift implementation code or -being coupled to the macOS migration schedule. An implementation of this -contract must preserve these invariants: - -- A disabled module is not instantiated and owns no task, timer, watcher, - session, connection, or child process. -- An on-demand module is instantiated only after its capability is requested. -- Sleeping stops every owned resource and releases the module instance. -- Active non-interruptible work holds a lease that blocks sleep with a reason. -- Wake reconstructs the module, activates declared dependencies first, and - republishes capabilities and contributions. -- Required modules cannot be disabled. A provider cannot be disabled while an - enabled module depends on it. -- Module state is one of `disabled`, `inactive`, `activating`, `active`, `idle`, - `preparingToSleep`, `sleeping`, `sleepBlocked`, or `failed`. -- Native plugin manifests, compatibility, ownership, and signatures are - validated before Bundle loading. A failed optional package is reported to - plugin management and does not prevent required modules from starting. -- Successfully loaded native plugin module IDs remain durably marked for the - process lifetime. An unclean exit leaves the mark behind, so the next launch - quarantines those modules before constructing any plugin Bundle. A clean - application termination clears the mark. -- Disabling a loaded in-process native plugin stops its module-owned resources - immediately. Its code remains mapped until restart, and the next launch - skips the Bundle before invoking its principal class or factories. -- Plugin update, rollback, and uninstall operations that affect mapped code - are finalized before plugin scanning on the next launch. -- Native plugin factories receive a read-only host context. Host services use - stable IDs and shared protocols; plugin code cannot import a platform - composition root or the application executable. -- AI Assistance, Terminal, Git, Search, Local History, and Debug are built-in - lifecycle modules. They are not marketplace plugins. -- Every language provider, build tool and ecosystem runner is represented by an - independently configurable language-support plugin; Core names none of them. - On macOS, Go and PHP are community declarative - plugins from Lithe Store (`io.github.lithe-idea.go-support`, - `io.github.lithe-idea.php-support`); the retired official IDs - `dev.lithe.plugin.go-support` and `dev.lithe.plugin.php-support` keep their - pinned publisher signature requirement but have no official source. The - remaining providers share the host's generic language-server module. -- A downloadable language support package may declare language-server, - execution, testing, and debug module IDs under one language ID. All referenced - modules must be owned by the same package. Execution and testing may share a - module when they share one toolchain lifecycle; language-server and debug - lifecycles remain independently addressable. -- Plugin-owned Run and Test operations may reuse deterministic shared launch - plans, but the actual child process must use a session owned by the plugin - module. A disabled plugin language must not fall back to a built-in process - provider. -- Process-backed language ownership comes from verified installed manifests, - including packages that are disabled, quarantined, or failed to load. Those - states make the capability unavailable; they never restore a host process - fallback. -- Extension execution shutdown completes only after its operating-system - process exits. A bounded force-stop failure remains visible as an active - module resource. Active Run and Test sessions hold leases; successful LSP - document synchronization refreshes the owning module's idle timer. -- Language package manifests include inert file-extension, file-name, and - project-file recognition metadata. The host may use this metadata to suggest - an uninstalled plugin, but it must not load the Bundle or probe a toolchain - during recognition. - -Platform products do not share module-runtime implementation code. The stable -manifest, lifecycle semantics, and deterministic JSON representation are the -portable boundary. - ## Document Lifecycle Contract An open document, rather than an editor widget, owns live text for the lifetime @@ -506,7 +435,7 @@ by the platform. The behavior fixture is ### Optional PHP support PHP plugin installation is explicit. The base application ships neither a PHP -package nor Node/Bun/Intelephense. macOS installs the Lithe Store community package +package nor Node/Bun/Intelephense. macOS installs the Lithe Store package `io.github.lithe-idea.php-support` through plugin management; the package does not bundle Intelephense, which the user installs, and its tools are user-owned and never deleted by Lithe. diff --git a/rust/contracts/vcs-provider-v1.md b/rust/contracts/vcs-provider-v1.md index 02142915..df076b25 100644 --- a/rust/contracts/vcs-provider-v1.md +++ b/rust/contracts/vcs-provider-v1.md @@ -359,7 +359,7 @@ must not contain credentials. ## Subversion provider notes -Subversion is provided by the community plugin +Subversion is provided by the plugin [lithe-plugin-svn](https://github.com/Lithe-IDEA/lithe-plugin-svn) (`io.github.lithe-idea.svn-support`), released independently of Lithe. It calls the user's `svn` client, 1.8 or newer, and parses its `--xml` output. Its README diff --git a/rust/crates/build/Cargo.toml b/rust/crates/build/Cargo.toml new file mode 100644 index 00000000..a35df12a --- /dev/null +++ b/rust/crates/build/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "lithe-build" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Build systems declared by Lithe plugins: validation, static models, argv plans and normalized output decoding" + +[dependencies] +base64 = "0.22" +lithe-debug = { path = "../debug" } +lithe-execution = { path = "../execution" } +lithe-plugin-model = { path = "../plugin-model" } +lithe-protocol = { path = "../protocol" } +lithe-remote = { path = "../remote", default-features = false, features = ["client"] } +lithe-workspace = { path = "../workspace" } +quick-xml = "0.37" +regex = "1.11" +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0", features = ["preserve_order"] } +sha2 = "0.10" +toml = { version = "1.1.4", default-features = false, features = ["parse", "serde"] } +url = "2.5" diff --git a/rust/crates/core/src/build/commands.rs b/rust/crates/build/src/commands.rs similarity index 72% rename from rust/crates/core/src/build/commands.rs rename to rust/crates/build/src/commands.rs index afae0260..515283c3 100644 --- a/rust/crates/core/src/build/commands.rs +++ b/rust/crates/build/src/commands.rs @@ -16,11 +16,9 @@ use super::outputs::{ }; use super::plans::{self, OutputPlanRequest, TaskPlanRequest, TestPlanRequest}; use super::test_discovery::{self, DecodeTestDiscoveryRequest}; -use crate::plugins::declarative::{ - decode_payload, outcome, validate_manifest_value, DeclarativePluginManifest, PluginRejection, - Validation, -}; -use crate::protocol::CoreError; +use super::BuildManifest; +use lithe_plugin_model::{decode_payload, outcome, PluginRejection, Validation}; +use lithe_protocol::CoreError; use serde::de::DeserializeOwned; use serde::Deserialize; use serde_json::Value; @@ -50,25 +48,25 @@ struct ResolveOptionsRequest { model_values: Option>>, } -fn with_build_system( +fn with_build_system( payload: Value, command: &str, key: &'static str, run: impl FnOnce(&BuildSystemDeclaration, &T) -> Validation, ) -> Result { - with_manifest(payload, command, key, |build_system, request, _| { + with_manifest::(payload, command, key, |build_system, request, _| { run(build_system, request) }) } -fn with_manifest( +fn with_manifest( payload: Value, command: &str, key: &'static str, - run: impl FnOnce(&BuildSystemDeclaration, &T, &DeclarativePluginManifest) -> Validation, + run: impl FnOnce(&BuildSystemDeclaration, &T, &M) -> Validation, ) -> Result { let payload: BuildPayload = decode_payload(payload, command)?; - Ok(outcome(validate_manifest_value(payload.manifest).and_then( + Ok(outcome(M::validate(payload.manifest).and_then( |manifest| { let build_system = manifest .build_systems() @@ -90,16 +88,16 @@ fn with_manifest( /// `build.detect`: build-system instances of a workspace, with the models /// that need no process. -pub(crate) fn build_detect_command(payload: Value) -> Result { +pub fn build_detect_command(payload: Value) -> Result { let request: DetectRequest = decode_payload(payload, "build.detect")?; Ok(outcome( - detect::detect(request).map(|detection| ("detection", detection)), + detect::detect::(request).map(|detection| ("detection", detection)), )) } /// `build.invocationPlan`: executable candidates and version probes. -pub(crate) fn build_invocation_plan_command(payload: Value) -> Result { - with_manifest( +pub fn build_invocation_plan_command(payload: Value) -> Result { + with_manifest::( payload, "build.invocationPlan", "invocation", @@ -110,8 +108,10 @@ pub(crate) fn build_invocation_plan_command(payload: Value) -> Result Result { - with_build_system( +pub fn build_check_tool_version_command( + payload: Value, +) -> Result { + with_build_system::( payload, "build.checkToolVersion", "version", @@ -123,8 +123,8 @@ pub(crate) fn build_check_tool_version_command(payload: Value) -> Result Result { - with_build_system( +pub fn build_resolve_options_command(payload: Value) -> Result { + with_build_system::( payload, "build.resolveOptions", "options", @@ -144,18 +144,20 @@ pub(crate) fn build_resolve_options_command(payload: Value) -> Result Result { - with_build_system( +pub fn build_edit_stored_option_command( + payload: Value, +) -> Result { + with_build_system::( payload, "build.editStoredOption", "edit", - |system, request| super::stored_options::edit(system, request), + super::stored_options::edit, ) } /// `build.modelPlan`: the command that prints a command-sourced model. -pub(crate) fn build_model_plan_command(payload: Value) -> Result { - with_build_system( +pub fn build_model_plan_command(payload: Value) -> Result { + with_build_system::( payload, "build.modelPlan", "plan", @@ -164,8 +166,8 @@ pub(crate) fn build_model_plan_command(payload: Value) -> Result Result { - with_build_system( +pub fn build_task_plan_command(payload: Value) -> Result { + with_build_system::( payload, "build.taskPlan", "plan", @@ -174,8 +176,8 @@ pub(crate) fn build_task_plan_command(payload: Value) -> Result Result { - with_build_system( +pub fn build_test_plan_command(payload: Value) -> Result { + with_build_system::( payload, "build.testPlan", "plan", @@ -184,8 +186,10 @@ pub(crate) fn build_test_plan_command(payload: Value) -> Result Result { - with_build_system( +pub fn build_test_discovery_plan_command( + payload: Value, +) -> Result { + with_build_system::( payload, "build.testDiscoveryPlan", "plan", @@ -196,8 +200,10 @@ pub(crate) fn build_test_discovery_plan_command(payload: Value) -> Result Result { - with_build_system( +pub fn build_decode_test_discovery_command( + payload: Value, +) -> Result { + with_build_system::( payload, "build.decodeTestDiscovery", "discovery", @@ -208,8 +214,10 @@ pub(crate) fn build_decode_test_discovery_command(payload: Value) -> Result Result { - with_manifest( +pub fn build_prepare_run_plan_command( + payload: Value, +) -> Result { + with_manifest::( payload, "build.prepareRunPlan", "plan", @@ -220,8 +228,10 @@ pub(crate) fn build_prepare_run_plan_command(payload: Value) -> Result Result { - with_manifest( +pub fn build_decode_run_preparation_command( + payload: Value, +) -> Result { + with_manifest::( payload, "build.decodeRunPreparation", "launch", @@ -232,8 +242,8 @@ pub(crate) fn build_decode_run_preparation_command(payload: Value) -> Result Result { - with_build_system( +pub fn build_dependency_plan_command(payload: Value) -> Result { + with_build_system::( payload, "build.dependencyPlan", "plan", @@ -242,8 +252,8 @@ pub(crate) fn build_dependency_plan_command(payload: Value) -> Result Result { - with_build_system( +pub fn build_decode_model_command(payload: Value) -> Result { + with_build_system::( payload, "build.decodeModel", "model", @@ -252,8 +262,10 @@ pub(crate) fn build_decode_model_command(payload: Value) -> Result Result { - with_build_system( +pub fn build_decode_test_results_command( + payload: Value, +) -> Result { + with_build_system::( payload, "build.decodeTestResults", "results", @@ -264,8 +276,10 @@ pub(crate) fn build_decode_test_results_command(payload: Value) -> Result Result { - with_build_system( +pub fn build_decode_diagnostics_command( + payload: Value, +) -> Result { + with_build_system::( payload, "build.decodeDiagnostics", "diagnostics", @@ -276,8 +290,10 @@ pub(crate) fn build_decode_diagnostics_command(payload: Value) -> Result Result { - with_build_system( +pub fn build_decode_dependencies_command( + payload: Value, +) -> Result { + with_build_system::( payload, "build.decodeDependencies", "dependencies", @@ -288,8 +304,10 @@ pub(crate) fn build_decode_dependencies_command(payload: Value) -> Result Result { - with_build_system( +pub fn build_sync_fingerprint_command( + payload: Value, +) -> Result { + with_build_system::( payload, "build.syncFingerprint", "sync", @@ -300,8 +318,10 @@ pub(crate) fn build_sync_fingerprint_command(payload: Value) -> Result Result { - with_build_system( +pub fn build_prepare_debug_plan_command( + payload: Value, +) -> Result { + with_build_system::( payload, "build.prepareDebugPlan", "plan", @@ -312,8 +332,10 @@ pub(crate) fn build_prepare_debug_plan_command(payload: Value) -> Result Result { - with_build_system( +pub fn build_decode_debug_preparation_command( + payload: Value, +) -> Result { + with_build_system::( payload, "build.decodeDebugPreparation", "preparation", @@ -324,20 +346,24 @@ pub(crate) fn build_decode_debug_preparation_command(payload: Value) -> Result Result { - with_build_system( +pub fn build_resolve_model_inputs_command( + payload: Value, +) -> Result { + with_build_system::( payload, "build.resolveModelInputs", "modelInputs", |build, request: &super::model_inputs::ResolveRequest| { - super::model_inputs::resolve(build, request) + super::model_inputs::resolve::(build, request) }, ) } /// `build.configurationPlan`: read native facts through the owning provider. -pub(crate) fn build_configuration_plan_command(payload: Value) -> Result { - with_build_system( +pub fn build_configuration_plan_command( + payload: Value, +) -> Result { + with_build_system::( payload, "build.configurationPlan", "plan", @@ -346,8 +372,10 @@ pub(crate) fn build_configuration_plan_command(payload: Value) -> Result Result { - with_build_system( +pub fn build_decode_configuration_command( + payload: Value, +) -> Result { + with_build_system::( payload, "build.decodeConfiguration", "configuration", @@ -358,8 +386,10 @@ pub(crate) fn build_decode_configuration_command(payload: Value) -> Result Result { - with_build_system( +pub fn build_configuration_edit_plan_command( + payload: Value, +) -> Result { + with_build_system::( payload, "build.configurationEditPlan", "plan", @@ -370,8 +400,10 @@ pub(crate) fn build_configuration_edit_plan_command(payload: Value) -> Result Result { - with_build_system( +pub fn build_decode_configuration_edit_command( + payload: Value, +) -> Result { + with_build_system::( payload, "build.decodeConfigurationEdit", "edit", diff --git a/rust/crates/core/src/build/configuration.rs b/rust/crates/build/src/configuration.rs similarity index 98% rename from rust/crates/core/src/build/configuration.rs rename to rust/crates/build/src/configuration.rs index 90fde4eb..f09b4746 100644 --- a/rust/crates/core/src/build/configuration.rs +++ b/rust/crates/build/src/configuration.rs @@ -5,7 +5,7 @@ use super::manifest::BuildSystemDeclaration; use super::plans::{self, OutputPlanRequest}; -use crate::plugins::declarative::Validation; +use lithe_plugin_model::Validation; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::{BTreeMap, BTreeSet}; @@ -148,8 +148,8 @@ pub(crate) fn decode( Ok(json!(snapshot)) } -fn invalid(message: &str) -> crate::plugins::declarative::PluginRejection { - crate::plugins::declarative::PluginRejection::new("plugin.build.invalidConfiguration", message) +fn invalid(message: &str) -> lithe_plugin_model::PluginRejection { + lithe_plugin_model::PluginRejection::new("plugin.build.invalidConfiguration", message) } /// Shared by snapshot decoding and format-independent edit planning. diff --git a/rust/crates/core/src/build/debug_preparation.rs b/rust/crates/build/src/debug_preparation.rs similarity index 97% rename from rust/crates/core/src/build/debug_preparation.rs rename to rust/crates/build/src/debug_preparation.rs index 49e412eb..ed776ab2 100644 --- a/rust/crates/core/src/build/debug_preparation.rs +++ b/rust/crates/build/src/debug_preparation.rs @@ -5,8 +5,8 @@ use super::decode::{self, Workspace}; use super::manifest::{BuildSystemDeclaration, RunDeclaration, TaskSource}; use super::plans; use super::run_preparation::{self, DecodeRunPreparationRequest, PrepareRunRequest}; -use crate::debug::adapters::{DebugAdapterDeclaration, DebugRequest}; -use crate::plugins::declarative::Validation; +use lithe_debug::adapters::{DebugAdapterDeclaration, DebugRequest}; +use lithe_plugin_model::Validation; use serde::Deserialize; use serde_json::{json, Value}; diff --git a/rust/crates/core/src/build/decode.rs b/rust/crates/build/src/decode.rs similarity index 99% rename from rust/crates/core/src/build/decode.rs rename to rust/crates/build/src/decode.rs index 1ce589a6..5081de5d 100644 --- a/rust/crates/core/src/build/decode.rs +++ b/rust/crates/build/src/decode.rs @@ -6,8 +6,8 @@ //! path or dropped, so a hostile tool cannot make the host open files //! outside the workspace. -use crate::plugins::declarative::{PluginRejection, Validation}; -use crate::remote::{workspace, workspace_io as fs}; +use lithe_plugin_model::{PluginRejection, Validation}; +use lithe_workspace::{self as workspace, fs}; use serde_json::Value; use std::path::{Component, Path, PathBuf}; use std::time::UNIX_EPOCH; diff --git a/rust/crates/core/src/build/detect.rs b/rust/crates/build/src/detect.rs similarity index 93% rename from rust/crates/core/src/build/detect.rs rename to rust/crates/build/src/detect.rs index 67e834dd..ef84f703 100644 --- a/rust/crates/core/src/build/detect.rs +++ b/rust/crates/build/src/detect.rs @@ -10,8 +10,9 @@ use super::decode::Workspace; use super::manifest::{BuildSystemDeclaration, ModelSource, Nesting, OptionKind, OptionScope}; use super::options; use super::outputs::{model_value, read_model, source_marker}; -use crate::plugins::declarative::{validate_manifest_value, PluginRejection, Validation}; -use crate::protocol::CoreError; +use super::BuildManifest; +use lithe_plugin_model::{PluginRejection, Validation}; +use lithe_protocol::CoreError; use serde::Deserialize; use serde_json::{json, Value}; use std::collections::{BTreeMap, BTreeSet}; @@ -58,7 +59,6 @@ fn model_source_name(source: Option<&ModelSource>) -> Value { None => Value::Null, Some(ModelSource::Static { .. }) => json!("static"), Some(ModelSource::Command { .. }) => json!("command"), - Some(ModelSource::HostIntegration) => json!("hostIntegration"), Some(ModelSource::Provider) => json!("provider"), } } @@ -71,7 +71,7 @@ fn is_below(path: &str, ancestor: &str) -> bool { /// with nested ones removed when the build system nests its projects. fn roots( declaration: &BuildSystemDeclaration, - directories: &[crate::execution::DirectoryContext], + directories: &[lithe_execution::DirectoryContext], ) -> Vec<(String, Vec)> { let mut candidates: Vec<(String, Vec)> = directories .iter() @@ -112,10 +112,7 @@ fn instance( "modelSource": model_source_name(declaration.model.as_ref()), }); let mut model_values = None; - if matches!( - declaration.model, - Some(ModelSource::Static { .. } | ModelSource::HostIntegration) - ) { + if matches!(declaration.model, Some(ModelSource::Static { .. })) { let source = source_marker(declaration, workspace, root); match read_model(declaration, workspace, root, None, None, None).and_then(|model| { let value = model_value(declaration, &model, root, stored, source.as_deref())?; @@ -147,9 +144,9 @@ fn instance( // the unmaterialized local projection. fn scan_directory_root( root: &Path, - scan: impl FnOnce(&Path) -> Result, CoreError>, -) -> Validation> { - match crate::remote::workspace_io::metadata(root) { + scan: impl FnOnce(&Path) -> Result, CoreError>, +) -> Validation> { + match lithe_workspace::fs::metadata(root) { Ok(metadata) if !metadata.is_dir() => return Ok(Vec::new()), Err(error) if matches!(error.kind(), ErrorKind::NotFound | ErrorKind::NotADirectory) => { return Ok(Vec::new()); @@ -168,12 +165,12 @@ fn scan_directory_root( /// `build.detect`: every instance of every build system the enabled plugins /// declare. Invalid manifests are reported, not fatal; when two plugins /// declare one build-system ID, the higher priority answers for it. -pub(crate) fn detect(request: DetectRequest) -> Validation { +pub(crate) fn detect(request: DetectRequest) -> Validation { let workspace = Workspace::new(&request.workspace_root)?; let mut rejected = Vec::new(); let mut manifests = Vec::new(); for (index, plugin) in request.plugins.into_iter().enumerate() { - match validate_manifest_value(plugin.manifest) { + match M::validate(plugin.manifest) { Ok(manifest) => manifests.push(manifest), Err(error) => rejected.push(json!({ "index": index, "error": error })), } @@ -221,7 +218,7 @@ pub(crate) fn detect(request: DetectRequest) -> Validation { if owners.is_empty() { return Ok(json!({ "instances": [], "rejected": rejected, "shadowed": shadowed })); } - let directories = scan_directory_root(&workspace.path, crate::execution::scan)?; + let directories = scan_directory_root(&workspace.path, lithe_execution::scan)?; let empty = BTreeMap::new(); let mut seen = BTreeSet::new(); let mut instances = Vec::new(); diff --git a/rust/crates/core/src/build/invocation.rs b/rust/crates/build/src/invocation.rs similarity index 96% rename from rust/crates/core/src/build/invocation.rs rename to rust/crates/build/src/invocation.rs index 0a9e3c74..0de23fa6 100644 --- a/rust/crates/core/src/build/invocation.rs +++ b/rust/crates/build/src/invocation.rs @@ -11,11 +11,11 @@ use super::manifest::{ BuildSystemDeclaration, ExecutableCandidate, ExecutableSource, Platform, RuntimeDependency, VersionProbe, }; -use crate::plugins::declarative::{ - reported_version, validate_minimum_version, version_components, DeclarativePluginManifest, - PluginRejection, Validation, +use super::BuildManifest; +use lithe_plugin_model::executables::ordered_indices; +use lithe_plugin_model::{ + reported_version, validate_minimum_version, version_components, PluginRejection, Validation, }; -use crate::plugins::executables::ordered_indices; use serde::Deserialize; use serde_json::{json, Value}; use std::collections::BTreeMap; @@ -45,7 +45,7 @@ fn candidates( candidates: &[ExecutableCandidate], root: &str, platform: Platform, - manifest: &DeclarativePluginManifest, + manifest: &impl BuildManifest, settings: &BTreeMap, ) -> Validation> { ordered_indices(candidates, "executables")?.into_iter() @@ -84,7 +84,7 @@ fn runtime( runtime: &RuntimeDependency, root: &str, platform: Platform, - manifest: &DeclarativePluginManifest, + manifest: &impl BuildManifest, settings: &BTreeMap, ) -> Validation { let version = if runtime.is_host_managed() { @@ -118,10 +118,10 @@ fn runtime( pub(crate) fn invocation_plan( build_system: &BuildSystemDeclaration, request: &InvocationRequest, - manifest: &DeclarativePluginManifest, + manifest: &impl BuildManifest, ) -> Validation { let root = decode::request_path(&request.root, "root")?; - let platform = crate::remote::workspace::current() + let platform = lithe_workspace::current() .map(|host| match host.connection.session.hello().host.os { lithe_remote::protocol::methods::OperatingSystem::Linux => Platform::Linux, lithe_remote::protocol::methods::OperatingSystem::Macos => Platform::Macos, diff --git a/rust/crates/core/src/build/mod.rs b/rust/crates/build/src/lib.rs similarity index 52% rename from rust/crates/core/src/build/mod.rs rename to rust/crates/build/src/lib.rs index fc32e86b..f93d41c0 100644 --- a/rust/crates/core/src/build/mod.rs +++ b/rust/crates/build/src/lib.rs @@ -37,13 +37,39 @@ mod test_discovery; mod test_outcome; mod test_outcome_merge; -pub(crate) use commands::*; -pub(crate) use manifest::{ +pub use commands::*; +pub use manifest::{ activation_event, programs, validate_declarations, BuildSystemDeclaration, DeclarationContext, ProviderProcess, }; -pub(crate) use project_options::project_options_command; -pub(crate) use run_start::run_decode_start_command; -pub(crate) use stored_options::resolve_stored_options_command; -pub(crate) use test_outcome::resolve_test_run_outcome; -pub(crate) use test_outcome_merge::merge_test_outcomes; +pub use project_options::project_options_command; +pub use run_start::run_decode_start_command; +pub use stored_options::resolve_stored_options_command; +pub use test_outcome::resolve_test_run_outcome; +pub use test_outcome_merge::merge_test_outcomes; + +use lithe_plugin_model::{PluginManifest, Validation}; +use serde_json::Value; +use std::collections::BTreeMap; + +/// What the build commands read from a validated plugin manifest. +/// +/// Every command that takes a manifest validates it again through +/// [`PluginManifest::validate`], so a host never has to trust its own cache +/// of a manifest; the plugin host implements this trait for its manifest type. +pub trait BuildManifest: PluginManifest { + /// The build systems the plugin declares, in declaration order. + fn build_systems(&self) -> &[BuildSystemDeclaration]; + + /// The tool definition IDs the plugin requires. + fn required_tools(&self) -> &[String]; + + /// The executable path a declared executable setting selects, from the + /// stored setting values. Strict: an invalid stored value is rejected + /// rather than replaced by the setting's default. + fn executable_setting_value( + &self, + id: &str, + values: &BTreeMap, + ) -> Validation>; +} diff --git a/rust/crates/build/src/manifest.rs b/rust/crates/build/src/manifest.rs new file mode 100644 index 00000000..b9b89730 --- /dev/null +++ b/rust/crates/build/src/manifest.rs @@ -0,0 +1,219 @@ +//! `buildSystems` declarations and their validation. +//! +//! A declaration is data only. Everything a plugin needs is +//! expressible here: any command or packaged script as the executable, any +//! runtime it depends on (described by how to find and probe it), and any +//! output, either through a selector map over a public format or by printing +//! one of the canonical `lithe*` formats. What no command can print is +//! answered by a `provider`: a server process of the plugin, declared with +//! the language-server launch fields or shared with one of its language +//! servers. The host never special-cases a build tool by name: +//! `hostIntegration` is reserved and refused whatever it names. + +use super::select::{self, FieldSpec, Predicate}; +use super::template::{self, Allowed, Availability, BuildArgument, Variable}; +use lithe_plugin_model::process::{ServerProcess, ServerRole}; +use lithe_plugin_model::settings::is_setting_identifier; +use lithe_plugin_model::{ + invalid, invalid_template as template_error, is_bare_command, is_identifier, + is_safe_relative_path, sorted_unique, validate_minimum_version, PluginRejection, UIIcon, + Validation, +}; +use regex::Regex; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet}; + +mod checks; +mod dependencies; +mod detection; +mod diagnostics; +mod invocation; +mod model; +mod options; +mod provider; +mod run; +mod tasks; +mod testing; + +pub use checks::*; +pub(crate) use dependencies::*; +pub(crate) use detection::*; +pub(crate) use diagnostics::*; +pub(crate) use invocation::*; +pub(crate) use model::*; +pub(crate) use options::*; +pub use provider::*; +pub(crate) use run::*; +pub(crate) use tasks::*; +pub(crate) use testing::*; + +const MAX_BUILD_SYSTEMS: usize = 16; +const MAX_LIST: usize = 32; +const MAX_TASKS: usize = 128; +const MAX_CHOICES: usize = 64; +const MAX_ARGUMENTS: usize = 64; +const MAX_TEXT: usize = 120; +const MAX_OPTION_VALUE: usize = 256; +const MAX_REGEX: usize = 512; +const MAX_IDENTIFIER: usize = 64; +const MAX_PRIORITY: u8 = 100; +const DEFAULT_PRIORITY: u8 = 50; +const DEFAULT_SEPARATOR: &str = ","; + +/// Deepest heading path a task can be filed under, e.g. `Plugins › compiler`. +pub(crate) const MAX_CATEGORY_DEPTH: usize = 4; + +/// One project-level build tool a plugin describes. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct BuildSystemDeclaration { + /// The build-system ID, unique within the plugin. + pub id: String, + pub(crate) display_name: String, + /// Optional, explicit read-only publication of the normalized model. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_sharing: Option, + /// Exact optional producers; these are not installation dependencies. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub model_inputs: Vec, + /// Icon of the build system's tool window; display only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) icon: Option, + pub(crate) detection: Detection, + pub(crate) invocation: Invocation, + /// Reserved. Core implements no build-system host integration: every + /// build tool is described by its plugin's data and backend, so any + /// value is refused with `plugin.integration.unsupported`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) host_integration: Option, + /// The process that answers the `provider` sources. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) options: Vec, + /// Native non-sensitive settings and generic write bindings from the engine. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) configuration: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) model: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) tasks: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) run: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) test: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) diagnostics: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) dependencies: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) sync: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) layout: Option, +} + +/// Validates and normalizes every build system of a manifest. +/// Normalizes the tags a selected target must carry; they only make sense +/// when the command can run on a target. +fn check_target_tags(tags: &mut Vec, scopes: &[Scope], field: &str) -> Validation<()> { + if !tags.is_empty() && !scopes.contains(&Scope::Target) { + return Err(invalid( + format!("{field}.targetTags"), + "targetTags need the target scope.", + )); + } + if tags.iter().any(|tag| !is_tag(tag)) { + return Err(invalid( + format!("{field}.targetTags"), + "Tags are identifiers.", + )); + } + *tags = sorted_unique(std::mem::take(tags)); + Ok(()) +} + +/// Facts of the rest of the manifest a build-system declaration is checked +/// against. +pub struct DeclarationContext<'a> { + /// Debug adapters the plugin declares; a debug run names one of them. + pub debug_adapters: &'a [lithe_debug::adapters::DebugAdapterDeclaration], + /// Setting IDs a provider's launch templates may name. + pub declared_settings: &'a lithe_plugin_model::settings::DeclaredSettings, + /// Language IDs the plugin declares a language server for. + pub language_servers: &'a BTreeSet, + /// Whether tool names are host runtimes or tool-definition IDs. + pub runtime_tools: lithe_plugin_model::process::RuntimeTools<'a>, +} + +/// Validates every build-system declaration of a manifest against the rest +/// of it, and sorts the declarations by ID. +pub fn validate_declarations( + build_systems: &mut [BuildSystemDeclaration], + context: &DeclarationContext<'_>, +) -> Validation<()> { + if build_systems.is_empty() { + return Ok(()); + } + if build_systems.len() > MAX_BUILD_SYSTEMS { + return Err(invalid( + "buildSystems", + "The plugin declares too many build systems.", + )); + } + let mut ids = BTreeSet::new(); + for (index, build_system) in build_systems.iter_mut().enumerate() { + let field = format!("buildSystems[{index}]"); + build_system.validate(&field, context)?; + if !ids.insert(build_system.id.clone()) { + return Err(invalid( + format!("{field}.id"), + "A build system is declared more than once.", + )); + } + } + build_systems.sort_by(|left, right| left.id.cmp(&right.id)); + Ok(()) +} + +impl BuildSystemDeclaration { + fn validate(&mut self, field: &str, context: &DeclarationContext<'_>) -> Validation<()> { + if !is_entry_identifier(&self.id) { + return Err(invalid( + format!("{field}.id"), + "A build-system ID starts with a letter and uses lowercase letters, digits, and hyphens.", + )); + } + check_text( + &self.display_name, + &format!("{field}.displayName"), + "display name", + )?; + if let Some(icon) = &self.icon { + icon.validate_at(&format!("{field}.icon"))?; + } + self.validate_integration(field)?; + self.validate_provider(field, context)?; + self.validate_detection(field)?; + let option_ids = self.validate_options(field)?; + super::model_inputs::validate_declaration(self)?; + self.validate_invocation(field, &option_ids, context)?; + let model_templates = self.validate_tasks(field, &option_ids)?; + self.validate_model(field, &option_ids)?; + self.validate_run(field, &option_ids, &model_templates)?; + self.validate_test(field, &option_ids)?; + self.validate_diagnostics(field)?; + self.validate_dependencies(field, &option_ids)?; + if let Some(sync) = &mut self.sync { + check_paths(&mut sync.watch, &format!("{field}.sync.watch"), false)?; + } + if let Some(layout) = &mut self.layout { + check_paths( + &mut layout.excluded, + &format!("{field}.layout.excluded"), + false, + )?; + } + self.validate_option_emission(field) + } +} diff --git a/rust/crates/build/src/manifest/checks.rs b/rust/crates/build/src/manifest/checks.rs new file mode 100644 index 00000000..558d485c --- /dev/null +++ b/rust/crates/build/src/manifest/checks.rs @@ -0,0 +1,233 @@ +//! Shared field checks used by every declaration section. + +use super::*; + +/// Activation event the host raises before using a build system. +pub fn activation_event(build_system: &BuildSystemDeclaration) -> String { + format!("onBuildSystem:{}", build_system.id) +} + +/// Install-review entries: everything that decides which process runs with +/// which arguments. Display text, detection, and output decoding are left +/// out; changing them does not run anything new. A provider process of its +/// own is reviewed like a language server, with the runtimes it needs. +pub fn programs(build_system: &BuildSystemDeclaration) -> Vec { + let mut programs = vec![program(build_system)]; + if let Some(ProviderDeclaration::Process(own)) = &build_system.provider { + let mut provider = own.process.program(ServerRole::BuildProvider); + provider["buildSystemID"] = json!(build_system.id); + provider["executesProjectCode"] = json!(own.executes_project_code); + provider["mayUseNetwork"] = json!(own.may_use_network); + programs.push(provider); + programs.extend(own.process.runtime_programs()); + } + programs +} + +fn program(build_system: &BuildSystemDeclaration) -> Value { + let options: Vec = build_system + .options + .iter() + .map(|option| { + json!({ + "id": option.id, + "kind": option.kind, + "default": option.default, + "choices": option.choices.iter().map(|choice| &choice.value).collect::>(), + "choicesFromModel": option.choices_from_model, + "allowCustom": option.allow_custom, + "separator": option.separator(), + "arguments": option.arguments, + "environment": option.environment, + }) + }) + .collect(); + let tasks: Vec = build_system + .tasks + .iter() + .map(|task| { + let mut review = json!({ "id": task.id, "arguments": task.arguments }); + if task.source == TaskSource::Provider { + review["source"] = json!("provider"); + } + // Automatic preparation changes when a task can execute, not merely + // its display. Keep that behavior in the existing install review. + if let Some(preparation) = &task.preparation { + review["preparation"] = json!(preparation); + } + review + }) + .collect(); + let model = match &build_system.model { + Some(ModelSource::Command { arguments, .. }) => json!({ "arguments": arguments }), + _ => Value::Null, + }; + let dependencies = match &build_system.dependencies { + Some(DependenciesSource::Command { arguments, .. }) => json!({ "arguments": arguments }), + _ => Value::Null, + }; + let mut program = json!({ + "kind": "buildSystem", + "buildSystemID": build_system.id, + "invocation": build_system.invocation, + "options": options, + "tasks": tasks, + "run": build_system.run, + "test": build_system.test.as_ref().map(|test| { + let mut review = json!({ "arguments": test.arguments }); + if test.source == TaskSource::Provider { + review["source"] = json!("provider"); + } + if test.discovery { review["discovery"] = json!(true); } + review + }), + "model": model, + "dependencies": dependencies, + }); + // Build requests reaching a language server change what that process + // is asked to do, so the shared form is part of this entry. + if let Some(ProviderDeclaration::LanguageServer(shared)) = &build_system.provider { + program["provider"] = json!(shared); + } + program +} + +/// A build-system or run ID: also a run-configuration provider segment, so +/// it starts with a letter. +pub(crate) fn is_entry_identifier(value: &str) -> bool { + is_identifier(value) + && value.len() <= MAX_IDENTIFIER + && value + .chars() + .next() + .is_some_and(|first| first.is_ascii_lowercase()) +} + +pub(crate) fn is_tag(value: &str) -> bool { + is_identifier(value) && value.len() <= MAX_IDENTIFIER +} + +pub(crate) fn is_environment_name(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_IDENTIFIER + && value + .chars() + .next() + .is_some_and(|first| first.is_ascii_uppercase() || first == '_') + && value.chars().all(|character| { + character.is_ascii_uppercase() || character.is_ascii_digit() || character == '_' + }) +} + +/// A file name with no directory part. +pub(crate) fn is_file_name(value: &str) -> bool { + is_safe_relative_path(value) && !value.contains('/') && value != "." +} + +/// An option value: no control characters, bounded. +pub(crate) fn is_option_value(value: &str) -> bool { + !value.is_empty() && value.len() <= MAX_OPTION_VALUE && !value.chars().any(char::is_control) +} + +pub(crate) fn check_text(value: &str, field: &str, what: &str) -> Validation<()> { + if value.trim().is_empty() || value.len() > MAX_TEXT || value.chars().any(char::is_control) { + return Err(invalid( + field, + format!("The {what} must be 1-{MAX_TEXT} printable characters."), + )); + } + Ok(()) +} + +/// Trimmed headings of a task category, or `None` when the path is too deep +/// or a heading is empty, too long, or not printable. +pub(crate) fn category_labels(labels: &[String]) -> Option> { + if labels.len() > MAX_CATEGORY_DEPTH { + return None; + } + labels + .iter() + .map(|label| { + let label = label.trim(); + (!label.is_empty() && label.len() <= MAX_TEXT && !label.chars().any(char::is_control)) + .then(|| label.to_string()) + }) + .collect() +} + +pub(crate) fn check_paths(values: &mut Vec, field: &str, glob: bool) -> Validation<()> { + if values.len() > MAX_LIST { + return Err(invalid(field, "The list is too long.")); + } + for (index, value) in values.iter().enumerate() { + let safe = is_safe_relative_path(value) + && (glob || !value.contains('*')) + && !value + .split('/') + .any(|segment| segment.is_empty() || segment == "."); + if !safe { + return Err(PluginRejection::at( + "plugin.path.invalid", + format!("{field}[{index}]"), + "Paths must be relative, use /, and stay inside the project.", + )); + } + } + *values = sorted_unique(std::mem::take(values)); + Ok(()) +} + +pub(crate) fn check_regex(value: &str, field: &str, groups: usize) -> Validation { + let compiled = (value.len() <= MAX_REGEX) + .then(|| Regex::new(value).ok()) + .flatten() + .ok_or_else(|| invalid(field, "The regular expression is invalid or too long."))?; + if compiled.captures_len() <= groups { + return Err(invalid( + field, + format!("The regular expression needs at least {groups} capture group(s)."), + )); + } + Ok(compiled) +} + +pub(crate) fn check_argument_count(arguments: &[BuildArgument], field: &str) -> Validation<()> { + if arguments.len() > MAX_ARGUMENTS { + return Err(invalid(field, "The argument list is too long.")); + } + Ok(()) +} + +pub(crate) fn availability(scopes: &[Scope], scope: Scope) -> Availability { + let workspace = scopes.contains(&Scope::Workspace); + match scope { + Scope::Project if scopes.iter().all(|value| *value == Scope::Workspace) => { + Availability::Never + } + Scope::Project if workspace => Availability::Maybe, + Scope::Project => Availability::Always, + Scope::Target if !scopes.contains(&Scope::Target) => Availability::Never, + Scope::Target if scopes.len() == 1 => Availability::Always, + Scope::Target => Availability::Maybe, + Scope::Workspace => Availability::Always, + } +} + +pub(crate) fn check_scopes( + scopes: &mut Vec, + field: &str, + allowed: &[Scope], +) -> Validation<()> { + *scopes = std::mem::take(scopes) + .into_iter() + .collect::>() + .into_iter() + .collect(); + if scopes.is_empty() || scopes.iter().any(|scope| !allowed.contains(scope)) { + return Err(invalid( + field, + format!("Scopes must be a non-empty subset of {allowed:?}."), + )); + } + Ok(()) +} diff --git a/rust/crates/build/src/manifest/dependencies.rs b/rust/crates/build/src/manifest/dependencies.rs new file mode 100644 index 00000000..0759d31a --- /dev/null +++ b/rust/crates/build/src/manifest/dependencies.rs @@ -0,0 +1,139 @@ +//! `dependencies`, `sync` and `layout` declarations. + +use super::*; + +/// Where dependency trees come from. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde( + tag = "source", + rename_all = "camelCase", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +#[allow(clippy::large_enum_variant)] // Decoded once per manifest validation. +pub(crate) enum DependenciesSource { + Command { + scopes: Vec, + arguments: Vec, + #[serde(default)] + output: OutputChannel, + decoder: DependencyDecoder, + #[serde(default, skip_serializing_if = "Option::is_none")] + map: Option, + executes_project_code: bool, + may_use_network: bool, + }, + /// The provider answers `lithe/build/dependencies` with + /// `litheDependencies`. + Provider { scopes: Vec }, +} + +/// Mapping of one dependency element onto a flat list. Dependency trees +/// come from `litheDependencies`. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct DependencyRule { + pub(crate) each: String, + #[serde(default, rename = "where", skip_serializing_if = "Vec::is_empty")] + pub(crate) filter: Vec, + pub(crate) name: FieldSpec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) group: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) scope: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub(crate) properties: BTreeMap, +} + +/// Files whose change makes the model stale, relative to the instance root +/// and to each project directory. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct SyncDeclaration { + pub(crate) watch: Vec, +} + +/// Output directories excluded from every project, relative to each +/// project directory. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct LayoutDeclaration { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) excluded: Vec, +} + +impl BuildSystemDeclaration { + pub(crate) fn validate_dependencies( + &mut self, + field: &str, + options: &BTreeSet, + ) -> Validation<()> { + if let Some(DependenciesSource::Provider { scopes }) = &mut self.dependencies { + return check_scopes( + scopes, + &format!("{field}.dependencies.scopes"), + &[Scope::Workspace, Scope::Project], + ); + } + let Some(DependenciesSource::Command { + scopes, + arguments, + output, + decoder, + map, + .. + }) = &mut self.dependencies + else { + return Ok(()); + }; + let field = format!("{field}.dependencies"); + check_scopes( + scopes, + &format!("{field}.scopes"), + &[Scope::Workspace, Scope::Project], + )?; + check_argument_count(arguments, &format!("{field}.arguments"))?; + template::validate_arguments( + arguments, + &Allowed { + root: true, + project: availability(scopes, Scope::Project), + host_directories: true, + conditions: true, + options: Some(options), + ..Allowed::none() + }, + &format!("{field}.arguments"), + )?; + check_output_channel(*output, arguments, &field)?; + match (decoder, map) { + (DependencyDecoder::LitheDependencies, None) => Ok(()), + (DependencyDecoder::LitheDependencies, Some(_)) => Err(invalid( + format!("{field}.map"), + "The litheDependencies format needs no map.", + )), + (_, None) => Err(invalid( + format!("{field}.map"), + "A dependency command with a generic format needs a map.", + )), + (_, Some(rule)) => rule.validate(&format!("{field}.map")), + } + } +} + +impl DependencyRule { + fn validate(&self, field: &str) -> Validation<()> { + select::validate(&self.each, &format!("{field}.each"), false)?; + check_predicates(&self.filter, field)?; + self.name.validate(&format!("{field}.name"))?; + for spec in [&self.group, &self.version, &self.scope] + .into_iter() + .flatten() + { + spec.validate(field)?; + } + check_properties(&self.properties, field) + } +} diff --git a/rust/crates/build/src/manifest/detection.rs b/rust/crates/build/src/manifest/detection.rs new file mode 100644 index 00000000..f37f9dcb --- /dev/null +++ b/rust/crates/build/src/manifest/detection.rs @@ -0,0 +1,60 @@ +//! `detect` and `nesting`: how a build system recognizes its projects. + +use super::*; + +/// How instances of the build system are recognized during the shared walk. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct Detection { + /// Exact file names; a directory containing any of them is a candidate. + pub(crate) markers: Vec, + #[serde(default)] + pub(crate) nesting: Nesting, + /// Decides between plugins declaring the same build-system ID. + #[serde(default = "default_priority")] + pub(crate) priority: u8, +} + +fn default_priority() -> u8 { + DEFAULT_PRIORITY +} + +/// What a marker below another instance's root means. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum Nesting { + /// Every candidate directory is its own instance (Cargo packages). + #[default] + Separate, + /// Only candidates with no candidate above them are instances; deeper + /// markers belong to them (multi-project builds and reactors). + Outermost, +} + +impl BuildSystemDeclaration { + pub(crate) fn validate_detection(&mut self, field: &str) -> Validation<()> { + let detection = &mut self.detection; + let markers = format!("{field}.detection.markers"); + if detection.markers.is_empty() || detection.markers.len() > MAX_LIST { + return Err(invalid(markers, "Detection needs 1-32 marker file names.")); + } + if let Some(index) = detection + .markers + .iter() + .position(|marker| !is_file_name(marker)) + { + return Err(invalid( + format!("{markers}[{index}]"), + "A marker is a file name without a directory.", + )); + } + detection.markers = sorted_unique(std::mem::take(&mut detection.markers)); + if detection.priority > MAX_PRIORITY { + return Err(invalid( + format!("{field}.detection.priority"), + "The priority must be 0-100.", + )); + } + Ok(()) + } +} diff --git a/rust/crates/build/src/manifest/diagnostics.rs b/rust/crates/build/src/manifest/diagnostics.rs new file mode 100644 index 00000000..b3b294f4 --- /dev/null +++ b/rust/crates/build/src/manifest/diagnostics.rs @@ -0,0 +1,92 @@ +//! `diagnostics`: output line patterns and their severities. + +use super::*; + +/// How build output becomes file diagnostics. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde( + tag = "decoder", + rename_all = "camelCase", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +pub(crate) enum DiagnosticsDeclaration { + LineMatcher { + patterns: Vec, + }, + /// Output lines holding canonical diagnostic objects. + LitheDiagnostics, +} + +/// A regular expression and which capture group holds each field. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct LinePattern { + pub(crate) regex: String, + pub(crate) file: usize, + pub(crate) line: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) column: Option, + pub(crate) message: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) severity: Option, + /// Raw severity text to severity; without it the text is lowercased. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub(crate) severities: BTreeMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) default_severity: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum Severity { + Error, + Warning, + Information, + Hint, +} + +impl Severity { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Error => "error", + Self::Warning => "warning", + Self::Information => "information", + Self::Hint => "hint", + } + } +} + +impl BuildSystemDeclaration { + pub(crate) fn validate_diagnostics(&self, field: &str) -> Validation<()> { + let Some(DiagnosticsDeclaration::LineMatcher { patterns }) = &self.diagnostics else { + return Ok(()); + }; + let field = format!("{field}.diagnostics.patterns"); + if patterns.is_empty() || patterns.len() > MAX_LIST { + return Err(invalid(field, "A line matcher needs 1-32 patterns.")); + } + for (index, pattern) in patterns.iter().enumerate() { + let field = format!("{field}[{index}]"); + let groups = [ + Some(pattern.file), + Some(pattern.line), + pattern.column, + Some(pattern.message), + pattern.severity, + ]; + let highest = groups.iter().flatten().copied().max().unwrap_or(0); + check_regex(&pattern.regex, &format!("{field}.regex"), highest)?; + if groups.iter().flatten().any(|group| *group == 0) { + return Err(invalid(field, "Capture groups are numbered from 1.")); + } + if !pattern.severities.is_empty() && pattern.severity.is_none() { + return Err(invalid( + format!("{field}.severities"), + "Severity names need a severity group.", + )); + } + } + Ok(()) + } +} diff --git a/rust/crates/build/src/manifest/invocation.rs b/rust/crates/build/src/manifest/invocation.rs new file mode 100644 index 00000000..64066265 --- /dev/null +++ b/rust/crates/build/src/manifest/invocation.rs @@ -0,0 +1,374 @@ +//! `invocation`: the executable, its runtime dependencies and version probes. + +use super::*; + +/// Which executable runs the tool and what it needs. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct Invocation { + /// Candidates in preference order; the host takes the first that exists. + pub(crate) executables: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) global_arguments: Vec, + /// Environment variables the plugin may read. The host passes only these. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) environment_inputs: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) runtime_dependencies: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) minimum_version: Option, +} + +/// Runtime tools the host discovers and manages itself for build-system +/// runtime dependencies: it exports each as `LITHE__PATH` and puts its +/// directory on the process's `PATH`. Server processes never use this list; +/// they name tool definitions from `requiresTools`. +pub(crate) const KNOWN_RUNTIME_TOOLS: &[&str] = &["go", "node"]; + +pub(crate) use lithe_plugin_model::executables::{ExecutableCandidate, ExecutableSource, Platform}; + +/// A runtime the tool needs. Runtimes the host already manages (`node`, +/// `go`) take only an ID and an optional minimum version and follow the +/// user's runtime settings. Any other runtime either describes how to find +/// and probe it, or lists no executables and names the tool definition with +/// its ID, which finds the executable and checks `minimumVersion`. A +/// plugin is never limited to a host list. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct RuntimeDependency { + pub(crate) id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) display_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) minimum_version: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) executables: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) version: Option, +} + +impl RuntimeDependency { + /// True when the host discovers this runtime with its own settings. + pub(crate) fn is_host_managed(&self) -> bool { + KNOWN_RUNTIME_TOOLS.contains(&self.id.as_str()) + } + + /// True when the runtime is the tool definition with the same ID: it + /// lists no executables and the host does not manage it. Plans give it + /// a single toolchain candidate for that ID. + pub(crate) fn names_tool_definition(&self) -> bool { + self.executables.is_empty() && !self.is_host_managed() + } + + /// True when the tool definition the runtime resolves through checks + /// `minimumVersion`, so the manifest needs no version probe: a runtime + /// the host does not manage that is an implicit reference or has exactly + /// one toolchain candidate. + pub(crate) fn checked_by_tool_definition(&self) -> bool { + !self.is_host_managed() + && matches!( + self.executables.as_slice(), + [] | [ExecutableCandidate { + source: ExecutableSource::Toolchain { .. }, + .. + }] + ) + } +} + +/// How to ask an executable for its version: literal arguments, and a +/// pattern whose first capture group is the dotted version. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct VersionProbe { + pub(crate) arguments: Vec, + pub(crate) pattern: String, +} + +impl BuildSystemDeclaration { + pub(crate) fn validate_invocation( + &mut self, + field: &str, + options: &BTreeSet, + context: &DeclarationContext<'_>, + ) -> Validation<()> { + let invocation = &mut self.invocation; + let field = format!("{field}.invocation"); + if invocation.environment_inputs.len() > MAX_LIST { + return Err(invalid( + format!("{field}.environmentInputs"), + "Too many environment inputs.", + )); + } + if let Some(index) = invocation + .environment_inputs + .iter() + .position(|name| !is_environment_name(name)) + { + return Err(invalid( + format!("{field}.environmentInputs[{index}]"), + "Environment variable names use uppercase letters, digits, and _.", + )); + } + invocation.environment_inputs = + sorted_unique(std::mem::take(&mut invocation.environment_inputs)); + + let mut runtimes = BTreeSet::new(); + for (index, runtime) in invocation.runtime_dependencies.iter_mut().enumerate() { + let field = format!("{field}.runtimeDependencies[{index}]"); + // A runtime without executables is a tool-definition ID, which + // may also be plugin-qualified. + let valid_id = if runtime.names_tool_definition() { + lithe_plugin_model::is_tool_id(&runtime.id) + } else { + is_entry_identifier(&runtime.id) + }; + if !valid_id || !runtimes.insert(runtime.id.clone()) { + return Err(invalid( + format!("{field}.id"), + "Runtime IDs are unique identifiers; a runtime without executables is a tool-definition ID.", + )); + } + if let Some(name) = &runtime.display_name { + check_text(name, &format!("{field}.displayName"), "display name")?; + } + if let Some(minimum) = &runtime.minimum_version { + validate_minimum_version(minimum, &format!("{field}.minimumVersion"))?; + } + if runtime.is_host_managed() { + // The host finds these with the user's own runtime settings; + // a plugin must not route around that choice. + let toolchain = runtime.executables.len() == 1 + && matches!( + runtime.executables[0].source, + ExecutableSource::Toolchain { .. } + ); + if (!runtime.executables.is_empty() && !toolchain) || runtime.version.is_some() { + return Err(invalid( + field, + format!( + "{} is discovered by the host; declare only its minimumVersion.", + runtime.id + ), + )); + } + if toolchain { + check_candidate( + &mut runtime.executables[0], + &format!("{field}.executables[0]"), + &invocation.environment_inputs, + &BTreeSet::new(), + )?; + } + continue; + } + if runtime.names_tool_definition() { + // The tool definition finds the executable and checks + // `minimumVersion`; a probe here would have nothing to run. + if runtime.version.is_some() { + return Err(invalid( + format!("{field}.version"), + format!( + "{} names a tool definition, which checks its version; declare only its minimumVersion.", + runtime.id + ), + )); + } + continue; + } + if runtime.executables.len() > MAX_LIST { + return Err(invalid( + format!("{field}.executables"), + "A plugin-described runtime has at most 32 executable candidates.", + )); + } + for (candidate_index, candidate) in runtime.executables.iter_mut().enumerate() { + let candidate_field = format!("{field}.executables[{candidate_index}]"); + if matches!(candidate.source, ExecutableSource::Runtime { .. }) { + return Err(invalid( + candidate_field, + "A runtime cannot be provided by another runtime.", + )); + } + check_candidate( + candidate, + &candidate_field, + &invocation.environment_inputs, + &BTreeSet::new(), + )?; + } + match &runtime.version { + Some(probe) => probe.validate(&format!("{field}.version"))?, + None if runtime.minimum_version.is_some() + && !runtime.checked_by_tool_definition() => + { + return Err(invalid( + format!("{field}.version"), + "A minimumVersion needs a version probe or a single toolchain candidate.", + )) + } + None => {} + } + } + + if invocation.executables.is_empty() || invocation.executables.len() > MAX_LIST { + return Err(invalid( + format!("{field}.executables"), + "The invocation needs 1-32 executable candidates.", + )); + } + for (index, candidate) in invocation.executables.iter_mut().enumerate() { + check_candidate( + candidate, + &format!("{field}.executables[{index}]"), + &invocation.environment_inputs, + &runtimes, + )?; + } + for (list, location) in + std::iter::once((&invocation.executables, format!("{field}.executables"))).chain( + invocation.runtime_dependencies.iter().map(|runtime| { + ( + &runtime.executables, + format!("{field}.runtimeDependencies.{}.executables", runtime.id), + ) + }), + ) + { + lithe_plugin_model::executables::ordered_indices(list, &location)?; + for candidate in list { + if let ExecutableSource::Setting { setting_id } = &candidate.source { + if !context.declared_settings.is_executable(setting_id) { + return Err(invalid(&location, "A setting candidate needs a global file setting with effect reload or nextRun.")); + } + } + } + } + if let Some(probe) = &invocation.version { + probe.validate(&format!("{field}.version"))?; + } + if let Some(minimum) = &invocation.minimum_version { + if invocation.version.is_none() { + return Err(invalid( + format!("{field}.minimumVersion"), + "A minimumVersion needs a version probe.", + )); + } + validate_minimum_version(minimum, &format!("{field}.minimumVersion"))?; + } + check_argument_count( + &invocation.global_arguments, + &format!("{field}.globalArguments"), + )?; + template::validate_arguments( + &invocation.global_arguments, + &Allowed { + root: true, + options: Some(options), + ..Allowed::none() + }, + &format!("{field}.globalArguments"), + ) + } +} + +fn executable_path(field: &str) -> PluginRejection { + PluginRejection::at( + "plugin.path.invalid", + format!("{field}.path"), + "Executable paths are relative, use /, and stay inside their base directory.", + ) +} + +fn check_candidate( + candidate: &mut ExecutableCandidate, + field: &str, + environment_inputs: &[String], + runtimes: &BTreeSet, +) -> Validation<()> { + match &mut candidate.source { + ExecutableSource::ProjectFile { path, requires, .. } => { + *requires = sorted_unique(std::mem::take(requires)); + if !is_safe_relative_path(path) + || !requires.iter().all(|value| is_safe_relative_path(value)) + { + return Err(executable_path(field)); + } + } + ExecutableSource::Environment { variable, path, .. } => { + if !environment_inputs.contains(variable) { + return Err(invalid( + format!("{field}.variable"), + "The variable must be listed in environmentInputs.", + )); + } + if !is_safe_relative_path(path) { + return Err(executable_path(field)); + } + } + ExecutableSource::Command { command, .. } => { + if !is_bare_command(command) { + return Err(invalid( + format!("{field}.command"), + "A command is a bare name looked up on PATH.", + )); + } + } + ExecutableSource::Setting { setting_id } => { + if !is_setting_identifier(setting_id) { + return Err(invalid(format!("{field}.settingID"), "Invalid setting ID.")); + } + } + ExecutableSource::Toolchain { toolchain_id } => { + if !lithe_plugin_model::is_tool_id(toolchain_id) { + return Err(invalid( + format!("{field}.toolchainID"), + "Invalid toolchain ID.", + )); + } + } + ExecutableSource::Runtime { runtime, .. } => { + if !runtimes.contains(runtime) { + return Err(invalid( + format!("{field}.runtime"), + "The runtime must be declared in runtimeDependencies.", + )); + } + } + } + let platforms = &mut candidate.platforms; + platforms.sort(); + platforms.dedup(); + Ok(()) +} + +impl VersionProbe { + fn validate(&self, field: &str) -> Validation<()> { + if self.arguments.len() > 8 + || self + .arguments + .iter() + .any(|argument| argument.contains('\0') || argument.contains("${")) + { + return Err(invalid( + format!("{field}.arguments"), + "Version arguments are at most 8 literal strings.", + )); + } + check_regex(&self.pattern, &format!("{field}.pattern"), 1).map(|_| ()) + } + + /// The dotted version the probe output reports, if any. + pub(crate) fn version(&self, output: &str) -> Option> { + let pattern = Regex::new(&self.pattern).ok()?; + let text = pattern.captures(output)?.get(1)?.as_str().to_string(); + let components: Vec = text + .split('.') + .take(3) + .map(|part| part.parse().ok()) + .collect::>()?; + (!components.is_empty()).then_some(components) + } +} diff --git a/rust/crates/build/src/manifest/model.rs b/rust/crates/build/src/manifest/model.rs new file mode 100644 index 00000000..e2b71e37 --- /dev/null +++ b/rust/crates/build/src/manifest/model.rs @@ -0,0 +1,399 @@ +//! `model`: the project model source and the rules that map it. + +use super::*; + +/// Where the project model comes from. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde( + tag = "source", + rename_all = "camelCase", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +pub(crate) enum ModelSource { + /// A file in the instance root, read during detection without starting a + /// process. + Static { + file: String, + decoder: DocumentDecoder, + map: ModelMap, + }, + /// A command the host runs on request; its output is decoded by Core. + Command { + arguments: Vec, + #[serde(default)] + output: OutputChannel, + decoder: ModelDecoder, + #[serde(default, skip_serializing_if = "Option::is_none")] + map: Option, + executes_project_code: bool, + may_use_network: bool, + }, + /// The provider answers `lithe/build/model` with a `litheBuildModel`. + Provider, +} + +/// Structured formats a static model file may use. A tool whose manifest +/// uses another format gets a plugin command that prints `litheBuildModel`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum DocumentDecoder { + Toml, +} + +/// Formats a model command may print. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum ModelDecoder { + /// Whitespace-separated JSON values: NDJSON, concatenated objects such + /// as `go list -json` prints, or a single document. + JsonStream, + /// The canonical model format; needs no map. + LitheBuildModel, +} + +/// Formats a dependency command may print. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum DependencyDecoder { + /// Whitespace-separated JSON values: NDJSON, concatenated objects such + /// as `go list -json` prints, or a single document. + JsonStream, + /// The canonical dependency format; needs no map. + LitheDependencies, +} + +/// Where a command leaves its result. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum OutputChannel { + #[default] + Stdout, + /// A host-allocated file passed as `${outputFile}`. + OutputFile, +} + +/// Mapping of a decoded document onto the model: projects with their +/// targets and content roots, and option values. The map only selects +/// values; anything that needs more, such as tasks or parent links read from +/// the tool, comes from a plugin command that prints `litheBuildModel`. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ModelMap { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) projects: Vec, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub(crate) option_values: BTreeMap, +} + +/// Fields every rule shares: which elements it reads and when it applies. +/// Written out in each rule because `deny_unknown_fields` and `flatten` +/// cannot be combined. +pub(crate) trait RuleMatch { + fn each(&self) -> Option<&str>; + fn filter(&self) -> &[Predicate]; + fn fallback(&self) -> bool; + fn when_file(&self) -> Option<&str>; +} + +macro_rules! rule_match { + ($rule:ty) => { + impl RuleMatch for $rule { + fn each(&self) -> Option<&str> { + self.each.as_deref() + } + fn filter(&self) -> &[Predicate] { + &self.filter + } + fn fallback(&self) -> bool { + self.fallback + } + fn when_file(&self) -> Option<&str> { + self.when_file.as_deref() + } + } + }; +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ProjectRule { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) each: Option, + #[serde(default, rename = "where", skip_serializing_if = "Vec::is_empty")] + pub(crate) filter: Vec, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub(crate) fallback: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) when_file: Option, + pub(crate) name: FieldSpec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) display_name: Option, + /// Project directory; the instance root when absent. The parent is the + /// deepest project containing it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) path: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub(crate) properties: BTreeMap, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) targets: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) content_roots: Vec, +} +rule_match!(ProjectRule); + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct TargetRule { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) each: Option, + #[serde(default, rename = "where", skip_serializing_if = "Vec::is_empty")] + pub(crate) filter: Vec, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub(crate) fallback: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) when_file: Option, + pub(crate) name: FieldSpec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) path: Option, + pub(crate) tags: Vec, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub(crate) properties: BTreeMap, +} +rule_match!(TargetRule); + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ContentRootRule { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) each: Option, + #[serde(default, rename = "where", skip_serializing_if = "Vec::is_empty")] + pub(crate) filter: Vec, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub(crate) fallback: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) when_file: Option, + /// Directory relative to the project, or absolute inside the workspace. + pub(crate) path: FieldSpec, + pub(crate) kind: ContentRootKind, +} +rule_match!(ContentRootRule); + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct OptionValueRule { + pub(crate) each: String, + #[serde(default, rename = "where", skip_serializing_if = "Vec::is_empty")] + pub(crate) filter: Vec, + pub(crate) value: FieldSpec, +} + +/// Role of a source directory in a project. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum ContentRootKind { + Source, + Resource, + TestSource, + TestResource, + GeneratedSource, + GeneratedTestSource, + Excluded, +} + +impl BuildSystemDeclaration { + pub(crate) fn validate_model(&self, field: &str, options: &BTreeSet) -> Validation<()> { + let field = format!("{field}.model"); + let model_options: BTreeSet = self + .options + .iter() + .filter(|option| option.choices_from_model) + .map(|option| option.id.clone()) + .collect(); + match &self.model { + None => { + if !model_options.is_empty() { + return Err(invalid(field, "choicesFromModel needs a model.")); + } + Ok(()) + } + Some(ModelSource::Static { file, map, .. }) => { + if !is_safe_relative_path(file) { + return Err(PluginRejection::at( + "plugin.path.invalid", + format!("{field}.file"), + "The model file must be relative to the build-system root.", + )); + } + map.validate(&format!("{field}.map"), &model_options) + } + Some(ModelSource::Command { + arguments, + output, + decoder, + map, + .. + }) => { + check_argument_count(arguments, &format!("{field}.arguments"))?; + template::validate_arguments( + arguments, + &Allowed { + root: true, + host_directories: true, + options: Some(options), + ..Allowed::none() + }, + &format!("{field}.arguments"), + )?; + check_output_channel(*output, arguments, &field)?; + match (decoder, map) { + (ModelDecoder::LitheBuildModel, None) => Ok(()), + (ModelDecoder::LitheBuildModel, Some(_)) => Err(invalid( + format!("{field}.map"), + "The litheBuildModel format needs no map.", + )), + (_, None) => Err(invalid( + format!("{field}.map"), + "A model command with a generic format needs a map.", + )), + (_, Some(map)) => map.validate(&format!("{field}.map"), &model_options), + } + } + Some(ModelSource::Provider) => Ok(()), + } + } +} + +pub(crate) fn check_output_channel( + output: OutputChannel, + arguments: &[BuildArgument], + field: &str, +) -> Validation<()> { + let uses_file = template::uses(arguments, &Variable::OutputFile); + match output { + OutputChannel::OutputFile if !uses_file => Err(invalid( + format!("{field}.output"), + "An outputFile command must pass ${outputFile}.", + )), + OutputChannel::Stdout if uses_file => Err(invalid( + format!("{field}.output"), + "${outputFile} needs \"output\": \"outputFile\".", + )), + _ => Ok(()), + } +} + +impl ModelMap { + fn validate(&self, field: &str, model_options: &BTreeSet) -> Validation<()> { + if self.projects.is_empty() { + return Err(invalid( + format!("{field}.projects"), + "A model map needs at least one project rule.", + )); + } + check_rule_count(self.projects.len(), &format!("{field}.projects"))?; + for (index, rule) in self.projects.iter().enumerate() { + let field = format!("{field}.projects[{index}]"); + check_rule(rule, &field)?; + rule.name.validate(&format!("{field}.name"))?; + for spec in [&rule.display_name, &rule.path].into_iter().flatten() { + spec.validate(&field)?; + } + check_properties(&rule.properties, &field)?; + check_rule_count(rule.targets.len(), &format!("{field}.targets"))?; + for (target_index, target) in rule.targets.iter().enumerate() { + target.validate(&format!("{field}.targets[{target_index}]"))?; + } + check_rule_count(rule.content_roots.len(), &format!("{field}.contentRoots"))?; + for (root_index, content_root) in rule.content_roots.iter().enumerate() { + let field = format!("{field}.contentRoots[{root_index}]"); + check_rule(content_root, &field)?; + content_root.path.validate(&format!("{field}.path"))?; + } + } + for (id, rule) in &self.option_values { + let field = format!("{field}.optionValues.{id}"); + if !model_options.contains(id) { + return Err(invalid( + field, + "optionValues may only fill options declared with choicesFromModel.", + )); + } + select::validate(&rule.each, &format!("{field}.each"), false)?; + check_predicates(&rule.filter, &field)?; + rule.value.validate(&format!("{field}.value"))?; + } + Ok(()) + } +} + +fn check_rule_count(count: usize, field: &str) -> Validation<()> { + if count > MAX_LIST { + return Err(invalid(field, "Too many rules.")); + } + Ok(()) +} + +fn check_rule(rule: &impl RuleMatch, field: &str) -> Validation<()> { + if let Some(each) = rule.each() { + select::validate(each, &format!("{field}.each"), false)?; + } + check_predicates(rule.filter(), field)?; + if let Some(file) = rule.when_file() { + if !is_safe_relative_path(file) { + return Err(PluginRejection::at( + "plugin.path.invalid", + format!("{field}.whenFile"), + "whenFile is relative to the project directory.", + )); + } + } + Ok(()) +} + +pub(crate) fn check_predicates(predicates: &[Predicate], field: &str) -> Validation<()> { + if predicates.len() > 8 { + return Err(invalid(format!("{field}.where"), "Too many predicates.")); + } + for (index, predicate) in predicates.iter().enumerate() { + predicate.validate(&format!("{field}.where[{index}]"))?; + } + Ok(()) +} + +pub(crate) fn check_properties( + properties: &BTreeMap, + field: &str, +) -> Validation<()> { + if properties.len() > MAX_LIST { + return Err(invalid( + format!("{field}.properties"), + "Too many properties.", + )); + } + for (key, spec) in properties { + if !template::is_property_key(key) { + return Err(invalid( + format!("{field}.properties"), + "Property keys use letters, digits, ., _, and -.", + )); + } + spec.validate(&format!("{field}.properties.{key}"))?; + } + Ok(()) +} + +impl TargetRule { + fn validate(&self, field: &str) -> Validation<()> { + check_rule(self, field)?; + self.name.validate(&format!("{field}.name"))?; + if let Some(path) = &self.path { + path.validate(&format!("{field}.path"))?; + } + check_properties(&self.properties, field)?; + if self.tags.iter().any(|tag| !is_tag(tag)) { + return Err(invalid(format!("{field}.tags"), "Tags are identifiers.")); + } + Ok(()) + } +} diff --git a/rust/crates/build/src/manifest/options.rs b/rust/crates/build/src/manifest/options.rs new file mode 100644 index 00000000..cd253961 --- /dev/null +++ b/rust/crates/build/src/manifest/options.rs @@ -0,0 +1,349 @@ +//! `options`: declared build options and how their values are emitted. + +use super::*; + +/// A build dimension the user chooses, stored in its declared scope by the host. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct OptionDeclaration { + pub(crate) id: String, + pub(crate) display_name: String, + pub(crate) kind: OptionKind, + /// Instance values belong to one detected root, never to its siblings. + #[serde(default, skip_serializing_if = "OptionScope::is_workspace")] + pub(crate) scope: OptionScope, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) default: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) choices: Vec, + /// Choices also come from the model's `optionValues` for this option. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub(crate) choices_from_model: bool, + /// Values outside the known choices are accepted. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub(crate) allow_custom: bool, + /// Joins multi-select values in `${value}`; `,` by default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) separator: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) arguments: Vec, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub(crate) environment: BTreeMap, + /// Where the host offers the option; display only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) presentation: Option, + /// Icon of a `toolbar` toggle; display only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) icon: Option, +} + +/// Persistence boundary of an option. Existing declarations remain workspace-wide. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum OptionScope { + #[default] + Workspace, + Instance, +} + +impl OptionScope { + fn is_workspace(&self) -> bool { + *self == Self::Workspace + } +} + +/// Where a host offers an option besides its option list. Neither placement +/// changes how the option is resolved or emitted. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum OptionPresentation { + /// A toggle button in the tool window's toolbar; boolean options with an + /// icon only (offline mode, skipping tests). + Toolbar, + /// A checklist node at the top of the tool window's tree; multiSelect + /// options only (build profiles). + Tree, +} + +impl OptionDeclaration { + pub(crate) fn separator(&self) -> &str { + self.separator.as_deref().unwrap_or(DEFAULT_SEPARATOR) + } + + pub(crate) fn is_select(&self) -> bool { + matches!( + self.kind, + OptionKind::SingleSelect | OptionKind::MultiSelect + ) + } + + /// Whether a single value is acceptable, given the model's values for a + /// `choicesFromModel` option (`None` while no model is loaded, in which + /// case model choices cannot be checked and are accepted). + pub(crate) fn accepts_value(&self, value: &str, model_values: Option<&[String]>) -> bool { + if !is_option_value(value) { + return false; + } + if self.kind == OptionKind::MultiSelect && value.contains(self.separator()) { + return false; + } + if !self.is_select() || self.allow_custom { + return true; + } + if self.choices.iter().any(|choice| choice.value == value) { + return true; + } + self.choices_from_model + && model_values.is_none_or(|values| values.iter().any(|known| known == value)) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum OptionKind { + Boolean, + SingleSelect, + MultiSelect, + Text, + File, + Directory, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct OptionChoice { + pub(crate) value: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) title: Option, +} + +impl BuildSystemDeclaration { + pub(crate) fn option(&self, id: &str) -> Option<&OptionDeclaration> { + self.options.iter().find(|option| option.id == id) + } + + pub(crate) fn task(&self, id: &str) -> Option<&TaskDeclaration> { + self.tasks.iter().find(|task| task.id == id) + } +} + +// MARK: Validation + +impl BuildSystemDeclaration { + pub(crate) fn validate_options(&mut self, field: &str) -> Validation> { + if self.options.len() > MAX_LIST { + return Err(invalid(format!("{field}.options"), "Too many options.")); + } + let mut ids = BTreeSet::new(); + for (index, option) in self.options.iter_mut().enumerate() { + let field = format!("{field}.options[{index}]"); + option.validate(&field)?; + if !ids.insert(option.id.clone()) { + return Err(invalid( + format!("{field}.id"), + "An option is declared more than once.", + )); + } + } + Ok(ids) + } +} + +impl BuildSystemDeclaration { + /// An option's arguments must be emitted at most once per command line: + /// either all options in one place, or each option in exactly one of the + /// global and the command-specific lists, and once within each list. + pub(crate) fn validate_option_emission(&self, field: &str) -> Validation<()> { + let twice = |list_field: String| { + template_error( + list_field, + "An option's arguments would be emitted twice; reference each option in one place.", + ) + }; + let globals = &self.invocation.global_arguments; + if template::repeats_option_arguments(globals) { + return Err(twice(format!("{field}.invocation.globalArguments"))); + } + let (global_all, global_named) = template::option_references(globals); + let mut lists: Vec<(String, &[BuildArgument])> = Vec::new(); + for (index, task) in self.tasks.iter().enumerate() { + lists.push((format!("{field}.tasks[{index}].arguments"), &task.arguments)); + } + for (index, run) in self.run.iter().enumerate() { + lists.push((format!("{field}.run[{index}].arguments"), &run.arguments)); + } + if let Some(test) = &self.test { + lists.push((format!("{field}.test.arguments"), &test.arguments)); + } + if let Some(ModelSource::Command { arguments, .. }) = &self.model { + lists.push((format!("{field}.model.arguments"), arguments)); + } + if let Some(DependenciesSource::Command { arguments, .. }) = &self.dependencies { + lists.push((format!("{field}.dependencies.arguments"), arguments)); + } + for (list_field, arguments) in lists { + let (all, named) = template::option_references(arguments); + if (global_all && (all || !named.is_empty())) + || (all && !global_named.is_empty()) + || global_named.intersection(&named).next().is_some() + || template::repeats_option_arguments(arguments) + { + return Err(twice(list_field)); + } + } + Ok(()) + } +} + +impl OptionDeclaration { + fn validate(&mut self, field: &str) -> Validation<()> { + if !is_setting_identifier(&self.id) { + return Err(invalid( + format!("{field}.id"), + "Option IDs start with a lowercase letter and use letters and digits.", + )); + } + check_text( + &self.display_name, + &format!("{field}.displayName"), + "display name", + )?; + let select = self.is_select(); + if !select && (!self.choices.is_empty() || self.choices_from_model || self.allow_custom) { + return Err(invalid( + field, + "Choices apply to singleSelect and multiSelect options only.", + )); + } + if select && self.choices.is_empty() && !self.choices_from_model && !self.allow_custom { + return Err(invalid( + format!("{field}.choices"), + "A select option needs choices, choicesFromModel, or allowCustom.", + )); + } + if let Some(separator) = &self.separator { + if self.kind != OptionKind::MultiSelect + || separator.is_empty() + || separator.len() > 4 + || separator.chars().any(char::is_control) + { + return Err(invalid( + format!("{field}.separator"), + "Only multiSelect options take a separator of 1-4 printable characters.", + )); + } + } + if self.choices.len() > MAX_CHOICES { + return Err(invalid(format!("{field}.choices"), "Too many choices.")); + } + let mut values = BTreeSet::new(); + for (index, choice) in self.choices.iter().enumerate() { + let bad_value = !is_option_value(&choice.value) + || (self.kind == OptionKind::MultiSelect + && choice.value.contains(self.separator())); + if bad_value || !values.insert(choice.value.clone()) { + return Err(invalid( + format!("{field}.choices[{index}].value"), + "Choice values are unique, printable, and free of the separator.", + )); + } + if let Some(title) = &choice.title { + check_text(title, &format!("{field}.choices[{index}].title"), "title")?; + } + } + self.validate_presentation(field)?; + self.validate_default(field)?; + if self.arguments.is_empty() && self.environment.is_empty() { + return Err(invalid( + field, + "An option must map to arguments, environment variables, or both.", + )); + } + if self.arguments.len() > 8 || self.environment.len() > 8 { + return Err(invalid( + field, + "An option maps to at most 8 arguments and 8 variables.", + )); + } + let boolean = self.kind == OptionKind::Boolean; + let allowed = Allowed { + value: !boolean, + ..Allowed::none() + }; + let mut uses_value = false; + for (index, argument) in self.arguments.iter().enumerate() { + template::validate_text(argument, &allowed, &format!("{field}.arguments[{index}]"))?; + uses_value |= argument.contains("${value}"); + } + for (name, value) in &self.environment { + if !is_environment_name(name) { + return Err(invalid( + format!("{field}.environment"), + "Environment variable names use uppercase letters, digits, and _.", + )); + } + template::validate_text(value, &allowed, &format!("{field}.environment.{name}"))?; + uses_value |= value.contains("${value}"); + } + if !boolean && !uses_value { + return Err(template_error( + field, + "The option's value is never passed; use ${value}.", + )); + } + Ok(()) + } + + fn validate_presentation(&self, field: &str) -> Validation<()> { + if let Some(icon) = &self.icon { + icon.validate_at(&format!("{field}.icon"))?; + } + match self.presentation { + Some(OptionPresentation::Toolbar) + if self.kind != OptionKind::Boolean || self.icon.is_none() => + { + Err(invalid( + format!("{field}.presentation"), + "A toolbar option is a boolean option with an icon.", + )) + } + Some(OptionPresentation::Tree) if self.kind != OptionKind::MultiSelect => Err(invalid( + format!("{field}.presentation"), + "A tree option is a multiSelect option.", + )), + None if self.icon.is_some() => Err(invalid( + format!("{field}.icon"), + "Only toolbar options have an icon.", + )), + _ => Ok(()), + } + } + + fn validate_default(&self, field: &str) -> Validation<()> { + let Some(default) = &self.default else { + return Ok(()); + }; + let field = format!("{field}.default"); + let valid = match (self.kind, default) { + (OptionKind::Boolean, Value::Bool(_)) => true, + (OptionKind::MultiSelect, Value::Array(items)) => items.iter().all(|item| { + item.as_str() + .is_some_and(|value| self.accepts_value(value, Some(&[]))) + }), + (OptionKind::SingleSelect, Value::String(value)) => { + self.accepts_value(value, Some(&[])) + } + (OptionKind::Text, Value::String(value)) => value.is_empty() || is_option_value(value), + // Paths are the user's: a plugin cannot know one on this machine. + (OptionKind::File | OptionKind::Directory, Value::String(value)) => value.is_empty(), + _ => false, + }; + if !valid { + return Err(invalid( + field, + "The default does not fit the option's kind and choices; file and directory options have no default.", + )); + } + Ok(()) + } +} diff --git a/rust/crates/build/src/manifest/provider.rs b/rust/crates/build/src/manifest/provider.rs new file mode 100644 index 00000000..4b9e877e --- /dev/null +++ b/rust/crates/build/src/manifest/provider.rs @@ -0,0 +1,196 @@ +//! The `provider` server process: its declaration and validation. + +use super::*; + +/// A server process that answers `lithe/build/*` requests over the LSP base +/// protocol. It is a language-server process: either one of its own, with +/// the same launch fields as `languageServers[]`, or the plugin's language +/// server of one language, which then answers build requests besides its +/// language features. The form is decided by `languageServer`, so a mistake +/// is reported against that form's fields. +#[derive(Debug, Clone, Serialize)] +#[serde(untagged)] +#[allow(clippy::large_enum_variant)] // Decoded once per manifest validation. +pub enum ProviderDeclaration { + /// The plugin's language server of one language answers build requests. + LanguageServer(SharedProvider), + /// A provider process of its own. + Process(ProcessProvider), +} + +/// Build requests go to the language server of `languageServer`. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SharedProvider { + #[serde(rename = "languageServer")] + pub(crate) language_id: String, + pub(crate) executes_project_code: bool, + pub(crate) may_use_network: bool, +} + +/// A provider process of its own. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ProcessProvider { + #[serde(flatten)] + pub(crate) process: ServerProcess, + pub(crate) executes_project_code: bool, + pub(crate) may_use_network: bool, +} + +impl<'de> Deserialize<'de> for ProviderDeclaration { + fn deserialize>(deserializer: D) -> Result { + let value = Value::deserialize(deserializer)?; + if value.get("languageServer").is_some() { + SharedProvider::deserialize(value).map(Self::LanguageServer) + } else { + ProcessProvider::deserialize(value).map(Self::Process) + } + .map_err(serde::de::Error::custom) + } +} + +/// Which process a provider's requests go to. +pub enum ProviderProcess<'a> { + /// The language server of this language ID. + LanguageServer(&'a str), + /// A process the build system declares for itself. + Own(&'a ServerProcess), +} + +impl ProviderDeclaration { + /// The process the provider's requests go to. + pub fn process(&self) -> ProviderProcess<'_> { + match self { + Self::LanguageServer(shared) => ProviderProcess::LanguageServer(&shared.language_id), + Self::Process(own) => ProviderProcess::Own(&own.process), + } + } + + /// Shown to the user; whether a provider may run is decided by trust. + pub fn executes_project_code(&self) -> bool { + match self { + Self::LanguageServer(shared) => shared.executes_project_code, + Self::Process(own) => own.executes_project_code, + } + } + + /// Whether the provider may use the network; shown to the user. + pub fn may_use_network(&self) -> bool { + match self { + Self::LanguageServer(shared) => shared.may_use_network, + Self::Process(own) => own.may_use_network, + } + } +} + +impl BuildSystemDeclaration { + /// Whether the build system starts a provider process of its own. + pub fn starts_own_provider(&self) -> bool { + matches!(self.provider, Some(ProviderDeclaration::Process(_))) + } +} + +impl BuildSystemDeclaration { + pub(crate) fn validate_integration(&self, field: &str) -> Validation<()> { + let Some(integration) = &self.host_integration else { + return Ok(()); + }; + let message = match integration.as_str() { + Some(name) => format!("The host has no build integration named {name}."), + None => "The host has no build integration.".to_owned(), + }; + Err(PluginRejection::at( + "plugin.integration.unsupported", + format!("{field}.hostIntegration"), + message, + )) + } + + pub(crate) fn validate_provider( + &self, + field: &str, + context: &DeclarationContext<'_>, + ) -> Validation<()> { + let provider_runs = self + .run + .iter() + .any(|run| run.source == TaskSource::Provider); + let provider_tests = self + .test + .as_ref() + .is_some_and(|test| test.source == TaskSource::Provider); + let uses = self.configuration.is_some() + || matches!(self.model, Some(ModelSource::Provider)) + || provider_runs + || matches!(self.dependencies, Some(DependenciesSource::Provider { .. })) + || provider_tests + || self + .tasks + .iter() + .any(|task| task.source == TaskSource::Provider); + let field = format!("{field}.provider"); + let Some(provider) = &self.provider else { + if uses { + return Err(invalid(field, "A provider source requires provider.")); + } + return Ok(()); + }; + if self.configuration.is_some() && !matches!(provider, ProviderDeclaration::Process(_)) { + return Err(invalid( + &field, + "Native configuration requires an owned provider process.", + )); + } + if !uses { + return Err(invalid( + field, + "No model, dependencies, task or test source uses the provider.", + )); + } + for run in &self.run { + if let Some(debug) = &run.debug { + match (&debug.adapter, &debug.configuration_type) { + (Some(_), None) => { + crate::debug_preparation::adapter(run, context.debug_adapters)?; + } + (None, Some(kind)) + if crate::run_preparation::valid_configuration_type(kind) + && run.execution == Some(BuildExecution::Service) => {} + _ => return Err(invalid(&field, + "Debug requires a same-package native adapter, or one registry configurationType for a service.")), + } + } + } + if (provider_runs + || provider_tests + || self + .tasks + .iter() + .any(|task| task.source == TaskSource::Provider)) + && !matches!(provider, ProviderDeclaration::Process(_)) + { + return Err(invalid( + &field, + "Provider execution requires a plugin-owned provider process.", + )); + } + match provider { + ProviderDeclaration::LanguageServer(shared) => { + if !context.language_servers.contains(&shared.language_id) { + return Err(invalid( + format!("{field}.languageServer"), + "The provider names a language the plugin declares no language server for.", + )); + } + Ok(()) + } + ProviderDeclaration::Process(own) => own.process.validate( + &field, + context.declared_settings, + ServerRole::BuildProvider, + context.runtime_tools, + ), + } + } +} diff --git a/rust/crates/build/src/manifest/run.rs b/rust/crates/build/src/manifest/run.rs new file mode 100644 index 00000000..2cb9495d --- /dev/null +++ b/rust/crates/build/src/manifest/run.rs @@ -0,0 +1,233 @@ +//! `run`: run declarations derived from targets or tasks. + +use super::*; + +/// Run entries derived from model targets or tasks. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct RunDeclaration { + /// A same-package adapter for native executable debug preparation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) debug: Option, + pub(crate) id: String, + /// Provider runs prepare a launch plan; they never become empty argv entries. + #[serde(default, skip_serializing_if = "TaskSource::is_command")] + pub(crate) source: TaskSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) from_targets: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) from_tasks: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) execution: Option, + /// Entry name; the target or task name when absent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) name: Option, + /// Argument templates for target entries; task entries reuse the task's. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) arguments: Vec, + #[serde(default)] + pub(crate) working_directory: WorkingDirectory, + /// Metadata stored under `extensions.` of the entry. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub(crate) extension: BTreeMap, +} + +/// Native launch binds a local adapter; connection attach names a public registry type. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct RunDebug { + /// Local adapter ID for v5 native executable launch. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) adapter: Option, + /// Opaque public registry alias for v11 service connection attach. + #[serde( + default, + rename = "configurationType", + skip_serializing_if = "Option::is_none" + )] + pub(crate) configuration_type: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct FromTargets { + pub(crate) tags: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct FromTasks { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) templates: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) executions: Vec, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum WorkingDirectory { + #[default] + Project, + Root, +} + +impl BuildSystemDeclaration { + pub(crate) fn validate_run( + &mut self, + field: &str, + options: &BTreeSet, + templates: &BTreeSet, + ) -> Validation<()> { + if self.run.is_empty() { + return Ok(()); + } + if self.model.is_none() { + return Err(invalid( + format!("{field}.run"), + "Run entries come from the model; declare a model.", + )); + } + if self.run.len() > MAX_LIST { + return Err(invalid( + format!("{field}.run"), + "Too many run declarations.", + )); + } + let mut ids = BTreeSet::new(); + for (index, run) in self.run.iter_mut().enumerate() { + let field = format!("{field}.run[{index}]"); + if !is_entry_identifier(&run.id) || !ids.insert(run.id.clone()) { + return Err(invalid( + format!("{field}.id"), + "Run IDs are unique identifiers that start with a letter.", + )); + } + if run.debug.is_some() && run.source != TaskSource::Provider { + return Err(invalid( + &field, + "Debug preparation requires a provider run.", + )); + } + if run.source == TaskSource::Provider { + let valid_targets = run.from_targets.as_ref().is_some_and(|from| { + !from.tags.is_empty() + && from.tags.len() <= MAX_LIST + && from.tags.iter().all(|tag| is_tag(tag)) + }); + if !valid_targets + || run.from_tasks.is_some() + || !run.arguments.is_empty() + || !run.extension.is_empty() + || run.name.as_ref().is_some_and(|name| { + name.trim().is_empty() || name.len() > MAX_TEXT || name.contains("${") + }) + { + return Err(invalid( + &field, + "Provider runs require target tags and no host command templates.", + )); + } + let from = run.from_targets.as_mut().expect("validated target source"); + from.tags = sorted_unique(std::mem::take(&mut from.tags)); + continue; + } + let from_tasks = match (&mut run.from_targets, &mut run.from_tasks) { + (Some(targets), None) => { + if targets.tags.is_empty() || targets.tags.iter().any(|tag| !is_tag(tag)) { + return Err(invalid( + format!("{field}.fromTargets.tags"), + "fromTargets needs one or more tag identifiers.", + )); + } + targets.tags = sorted_unique(std::mem::take(&mut targets.tags)); + if run.arguments.is_empty() { + return Err(invalid( + format!("{field}.arguments"), + "Target run entries need arguments.", + )); + } + false + } + (None, Some(tasks)) => { + if tasks.templates.is_empty() && tasks.executions.is_empty() { + return Err(invalid( + format!("{field}.fromTasks"), + "fromTasks needs templates, executions, or both.", + )); + } + if let Some(unknown) = + tasks.templates.iter().find(|id| !templates.contains(*id)) + { + return Err(invalid( + format!("{field}.fromTasks.templates"), + format!("{unknown} is not a fromModel task."), + )); + } + if self.tasks.iter().any(|task| { + task.source == TaskSource::Provider + && task.from_model + && (tasks.templates.is_empty() || tasks.templates.contains(&task.id)) + }) { + return Err(invalid(format!("{field}.fromTasks"), + "Provider tasks need a typed run capability and cannot become command run entries.")); + } + tasks.templates = sorted_unique(std::mem::take(&mut tasks.templates)); + tasks.executions.sort(); + tasks.executions.dedup(); + if !run.arguments.is_empty() { + return Err(invalid( + format!("{field}.arguments"), + "Task run entries reuse the task's arguments.", + )); + } + true + } + _ => { + return Err(invalid( + field, + "A run declaration needs exactly one of fromTargets and fromTasks.", + )) + } + }; + let allowed = Allowed { + root: true, + project: Availability::Always, + target: if from_tasks { + Availability::Never + } else { + Availability::Always + }, + task: from_tasks, + user_arguments: true, + options: Some(options), + ..Allowed::none() + }; + check_argument_count(&run.arguments, &format!("{field}.arguments"))?; + template::validate_arguments(&run.arguments, &allowed, &format!("{field}.arguments"))?; + let names = Allowed { + user_arguments: false, + options: None, + ..allowed.clone() + }; + if let Some(name) = &run.name { + template::validate_text(name, &names, &format!("{field}.name"))?; + } + if run.extension.len() > MAX_LIST { + return Err(invalid( + format!("{field}.extension"), + "Too many extension values.", + )); + } + for (key, value) in &run.extension { + if !template::is_property_key(key) { + return Err(invalid( + format!("{field}.extension"), + "Extension keys use letters, digits, ., _, and -.", + )); + } + template::validate_text(value, &names, &format!("{field}.extension.{key}"))?; + } + } + Ok(()) + } +} diff --git a/rust/crates/build/src/manifest/tasks.rs b/rust/crates/build/src/manifest/tasks.rs new file mode 100644 index 00000000..398d9afe --- /dev/null +++ b/rust/crates/build/src/manifest/tasks.rs @@ -0,0 +1,229 @@ +//! `execution` and `tasks`: task groups, scopes, preparation and sources. + +use super::*; + +/// How a task's process behaves; the same values as run configurations. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum BuildExecution { + Application, + Service, + Task, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum TaskGroup { + Lifecycle, + Build, + Test, + Run, + Verification, + Documentation, + Other, +} + +/// What a task, test, or dependency command acts on. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum Scope { + Workspace, + Project, + Target, +} + +/// A task in the build system's catalog. `fromModel` tasks are templates +/// for the tasks the model lists. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct TaskDeclaration { + pub(crate) id: String, + /// Optional automatic preparation selected by purpose and model source role. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) preparation: Option, + pub(crate) display_name: String, + pub(crate) group: TaskGroup, + #[serde(default = "task_execution")] + pub(crate) execution: BuildExecution, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) scopes: Vec, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub(crate) from_model: bool, + /// For target-scoped tasks: tags of the targets it applies to. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) target_tags: Vec, + /// The host must ask for arguments before running it. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub(crate) requires_user_arguments: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) description: Option, + /// Headings the host files the task under in its project, outermost + /// first (`["Lifecycle"]`); also the default of the model tasks this + /// template runs. Display only; empty files the task by `group`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) category: Vec, + #[serde(default, skip_serializing_if = "TaskSource::is_command")] + pub(crate) source: TaskSource, + pub(crate) arguments: Vec, +} + +/// Tool-independent association between a project task and a language workflow. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct TaskPreparation { + /// Opaque workflow purpose, such as `run`; never a task ID or tool name. + pub(crate) purpose: String, + /// Canonical content-root roles for which this task prepares its project. + pub(crate) source_roles: Vec, +} + +/// Whether Core expands a command or a workspace provider executes the task. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum TaskSource { + #[default] + #[serde(skip_deserializing)] + Command, + Provider, +} + +impl TaskSource { + pub(crate) fn is_command(&self) -> bool { + *self == Self::Command + } +} + +fn task_execution() -> BuildExecution { + BuildExecution::Task +} + +impl BuildSystemDeclaration { + /// Validates the catalog and returns the IDs of `fromModel` templates. + pub(crate) fn validate_tasks( + &mut self, + field: &str, + options: &BTreeSet, + ) -> Validation> { + if self.tasks.len() > MAX_TASKS { + return Err(invalid(format!("{field}.tasks"), "Too many tasks.")); + } + let mut ids = BTreeSet::new(); + let mut templates = BTreeSet::new(); + let mut preparations = BTreeSet::new(); + for (index, task) in self.tasks.iter_mut().enumerate() { + let field = format!("{field}.tasks[{index}]"); + if let Some(preparation) = &mut task.preparation { + if !is_entry_identifier(&preparation.purpose) + || preparation.source_roles.is_empty() + || preparation.source_roles.len() > 6 + || task.from_model + || task.execution != BuildExecution::Task + || task.requires_user_arguments + || !task.scopes.contains(&Scope::Project) + || preparation.source_roles.iter().any(|role| { + !matches!( + role.as_str(), + "source" + | "generatedSource" + | "resource" + | "testSource" + | "generatedTestSource" + | "testResource" + ) || !preparations.insert((preparation.purpose.clone(), role.clone())) + }) + { + return Err(invalid(format!("{field}.preparation"), + "Preparation requires a project task without prompts and unique purpose/source-role bindings.")); + } + preparation.source_roles.sort(); + } + if !is_entry_identifier(&task.id) || !ids.insert(task.id.clone()) { + return Err(invalid( + format!("{field}.id"), + "Task IDs are unique identifiers that start with a letter.", + )); + } + check_text( + &task.display_name, + &format!("{field}.displayName"), + "display name", + )?; + if let Some(description) = &task.description { + if description.len() > 1024 { + return Err(invalid( + format!("{field}.description"), + "The description is too long.", + )); + } + } + if !task.category.is_empty() && category_labels(&task.category).is_none() { + return Err(invalid( + format!("{field}.category"), + format!( + "A category is 1-{MAX_CATEGORY_DEPTH} headings of 1-{MAX_TEXT} printable characters." + ), + )); + } + let allowed = if task.from_model { + if !task.scopes.is_empty() || !task.target_tags.is_empty() { + return Err(invalid( + field, + "A fromModel task acts on its model task; it has no scopes or targetTags.", + )); + } + templates.insert(task.id.clone()); + Allowed { + root: true, + project: Availability::Always, + task: true, + user_arguments: true, + conditions: true, + options: Some(options), + ..Allowed::none() + } + } else { + check_scopes( + &mut task.scopes, + &format!("{field}.scopes"), + &[Scope::Workspace, Scope::Project, Scope::Target], + )?; + check_target_tags(&mut task.target_tags, &task.scopes, &field)?; + Allowed { + root: true, + project: availability(&task.scopes, Scope::Project), + target: availability(&task.scopes, Scope::Target), + user_arguments: true, + conditions: true, + options: Some(options), + ..Allowed::none() + } + }; + if task.source == TaskSource::Provider { + if !task.arguments.is_empty() || task.execution != BuildExecution::Task { + return Err(invalid( + &field, + "A provider task has empty arguments and task execution.", + )); + } + continue; + } + check_argument_count(&task.arguments, &format!("{field}.arguments"))?; + template::validate_arguments(&task.arguments, &allowed, &format!("{field}.arguments"))?; + if task.requires_user_arguments + && !template::uses(&task.arguments, &Variable::UserArguments) + { + return Err(invalid( + format!("{field}.requiresUserArguments"), + "A task that asks for arguments must pass ${userArguments}.", + )); + } + } + if !templates.is_empty() && self.model.is_none() { + return Err(invalid( + format!("{field}.model"), + "fromModel tasks need a model.", + )); + } + Ok(templates) + } +} diff --git a/rust/crates/build/src/manifest/testing.rs b/rust/crates/build/src/manifest/testing.rs new file mode 100644 index 00000000..e7157c87 --- /dev/null +++ b/rust/crates/build/src/manifest/testing.rs @@ -0,0 +1,149 @@ +//! `test`: test declarations, filters and result sources. + +use super::*; + +fn is_test_selector_kind(value: &str) -> bool { + !value.is_empty() + && value.len() <= 120 + && value + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_alphanumeric()) + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte)) +} + +/// How tests run and how their results are read. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct TestDeclaration { + pub(crate) scopes: Vec, + /// Tags a selected target must carry, as for tasks. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) target_tags: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) filters: Vec, + /// Public opaque selector namespaces interpreted only by the owning backend. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) selector_kinds: Vec, + /// Omitted for argv templates; provider sends a typed test request. + #[serde(default, skip_serializing_if = "TaskSource::is_command")] + pub(crate) source: TaskSource, + /// The provider can enumerate exact selectors for the native test tree. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub(crate) discovery: bool, + pub(crate) arguments: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) results: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum TestFilter { + Suite, + Case, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde( + tag = "decoder", + rename_all = "camelCase", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +pub(crate) enum TestResultsSource { + /// A canonical document returned by the same provider test operation. + Provider, + /// JUnit XML report files, globbed relative to the project directory. + JunitXml { reports: Vec }, + /// Canonical result files, globbed relative to the project directory. + LitheTestResults { reports: Vec }, +} + +impl BuildSystemDeclaration { + pub(crate) fn validate_test( + &mut self, + field: &str, + options: &BTreeSet, + ) -> Validation<()> { + let Some(test) = &mut self.test else { + return Ok(()); + }; + let field = format!("{field}.test"); + check_scopes( + &mut test.scopes, + &format!("{field}.scopes"), + &[Scope::Workspace, Scope::Project, Scope::Target], + )?; + check_target_tags(&mut test.target_tags, &test.scopes, &field)?; + let distinct_selector_kinds: BTreeSet<_> = test.selector_kinds.iter().collect(); + if !test.selector_kinds.is_empty() + && (test.source != TaskSource::Provider + || test.selector_kinds.len() > 16 + || distinct_selector_kinds.len() != test.selector_kinds.len() + || test + .selector_kinds + .iter() + .any(|kind| !is_test_selector_kind(kind))) + { + return Err(invalid( + &field, + "Test selectorKinds require a provider and bounded identifiers.", + )); + } + test.selector_kinds.sort(); + test.filters.sort(); + test.filters.dedup(); + if test.discovery + && (test.source != TaskSource::Provider + || !test.filters.contains(&TestFilter::Suite) + || !test.filters.contains(&TestFilter::Case)) + { + return Err(invalid( + &field, + "Test discovery requires a provider and suite/case filters.", + )); + } + if test.source == TaskSource::Provider { + if !test.arguments.is_empty() { + return Err(invalid(&field, "Provider tests require empty arguments.")); + } + } else { + if matches!(test.results, Some(TestResultsSource::Provider)) { + return Err(invalid( + &field, + "Provider-owned reports require provider test execution.", + )); + } + check_argument_count(&test.arguments, &format!("{field}.arguments"))?; + template::validate_arguments( + &test.arguments, + &Allowed { + root: true, + project: availability(&test.scopes, Scope::Project), + target: availability(&test.scopes, Scope::Target), + test: true, + user_arguments: true, + conditions: true, + options: Some(options), + ..Allowed::none() + }, + &format!("{field}.arguments"), + )?; + } + match &mut test.results { + Some(TestResultsSource::JunitXml { reports }) + | Some(TestResultsSource::LitheTestResults { reports }) => { + if reports.is_empty() { + return Err(invalid( + format!("{field}.results.reports"), + "Declare at least one report pattern.", + )); + } + check_paths(reports, &format!("{field}.results.reports"), true) + } + _ => Ok(()), + } + } +} diff --git a/rust/crates/core/src/build/model.rs b/rust/crates/build/src/model.rs similarity index 99% rename from rust/crates/core/src/build/model.rs rename to rust/crates/build/src/model.rs index dff0fd39..b489c5e2 100644 --- a/rust/crates/core/src/build/model.rs +++ b/rust/crates/build/src/model.rs @@ -12,7 +12,7 @@ use super::manifest::{ ModelMap, ProjectRule, RuleMatch, TargetRule, TaskGroup, }; use super::select::{self, FieldSpec, Node}; -use crate::plugins::declarative::{is_identifier, Validation}; +use lithe_plugin_model::{is_identifier, Validation}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::{BTreeMap, BTreeSet}; @@ -545,7 +545,7 @@ fn when_file_holds(builder: &ModelBuilder<'_>, rule: &impl RuleMatch, directory: builder .workspace .read_path(&decode::join_relative(directory, file)) - .is_some_and(|path| crate::remote::workspace_io::is_file(path).unwrap_or(false)) + .is_some_and(|path| lithe_workspace::fs::is_file(path).unwrap_or(false)) }) } diff --git a/rust/crates/core/src/build/model_inputs.rs b/rust/crates/build/src/model_inputs.rs similarity index 97% rename from rust/crates/core/src/build/model_inputs.rs rename to rust/crates/build/src/model_inputs.rs index a47d6cbf..f0b911bb 100644 --- a/rust/crates/core/src/build/model_inputs.rs +++ b/rust/crates/build/src/model_inputs.rs @@ -1,13 +1,14 @@ //! Optional read-only model inputs between already enabled workspace providers. //! Sharing grants no installation, process, resource or private-function authority. +use super::BuildManifest; use super::{ decode, manifest::{is_entry_identifier, BuildSystemDeclaration}, model::{ModelContentRoot, ModelProject, ModelTarget}, options, plans, }; -use crate::plugins::declarative::{is_plugin_id, validate_manifest_value, Validation}; +use lithe_plugin_model::{is_plugin_id, Validation}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::{BTreeMap, BTreeSet}; @@ -22,7 +23,7 @@ const MAX_BYTES: usize = 8 * 1024 * 1024; /// Producer opt-in; only these ordinary build options leave their owner. #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(deny_unknown_fields)] -pub(crate) struct ModelSharing { +pub struct ModelSharing { #[serde(default)] pub(crate) options: Vec, } @@ -30,7 +31,7 @@ pub(crate) struct ModelSharing { /// Exact optional producer identity; package versions and permissions are not inherited. #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct ModelInput { +pub struct ModelInput { #[serde(rename = "pluginID")] pub(crate) plugin_id: String, #[serde(rename = "buildSystemID")] @@ -121,12 +122,12 @@ pub(crate) struct ResolveRequest { pub(crate) sources: Vec, } -fn invalid() -> crate::plugins::declarative::PluginRejection { +fn invalid() -> lithe_plugin_model::PluginRejection { plans::invalid_selection("Invalid or oversized shared build model input.") } /// Selects exact public producers and strips tasks, run plans, settings and unshared options. -pub(crate) fn resolve( +pub(crate) fn resolve( consumer: &BuildSystemDeclaration, request: &ResolveRequest, ) -> Validation { @@ -138,7 +139,7 @@ pub(crate) fn resolve( } let mut sources = BTreeMap::new(); for source in &request.sources { - let manifest = validate_manifest_value(source.manifest.clone())?; + let manifest = M::validate(source.manifest.clone())?; if source.instances.len() > MAX_INSTANCES || sources .insert(manifest.id().to_string(), (manifest, source)) diff --git a/rust/crates/core/src/build/options.rs b/rust/crates/build/src/options.rs similarity index 99% rename from rust/crates/core/src/build/options.rs rename to rust/crates/build/src/options.rs index 16289c95..ceaeecfd 100644 --- a/rust/crates/core/src/build/options.rs +++ b/rust/crates/build/src/options.rs @@ -5,7 +5,7 @@ use super::manifest::{BuildSystemDeclaration, OptionDeclaration, OptionKind}; use super::template::{self, Values}; -use crate::plugins::declarative::Validation; +use lithe_plugin_model::Validation; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; use std::collections::BTreeMap; diff --git a/rust/crates/core/src/build/outputs.rs b/rust/crates/build/src/outputs.rs similarity index 98% rename from rust/crates/core/src/build/outputs.rs rename to rust/crates/build/src/outputs.rs index e2bcbf25..8fbb5c6d 100644 --- a/rust/crates/core/src/build/outputs.rs +++ b/rust/crates/build/src/outputs.rs @@ -14,9 +14,9 @@ use super::manifest::{ }; use super::model::{self, BuildModel, ModelBuilder}; use super::options; -use super::plans::{self, invalid_selection, unavailable, unsupported_integration}; +use super::plans::{self, invalid_selection, unavailable}; use super::select::{self, Node}; -use crate::plugins::declarative::Validation; +use lithe_plugin_model::Validation; use quick_xml::events::{BytesStart, Event}; use quick_xml::Reader; use regex::Regex; @@ -151,7 +151,6 @@ pub(crate) fn read_model( _ => model::map_canonical(&mut builder, decode::document(&text, Format::Json)?)?, } } - Some(ModelSource::HostIntegration) => return Err(unsupported_integration(build_system)), Some(ModelSource::Provider) => { model::map_canonical(&mut builder, provider_result(result)?.clone())?; } @@ -173,7 +172,7 @@ pub(crate) fn source_marker( .find(|path| { workspace .read_path(path) - .is_some_and(|path| crate::remote::workspace_io::is_file(path).unwrap_or(false)) + .is_some_and(|path| lithe_workspace::fs::is_file(path).unwrap_or(false)) }) } @@ -408,7 +407,6 @@ pub(crate) fn decode_test_results( request.not_before_millis, |text, base| canonical_cases(text, &workspace, base), ), - TestResultsSource::HostIntegration => return Err(unsupported_integration(build_system)), }; let cases = aggregate(cases); let summary = summary(&cases); @@ -1028,7 +1026,6 @@ pub(crate) fn decode_dependencies( CanonicalDependencies::deserialize(provider_result(request.result.as_ref())?), &mut budget, )?, - DependenciesSource::HostIntegration => return Err(unsupported_integration(build_system)), }; Ok(json!({ "project": project, diff --git a/rust/crates/core/src/build/plans.rs b/rust/crates/build/src/plans.rs similarity index 98% rename from rust/crates/core/src/build/plans.rs rename to rust/crates/build/src/plans.rs index f6ee980d..83b0e56f 100644 --- a/rust/crates/core/src/build/plans.rs +++ b/rust/crates/build/src/plans.rs @@ -17,7 +17,7 @@ use super::manifest::{ use super::model::{entry_id, BuildModel, ModelProject}; use super::options::{self, OptionContribution}; use super::template::{self, BuildArgument, Selected, Values, Variable}; -use crate::plugins::declarative::{PluginRejection, Validation}; +use lithe_plugin_model::{PluginRejection, Validation}; use serde::Deserialize; use serde_json::{json, Map, Value}; use std::collections::{BTreeMap, BTreeSet}; @@ -45,20 +45,6 @@ pub(crate) fn unavailable(build_system: &BuildSystemDeclaration, what: &str) -> ) } -/// Core provides no host integration any more: the only one moved a -/// built-in tool onto the plugin model, and that tool now ships as a plugin -/// that decodes its own output. A declaration that still names one fails -/// here instead of reaching tool-specific host code. -pub(super) fn unsupported_integration(build_system: &BuildSystemDeclaration) -> PluginRejection { - PluginRejection::new( - "plugin.integration.unsupported", - format!( - "{} names no host integration this host provides.", - build_system.display_name - ), - ) -} - /// A project, target, or task the host selected from the model. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] @@ -801,7 +787,6 @@ pub(crate) fn test_plan( "decoder": "litheTestResults", "reports": reports(globs), }), - Some(TestResultsSource::HostIntegration) => json!({ "decoder": "hostIntegration" }), Some(TestResultsSource::Provider) => json!({ "decoder": "provider" }), }; let mut extra = Map::new(); @@ -979,7 +964,6 @@ pub(crate) fn dependency_plan( ), ) } - DependenciesSource::HostIntegration => Err(unsupported_integration(build_system)), DependenciesSource::Provider { scopes } => { check_scope(scopes)?; let mut params = provider_hints(request)?; diff --git a/rust/crates/core/src/build/project_options.rs b/rust/crates/build/src/project_options.rs similarity index 94% rename from rust/crates/core/src/build/project_options.rs rename to rust/crates/build/src/project_options.rs index ebfd14de..c238e5b1 100644 --- a/rust/crates/core/src/build/project_options.rs +++ b/rust/crates/build/src/project_options.rs @@ -2,8 +2,8 @@ //! This is storage syntax only: declarations still own option types, scope, //! defaults and contributions, and platforms own file access and publication. -use crate::plugins::declarative::{decode_payload, outcome, PluginRejection, Validation}; -use crate::protocol::CoreError; +use lithe_plugin_model::{decode_payload, outcome, PluginRejection, Validation}; +use lithe_protocol::CoreError; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::BTreeMap; @@ -104,7 +104,7 @@ pub(super) fn validate(value: Value) -> Validation { /// Validates storage without dropping values belonging to inactive plugins or /// treating malformed documents as empty defaults. Never reads or writes a file. -pub(crate) fn project_options_command(payload: Value) -> Result { +pub fn project_options_command(payload: Value) -> Result { let request: Request = decode_payload(payload, "build.projectOptions")?; Ok(outcome( validate(request.document).map(|document| ("document", document)), diff --git a/rust/crates/core/src/build/run_preparation.rs b/rust/crates/build/src/run_preparation.rs similarity index 98% rename from rust/crates/core/src/build/run_preparation.rs rename to rust/crates/build/src/run_preparation.rs index 1c956aaf..a4213775 100644 --- a/rust/crates/core/src/build/run_preparation.rs +++ b/rust/crates/build/src/run_preparation.rs @@ -5,7 +5,7 @@ use super::decode::{self, Workspace}; use super::manifest::{BuildExecution, BuildSystemDeclaration, TaskSource}; use super::plans::{self, OutputPlanRequest, SelectedEntity}; -use crate::plugins::declarative::{is_bare_command, is_identifier, is_plugin_id, Validation}; +use lithe_plugin_model::{is_bare_command, is_identifier, is_plugin_id, Validation}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::BTreeMap; @@ -293,7 +293,7 @@ pub(crate) fn decode( let kind = executable["kind"].as_str().ok_or_else(invalid)?; let name = executable["value"].as_str().ok_or_else(invalid)?; if !(kind == "command" && name.len() <= 120 && is_bare_command(name) - || kind == "toolchain" && crate::toolchain::definitions::tool_id(name)) + || kind == "toolchain" && lithe_plugin_model::is_tool_id(name)) { return Err(invalid()); } diff --git a/rust/crates/core/src/build/run_start.rs b/rust/crates/build/src/run_start.rs similarity index 97% rename from rust/crates/core/src/build/run_start.rs rename to rust/crates/build/src/run_start.rs index f6cca314..13b1c8a2 100644 --- a/rust/crates/core/src/build/run_start.rs +++ b/rust/crates/build/src/run_start.rs @@ -9,11 +9,10 @@ use super::decode; use super::run_preparation::{ process_fields, valid_configuration_type, DecodeRunPreparationRequest, MAX_ARGUMENT_BYTES, }; -use crate::lsp::interface::plugin_function::is_function_id; -use crate::plugins::declarative::{ - decode_payload, outcome, validate_manifest_value, PluginRejection, Validation, -}; -use crate::protocol::CoreError; +use super::BuildManifest; +use lithe_plugin_model::is_function_id; +use lithe_plugin_model::{decode_payload, outcome, PluginRejection, Validation}; +use lithe_protocol::CoreError; use serde::Deserialize; use serde_json::{json, Map, Value}; use std::collections::{BTreeMap, BTreeSet}; @@ -89,10 +88,10 @@ fn invalid(field: &str, message: impl Into) -> PluginRejection { /// `run.decodeStart`: the normalized start with its executable reference, process, the files /// to write with their final paths, and the debug and rerun choices. -pub(crate) fn run_decode_start_command(payload: Value) -> Result { +pub fn run_decode_start_command(payload: Value) -> Result { let payload: DecodeStartPayload = decode_payload(payload, "run.decodeStart")?; Ok(outcome( - validate_manifest_value(payload.manifest.clone()) + M::validate(payload.manifest.clone()) .and_then(|manifest| decode_start(&payload, manifest.required_tools())) .map(|start| ("start", start)), )) diff --git a/rust/crates/core/src/build/select.rs b/rust/crates/build/src/select.rs similarity index 99% rename from rust/crates/core/src/build/select.rs rename to rust/crates/build/src/select.rs index afaeaad4..bc990f06 100644 --- a/rust/crates/core/src/build/select.rs +++ b/rust/crates/build/src/select.rs @@ -8,7 +8,7 @@ //! There are no functions or expressions; filtering is done by `where` //! predicates, so a mapping stays data a reviewer can read. -use crate::plugins::declarative::{invalid, Validation}; +use lithe_plugin_model::{invalid, Validation}; use serde::{Deserialize, Serialize}; use serde_json::Value; diff --git a/rust/crates/core/src/build/stored_options.rs b/rust/crates/build/src/stored_options.rs similarity index 98% rename from rust/crates/core/src/build/stored_options.rs rename to rust/crates/build/src/stored_options.rs index 935f2f93..c030926e 100644 --- a/rust/crates/core/src/build/stored_options.rs +++ b/rust/crates/build/src/stored_options.rs @@ -3,8 +3,8 @@ use super::manifest::{BuildSystemDeclaration, OptionKind, OptionScope}; use super::{options, project_options}; -use crate::plugins::declarative::{decode_payload, outcome, PluginRejection, Validation}; -use crate::protocol::CoreError; +use lithe_plugin_model::{decode_payload, outcome, PluginRejection, Validation}; +use lithe_protocol::CoreError; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::{BTreeMap, BTreeSet}; @@ -148,7 +148,7 @@ fn effective( } /// Resolves storage only, retaining inactive systems and custom choices. -pub(crate) fn resolve_stored_options_command(payload: Value) -> Result { +pub fn resolve_stored_options_command(payload: Value) -> Result { let request: ResolveRequest = decode_payload(payload, "build.resolveStoredOptions")?; Ok(outcome((|| { let team = project_options::validate(request.document)?; diff --git a/rust/crates/core/src/build/template.rs b/rust/crates/build/src/template.rs similarity index 97% rename from rust/crates/core/src/build/template.rs rename to rust/crates/build/src/template.rs index d665f1ab..abe8897d 100644 --- a/rust/crates/core/src/build/template.rs +++ b/rust/crates/build/src/template.rs @@ -7,9 +7,9 @@ //! must be guarded by a condition, so a template never expands to an empty //! or half-filled argument. -use crate::plugins::declarative::{ - is_setting_identifier, parse_template_with, regex_literal, template, PluginRejection, - TemplatePart, Validation, +use lithe_plugin_model::settings::is_setting_identifier; +use lithe_plugin_model::{ + invalid_template, parse_template_with, regex_literal, PluginRejection, TemplatePart, Validation, }; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; @@ -274,7 +274,7 @@ pub(crate) fn validate_arguments( let element = format!("{field}[{index}]"); let conditions = argument.conditions(); if !conditions.is_empty() && !allowed.conditions { - return Err(template( + return Err(invalid_template( element, "Conditional arguments are not allowed in this list.", )); @@ -283,7 +283,7 @@ pub(crate) fn validate_arguments( .iter() .find(|condition| conditions.contains(&condition.opposite())) { - return Err(template( + return Err(invalid_template( element, format!("The conditions contradict each other ({condition:?})."), )); @@ -293,7 +293,7 @@ pub(crate) fn validate_arguments( .iter() .any(|condition| condition.is_test_filter()) { - return Err(template( + return Err(invalid_template( element, "Suite and case conditions are only allowed in test arguments.", )); @@ -310,10 +310,10 @@ fn validate_element( field: &str, ) -> Validation<()> { if value.contains('\0') { - return Err(template(field, "Arguments must not contain NUL.")); + return Err(invalid_template(field, "Arguments must not contain NUL.")); } let Some(parts) = parse(value) else { - return Err(template( + return Err(invalid_template( field, "The argument uses an unknown or unterminated template variable.", )); @@ -330,19 +330,19 @@ fn validate_element( continue; }; if variable.is_whole_argument() && parts.len() != 1 { - return Err(template( + return Err(invalid_template( field, "${optionArguments} and ${userArguments} must be a whole argument.", )); } let unavailable = || { - template( + invalid_template( field, "The argument uses a variable that is not available in this list.", ) }; let unguarded = |condition: &str| { - template( + invalid_template( field, format!("This variable may be absent; guard it with \"when\": \"{condition}\"."), ) @@ -397,7 +397,7 @@ fn validate_element( }; if let Some(id) = id { if !options.contains(id) { - return Err(template( + return Err(invalid_template( field, format!("${{optionArguments:{id}}} names an undeclared option."), )); diff --git a/rust/crates/core/src/build/test_discovery.rs b/rust/crates/build/src/test_discovery.rs similarity index 98% rename from rust/crates/core/src/build/test_discovery.rs rename to rust/crates/build/src/test_discovery.rs index 5f2a2cc3..0c67337a 100644 --- a/rust/crates/core/src/build/test_discovery.rs +++ b/rust/crates/build/src/test_discovery.rs @@ -5,7 +5,7 @@ use super::decode::{invalid_output, MAX_OUTPUT_BYTES}; use super::manifest::BuildSystemDeclaration; use super::plans::{self, PlanBase, SelectedEntity, TestPlanRequest}; -use crate::plugins::declarative::Validation; +use lithe_plugin_model::Validation; use serde::Deserialize; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; diff --git a/rust/crates/core/src/build/test_outcome.rs b/rust/crates/build/src/test_outcome.rs similarity index 96% rename from rust/crates/core/src/build/test_outcome.rs rename to rust/crates/build/src/test_outcome.rs index 94d20bf9..c1abb372 100644 --- a/rust/crates/core/src/build/test_outcome.rs +++ b/rust/crates/build/src/test_outcome.rs @@ -7,7 +7,7 @@ //! only the scope the host asked for, and the summary is whatever the //! provider's decoder produced. -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; /// How the test process ended, as observed by the host. @@ -52,7 +52,7 @@ pub(crate) struct TestRunSummary { /// Input of `build.resolveTestRunOutcome`. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct ResolveTestRunOutcomeRequest { +pub struct ResolveTestRunOutcomeRequest { pub(crate) termination: TestRunTermination, pub(crate) scope: TestRunScope, /// `None` when the run has no structured results (the provider declares @@ -82,7 +82,7 @@ pub(crate) enum TestRunOutcome { /// Output of `build.resolveTestRunOutcome`. #[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] -pub(crate) struct TestRunVerdict { +pub struct TestRunVerdict { pub(crate) outcome: TestRunOutcome, pub(crate) success: bool, /// The tool's exit code when it exited by itself. @@ -95,7 +95,7 @@ pub(crate) struct TestRunVerdict { /// outcome; reported failures outrank the exit code; a non-zero exit outranks /// an empty result; and a zero exit with decoded results but no executed test /// is not a pass. -pub(crate) fn resolve_test_run_outcome( +pub fn resolve_test_run_outcome( request: ResolveTestRunOutcomeRequest, ) -> Result { if let Some(summary) = request.summary { @@ -146,7 +146,7 @@ mod tests { fn fixture() -> Value { serde_json::from_str(include_str!( - "../../../../fixtures/build/test-run-outcome-v1.json" + "../../../fixtures/build/test-run-outcome-v1.json" )) .unwrap() } diff --git a/rust/crates/core/src/build/test_outcome_merge.rs b/rust/crates/build/src/test_outcome_merge.rs similarity index 95% rename from rust/crates/core/src/build/test_outcome_merge.rs rename to rust/crates/build/src/test_outcome_merge.rs index 2bf703dc..c7ec05b5 100644 --- a/rust/crates/core/src/build/test_outcome_merge.rs +++ b/rust/crates/build/src/test_outcome_merge.rs @@ -7,7 +7,7 @@ //! compares strings only; how a tool names nested suites or how a host builds //! identifiers is decided by the provider adapter that builds the scope. -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use std::collections::HashSet; @@ -26,7 +26,7 @@ pub(crate) struct TestOutcomeScope { /// host keeps the payloads and maps the answer back by position. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct MergeTestOutcomesRequest { +pub struct MergeTestOutcomesRequest { /// Identifiers of the stored history, in display order. #[serde(default)] pub(crate) previous: Vec, @@ -56,7 +56,7 @@ pub(crate) struct MergedTestOutcome { /// Output of `build.mergeTestOutcomes`. #[derive(Debug, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] -pub(crate) struct MergedTestOutcomes { +pub struct MergedTestOutcomes { pub(crate) entries: Vec, } @@ -66,7 +66,7 @@ pub(crate) struct MergedTestOutcomes { /// run's results follow, but only those inside the scope: a run cannot /// overwrite what it did not claim. When an identifier repeats, the later /// entry wins and takes the later position. -pub(crate) fn merge_test_outcomes( +pub fn merge_test_outcomes( request: MergeTestOutcomesRequest, ) -> Result { if request.scope.prefixes.iter().any(String::is_empty) { @@ -121,7 +121,7 @@ mod tests { #[test] fn shared_fixture_cases_merge_to_their_expected_history() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../fixtures/build/test-outcome-merge-v1.json" + "../../../fixtures/build/test-outcome-merge-v1.json" )) .unwrap(); let cases = fixture["cases"].as_array().unwrap(); diff --git a/rust/crates/core/src/project/file_name_order.rs b/rust/crates/core-rules/src/file_names.rs similarity index 67% rename from rust/crates/core/src/project/file_name_order.rs rename to rust/crates/core-rules/src/file_names.rs index 475f7187..d688fd9c 100644 --- a/rust/crates/core/src/project/file_name_order.rs +++ b/rust/crates/core-rules/src/file_names.rs @@ -8,25 +8,16 @@ //! //! Adapted from intellij-community `NaturalComparator` and `FileNameComparator` at //! d8c6714c7a3c110c56994d6b0f10814f902100e7 (Apache-2.0, Copyright JetBrains s.r.o. -//! and contributors). The macOS Swift port (`NaturalNameOrder`) and the Windows -//! TypeScript port follow the same source and share -//! `rust/fixtures/workspace/file-name-order-v1.json`. +//! and contributors). The shared fixture +//! `rust/fixtures/workspace/file-name-order-v1.json` fixes the expected order. //! -//! Hosts that enumerate a directory natively (Windows local, SSH and WSL trees, and -//! macOS plugin listings) do not compare names themselves; they send the siblings to -//! `workspace.orderEntries` and apply the returned permutation. +//! Hosts that enumerate a directory natively (local, SSH and WSL trees, and plugin +//! listings) do not compare names themselves; they send the siblings to +//! `workspace.orderEntries`, which `lithe-core` answers with this order, and apply the +//! returned permutation. -use crate::protocol::{CoreError, ErrorCode}; -use serde::Deserialize; -use serde_json::{json, Value}; use std::cmp::Ordering; -/// Upper bound for one `workspace.orderEntries` request, matching the plugin directory scan -/// budget; hosts send one directory at a time. -const MAX_ORDER_ENTRIES: usize = 100_000; -/// Longest accepted sibling name, in UTF-8 bytes; longer names are rejected, never truncated. -const MAX_ORDER_NAME_BYTES: usize = 4_096; - const SPACE: u16 = 0x20; const ZERO: u16 = 0x30; const DASH: u16 = 0x2D; @@ -38,10 +29,11 @@ const UNDERSCORE: u16 = 0x5F; /// Java compares `char` values, so UTF-16 units keep the upstream order for /// supplementary characters as well. #[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct FileNameKey(Vec); +pub struct FileNameKey(Vec); impl FileNameKey { - pub(crate) fn new(name: &str) -> Self { + /// Converts `name` once for repeated comparisons. + pub fn new(name: &str) -> Self { Self(name.encode_utf16().collect()) } } @@ -58,74 +50,8 @@ impl PartialOrd for FileNameKey { } } -#[derive(Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -/// Siblings of one natively enumerated directory, in enumeration order. -struct OrderEntriesRequest { - entries: Vec, - /// Ranks directories before files, the Project view default; `false` orders by name only. - #[serde(default = "directories_first_default")] - directories_first: bool, -} - -fn directories_first_default() -> bool { - true -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -/// One sibling name and its kind; the host keeps the full entry and only uses the index. -struct OrderEntry { - name: String, - is_directory: bool, -} - -/// Orders natively enumerated siblings for `workspace.orderEntries`. -/// -/// Returns `{ order }`, the request indices in display order. The order is total and -/// deterministic: names compare with IDEA's `FileNameComparator`, and identical names -/// keep their request order. Oversized requests fail with `invalid_request`. -pub(crate) fn order_entries_command(payload: Value) -> Result { - let request: OrderEntriesRequest = serde_json::from_value(payload).map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid entry order request") - .with_details(error.to_string()) - })?; - if request.entries.len() > MAX_ORDER_ENTRIES { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Too many entries to order", - )); - } - if request - .entries - .iter() - .any(|entry| entry.name.len() > MAX_ORDER_NAME_BYTES) - { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Entry name is too long to order", - )); - } - let directories_first = request.directories_first; - let mut keyed = request - .entries - .into_iter() - .enumerate() - .map(|(index, entry)| { - ( - directories_first && !entry.is_directory, - FileNameKey::new(&entry.name), - index, - ) - }) - .collect::>(); - keyed.sort(); - Ok(json!({ "order": keyed.into_iter().map(|(_, _, index)| index).collect::>() })) -} - /// Compares two file names in IDEA's Project view order. -#[cfg(test)] -pub(crate) fn compare_file_names(left: &str, right: &str) -> Ordering { +pub fn compare_file_names(left: &str, right: &str) -> Ordering { FileNameKey::new(left).cmp(&FileNameKey::new(right)) } @@ -135,7 +61,7 @@ fn compare_utf16(left: &[u16], right: &[u16]) -> Ordering { /// Compares two Git reference names with IDEA's `NaturalComparator` (`ignoreCase`, not /// `likeFileNames`), so `-` keeps its own code unit rank. Inputs are UTF-16 code units. -pub(crate) fn compare_reference_names(left: &[u16], right: &[u16]) -> Ordering { +pub fn compare_reference_names(left: &[u16], right: &[u16]) -> Ordering { natural_compare(left, right, true, false) } diff --git a/rust/crates/core-rules/src/keymap/bindings.rs b/rust/crates/core-rules/src/keymap/bindings.rs index fef5f405..a041d37c 100644 --- a/rust/crates/core-rules/src/keymap/bindings.rs +++ b/rust/crates/core-rules/src/keymap/bindings.rs @@ -84,7 +84,7 @@ fn key_alias(token: &str) -> String { fn parse_step(text: &str, platform: BindingPlatform) -> Result { // A trailing plus is the `+` key itself (`ctrl++`), not an empty segment. let (head, plus_key) = match text.strip_suffix('+') { - Some(rest) if rest.is_empty() => ("", true), + Some("") => ("", true), Some(rest) => match rest.strip_suffix('+') { Some(head) => (head, true), None => return Err("empty_key"), diff --git a/rust/crates/core-rules/src/keymap/context.rs b/rust/crates/core-rules/src/keymap/context.rs index 5c93ab40..36bd10ba 100644 --- a/rust/crates/core-rules/src/keymap/context.rs +++ b/rust/crates/core-rules/src/keymap/context.rs @@ -133,7 +133,7 @@ fn tokenize(source: &str) -> Result, ContextError> { } '!' => Token::Not, '&' | '|' | '=' => { - if !chars.peek().is_some_and(|(_, next)| *next == ch) { + if chars.peek().is_none_or(|(_, next)| *next != ch) { return Err(diagnostic("invalid_character", offset)); } chars.next(); diff --git a/rust/crates/core-rules/src/keymap/modifier_gesture.rs b/rust/crates/core-rules/src/keymap/modifier_gesture.rs index ebb50656..40119ccb 100644 --- a/rust/crates/core-rules/src/keymap/modifier_gesture.rs +++ b/rust/crates/core-rules/src/keymap/modifier_gesture.rs @@ -114,7 +114,7 @@ fn flags_changed( } if let Some(previous) = state.last_standalone_tap { let gap = timestamp - previous; - if gap >= 0.0 && gap < THRESHOLD_SECONDS { + if (0.0..THRESHOLD_SECONDS).contains(&gap) { state.last_standalone_tap = None; return true; } diff --git a/rust/crates/core-rules/src/lib.rs b/rust/crates/core-rules/src/lib.rs index d3f3416a..8271e341 100644 --- a/rust/crates/core-rules/src/lib.rs +++ b/rust/crates/core-rules/src/lib.rs @@ -12,6 +12,7 @@ pub mod action_search; mod dispatch; pub mod editor; mod error; +pub mod file_names; pub mod keymap; pub mod visibility; pub mod workbench; diff --git a/rust/crates/core-rules/tests/file_name_order_fixture.rs b/rust/crates/core-rules/tests/file_name_order_fixture.rs new file mode 100644 index 00000000..b1cbf780 --- /dev/null +++ b/rust/crates/core-rules/tests/file_name_order_fixture.rs @@ -0,0 +1,92 @@ +//! Checks the shared file name order fixture against the comparison every host +//! tree uses. + +use lithe_core_rules::file_names::compare_file_names; +use serde_json::Value; +use std::cmp::Ordering; + +fn fixture() -> Value { + serde_json::from_str(include_str!( + "../../../fixtures/workspace/file-name-order-v1.json" + )) + .expect("file-name order fixture should be JSON") +} + +#[test] +fn shared_fixture_groups_sort_strictly_in_the_listed_order() { + let fixture = fixture(); + for group in fixture["orderedGroups"].as_array().unwrap() { + let names = group["names"] + .as_array() + .unwrap() + .iter() + .map(|name| name.as_str().unwrap()) + .collect::>(); + for (index, earlier) in names.iter().enumerate() { + for later in &names[index + 1..] { + assert_eq!( + compare_file_names(earlier, later), + Ordering::Less, + "{}: {earlier:?} should precede {later:?}", + group["id"] + ); + assert_eq!( + compare_file_names(later, earlier), + Ordering::Greater, + "{}: {later:?} should follow {earlier:?}", + group["id"] + ); + } + } + } +} + +// Rust's sort may panic on an inconsistent order, so the port must stay a total +// preorder even for the characters upstream treats specially: spaces, zeroes, +// digits, '#', '-', '_' and mixed-case letters. The generator is a fixed LCG, +// so a failure reproduces exactly. +#[test] +fn comparison_is_antisymmetric_and_transitive_for_special_characters() { + const ALPHABET: [char; 11] = [' ', '0', '1', '2', 'a', 'A', 'b', '-', '_', '#', '.']; + let mut state = 0x2545_F491_4F6C_DD1Du64; + let mut next = |bound: usize| { + state = state + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + ((state >> 33) as usize) % bound + }; + let names = (0..72) + .map(|_| { + let length = next(6); + (0..length) + .map(|_| ALPHABET[next(ALPHABET.len())]) + .collect::() + }) + .collect::>(); + + for left in &names { + for right in &names { + assert_eq!( + compare_file_names(left, right), + compare_file_names(right, left).reverse(), + "{left:?} against {right:?}" + ); + } + } + for first in &names { + for second in &names { + if compare_file_names(first, second) == Ordering::Greater { + continue; + } + for third in &names { + if compare_file_names(second, third) != Ordering::Greater { + assert_ne!( + compare_file_names(first, third), + Ordering::Greater, + "{first:?} <= {second:?} <= {third:?}" + ); + } + } + } + } +} diff --git a/rust/crates/core/Cargo.toml b/rust/crates/core/Cargo.toml index 8f934347..ed058f8a 100644 --- a/rust/crates/core/Cargo.toml +++ b/rust/crates/core/Cargo.toml @@ -15,32 +15,57 @@ plugin-sdk-e2e = [] [dependencies] lithe-ai = { path = "../ai" } +lithe-build = { path = "../build" } lithe-core-rules = { path = "../core-rules" } +lithe-debug = { path = "../debug" } +lithe-diagnostics = { path = "../diagnostics" } lithe-diff = { path = "../diff" } +lithe-document-uri = { path = "../document-uri" } +lithe-editor = { path = "../editor" } +lithe-execution = { path = "../execution" } +lithe-git = { path = "../git" } lithe-git-host = { path = "../git-host" } lithe-github = { path = "../github" } lithe-ide-host = { path = "../ide-host" } +lithe-lsp-engine = { path = "../lsp-engine" } +lithe-lsp-policy = { path = "../lsp-policy" } lithe-network = { path = "../network" } +lithe-plugin-content-providers = { path = "../plugin-content-providers" } +lithe-plugin-editor = { path = "../plugin-editor" } +lithe-plugin-function-relay = { path = "../plugin-function-relay" } +lithe-plugin-keymap-import = { path = "../plugin-keymap-import" } +lithe-plugin-language-servers = { path = "../plugin-language-servers" } +lithe-plugin-manifest = { path = "../plugin-manifest" } +lithe-plugin-marketplace = { path = "../plugin-marketplace" } +lithe-plugin-model = { path = "../plugin-model" } +lithe-plugin-native-ui = { path = "../plugin-native-ui" } +lithe-plugin-output-links = { path = "../plugin-output-links" } +lithe-plugin-rpc = { path = "../plugin-rpc" } +lithe-plugin-web-content = { path = "../plugin-web-content" } lithe-process = { path = "../process" } +lithe-project = { path = "../project" } +lithe-project-generators = { path = "../project-generators" } lithe-protocol = { path = "../protocol" } +lithe-request-context = { path = "../request-context" } lithe-terminal = { path = "../terminal" } +lithe-text = { path = "../text" } +lithe-toolchain = { path = "../toolchain" } +lithe-vcs = { path = "../vcs" } +lithe-workspace = { path = "../workspace" } lithe-remote = { path = "../remote", default-features = false, features = ["client"] } -ammonia = "4.1.4" -base64 = "0.22" -chrono = { version = "0.4", default-features = false, features = ["std", "clock"] } -encoding_rs = "0.8" -comrak = { version = "0.54.0", default-features = false, features = ["shortcodes"] } -rand = "0.8" -regex = "1.11" -quick-xml = "0.37" serde = { version = "1.0", features = ["derive", "rc"] } serde_json = { version = "1.0", features = ["preserve_order"] } -sha2 = "0.10" -toml = { version = "1.1.4", default-features = false, features = ["parse", "serde"] } -serde_yaml_ng = "0.10.0" -zip = { version = "2.4", default-features = false, features = ["deflate"] } url = "2.5" -ignore = "=0.4.33" + +[dev-dependencies] +base64 = "0.22" +lithe-diagnostics = { path = "../diagnostics", features = ["test-support"] } +lithe-execution = { path = "../execution", features = ["test-support"] } +lithe-git = { path = "../git", features = ["test-support"] } +lithe-lsp-transport = { path = "../lsp-transport", features = ["test-support"] } +lithe-project = { path = "../project", features = ["test-support"] } +lithe-workspace = { path = "../workspace", features = ["test-support"] } +sha2 = "0.10" [target.'cfg(unix)'.dev-dependencies] lithe-remote = { path = "../remote", default-features = false, features = ["server"] } diff --git a/rust/crates/core/src/build/manifest.rs b/rust/crates/core/src/build/manifest.rs deleted file mode 100644 index 53cace3d..00000000 --- a/rust/crates/core/src/build/manifest.rs +++ /dev/null @@ -1,2598 +0,0 @@ -//! `buildSystems` declarations and their validation. -//! -//! A declaration is data only. Everything a community plugin needs is -//! expressible here: any command or packaged script as the executable, any -//! runtime it depends on (described by how to find and probe it), and any -//! output, either through a selector map over a public format or by printing -//! one of the canonical `lithe*` formats. What no command can print is -//! answered by a `provider`: a server process of the plugin, declared with -//! the language-server launch fields or shared with one of its language -//! servers. The only named host code is a `hostIntegration`: a host-side -//! model decoder any plugin selects by ID; the host never special-cases a -//! build tool by name. - -use super::select::{self, FieldSpec, Predicate}; -use super::template::{self, Allowed, Availability, BuildArgument, Variable}; -use crate::plugins::declarative::{ - invalid, is_bare_command, is_identifier, is_safe_relative_path, is_setting_identifier, - sorted_unique, template as template_error, validate_minimum_version, PluginRejection, - ServerProcess, ServerRole, UIIcon, Validation, KNOWN_RUNTIME_TOOLS, -}; -use regex::Regex; -use serde::{Deserialize, Deserializer, Serialize}; -use serde_json::{json, Value}; -use std::collections::{BTreeMap, BTreeSet}; - -/// Host integrations a build system may name. The Core implements none: -/// every build tool is described by its plugin's data and backend, so any -/// name is refused with `plugin.integration.unsupported`. -const HOST_INTEGRATIONS: &[&str] = &[]; - -const MAX_BUILD_SYSTEMS: usize = 16; -const MAX_LIST: usize = 32; -const MAX_TASKS: usize = 128; -const MAX_CHOICES: usize = 64; -const MAX_ARGUMENTS: usize = 64; -const MAX_TEXT: usize = 120; -const MAX_OPTION_VALUE: usize = 256; -const MAX_REGEX: usize = 512; -const MAX_IDENTIFIER: usize = 64; -const MAX_PRIORITY: u8 = 100; -const DEFAULT_PRIORITY: u8 = 50; -const DEFAULT_SEPARATOR: &str = ","; -/// Deepest heading path a task can be filed under, e.g. `Plugins › compiler`. -pub(crate) const MAX_CATEGORY_DEPTH: usize = 4; - -/// One project-level build tool a plugin describes. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct BuildSystemDeclaration { - pub(crate) id: String, - pub(crate) display_name: String, - /// Optional, explicit read-only publication of the normalized model. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) model_sharing: Option, - /// Exact optional producers; these are not installation dependencies. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub(crate) model_inputs: Vec, - /// Icon of the build system's tool window; display only. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) icon: Option, - pub(crate) detection: Detection, - pub(crate) invocation: Invocation, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) host_integration: Option, - /// The process that answers the `provider` sources. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) provider: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub(crate) options: Vec, - /// Native non-sensitive settings and generic write bindings from the engine. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) configuration: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) model: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub(crate) tasks: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub(crate) run: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) test: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) diagnostics: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) dependencies: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) sync: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) layout: Option, -} - -/// A server process that answers `lithe/build/*` requests over the LSP base -/// protocol. It is a language-server process: either one of its own, with -/// the same launch fields as `languageServers[]`, or the plugin's language -/// server of one language, which then answers build requests besides its -/// language features. The form is decided by `languageServer`, so a mistake -/// is reported against that form's fields. -#[derive(Debug, Clone, Serialize)] -#[serde(untagged)] -#[allow(clippy::large_enum_variant)] // Decoded once per manifest validation. -pub(crate) enum ProviderDeclaration { - LanguageServer(SharedProvider), - Process(ProcessProvider), -} - -/// Build requests go to the language server of `languageServer`. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct SharedProvider { - #[serde(rename = "languageServer")] - pub(crate) language_id: String, - pub(crate) executes_project_code: bool, - pub(crate) may_use_network: bool, -} - -/// A provider process of its own. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct ProcessProvider { - #[serde(flatten)] - pub(crate) process: ServerProcess, - pub(crate) executes_project_code: bool, - pub(crate) may_use_network: bool, -} - -impl<'de> Deserialize<'de> for ProviderDeclaration { - fn deserialize>(deserializer: D) -> Result { - let value = Value::deserialize(deserializer)?; - if value.get("languageServer").is_some() { - SharedProvider::deserialize(value).map(Self::LanguageServer) - } else { - ProcessProvider::deserialize(value).map(Self::Process) - } - .map_err(serde::de::Error::custom) - } -} - -/// Which process a provider's requests go to. -pub(crate) enum ProviderProcess<'a> { - /// The language server of this language ID. - LanguageServer(&'a str), - Own(&'a ServerProcess), -} - -impl ProviderDeclaration { - pub(crate) fn process(&self) -> ProviderProcess<'_> { - match self { - Self::LanguageServer(shared) => ProviderProcess::LanguageServer(&shared.language_id), - Self::Process(own) => ProviderProcess::Own(&own.process), - } - } - - /// Shown to the user; whether a provider may run is decided by trust. - pub(crate) fn executes_project_code(&self) -> bool { - match self { - Self::LanguageServer(shared) => shared.executes_project_code, - Self::Process(own) => own.executes_project_code, - } - } - - pub(crate) fn may_use_network(&self) -> bool { - match self { - Self::LanguageServer(shared) => shared.may_use_network, - Self::Process(own) => own.may_use_network, - } - } -} - -/// How instances of the build system are recognized during the shared walk. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct Detection { - /// Exact file names; a directory containing any of them is a candidate. - pub(crate) markers: Vec, - #[serde(default)] - pub(crate) nesting: Nesting, - /// Decides between plugins declaring the same build-system ID. - #[serde(default = "default_priority")] - pub(crate) priority: u8, -} - -fn default_priority() -> u8 { - DEFAULT_PRIORITY -} - -/// What a marker below another instance's root means. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) enum Nesting { - /// Every candidate directory is its own instance (Cargo packages). - #[default] - Separate, - /// Only candidates with no candidate above them are instances; deeper - /// markers belong to them (multi-project builds and reactors). - Outermost, -} - -/// Which executable runs the tool and what it needs. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct Invocation { - /// Candidates in preference order; the host takes the first that exists. - pub(crate) executables: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub(crate) global_arguments: Vec, - /// Environment variables the plugin may read. The host passes only these. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub(crate) environment_inputs: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub(crate) runtime_dependencies: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) version: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) minimum_version: Option, -} - -pub(crate) use crate::plugins::executables::{ExecutableCandidate, ExecutableSource, Platform}; - -/// A runtime the tool needs. Runtimes the host already manages (`node`, -/// `go`) take only an ID and an optional minimum version and follow the -/// user's runtime settings. Any other runtime either describes how to find -/// and probe it, or lists no executables and names the tool definition with -/// its ID, which finds the executable and checks `minimumVersion`. A -/// community plugin is never limited to a host list. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct RuntimeDependency { - pub(crate) id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) display_name: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) minimum_version: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub(crate) executables: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) version: Option, -} - -impl RuntimeDependency { - /// True when the host discovers this runtime with its own settings. - pub(crate) fn is_host_managed(&self) -> bool { - KNOWN_RUNTIME_TOOLS.contains(&self.id.as_str()) - } - - /// True when the runtime is the tool definition with the same ID: it - /// lists no executables and the host does not manage it. Plans give it - /// a single toolchain candidate for that ID. - pub(crate) fn names_tool_definition(&self) -> bool { - self.executables.is_empty() && !self.is_host_managed() - } - - /// True when the tool definition the runtime resolves through checks - /// `minimumVersion`, so the manifest needs no version probe: a runtime - /// the host does not manage that is an implicit reference or has exactly - /// one toolchain candidate. - pub(crate) fn checked_by_tool_definition(&self) -> bool { - !self.is_host_managed() - && matches!( - self.executables.as_slice(), - [] | [ExecutableCandidate { - source: ExecutableSource::Toolchain { .. }, - .. - }] - ) - } -} - -/// How to ask an executable for its version: literal arguments, and a -/// pattern whose first capture group is the dotted version. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct VersionProbe { - pub(crate) arguments: Vec, - pub(crate) pattern: String, -} - -/// A build dimension the user chooses, stored in its declared scope by the host. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct OptionDeclaration { - pub(crate) id: String, - pub(crate) display_name: String, - pub(crate) kind: OptionKind, - /// Instance values belong to one detected root, never to its siblings. - #[serde(default, skip_serializing_if = "OptionScope::is_workspace")] - pub(crate) scope: OptionScope, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) default: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub(crate) choices: Vec, - /// Choices also come from the model's `optionValues` for this option. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub(crate) choices_from_model: bool, - /// Values outside the known choices are accepted. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub(crate) allow_custom: bool, - /// Joins multi-select values in `${value}`; `,` by default. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) separator: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub(crate) arguments: Vec, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub(crate) environment: BTreeMap, - /// Where the host offers the option; display only. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) presentation: Option, - /// Icon of a `toolbar` toggle; display only. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) icon: Option, -} - -/// Persistence boundary of an option. Existing declarations remain workspace-wide. -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) enum OptionScope { - #[default] - Workspace, - Instance, -} - -impl OptionScope { - fn is_workspace(&self) -> bool { - *self == Self::Workspace - } -} - -/// Where a host offers an option besides its option list. Neither placement -/// changes how the option is resolved or emitted. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) enum OptionPresentation { - /// A toggle button in the tool window's toolbar; boolean options with an - /// icon only (offline mode, skipping tests). - Toolbar, - /// A checklist node at the top of the tool window's tree; multiSelect - /// options only (build profiles). - Tree, -} - -impl OptionDeclaration { - pub(crate) fn separator(&self) -> &str { - self.separator.as_deref().unwrap_or(DEFAULT_SEPARATOR) - } - - pub(crate) fn is_select(&self) -> bool { - matches!( - self.kind, - OptionKind::SingleSelect | OptionKind::MultiSelect - ) - } - - /// Whether a single value is acceptable, given the model's values for a - /// `choicesFromModel` option (`None` while no model is loaded, in which - /// case model choices cannot be checked and are accepted). - pub(crate) fn accepts_value(&self, value: &str, model_values: Option<&[String]>) -> bool { - if !is_option_value(value) { - return false; - } - if self.kind == OptionKind::MultiSelect && value.contains(self.separator()) { - return false; - } - if !self.is_select() || self.allow_custom { - return true; - } - if self.choices.iter().any(|choice| choice.value == value) { - return true; - } - self.choices_from_model - && model_values.is_none_or(|values| values.iter().any(|known| known == value)) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) enum OptionKind { - Boolean, - SingleSelect, - MultiSelect, - Text, - File, - Directory, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct OptionChoice { - pub(crate) value: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) title: Option, -} - -/// Where the project model comes from. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde( - tag = "source", - rename_all = "camelCase", - rename_all_fields = "camelCase", - deny_unknown_fields -)] -pub(crate) enum ModelSource { - /// A file in the instance root, read during detection without starting a - /// process. - Static { - file: String, - decoder: DocumentDecoder, - map: ModelMap, - }, - /// A command the host runs on request; its output is decoded by Core. - Command { - arguments: Vec, - #[serde(default)] - output: OutputChannel, - decoder: ModelDecoder, - #[serde(default, skip_serializing_if = "Option::is_none")] - map: Option, - executes_project_code: bool, - may_use_network: bool, - }, - /// The named host integration reads the model. - HostIntegration, - /// The provider answers `lithe/build/model` with a `litheBuildModel`. - Provider, -} - -/// Structured formats a static model file may use. A tool whose manifest -/// uses another format gets a plugin command that prints `litheBuildModel`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) enum DocumentDecoder { - Toml, -} - -/// Formats a model command may print. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) enum ModelDecoder { - /// Whitespace-separated JSON values: NDJSON, concatenated objects such - /// as `go list -json` prints, or a single document. - JsonStream, - /// The canonical model format; needs no map. - LitheBuildModel, -} - -/// Formats a dependency command may print. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) enum DependencyDecoder { - /// Whitespace-separated JSON values: NDJSON, concatenated objects such - /// as `go list -json` prints, or a single document. - JsonStream, - /// The canonical dependency format; needs no map. - LitheDependencies, -} - -/// Where a command leaves its result. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) enum OutputChannel { - #[default] - Stdout, - /// A host-allocated file passed as `${outputFile}`. - OutputFile, -} - -/// Mapping of a decoded document onto the model: projects with their -/// targets and content roots, and option values. The map only selects -/// values; anything that needs more, such as tasks or parent links read from -/// the tool, comes from a plugin command that prints `litheBuildModel`. -#[derive(Debug, Clone, Default, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct ModelMap { - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub(crate) projects: Vec, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub(crate) option_values: BTreeMap, -} - -/// Fields every rule shares: which elements it reads and when it applies. -/// Written out in each rule because `deny_unknown_fields` and `flatten` -/// cannot be combined. -pub(crate) trait RuleMatch { - fn each(&self) -> Option<&str>; - fn filter(&self) -> &[Predicate]; - fn fallback(&self) -> bool; - fn when_file(&self) -> Option<&str>; -} - -macro_rules! rule_match { - ($rule:ty) => { - impl RuleMatch for $rule { - fn each(&self) -> Option<&str> { - self.each.as_deref() - } - fn filter(&self) -> &[Predicate] { - &self.filter - } - fn fallback(&self) -> bool { - self.fallback - } - fn when_file(&self) -> Option<&str> { - self.when_file.as_deref() - } - } - }; -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct ProjectRule { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) each: Option, - #[serde(default, rename = "where", skip_serializing_if = "Vec::is_empty")] - pub(crate) filter: Vec, - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub(crate) fallback: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) when_file: Option, - pub(crate) name: FieldSpec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) display_name: Option, - /// Project directory; the instance root when absent. The parent is the - /// deepest project containing it. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) path: Option, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub(crate) properties: BTreeMap, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub(crate) targets: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub(crate) content_roots: Vec, -} -rule_match!(ProjectRule); - -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct TargetRule { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) each: Option, - #[serde(default, rename = "where", skip_serializing_if = "Vec::is_empty")] - pub(crate) filter: Vec, - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub(crate) fallback: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) when_file: Option, - pub(crate) name: FieldSpec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) path: Option, - pub(crate) tags: Vec, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub(crate) properties: BTreeMap, -} -rule_match!(TargetRule); - -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct ContentRootRule { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) each: Option, - #[serde(default, rename = "where", skip_serializing_if = "Vec::is_empty")] - pub(crate) filter: Vec, - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub(crate) fallback: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) when_file: Option, - /// Directory relative to the project, or absolute inside the workspace. - pub(crate) path: FieldSpec, - pub(crate) kind: ContentRootKind, -} -rule_match!(ContentRootRule); - -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct OptionValueRule { - pub(crate) each: String, - #[serde(default, rename = "where", skip_serializing_if = "Vec::is_empty")] - pub(crate) filter: Vec, - pub(crate) value: FieldSpec, -} - -/// Role of a source directory in a project. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) enum ContentRootKind { - Source, - Resource, - TestSource, - TestResource, - GeneratedSource, - GeneratedTestSource, - Excluded, -} - -/// How a task's process behaves; the same values as run configurations. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] -#[serde(rename_all = "lowercase")] -pub(crate) enum BuildExecution { - Application, - Service, - Task, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) enum TaskGroup { - Lifecycle, - Build, - Test, - Run, - Verification, - Documentation, - Other, -} - -/// What a task, test, or dependency command acts on. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) enum Scope { - Workspace, - Project, - Target, -} - -/// A task in the build system's catalog. `fromModel` tasks are templates -/// for the tasks the model lists. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct TaskDeclaration { - pub(crate) id: String, - /// Optional automatic preparation selected by purpose and model source role. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) preparation: Option, - pub(crate) display_name: String, - pub(crate) group: TaskGroup, - #[serde(default = "task_execution")] - pub(crate) execution: BuildExecution, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub(crate) scopes: Vec, - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub(crate) from_model: bool, - /// For target-scoped tasks: tags of the targets it applies to. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub(crate) target_tags: Vec, - /// The host must ask for arguments before running it. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub(crate) requires_user_arguments: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) description: Option, - /// Headings the host files the task under in its project, outermost - /// first (`["Lifecycle"]`); also the default of the model tasks this - /// template runs. Display only; empty files the task by `group`. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub(crate) category: Vec, - #[serde(default, skip_serializing_if = "TaskSource::is_command")] - pub(crate) source: TaskSource, - pub(crate) arguments: Vec, -} - -/// Tool-independent association between a project task and a language workflow. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct TaskPreparation { - /// Opaque workflow purpose, such as `run`; never a task ID or tool name. - pub(crate) purpose: String, - /// Canonical content-root roles for which this task prepares its project. - pub(crate) source_roles: Vec, -} - -/// Whether Core expands a command or a workspace provider executes the task. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) enum TaskSource { - #[default] - #[serde(skip_deserializing)] - Command, - Provider, -} - -impl TaskSource { - fn is_command(&self) -> bool { - *self == Self::Command - } -} - -fn task_execution() -> BuildExecution { - BuildExecution::Task -} - -/// Run entries derived from model targets or tasks. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct RunDeclaration { - /// A same-package adapter for native executable debug preparation. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) debug: Option, - pub(crate) id: String, - /// Provider runs prepare a launch plan; they never become empty argv entries. - #[serde(default, skip_serializing_if = "TaskSource::is_command")] - pub(crate) source: TaskSource, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) from_targets: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) from_tasks: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) execution: Option, - /// Entry name; the target or task name when absent. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) name: Option, - /// Argument templates for target entries; task entries reuse the task's. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub(crate) arguments: Vec, - #[serde(default)] - pub(crate) working_directory: WorkingDirectory, - /// Metadata stored under `extensions.` of the entry. - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub(crate) extension: BTreeMap, -} - -/// Native launch binds a local adapter; connection attach names a public registry type. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub(crate) struct RunDebug { - /// Local adapter ID for v5 native executable launch. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) adapter: Option, - /// Opaque public registry alias for v11 service connection attach. - #[serde( - default, - rename = "configurationType", - skip_serializing_if = "Option::is_none" - )] - pub(crate) configuration_type: Option, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct FromTargets { - pub(crate) tags: Vec, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct FromTasks { - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub(crate) templates: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub(crate) executions: Vec, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) enum WorkingDirectory { - #[default] - Project, - Root, -} - -fn is_test_selector_kind(value: &str) -> bool { - !value.is_empty() - && value.len() <= 120 - && value - .bytes() - .next() - .is_some_and(|byte| byte.is_ascii_alphanumeric()) - && value - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte)) -} - -/// How tests run and how their results are read. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct TestDeclaration { - pub(crate) scopes: Vec, - /// Tags a selected target must carry, as for tasks. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub(crate) target_tags: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub(crate) filters: Vec, - /// Public opaque selector namespaces interpreted only by the owning backend. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub(crate) selector_kinds: Vec, - /// Omitted for argv templates; provider sends a typed test request. - #[serde(default, skip_serializing_if = "TaskSource::is_command")] - pub(crate) source: TaskSource, - /// The provider can enumerate exact selectors for the native test tree. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub(crate) discovery: bool, - pub(crate) arguments: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) results: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) enum TestFilter { - Suite, - Case, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde( - tag = "decoder", - rename_all = "camelCase", - rename_all_fields = "camelCase", - deny_unknown_fields -)] -pub(crate) enum TestResultsSource { - /// A canonical document returned by the same provider test operation. - Provider, - /// JUnit XML report files, globbed relative to the project directory. - JunitXml { - reports: Vec, - }, - /// Canonical result files, globbed relative to the project directory. - LitheTestResults { - reports: Vec, - }, - HostIntegration, -} - -/// How build output becomes file diagnostics. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde( - tag = "decoder", - rename_all = "camelCase", - rename_all_fields = "camelCase", - deny_unknown_fields -)] -pub(crate) enum DiagnosticsDeclaration { - LineMatcher { - patterns: Vec, - }, - /// Output lines holding canonical diagnostic objects. - LitheDiagnostics, -} - -/// A regular expression and which capture group holds each field. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct LinePattern { - pub(crate) regex: String, - pub(crate) file: usize, - pub(crate) line: usize, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) column: Option, - pub(crate) message: usize, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) severity: Option, - /// Raw severity text to severity; without it the text is lowercased. - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub(crate) severities: BTreeMap, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) default_severity: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) enum Severity { - Error, - Warning, - Information, - Hint, -} - -impl Severity { - pub(crate) fn as_str(self) -> &'static str { - match self { - Self::Error => "error", - Self::Warning => "warning", - Self::Information => "information", - Self::Hint => "hint", - } - } -} - -/// Where dependency trees come from. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde( - tag = "source", - rename_all = "camelCase", - rename_all_fields = "camelCase", - deny_unknown_fields -)] -#[allow(clippy::large_enum_variant)] // Decoded once per manifest validation. -pub(crate) enum DependenciesSource { - Command { - scopes: Vec, - arguments: Vec, - #[serde(default)] - output: OutputChannel, - decoder: DependencyDecoder, - #[serde(default, skip_serializing_if = "Option::is_none")] - map: Option, - executes_project_code: bool, - may_use_network: bool, - }, - HostIntegration, - /// The provider answers `lithe/build/dependencies` with - /// `litheDependencies`. - Provider { - scopes: Vec, - }, -} - -/// Mapping of one dependency element onto a flat list. Dependency trees -/// come from `litheDependencies`. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct DependencyRule { - pub(crate) each: String, - #[serde(default, rename = "where", skip_serializing_if = "Vec::is_empty")] - pub(crate) filter: Vec, - pub(crate) name: FieldSpec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) group: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) version: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) scope: Option, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub(crate) properties: BTreeMap, -} - -/// Files whose change makes the model stale, relative to the instance root -/// and to each project directory. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct SyncDeclaration { - pub(crate) watch: Vec, -} - -/// Output directories excluded from every project, relative to each -/// project directory. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct LayoutDeclaration { - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub(crate) excluded: Vec, -} - -impl BuildSystemDeclaration { - pub(crate) fn option(&self, id: &str) -> Option<&OptionDeclaration> { - self.options.iter().find(|option| option.id == id) - } - - pub(crate) fn task(&self, id: &str) -> Option<&TaskDeclaration> { - self.tasks.iter().find(|task| task.id == id) - } -} - -// MARK: Validation - -/// Validates and normalizes every build system of a manifest. -/// Normalizes the tags a selected target must carry; they only make sense -/// when the command can run on a target. -fn check_target_tags(tags: &mut Vec, scopes: &[Scope], field: &str) -> Validation<()> { - if !tags.is_empty() && !scopes.contains(&Scope::Target) { - return Err(invalid( - format!("{field}.targetTags"), - "targetTags need the target scope.", - )); - } - if tags.iter().any(|tag| !is_tag(tag)) { - return Err(invalid( - format!("{field}.targetTags"), - "Tags are identifiers.", - )); - } - *tags = sorted_unique(std::mem::take(tags)); - Ok(()) -} - -/// Facts of the rest of the manifest a build-system declaration is checked -/// against. -pub(crate) struct DeclarationContext<'a> { - pub(crate) debug_adapters: &'a [crate::debug::adapters::DebugAdapterDeclaration], - pub(crate) official: bool, - /// Setting IDs a provider's launch templates may name. - pub(crate) declared_settings: &'a crate::plugins::settings::DeclaredSettings, - /// Language IDs the plugin declares a language server for. - pub(crate) language_servers: &'a BTreeSet, - /// Whether tool names are host runtimes or tool-definition IDs. - pub(crate) runtime_tools: crate::plugins::declarative::RuntimeTools<'a>, -} - -pub(crate) fn validate_declarations( - build_systems: &mut [BuildSystemDeclaration], - context: &DeclarationContext<'_>, -) -> Validation<()> { - if build_systems.is_empty() { - return Ok(()); - } - if build_systems.len() > MAX_BUILD_SYSTEMS { - return Err(invalid( - "buildSystems", - "The plugin declares too many build systems.", - )); - } - let mut ids = BTreeSet::new(); - for (index, build_system) in build_systems.iter_mut().enumerate() { - let field = format!("buildSystems[{index}]"); - build_system.validate(&field, context)?; - if !ids.insert(build_system.id.clone()) { - return Err(invalid( - format!("{field}.id"), - "A build system is declared more than once.", - )); - } - } - build_systems.sort_by(|left, right| left.id.cmp(&right.id)); - Ok(()) -} - -/// Activation event the host raises before using a build system. -pub(crate) fn activation_event(build_system: &BuildSystemDeclaration) -> String { - format!("onBuildSystem:{}", build_system.id) -} - -/// Install-review entries: everything that decides which process runs with -/// which arguments. Display text, detection, and output decoding are left -/// out; changing them does not run anything new. A provider process of its -/// own is reviewed like a language server, with the runtimes it needs. -pub(crate) fn programs(build_system: &BuildSystemDeclaration) -> Vec { - let mut programs = vec![program(build_system)]; - if let Some(ProviderDeclaration::Process(own)) = &build_system.provider { - let mut provider = own.process.program(ServerRole::BuildProvider); - provider["buildSystemID"] = json!(build_system.id); - provider["executesProjectCode"] = json!(own.executes_project_code); - provider["mayUseNetwork"] = json!(own.may_use_network); - programs.push(provider); - programs.extend(own.process.runtime_programs()); - } - programs -} - -fn program(build_system: &BuildSystemDeclaration) -> Value { - let options: Vec = build_system - .options - .iter() - .map(|option| { - json!({ - "id": option.id, - "kind": option.kind, - "default": option.default, - "choices": option.choices.iter().map(|choice| &choice.value).collect::>(), - "choicesFromModel": option.choices_from_model, - "allowCustom": option.allow_custom, - "separator": option.separator(), - "arguments": option.arguments, - "environment": option.environment, - }) - }) - .collect(); - let tasks: Vec = build_system - .tasks - .iter() - .map(|task| { - let mut review = json!({ "id": task.id, "arguments": task.arguments }); - if task.source == TaskSource::Provider { - review["source"] = json!("provider"); - } - // Automatic preparation changes when a task can execute, not merely - // its display. Keep that behavior in the existing install review. - if let Some(preparation) = &task.preparation { - review["preparation"] = json!(preparation); - } - review - }) - .collect(); - let model = match &build_system.model { - Some(ModelSource::Command { arguments, .. }) => json!({ "arguments": arguments }), - _ => Value::Null, - }; - let dependencies = match &build_system.dependencies { - Some(DependenciesSource::Command { arguments, .. }) => json!({ "arguments": arguments }), - _ => Value::Null, - }; - let mut program = json!({ - "kind": "buildSystem", - "buildSystemID": build_system.id, - "invocation": build_system.invocation, - "options": options, - "tasks": tasks, - "run": build_system.run, - "test": build_system.test.as_ref().map(|test| { - let mut review = json!({ "arguments": test.arguments }); - if test.source == TaskSource::Provider { - review["source"] = json!("provider"); - } - if test.discovery { review["discovery"] = json!(true); } - review - }), - "model": model, - "dependencies": dependencies, - }); - if let Some(integration) = &build_system.host_integration { - program["hostIntegration"] = json!(integration); - } - // Build requests reaching a language server change what that process - // is asked to do, so the shared form is part of this entry. - if let Some(ProviderDeclaration::LanguageServer(shared)) = &build_system.provider { - program["provider"] = json!(shared); - } - program -} - -/// A build-system or run ID: also a run-configuration provider segment, so -/// it starts with a letter. -pub(super) fn is_entry_identifier(value: &str) -> bool { - is_identifier(value) - && value.len() <= MAX_IDENTIFIER - && value - .chars() - .next() - .is_some_and(|first| first.is_ascii_lowercase()) -} - -fn is_tag(value: &str) -> bool { - is_identifier(value) && value.len() <= MAX_IDENTIFIER -} - -pub(crate) fn is_environment_name(value: &str) -> bool { - !value.is_empty() - && value.len() <= MAX_IDENTIFIER - && value - .chars() - .next() - .is_some_and(|first| first.is_ascii_uppercase() || first == '_') - && value.chars().all(|character| { - character.is_ascii_uppercase() || character.is_ascii_digit() || character == '_' - }) -} - -/// A file name with no directory part. -fn is_file_name(value: &str) -> bool { - is_safe_relative_path(value) && !value.contains('/') && value != "." -} - -/// An option value: no control characters, bounded. -pub(crate) fn is_option_value(value: &str) -> bool { - !value.is_empty() && value.len() <= MAX_OPTION_VALUE && !value.chars().any(char::is_control) -} - -fn check_text(value: &str, field: &str, what: &str) -> Validation<()> { - if value.trim().is_empty() || value.len() > MAX_TEXT || value.chars().any(char::is_control) { - return Err(invalid( - field, - format!("The {what} must be 1-{MAX_TEXT} printable characters."), - )); - } - Ok(()) -} - -/// Trimmed headings of a task category, or `None` when the path is too deep -/// or a heading is empty, too long, or not printable. -pub(crate) fn category_labels(labels: &[String]) -> Option> { - if labels.len() > MAX_CATEGORY_DEPTH { - return None; - } - labels - .iter() - .map(|label| { - let label = label.trim(); - (!label.is_empty() && label.len() <= MAX_TEXT && !label.chars().any(char::is_control)) - .then(|| label.to_string()) - }) - .collect() -} - -fn check_paths(values: &mut Vec, field: &str, glob: bool) -> Validation<()> { - if values.len() > MAX_LIST { - return Err(invalid(field, "The list is too long.")); - } - for (index, value) in values.iter().enumerate() { - let safe = is_safe_relative_path(value) - && (glob || !value.contains('*')) - && !value - .split('/') - .any(|segment| segment.is_empty() || segment == "."); - if !safe { - return Err(PluginRejection::at( - "plugin.path.invalid", - format!("{field}[{index}]"), - "Paths must be relative, use /, and stay inside the project.", - )); - } - } - *values = sorted_unique(std::mem::take(values)); - Ok(()) -} - -fn check_regex(value: &str, field: &str, groups: usize) -> Validation { - let compiled = (value.len() <= MAX_REGEX) - .then(|| Regex::new(value).ok()) - .flatten() - .ok_or_else(|| invalid(field, "The regular expression is invalid or too long."))?; - if compiled.captures_len() <= groups { - return Err(invalid( - field, - format!("The regular expression needs at least {groups} capture group(s)."), - )); - } - Ok(compiled) -} - -fn check_argument_count(arguments: &[BuildArgument], field: &str) -> Validation<()> { - if arguments.len() > MAX_ARGUMENTS { - return Err(invalid(field, "The argument list is too long.")); - } - Ok(()) -} - -fn availability(scopes: &[Scope], scope: Scope) -> Availability { - let workspace = scopes.contains(&Scope::Workspace); - match scope { - Scope::Project if scopes.iter().all(|value| *value == Scope::Workspace) => { - Availability::Never - } - Scope::Project if workspace => Availability::Maybe, - Scope::Project => Availability::Always, - Scope::Target if !scopes.contains(&Scope::Target) => Availability::Never, - Scope::Target if scopes.len() == 1 => Availability::Always, - Scope::Target => Availability::Maybe, - Scope::Workspace => Availability::Always, - } -} - -fn check_scopes(scopes: &mut Vec, field: &str, allowed: &[Scope]) -> Validation<()> { - *scopes = std::mem::take(scopes) - .into_iter() - .collect::>() - .into_iter() - .collect(); - if scopes.is_empty() || scopes.iter().any(|scope| !allowed.contains(scope)) { - return Err(invalid( - field, - format!("Scopes must be a non-empty subset of {allowed:?}."), - )); - } - Ok(()) -} - -impl BuildSystemDeclaration { - /// Whether the build system starts a provider process of its own. - pub(crate) fn starts_own_provider(&self) -> bool { - matches!(self.provider, Some(ProviderDeclaration::Process(_))) - } - - fn validate(&mut self, field: &str, context: &DeclarationContext<'_>) -> Validation<()> { - if !is_entry_identifier(&self.id) { - return Err(invalid( - format!("{field}.id"), - "A build-system ID starts with a letter and uses lowercase letters, digits, and hyphens.", - )); - } - check_text( - &self.display_name, - &format!("{field}.displayName"), - "display name", - )?; - if let Some(icon) = &self.icon { - icon.validate_at(&format!("{field}.icon"))?; - } - self.validate_integration(field, context.official)?; - self.validate_provider(field, context)?; - self.validate_detection(field)?; - let option_ids = self.validate_options(field)?; - super::model_inputs::validate_declaration(self)?; - self.validate_invocation(field, &option_ids, context)?; - let model_templates = self.validate_tasks(field, &option_ids)?; - self.validate_model(field, &option_ids)?; - self.validate_run(field, &option_ids, &model_templates)?; - self.validate_test(field, &option_ids)?; - self.validate_diagnostics(field)?; - self.validate_dependencies(field, &option_ids)?; - if let Some(sync) = &mut self.sync { - check_paths(&mut sync.watch, &format!("{field}.sync.watch"), false)?; - } - if let Some(layout) = &mut self.layout { - check_paths( - &mut layout.excluded, - &format!("{field}.layout.excluded"), - false, - )?; - } - self.validate_option_emission(field) - } - - fn validate_integration(&self, field: &str, official: bool) -> Validation<()> { - let Some(integration) = &self.host_integration else { - let named = matches!(self.model, Some(ModelSource::HostIntegration)) - || matches!(self.dependencies, Some(DependenciesSource::HostIntegration)) - || matches!( - self.test.as_ref().and_then(|test| test.results.as_ref()), - Some(TestResultsSource::HostIntegration) - ); - if named { - return Err(invalid( - format!("{field}.hostIntegration"), - "A host-integration source requires hostIntegration.", - )); - } - return Ok(()); - }; - let field = format!("{field}.hostIntegration"); - if !HOST_INTEGRATIONS.contains(&integration.as_str()) { - return Err(PluginRejection::at( - "plugin.integration.unsupported", - field, - format!("The host has no build integration named {integration}."), - )); - } - // An integration runs trusted host code against the plugin's files, - // so a community package must not be able to request one. - if !official { - return Err(PluginRejection::at( - "plugin.integration.unsupported", - field, - "Host integrations are reserved to official plugins.", - )); - } - Ok(()) - } - - fn validate_provider(&self, field: &str, context: &DeclarationContext<'_>) -> Validation<()> { - let provider_runs = self - .run - .iter() - .any(|run| run.source == TaskSource::Provider); - let provider_tests = self - .test - .as_ref() - .is_some_and(|test| test.source == TaskSource::Provider); - let uses = self.configuration.is_some() - || matches!(self.model, Some(ModelSource::Provider)) - || provider_runs - || matches!(self.dependencies, Some(DependenciesSource::Provider { .. })) - || provider_tests - || self - .tasks - .iter() - .any(|task| task.source == TaskSource::Provider); - let field = format!("{field}.provider"); - let Some(provider) = &self.provider else { - if uses { - return Err(invalid(field, "A provider source requires provider.")); - } - return Ok(()); - }; - if self.configuration.is_some() && !matches!(provider, ProviderDeclaration::Process(_)) { - return Err(invalid( - &field, - "Native configuration requires an owned provider process.", - )); - } - if !uses { - return Err(invalid( - field, - "No model, dependencies, task or test source uses the provider.", - )); - } - for run in &self.run { - if let Some(debug) = &run.debug { - match (&debug.adapter, &debug.configuration_type) { - (Some(_), None) => { - super::debug_preparation::adapter(run, context.debug_adapters)?; - } - (None, Some(kind)) - if super::run_preparation::valid_configuration_type(kind) - && run.execution == Some(BuildExecution::Service) => {} - _ => return Err(invalid(&field, - "Debug requires a same-package native adapter, or one registry configurationType for a service.")), - } - } - } - if provider_runs - || provider_tests - || self - .tasks - .iter() - .any(|task| task.source == TaskSource::Provider) - { - if !matches!(provider, ProviderDeclaration::Process(_)) { - return Err(invalid( - &field, - "Provider execution requires a plugin-owned provider process.", - )); - } - } - match provider { - ProviderDeclaration::LanguageServer(shared) => { - if !context.language_servers.contains(&shared.language_id) { - return Err(invalid( - format!("{field}.languageServer"), - "The provider names a language the plugin declares no language server for.", - )); - } - Ok(()) - } - ProviderDeclaration::Process(own) => own.process.validate( - &field, - context.declared_settings, - ServerRole::BuildProvider, - context.runtime_tools, - ), - } - } - - fn validate_detection(&mut self, field: &str) -> Validation<()> { - let detection = &mut self.detection; - let markers = format!("{field}.detection.markers"); - if detection.markers.is_empty() || detection.markers.len() > MAX_LIST { - return Err(invalid(markers, "Detection needs 1-32 marker file names.")); - } - if let Some(index) = detection - .markers - .iter() - .position(|marker| !is_file_name(marker)) - { - return Err(invalid( - format!("{markers}[{index}]"), - "A marker is a file name without a directory.", - )); - } - detection.markers = sorted_unique(std::mem::take(&mut detection.markers)); - if detection.priority > MAX_PRIORITY { - return Err(invalid( - format!("{field}.detection.priority"), - "The priority must be 0-100.", - )); - } - Ok(()) - } - - fn validate_options(&mut self, field: &str) -> Validation> { - if self.options.len() > MAX_LIST { - return Err(invalid(format!("{field}.options"), "Too many options.")); - } - let mut ids = BTreeSet::new(); - for (index, option) in self.options.iter_mut().enumerate() { - let field = format!("{field}.options[{index}]"); - option.validate(&field)?; - if !ids.insert(option.id.clone()) { - return Err(invalid( - format!("{field}.id"), - "An option is declared more than once.", - )); - } - } - Ok(ids) - } - - fn validate_invocation( - &mut self, - field: &str, - options: &BTreeSet, - context: &DeclarationContext<'_>, - ) -> Validation<()> { - let invocation = &mut self.invocation; - let field = format!("{field}.invocation"); - if invocation.environment_inputs.len() > MAX_LIST { - return Err(invalid( - format!("{field}.environmentInputs"), - "Too many environment inputs.", - )); - } - if let Some(index) = invocation - .environment_inputs - .iter() - .position(|name| !is_environment_name(name)) - { - return Err(invalid( - format!("{field}.environmentInputs[{index}]"), - "Environment variable names use uppercase letters, digits, and _.", - )); - } - invocation.environment_inputs = - sorted_unique(std::mem::take(&mut invocation.environment_inputs)); - - let mut runtimes = BTreeSet::new(); - for (index, runtime) in invocation.runtime_dependencies.iter_mut().enumerate() { - let field = format!("{field}.runtimeDependencies[{index}]"); - // A runtime without executables is a tool-definition ID, which - // may also be plugin-qualified. - let valid_id = if runtime.names_tool_definition() { - crate::toolchain::definitions::tool_id(&runtime.id) - } else { - is_entry_identifier(&runtime.id) - }; - if !valid_id || !runtimes.insert(runtime.id.clone()) { - return Err(invalid( - format!("{field}.id"), - "Runtime IDs are unique identifiers; a runtime without executables is a tool-definition ID.", - )); - } - if let Some(name) = &runtime.display_name { - check_text(name, &format!("{field}.displayName"), "display name")?; - } - if let Some(minimum) = &runtime.minimum_version { - validate_minimum_version(minimum, &format!("{field}.minimumVersion"))?; - } - if runtime.is_host_managed() { - // The host finds these with the user's own runtime settings; - // a plugin must not route around that choice. - let toolchain = runtime.executables.len() == 1 - && matches!( - runtime.executables[0].source, - ExecutableSource::Toolchain { .. } - ); - if (!runtime.executables.is_empty() && !toolchain) || runtime.version.is_some() { - return Err(invalid( - field, - format!( - "{} is discovered by the host; declare only its minimumVersion.", - runtime.id - ), - )); - } - if toolchain { - check_candidate( - &mut runtime.executables[0], - &format!("{field}.executables[0]"), - &invocation.environment_inputs, - &BTreeSet::new(), - )?; - } - continue; - } - if runtime.names_tool_definition() { - // The tool definition finds the executable and checks - // `minimumVersion`; a probe here would have nothing to run. - if runtime.version.is_some() { - return Err(invalid( - format!("{field}.version"), - format!( - "{} names a tool definition, which checks its version; declare only its minimumVersion.", - runtime.id - ), - )); - } - continue; - } - if runtime.executables.len() > MAX_LIST { - return Err(invalid( - format!("{field}.executables"), - "A plugin-described runtime has at most 32 executable candidates.", - )); - } - for (candidate_index, candidate) in runtime.executables.iter_mut().enumerate() { - let candidate_field = format!("{field}.executables[{candidate_index}]"); - if matches!(candidate.source, ExecutableSource::Runtime { .. }) { - return Err(invalid( - candidate_field, - "A runtime cannot be provided by another runtime.", - )); - } - check_candidate( - candidate, - &candidate_field, - &invocation.environment_inputs, - &BTreeSet::new(), - )?; - } - match &runtime.version { - Some(probe) => probe.validate(&format!("{field}.version"))?, - None if runtime.minimum_version.is_some() - && !runtime.checked_by_tool_definition() => - { - return Err(invalid( - format!("{field}.version"), - "A minimumVersion needs a version probe or a single toolchain candidate.", - )) - } - None => {} - } - } - - if invocation.executables.is_empty() || invocation.executables.len() > MAX_LIST { - return Err(invalid( - format!("{field}.executables"), - "The invocation needs 1-32 executable candidates.", - )); - } - for (index, candidate) in invocation.executables.iter_mut().enumerate() { - check_candidate( - candidate, - &format!("{field}.executables[{index}]"), - &invocation.environment_inputs, - &runtimes, - )?; - } - for (list, location) in - std::iter::once((&invocation.executables, format!("{field}.executables"))).chain( - invocation.runtime_dependencies.iter().map(|runtime| { - ( - &runtime.executables, - format!("{field}.runtimeDependencies.{}.executables", runtime.id), - ) - }), - ) - { - crate::plugins::executables::ordered_indices(list, &location)?; - for candidate in list { - if let ExecutableSource::Setting { setting_id } = &candidate.source { - if !context.declared_settings.is_executable(setting_id) { - return Err(invalid(&location, "A setting candidate needs a global file setting with effect reload or nextRun.")); - } - } - } - } - if let Some(probe) = &invocation.version { - probe.validate(&format!("{field}.version"))?; - } - if let Some(minimum) = &invocation.minimum_version { - if invocation.version.is_none() { - return Err(invalid( - format!("{field}.minimumVersion"), - "A minimumVersion needs a version probe.", - )); - } - validate_minimum_version(minimum, &format!("{field}.minimumVersion"))?; - } - check_argument_count( - &invocation.global_arguments, - &format!("{field}.globalArguments"), - )?; - template::validate_arguments( - &invocation.global_arguments, - &Allowed { - root: true, - options: Some(options), - ..Allowed::none() - }, - &format!("{field}.globalArguments"), - ) - } - - /// Validates the catalog and returns the IDs of `fromModel` templates. - fn validate_tasks( - &mut self, - field: &str, - options: &BTreeSet, - ) -> Validation> { - if self.tasks.len() > MAX_TASKS { - return Err(invalid(format!("{field}.tasks"), "Too many tasks.")); - } - let mut ids = BTreeSet::new(); - let mut templates = BTreeSet::new(); - let mut preparations = BTreeSet::new(); - for (index, task) in self.tasks.iter_mut().enumerate() { - let field = format!("{field}.tasks[{index}]"); - if let Some(preparation) = &mut task.preparation { - if !is_entry_identifier(&preparation.purpose) - || preparation.source_roles.is_empty() - || preparation.source_roles.len() > 6 - || task.from_model - || task.execution != BuildExecution::Task - || task.requires_user_arguments - || !task.scopes.contains(&Scope::Project) - || preparation.source_roles.iter().any(|role| { - !matches!( - role.as_str(), - "source" - | "generatedSource" - | "resource" - | "testSource" - | "generatedTestSource" - | "testResource" - ) || !preparations.insert((preparation.purpose.clone(), role.clone())) - }) - { - return Err(invalid(format!("{field}.preparation"), - "Preparation requires a project task without prompts and unique purpose/source-role bindings.")); - } - preparation.source_roles.sort(); - } - if !is_entry_identifier(&task.id) || !ids.insert(task.id.clone()) { - return Err(invalid( - format!("{field}.id"), - "Task IDs are unique identifiers that start with a letter.", - )); - } - check_text( - &task.display_name, - &format!("{field}.displayName"), - "display name", - )?; - if let Some(description) = &task.description { - if description.len() > 1024 { - return Err(invalid( - format!("{field}.description"), - "The description is too long.", - )); - } - } - if !task.category.is_empty() && category_labels(&task.category).is_none() { - return Err(invalid( - format!("{field}.category"), - format!( - "A category is 1-{MAX_CATEGORY_DEPTH} headings of 1-{MAX_TEXT} printable characters." - ), - )); - } - let allowed = if task.from_model { - if !task.scopes.is_empty() || !task.target_tags.is_empty() { - return Err(invalid( - field, - "A fromModel task acts on its model task; it has no scopes or targetTags.", - )); - } - templates.insert(task.id.clone()); - Allowed { - root: true, - project: Availability::Always, - task: true, - user_arguments: true, - conditions: true, - options: Some(options), - ..Allowed::none() - } - } else { - check_scopes( - &mut task.scopes, - &format!("{field}.scopes"), - &[Scope::Workspace, Scope::Project, Scope::Target], - )?; - check_target_tags(&mut task.target_tags, &task.scopes, &field)?; - Allowed { - root: true, - project: availability(&task.scopes, Scope::Project), - target: availability(&task.scopes, Scope::Target), - user_arguments: true, - conditions: true, - options: Some(options), - ..Allowed::none() - } - }; - if task.source == TaskSource::Provider { - if !task.arguments.is_empty() || task.execution != BuildExecution::Task { - return Err(invalid( - &field, - "A provider task has empty arguments and task execution.", - )); - } - continue; - } - check_argument_count(&task.arguments, &format!("{field}.arguments"))?; - template::validate_arguments(&task.arguments, &allowed, &format!("{field}.arguments"))?; - if task.requires_user_arguments - && !template::uses(&task.arguments, &Variable::UserArguments) - { - return Err(invalid( - format!("{field}.requiresUserArguments"), - "A task that asks for arguments must pass ${userArguments}.", - )); - } - } - if !templates.is_empty() && self.model.is_none() { - return Err(invalid( - format!("{field}.model"), - "fromModel tasks need a model.", - )); - } - Ok(templates) - } - - fn validate_model(&self, field: &str, options: &BTreeSet) -> Validation<()> { - let field = format!("{field}.model"); - let model_options: BTreeSet = self - .options - .iter() - .filter(|option| option.choices_from_model) - .map(|option| option.id.clone()) - .collect(); - match &self.model { - None => { - if !model_options.is_empty() { - return Err(invalid(field, "choicesFromModel needs a model.")); - } - Ok(()) - } - Some(ModelSource::Static { file, map, .. }) => { - if !is_safe_relative_path(file) { - return Err(PluginRejection::at( - "plugin.path.invalid", - format!("{field}.file"), - "The model file must be relative to the build-system root.", - )); - } - map.validate(&format!("{field}.map"), &model_options) - } - Some(ModelSource::Command { - arguments, - output, - decoder, - map, - .. - }) => { - check_argument_count(arguments, &format!("{field}.arguments"))?; - template::validate_arguments( - arguments, - &Allowed { - root: true, - host_directories: true, - options: Some(options), - ..Allowed::none() - }, - &format!("{field}.arguments"), - )?; - check_output_channel(*output, arguments, &field)?; - match (decoder, map) { - (ModelDecoder::LitheBuildModel, None) => Ok(()), - (ModelDecoder::LitheBuildModel, Some(_)) => Err(invalid( - format!("{field}.map"), - "The litheBuildModel format needs no map.", - )), - (_, None) => Err(invalid( - format!("{field}.map"), - "A model command with a generic format needs a map.", - )), - (_, Some(map)) => map.validate(&format!("{field}.map"), &model_options), - } - } - Some(ModelSource::HostIntegration | ModelSource::Provider) => Ok(()), - } - } - - fn validate_run( - &mut self, - field: &str, - options: &BTreeSet, - templates: &BTreeSet, - ) -> Validation<()> { - if self.run.is_empty() { - return Ok(()); - } - if self.model.is_none() { - return Err(invalid( - format!("{field}.run"), - "Run entries come from the model; declare a model.", - )); - } - if self.run.len() > MAX_LIST { - return Err(invalid( - format!("{field}.run"), - "Too many run declarations.", - )); - } - let mut ids = BTreeSet::new(); - for (index, run) in self.run.iter_mut().enumerate() { - let field = format!("{field}.run[{index}]"); - if !is_entry_identifier(&run.id) || !ids.insert(run.id.clone()) { - return Err(invalid( - format!("{field}.id"), - "Run IDs are unique identifiers that start with a letter.", - )); - } - if run.debug.is_some() && run.source != TaskSource::Provider { - return Err(invalid( - &field, - "Debug preparation requires a provider run.", - )); - } - if run.source == TaskSource::Provider { - let valid_targets = run.from_targets.as_ref().is_some_and(|from| { - !from.tags.is_empty() - && from.tags.len() <= MAX_LIST - && from.tags.iter().all(|tag| is_tag(tag)) - }); - if !valid_targets - || run.from_tasks.is_some() - || !run.arguments.is_empty() - || !run.extension.is_empty() - || run.name.as_ref().is_some_and(|name| { - name.trim().is_empty() || name.len() > MAX_TEXT || name.contains("${") - }) - { - return Err(invalid( - &field, - "Provider runs require target tags and no host command templates.", - )); - } - let from = run.from_targets.as_mut().expect("validated target source"); - from.tags = sorted_unique(std::mem::take(&mut from.tags)); - continue; - } - let from_tasks = match (&mut run.from_targets, &mut run.from_tasks) { - (Some(targets), None) => { - if targets.tags.is_empty() || targets.tags.iter().any(|tag| !is_tag(tag)) { - return Err(invalid( - format!("{field}.fromTargets.tags"), - "fromTargets needs one or more tag identifiers.", - )); - } - targets.tags = sorted_unique(std::mem::take(&mut targets.tags)); - if run.arguments.is_empty() { - return Err(invalid( - format!("{field}.arguments"), - "Target run entries need arguments.", - )); - } - false - } - (None, Some(tasks)) => { - if tasks.templates.is_empty() && tasks.executions.is_empty() { - return Err(invalid( - format!("{field}.fromTasks"), - "fromTasks needs templates, executions, or both.", - )); - } - if let Some(unknown) = - tasks.templates.iter().find(|id| !templates.contains(*id)) - { - return Err(invalid( - format!("{field}.fromTasks.templates"), - format!("{unknown} is not a fromModel task."), - )); - } - if self.tasks.iter().any(|task| { - task.source == TaskSource::Provider - && task.from_model - && (tasks.templates.is_empty() || tasks.templates.contains(&task.id)) - }) { - return Err(invalid(format!("{field}.fromTasks"), - "Provider tasks need a typed run capability and cannot become command run entries.")); - } - tasks.templates = sorted_unique(std::mem::take(&mut tasks.templates)); - tasks.executions.sort(); - tasks.executions.dedup(); - if !run.arguments.is_empty() { - return Err(invalid( - format!("{field}.arguments"), - "Task run entries reuse the task's arguments.", - )); - } - true - } - _ => { - return Err(invalid( - field, - "A run declaration needs exactly one of fromTargets and fromTasks.", - )) - } - }; - let allowed = Allowed { - root: true, - project: Availability::Always, - target: if from_tasks { - Availability::Never - } else { - Availability::Always - }, - task: from_tasks, - user_arguments: true, - options: Some(options), - ..Allowed::none() - }; - check_argument_count(&run.arguments, &format!("{field}.arguments"))?; - template::validate_arguments(&run.arguments, &allowed, &format!("{field}.arguments"))?; - let names = Allowed { - user_arguments: false, - options: None, - ..allowed.clone() - }; - if let Some(name) = &run.name { - template::validate_text(name, &names, &format!("{field}.name"))?; - } - if run.extension.len() > MAX_LIST { - return Err(invalid( - format!("{field}.extension"), - "Too many extension values.", - )); - } - for (key, value) in &run.extension { - if !template::is_property_key(key) { - return Err(invalid( - format!("{field}.extension"), - "Extension keys use letters, digits, ., _, and -.", - )); - } - template::validate_text(value, &names, &format!("{field}.extension.{key}"))?; - } - } - Ok(()) - } - - fn validate_test(&mut self, field: &str, options: &BTreeSet) -> Validation<()> { - let Some(test) = &mut self.test else { - return Ok(()); - }; - let field = format!("{field}.test"); - check_scopes( - &mut test.scopes, - &format!("{field}.scopes"), - &[Scope::Workspace, Scope::Project, Scope::Target], - )?; - check_target_tags(&mut test.target_tags, &test.scopes, &field)?; - let distinct_selector_kinds: BTreeSet<_> = test.selector_kinds.iter().collect(); - if !test.selector_kinds.is_empty() - && (test.source != TaskSource::Provider - || test.selector_kinds.len() > 16 - || distinct_selector_kinds.len() != test.selector_kinds.len() - || test - .selector_kinds - .iter() - .any(|kind| !is_test_selector_kind(kind))) - { - return Err(invalid( - &field, - "Test selectorKinds require a provider and bounded identifiers.", - )); - } - test.selector_kinds.sort(); - test.filters.sort(); - test.filters.dedup(); - if test.discovery - && (test.source != TaskSource::Provider - || !test.filters.contains(&TestFilter::Suite) - || !test.filters.contains(&TestFilter::Case)) - { - return Err(invalid( - &field, - "Test discovery requires a provider and suite/case filters.", - )); - } - if test.source == TaskSource::Provider { - if !test.arguments.is_empty() - || matches!(test.results, Some(TestResultsSource::HostIntegration)) - { - return Err(invalid( - &field, - "Provider tests require empty arguments and plugin-owned reports.", - )); - } - } else { - if matches!(test.results, Some(TestResultsSource::Provider)) { - return Err(invalid( - &field, - "Provider-owned reports require provider test execution.", - )); - } - check_argument_count(&test.arguments, &format!("{field}.arguments"))?; - template::validate_arguments( - &test.arguments, - &Allowed { - root: true, - project: availability(&test.scopes, Scope::Project), - target: availability(&test.scopes, Scope::Target), - test: true, - user_arguments: true, - conditions: true, - options: Some(options), - ..Allowed::none() - }, - &format!("{field}.arguments"), - )?; - } - match &mut test.results { - Some(TestResultsSource::JunitXml { reports }) - | Some(TestResultsSource::LitheTestResults { reports }) => { - if reports.is_empty() { - return Err(invalid( - format!("{field}.results.reports"), - "Declare at least one report pattern.", - )); - } - check_paths(reports, &format!("{field}.results.reports"), true) - } - _ => Ok(()), - } - } - - fn validate_diagnostics(&self, field: &str) -> Validation<()> { - let Some(DiagnosticsDeclaration::LineMatcher { patterns }) = &self.diagnostics else { - return Ok(()); - }; - let field = format!("{field}.diagnostics.patterns"); - if patterns.is_empty() || patterns.len() > MAX_LIST { - return Err(invalid(field, "A line matcher needs 1-32 patterns.")); - } - for (index, pattern) in patterns.iter().enumerate() { - let field = format!("{field}[{index}]"); - let groups = [ - Some(pattern.file), - Some(pattern.line), - pattern.column, - Some(pattern.message), - pattern.severity, - ]; - let highest = groups.iter().flatten().copied().max().unwrap_or(0); - check_regex(&pattern.regex, &format!("{field}.regex"), highest)?; - if groups.iter().flatten().any(|group| *group == 0) { - return Err(invalid(field, "Capture groups are numbered from 1.")); - } - if !pattern.severities.is_empty() && pattern.severity.is_none() { - return Err(invalid( - format!("{field}.severities"), - "Severity names need a severity group.", - )); - } - } - Ok(()) - } - - fn validate_dependencies(&mut self, field: &str, options: &BTreeSet) -> Validation<()> { - if let Some(DependenciesSource::Provider { scopes }) = &mut self.dependencies { - return check_scopes( - scopes, - &format!("{field}.dependencies.scopes"), - &[Scope::Workspace, Scope::Project], - ); - } - let Some(DependenciesSource::Command { - scopes, - arguments, - output, - decoder, - map, - .. - }) = &mut self.dependencies - else { - return Ok(()); - }; - let field = format!("{field}.dependencies"); - check_scopes( - scopes, - &format!("{field}.scopes"), - &[Scope::Workspace, Scope::Project], - )?; - check_argument_count(arguments, &format!("{field}.arguments"))?; - template::validate_arguments( - arguments, - &Allowed { - root: true, - project: availability(scopes, Scope::Project), - host_directories: true, - conditions: true, - options: Some(options), - ..Allowed::none() - }, - &format!("{field}.arguments"), - )?; - check_output_channel(*output, arguments, &field)?; - match (decoder, map) { - (DependencyDecoder::LitheDependencies, None) => Ok(()), - (DependencyDecoder::LitheDependencies, Some(_)) => Err(invalid( - format!("{field}.map"), - "The litheDependencies format needs no map.", - )), - (_, None) => Err(invalid( - format!("{field}.map"), - "A dependency command with a generic format needs a map.", - )), - (_, Some(rule)) => rule.validate(&format!("{field}.map")), - } - } - - /// An option's arguments must be emitted at most once per command line: - /// either all options in one place, or each option in exactly one of the - /// global and the command-specific lists, and once within each list. - fn validate_option_emission(&self, field: &str) -> Validation<()> { - let twice = |list_field: String| { - template_error( - list_field, - "An option's arguments would be emitted twice; reference each option in one place.", - ) - }; - let globals = &self.invocation.global_arguments; - if template::repeats_option_arguments(globals) { - return Err(twice(format!("{field}.invocation.globalArguments"))); - } - let (global_all, global_named) = template::option_references(globals); - let mut lists: Vec<(String, &[BuildArgument])> = Vec::new(); - for (index, task) in self.tasks.iter().enumerate() { - lists.push((format!("{field}.tasks[{index}].arguments"), &task.arguments)); - } - for (index, run) in self.run.iter().enumerate() { - lists.push((format!("{field}.run[{index}].arguments"), &run.arguments)); - } - if let Some(test) = &self.test { - lists.push((format!("{field}.test.arguments"), &test.arguments)); - } - if let Some(ModelSource::Command { arguments, .. }) = &self.model { - lists.push((format!("{field}.model.arguments"), arguments)); - } - if let Some(DependenciesSource::Command { arguments, .. }) = &self.dependencies { - lists.push((format!("{field}.dependencies.arguments"), arguments)); - } - for (list_field, arguments) in lists { - let (all, named) = template::option_references(arguments); - if (global_all && (all || !named.is_empty())) - || (all && !global_named.is_empty()) - || global_named.intersection(&named).next().is_some() - || template::repeats_option_arguments(arguments) - { - return Err(twice(list_field)); - } - } - Ok(()) - } -} - -fn check_output_channel( - output: OutputChannel, - arguments: &[BuildArgument], - field: &str, -) -> Validation<()> { - let uses_file = template::uses(arguments, &Variable::OutputFile); - match output { - OutputChannel::OutputFile if !uses_file => Err(invalid( - format!("{field}.output"), - "An outputFile command must pass ${outputFile}.", - )), - OutputChannel::Stdout if uses_file => Err(invalid( - format!("{field}.output"), - "${outputFile} needs \"output\": \"outputFile\".", - )), - _ => Ok(()), - } -} - -fn executable_path(field: &str) -> PluginRejection { - PluginRejection::at( - "plugin.path.invalid", - format!("{field}.path"), - "Executable paths are relative, use /, and stay inside their base directory.", - ) -} - -fn check_candidate( - candidate: &mut ExecutableCandidate, - field: &str, - environment_inputs: &[String], - runtimes: &BTreeSet, -) -> Validation<()> { - match &mut candidate.source { - ExecutableSource::ProjectFile { path, requires, .. } => { - *requires = sorted_unique(std::mem::take(requires)); - if !is_safe_relative_path(path) - || !requires.iter().all(|value| is_safe_relative_path(value)) - { - return Err(executable_path(field)); - } - } - ExecutableSource::Environment { variable, path, .. } => { - if !environment_inputs.contains(variable) { - return Err(invalid( - format!("{field}.variable"), - "The variable must be listed in environmentInputs.", - )); - } - if !is_safe_relative_path(path) { - return Err(executable_path(field)); - } - } - ExecutableSource::Command { command, .. } => { - if !is_bare_command(command) { - return Err(invalid( - format!("{field}.command"), - "A command is a bare name looked up on PATH.", - )); - } - } - ExecutableSource::Setting { setting_id } => { - if !is_setting_identifier(setting_id) { - return Err(invalid(format!("{field}.settingID"), "Invalid setting ID.")); - } - } - ExecutableSource::Toolchain { toolchain_id } => { - if !crate::toolchain::definitions::tool_id(toolchain_id) { - return Err(invalid( - format!("{field}.toolchainID"), - "Invalid toolchain ID.", - )); - } - } - ExecutableSource::Runtime { runtime, .. } => { - if !runtimes.contains(runtime) { - return Err(invalid( - format!("{field}.runtime"), - "The runtime must be declared in runtimeDependencies.", - )); - } - } - } - let platforms = &mut candidate.platforms; - platforms.sort(); - platforms.dedup(); - Ok(()) -} - -impl VersionProbe { - fn validate(&self, field: &str) -> Validation<()> { - if self.arguments.len() > 8 - || self - .arguments - .iter() - .any(|argument| argument.contains('\0') || argument.contains("${")) - { - return Err(invalid( - format!("{field}.arguments"), - "Version arguments are at most 8 literal strings.", - )); - } - check_regex(&self.pattern, &format!("{field}.pattern"), 1).map(|_| ()) - } - - /// The dotted version the probe output reports, if any. - pub(crate) fn version(&self, output: &str) -> Option> { - let pattern = Regex::new(&self.pattern).ok()?; - let text = pattern.captures(output)?.get(1)?.as_str().to_string(); - let components: Vec = text - .split('.') - .take(3) - .map(|part| part.parse().ok()) - .collect::>()?; - (!components.is_empty()).then_some(components) - } -} - -impl OptionDeclaration { - fn validate(&mut self, field: &str) -> Validation<()> { - if !is_setting_identifier(&self.id) { - return Err(invalid( - format!("{field}.id"), - "Option IDs start with a lowercase letter and use letters and digits.", - )); - } - check_text( - &self.display_name, - &format!("{field}.displayName"), - "display name", - )?; - let select = self.is_select(); - if !select && (!self.choices.is_empty() || self.choices_from_model || self.allow_custom) { - return Err(invalid( - field, - "Choices apply to singleSelect and multiSelect options only.", - )); - } - if select && self.choices.is_empty() && !self.choices_from_model && !self.allow_custom { - return Err(invalid( - format!("{field}.choices"), - "A select option needs choices, choicesFromModel, or allowCustom.", - )); - } - if let Some(separator) = &self.separator { - if self.kind != OptionKind::MultiSelect - || separator.is_empty() - || separator.len() > 4 - || separator.chars().any(char::is_control) - { - return Err(invalid( - format!("{field}.separator"), - "Only multiSelect options take a separator of 1-4 printable characters.", - )); - } - } - if self.choices.len() > MAX_CHOICES { - return Err(invalid(format!("{field}.choices"), "Too many choices.")); - } - let mut values = BTreeSet::new(); - for (index, choice) in self.choices.iter().enumerate() { - let bad_value = !is_option_value(&choice.value) - || (self.kind == OptionKind::MultiSelect - && choice.value.contains(self.separator())); - if bad_value || !values.insert(choice.value.clone()) { - return Err(invalid( - format!("{field}.choices[{index}].value"), - "Choice values are unique, printable, and free of the separator.", - )); - } - if let Some(title) = &choice.title { - check_text(title, &format!("{field}.choices[{index}].title"), "title")?; - } - } - self.validate_presentation(field)?; - self.validate_default(field)?; - if self.arguments.is_empty() && self.environment.is_empty() { - return Err(invalid( - field, - "An option must map to arguments, environment variables, or both.", - )); - } - if self.arguments.len() > 8 || self.environment.len() > 8 { - return Err(invalid( - field, - "An option maps to at most 8 arguments and 8 variables.", - )); - } - let boolean = self.kind == OptionKind::Boolean; - let allowed = Allowed { - value: !boolean, - ..Allowed::none() - }; - let mut uses_value = false; - for (index, argument) in self.arguments.iter().enumerate() { - template::validate_text(argument, &allowed, &format!("{field}.arguments[{index}]"))?; - uses_value |= argument.contains("${value}"); - } - for (name, value) in &self.environment { - if !is_environment_name(name) { - return Err(invalid( - format!("{field}.environment"), - "Environment variable names use uppercase letters, digits, and _.", - )); - } - template::validate_text(value, &allowed, &format!("{field}.environment.{name}"))?; - uses_value |= value.contains("${value}"); - } - if !boolean && !uses_value { - return Err(template_error( - field, - "The option's value is never passed; use ${value}.", - )); - } - Ok(()) - } - - fn validate_presentation(&self, field: &str) -> Validation<()> { - if let Some(icon) = &self.icon { - icon.validate_at(&format!("{field}.icon"))?; - } - match self.presentation { - Some(OptionPresentation::Toolbar) - if self.kind != OptionKind::Boolean || self.icon.is_none() => - { - Err(invalid( - format!("{field}.presentation"), - "A toolbar option is a boolean option with an icon.", - )) - } - Some(OptionPresentation::Tree) if self.kind != OptionKind::MultiSelect => Err(invalid( - format!("{field}.presentation"), - "A tree option is a multiSelect option.", - )), - None if self.icon.is_some() => Err(invalid( - format!("{field}.icon"), - "Only toolbar options have an icon.", - )), - _ => Ok(()), - } - } - - fn validate_default(&self, field: &str) -> Validation<()> { - let Some(default) = &self.default else { - return Ok(()); - }; - let field = format!("{field}.default"); - let valid = match (self.kind, default) { - (OptionKind::Boolean, Value::Bool(_)) => true, - (OptionKind::MultiSelect, Value::Array(items)) => items.iter().all(|item| { - item.as_str() - .is_some_and(|value| self.accepts_value(value, Some(&[]))) - }), - (OptionKind::SingleSelect, Value::String(value)) => { - self.accepts_value(value, Some(&[])) - } - (OptionKind::Text, Value::String(value)) => value.is_empty() || is_option_value(value), - // Paths are the user's: a plugin cannot know one on this machine. - (OptionKind::File | OptionKind::Directory, Value::String(value)) => value.is_empty(), - _ => false, - }; - if !valid { - return Err(invalid( - field, - "The default does not fit the option's kind and choices; file and directory options have no default.", - )); - } - Ok(()) - } -} - -impl ModelMap { - fn validate(&self, field: &str, model_options: &BTreeSet) -> Validation<()> { - if self.projects.is_empty() { - return Err(invalid( - format!("{field}.projects"), - "A model map needs at least one project rule.", - )); - } - check_rule_count(self.projects.len(), &format!("{field}.projects"))?; - for (index, rule) in self.projects.iter().enumerate() { - let field = format!("{field}.projects[{index}]"); - check_rule(rule, &field)?; - rule.name.validate(&format!("{field}.name"))?; - for spec in [&rule.display_name, &rule.path].into_iter().flatten() { - spec.validate(&field)?; - } - check_properties(&rule.properties, &field)?; - check_rule_count(rule.targets.len(), &format!("{field}.targets"))?; - for (target_index, target) in rule.targets.iter().enumerate() { - target.validate(&format!("{field}.targets[{target_index}]"))?; - } - check_rule_count(rule.content_roots.len(), &format!("{field}.contentRoots"))?; - for (root_index, content_root) in rule.content_roots.iter().enumerate() { - let field = format!("{field}.contentRoots[{root_index}]"); - check_rule(content_root, &field)?; - content_root.path.validate(&format!("{field}.path"))?; - } - } - for (id, rule) in &self.option_values { - let field = format!("{field}.optionValues.{id}"); - if !model_options.contains(id) { - return Err(invalid( - field, - "optionValues may only fill options declared with choicesFromModel.", - )); - } - select::validate(&rule.each, &format!("{field}.each"), false)?; - check_predicates(&rule.filter, &field)?; - rule.value.validate(&format!("{field}.value"))?; - } - Ok(()) - } -} - -fn check_rule_count(count: usize, field: &str) -> Validation<()> { - if count > MAX_LIST { - return Err(invalid(field, "Too many rules.")); - } - Ok(()) -} - -fn check_rule(rule: &impl RuleMatch, field: &str) -> Validation<()> { - if let Some(each) = rule.each() { - select::validate(each, &format!("{field}.each"), false)?; - } - check_predicates(rule.filter(), field)?; - if let Some(file) = rule.when_file() { - if !is_safe_relative_path(file) { - return Err(PluginRejection::at( - "plugin.path.invalid", - format!("{field}.whenFile"), - "whenFile is relative to the project directory.", - )); - } - } - Ok(()) -} - -fn check_predicates(predicates: &[Predicate], field: &str) -> Validation<()> { - if predicates.len() > 8 { - return Err(invalid(format!("{field}.where"), "Too many predicates.")); - } - for (index, predicate) in predicates.iter().enumerate() { - predicate.validate(&format!("{field}.where[{index}]"))?; - } - Ok(()) -} - -fn check_properties(properties: &BTreeMap, field: &str) -> Validation<()> { - if properties.len() > MAX_LIST { - return Err(invalid( - format!("{field}.properties"), - "Too many properties.", - )); - } - for (key, spec) in properties { - if !template::is_property_key(key) { - return Err(invalid( - format!("{field}.properties"), - "Property keys use letters, digits, ., _, and -.", - )); - } - spec.validate(&format!("{field}.properties.{key}"))?; - } - Ok(()) -} - -impl TargetRule { - fn validate(&self, field: &str) -> Validation<()> { - check_rule(self, field)?; - self.name.validate(&format!("{field}.name"))?; - if let Some(path) = &self.path { - path.validate(&format!("{field}.path"))?; - } - check_properties(&self.properties, field)?; - if self.tags.iter().any(|tag| !is_tag(tag)) { - return Err(invalid(format!("{field}.tags"), "Tags are identifiers.")); - } - Ok(()) - } -} - -impl DependencyRule { - fn validate(&self, field: &str) -> Validation<()> { - select::validate(&self.each, &format!("{field}.each"), false)?; - check_predicates(&self.filter, field)?; - self.name.validate(&format!("{field}.name"))?; - for spec in [&self.group, &self.version, &self.scope] - .into_iter() - .flatten() - { - spec.validate(field)?; - } - check_properties(&self.properties, field) - } -} diff --git a/rust/crates/core/src/debug/adapters.rs b/rust/crates/core/src/debug/adapters.rs deleted file mode 100644 index 25fe507c..00000000 --- a/rust/crates/core/src/debug/adapters.rs +++ /dev/null @@ -1,2158 +0,0 @@ -//! `debugAdapters[]` of an API level 4 plugin manifest: what a host must know -//! about a debug adapter before starting any process for it. -//! -//! A declaration names the adapter, how its process is started and reached -//! (stdio, a loopback TCP endpoint, or a port the plugin's own language server -//! opens on request), and optional matching, default DAP arguments and -//! capability suppression. It carries no behavior: there is no -//! resolver, workflow or host integration, so a plugin can only choose among -//! facts Core already understands. DAP capabilities belong to the running -//! adapter and are negotiated in `initialize`; `capabilities.disabled` can -//! only switch standard ones off. -//! -//! Validation has three layers. `validate_declarations` checks the manifest -//! on its own. `check_package_resources` checks the declaration against the -//! files of the installed package. `plan` checks one debug start against the -//! facts of that moment (target, registration and runtime generation). -//! -//! Note: the decision is recorded in .agents/notes/proposed/architecture/2026-10-25-unified-debug-engine-and-adapter-registry.md - -use super::types::{DebugAdapterDefaults, DebugAdapterFeatures, DeclaredAdapterBehavior}; -use crate::plugins::declarative::{ - invalid, is_safe_relative_path, validate_process_arguments, PluginRejection, ServerProcess, - ServerRole, Validation, -}; -use crate::plugins::settings::DeclaredSettings; -use serde::{Deserialize, Deserializer, Serialize}; -use serde_json::{json, Map, Value}; -use std::collections::{BTreeSet, HashSet}; -use std::net::IpAddr; - -/// Most adapters one plugin may declare. -const MAX_ADAPTERS: usize = 16; -/// Longest display name or DAP adapter ID, in characters. -const MAX_NAME: usize = 128; -/// Longest adapter-local ID, in characters. -const MAX_LOCAL_ID: usize = 64; -/// Most selectors one adapter may declare. -const MAX_SELECTORS: usize = 16; -/// Most imported `launch.json` type aliases one adapter may declare. -const MAX_CONFIGURATION_TYPES: usize = 16; -/// Longest announced-endpoint prefix, in bytes. -const MAX_ENDPOINT_PREFIX: usize = 256; -/// Longest language server command ID, in characters. -const MAX_ENDPOINT_COMMAND: usize = 128; -/// Most console commands one adapter may declare. -const MAX_CONSOLE_COMMANDS: usize = 1024; -/// Longest console command name, in characters. -const MAX_CONSOLE_COMMAND_NAME: usize = 128; -/// Longest console command description, in characters. -const MAX_CONSOLE_COMMAND_DESCRIPTION: usize = 240; -/// Longest console command prefix, in characters. -const MAX_CONSOLE_COMMAND_PREFIX: usize = 8; -/// Most places an executable lookup may list. -const MAX_EXECUTABLE_CANDIDATES: usize = 8; -/// Longest executable candidate, in characters. -const MAX_EXECUTABLE_CANDIDATE: usize = 1024; -/// The `targetBindings.program` source that reads the executable the host -/// resolved, instead of the target's own path. -const TARGET_EXECUTABLE: &str = "target.executable"; -/// The target kind of a remote `host:port` the debuggee listens on. -const TARGET_CONNECTION: &str = "connection"; -/// Most names an argument path joins, for example `connect.host`. -const MAX_ARGUMENT_PATH_DEPTH: usize = 4; -/// Longest single name in an argument path, in characters. -const MAX_ARGUMENT_NAME: usize = 64; -/// Most path mappings one remote target may carry. -const MAX_PATH_MAPPINGS: usize = 16; -/// Longest local or remote root of a path mapping, in characters. -const MAX_PATH_MAPPING_ROOT: usize = 1024; -/// Longest host name, as DNS limits it. -const MAX_HOST: usize = 253; -/// Longest label of a host name, as DNS limits it. -const MAX_HOST_LABEL: usize = 63; -/// Variables an executable candidate may name, and the only ones the host -/// can answer: the workspace root and the open source file's location. -const EXECUTABLE_VARIABLES: &[&str] = &[ - "workspaceFolder", - "fileDirectory", - "fileBasenameNoExtension", -]; -/// What a candidate may start with, so a lookup stays beside the user's work. -const EXECUTABLE_ROOTS: &[&str] = &["${workspaceFolder}/", "${fileDirectory}/"]; - -/// Target kinds a selector may match. -const TARGET_KINDS: &[&str] = &["connection", "executable", "file", "process", "runTarget"]; - -/// `process` keys of a debug adapter launch. `initializationOptions` is an -/// LSP field and has no meaning for a DAP transport. -const PROCESS_KEYS: &[&str] = &[ - "launch", - "arguments", - "validationArguments", - "runtimeDependencies", -]; - -/// Standard DAP `Capabilities` booleans plus the exception filter list; the -/// only names `capabilities.disabled` accepts. -const DAP_CAPABILITIES: &[&str] = &[ - "exceptionBreakpointFilters", - "supportSuspendDebuggee", - "supportTerminateDebuggee", - "supportsANSIStyling", - "supportsBreakpointLocationsRequest", - "supportsCancelRequest", - "supportsClipboardContext", - "supportsCompletionsRequest", - "supportsConditionalBreakpoints", - "supportsConfigurationDoneRequest", - "supportsDataBreakpointBytes", - "supportsDataBreakpoints", - "supportsDelayedStackTraceLoading", - "supportsDisassembleRequest", - "supportsEvaluateForHovers", - "supportsExceptionFilterOptions", - "supportsExceptionInfoRequest", - "supportsExceptionOptions", - "supportsFunctionBreakpoints", - "supportsGotoTargetsRequest", - "supportsHitConditionalBreakpoints", - "supportsInstructionBreakpoints", - "supportsLoadedSourcesRequest", - "supportsLogPoints", - "supportsModulesRequest", - "supportsReadMemoryRequest", - "supportsRestartFrame", - "supportsRestartRequest", - "supportsSetExpression", - "supportsSetVariable", - "supportsSingleThreadExecutionRequests", - "supportsStepBack", - "supportsStepInTargetsRequest", - "supportsSteppingGranularity", - "supportsTerminateRequest", - "supportsTerminateThreadsRequest", - "supportsValueFormattingOptions", - "supportsWriteMemoryRequest", -]; - -/// One debug adapter a plugin implements. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct DebugAdapterDeclaration { - /// Plugin-local ID. The Registry identity is qualified by the plugin ID. - pub(crate) id: String, - pub(crate) display_name: String, - /// DAP `initialize.adapterID`; the local ID when absent. - #[serde(default, rename = "adapterID", skip_serializing_if = "Option::is_none")] - pub(crate) adapter_id: Option, - /// Requests the adapter serves; `launch` only when absent. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub(crate) requests: Vec, - /// Automatic matching; absent means explicit selection only. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub(crate) selectors: Vec, - /// Imported `launch.json` `type` values this adapter answers to. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub(crate) configuration_types: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) configurations: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) capabilities: Option, - /// What the Debug Console offers while typing a debugger command. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) console: Option, - /// DAP extensions the adapter implements; `debugAdapters` 3. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) features: Option, - /// Values the adapter starts with when the user set none; `debugAdapters` 3. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) defaults: Option, - pub(crate) launch: DebugLaunch, -} - -/// Debugger commands the adapter's console understands. Adapters that answer -/// the DAP `completions` request complete live; this list is for the ones -/// that do not, and a host shows both together. -#[derive(Debug, Clone, Default, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct DebugConsole { - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub(crate) commands: Vec, - /// Text the adapter's console reads as "what follows is a debugger - /// command, not an expression". A host puts it before the typed line when - /// it asks the adapter to complete, so the adapter proposes commands - /// instead of expression keywords it would offer for a bare word. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) command_prefix: Option, -} - -/// One completable command word or phrase, for example `breakpoint set`. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct DebugConsoleCommand { - /// The text inserted when the command is chosen. - pub(crate) name: String, - /// One line shown beside the name. Adapter-owned text, shown as written. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) description: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) enum DebugRequest { - Launch, - Attach, -} - -/// Optional automatic matching: OR between selectors, AND between fields. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct DebugSelector { - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub(crate) target_kinds: Vec, - #[serde(default, rename = "languageIDs", skip_serializing_if = "Vec::is_empty")] - pub(crate) language_ids: Vec, -} - -#[derive(Debug, Clone, Default, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct DebugConfigurations { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) launch: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) attach: Option, -} - -/// Defaults for the DAP arguments of one request. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct DebugConfiguration { - /// Adapter-owned default DAP arguments, ordinary JSON data. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) arguments: Option>, - /// Packaged JSON schema of the final merged arguments. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) parameters_schema: Option, - /// Which target fact fills which missing DAP argument. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) target_bindings: Option, - /// Where the program to debug comes from when the target is a source - /// file. Launch only, and exactly when `program` binds to - /// `target.executable`. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) executable: Option, -} - -/// How a host finds the program a source file builds into. The user's own -/// choice in a `file` setting always wins; the candidates are what a host -/// looks for when the user chose nothing, so the setting can show it. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct DebugExecutable { - /// ID of a `file` setting of the same plugin. - pub(crate) setting: String, - /// Paths to try in order. Each starts with `${workspaceFolder}/` or - /// `${fileDirectory}/` and may use `${fileBasenameNoExtension}`. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub(crate) candidates: Vec, -} - -/// Typed projections from the target into known DAP fields. Each field has -/// exactly one legal source, so a binding is a fact, not an expression. -#[derive(Debug, Clone, Default, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct DebugTargetBindings { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) args: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) env: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) program: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) process_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) cwd: Option, - /// Where a remote `host:port` goes. Attach only. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) connection: Option, -} - -/// Where each fact of a remote target lands in the adapter's attach -/// arguments. Adapters name and nest these differently (`hostName` and `port`, -/// `connect.host`, a single `host:port` string), so unlike the fixed names -/// above each value here is the destination path, not the source fact: `host` -/// reads `target.host`, `port` reads `target.port`, `address` reads both. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct DebugConnectionBinding { - /// Argument that receives the host name or IP literal. Pairs with `port`. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) host: Option, - /// Argument that receives the port, as an integer. Pairs with `host`. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) port: Option, - /// Argument that receives `host:port` as one string, with an IPv6 host in - /// brackets. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) address: Option, - /// How local and remote source roots reach the adapter. Absent when the - /// adapter has no such argument; the host then does not offer the choice. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) path_mappings: Option, -} - -/// The ways adapters take source roots: where they go and in what form. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(tag = "shape", rename_all = "camelCase", deny_unknown_fields)] -pub(crate) enum DebugPathMappingBinding { - /// `[{ : …, : … }, …]` at `argument`. - #[serde(rename_all = "camelCase")] - Objects { - argument: String, - local_key: String, - remote_key: String, - }, - /// The first mapping only, as two strings: `local` and `remote` are the - /// destination arguments. - First { local: String, remote: String }, - /// `{ : , … }` at `argument`, keyed by one side. - #[serde(rename_all = "camelCase")] - Dictionary { - argument: String, - keyed_by: MappingRoot, - }, - /// `[[, ], …]` at `argument`, in the declared order. - Pairs { - argument: String, - order: MappingOrder, - }, -} - -/// Which side of a mapping a dictionary is keyed by. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) enum MappingRoot { - Local, - Remote, -} - -/// Which root comes first in a pair. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) enum MappingOrder { - RemoteLocal, - LocalRemote, -} - -impl DebugPathMappingBinding { - /// Destination paths the binding writes, with the field each belongs to. - fn destinations(&self) -> Vec<(&'static str, &str)> { - match self { - Self::Objects { argument, .. } - | Self::Dictionary { argument, .. } - | Self::Pairs { argument, .. } => vec![("pathMappings.argument", argument)], - Self::First { local, remote } => { - vec![ - ("pathMappings.local", local), - ("pathMappings.remote", remote), - ] - } - } - } - - fn key_fields(&self) -> Vec<(&'static str, &str)> { - match self { - Self::Objects { - local_key, - remote_key, - .. - } => vec![ - ("pathMappings.localKey", local_key), - ("pathMappings.remoteKey", remote_key), - ], - Self::First { .. } | Self::Dictionary { .. } | Self::Pairs { .. } => Vec::new(), - } - } - - /// How many mappings the adapter can take. A shape that holds one mapping - /// refuses more rather than keeping the first and dropping the rest. - fn limit(&self) -> usize { - match self { - Self::First { .. } => 1, - Self::Objects { .. } | Self::Dictionary { .. } | Self::Pairs { .. } => { - MAX_PATH_MAPPINGS - } - } - } -} - -impl DebugConnectionBinding { - /// Every destination argument path with the binding field it comes from. - fn destinations(&self) -> Vec<(&'static str, &str)> { - let mut destinations = Vec::new(); - for (name, path) in [ - ("host", &self.host), - ("port", &self.port), - ("address", &self.address), - ] { - if let Some(path) = path { - destinations.push((name, path.as_str())); - } - } - if let Some(mappings) = &self.path_mappings { - destinations.extend(mappings.destinations()); - } - destinations - } - - fn validate( - &self, - field: &str, - defaults: Option<&Map>, - fixed_names: &[&str], - ) -> Validation<()> { - if self.host.is_some() != self.port.is_some() { - return Err(invalid( - format!("{field}.host"), - "host and port are declared together.", - )); - } - if self.host.is_none() && self.address.is_none() { - return Err(invalid( - field, - "A connection binding receives the target through host and port, or address.", - )); - } - let destinations = self.destinations(); - for (name, path) in &destinations { - if !is_argument_path(path) { - return Err(invalid( - format!("{field}.{name}"), - format!( - "An argument path is up to {MAX_ARGUMENT_PATH_DEPTH} names joined by dots, each a letter or underscore followed by letters, digits, underscores, or hyphens." - ), - )); - } - } - if let Some(mappings) = &self.path_mappings { - for (name, key) in mappings.key_fields() { - if !is_argument_name(key) { - return Err(invalid( - format!("{field}.{name}"), - "A key is a letter or underscore followed by letters, digits, underscores, or hyphens.", - )); - } - } - if let DebugPathMappingBinding::Objects { - local_key, - remote_key, - .. - } = mappings - { - if local_key == remote_key { - return Err(invalid( - format!("{field}.pathMappings.remoteKey"), - "The local and remote keys differ.", - )); - } - } - } - // Two facts can never share a destination, and a destination the - // plugin's own defaults already set would make the default win over - // the host the user confirmed: the adapter would connect somewhere the - // confirmation did not name. - for (index, (name, path)) in destinations.iter().enumerate() { - if let Some((other, _)) = destinations[..index] - .iter() - .find(|(_, other)| paths_overlap(path, other)) - { - return Err(invalid( - format!("{field}.{name}"), - format!("The argument path overlaps {other}."), - )); - } - if fixed_names.iter().any(|fixed| paths_overlap(path, fixed)) { - return Err(invalid( - format!("{field}.{name}"), - "The argument path overlaps another binding.", - )); - } - if defaults.is_some_and(|defaults| default_occupies(defaults, path)) { - return Err(invalid( - format!("{field}.{name}"), - "The argument path is already set by the configuration's own arguments.", - )); - } - } - Ok(()) - } -} - -#[derive(Debug, Clone, Default, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct DebugCapabilities { - /// Standard DAP capability names the host treats as absent. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub(crate) disabled: Vec, -} - -/// How the adapter process is started and reached. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct DebugLaunch { - /// Absent exactly for `tcpLanguageServer`, whose adapter lives inside a - /// language server the plugin already declares and has no process of its - /// own. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) process: Option, - pub(crate) transport: DebugTransport, - /// Required by, and only allowed for, the TCP transports. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) endpoint: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) enum DebugTransport { - Stdio, - TcpAnnounced, - TcpPort, - /// The adapter runs inside the plugin's language server, which opens a - /// loopback port when asked by a command. - TcpLanguageServer, -} - -/// Where a TCP adapter listens. The fields of the other transports must stay -/// absent, which `validate` checks. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct DebugEndpoint { - /// `tcpLanguageServer`: language ID of a language server of this plugin. - #[serde( - default, - rename = "languageID", - skip_serializing_if = "Option::is_none" - )] - pub(crate) language_id: Option, - /// `tcpLanguageServer`: the `workspace/executeCommand` command that starts - /// the adapter and answers with its loopback port. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) command: Option, - /// `tcpAnnounced`: the stream the adapter prints its endpoint on. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) stream: Option, - /// `tcpAnnounced`: the text before the endpoint on that line. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) prefix: Option, - /// `tcpAnnounced`: `port` or `hostPort`. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) value: Option, - /// `tcpPort`: a fixed loopback port of the adapter process. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) port: Option, -} - -/// The adapter process: the launch fields every plugin server process shares, -/// or the runtime Run selected for the target. -#[derive(Debug, Clone, Serialize)] -#[serde(untagged)] -pub(crate) enum DebugProcess { - Server(ServerProcess), - SelectedRuntime(SelectedRuntimeProcess), -} - -/// Start the adapter on the runtime Run selected for the target, not on a -/// runtime the plugin names a version of. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct SelectedRuntimeProcess { - launch: SelectedRuntimeLaunch, - #[serde(default)] - arguments: Vec, - #[serde(default)] - validation_arguments: Vec, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct SelectedRuntimeLaunch { - kind: SelectedRuntimeKind, - tool: String, -} - -#[derive(Debug, Clone, Copy, Deserialize, Serialize)] -enum SelectedRuntimeKind { - #[serde(rename = "runSelectedRuntime")] - RunSelectedRuntime, -} - -impl<'de> Deserialize<'de> for DebugProcess { - /// Chooses the branch by `launch.kind` and refuses fields a DAP adapter - /// cannot use, so `ServerProcess` keeps one shape for every owner. - fn deserialize>(deserializer: D) -> Result { - use serde::de::Error; - let value = Value::deserialize(deserializer)?; - let object = value - .as_object() - .ok_or_else(|| D::Error::custom("a debug adapter process is an object"))?; - let selected = object - .get("launch") - .and_then(|launch| launch.get("kind")) - .and_then(Value::as_str) - == Some("runSelectedRuntime"); - if selected { - return serde_json::from_value(value) - .map(Self::SelectedRuntime) - .map_err(D::Error::custom); - } - if let Some(key) = object - .keys() - .find(|key| !PROCESS_KEYS.contains(&key.as_str())) - { - return Err(D::Error::custom(format!( - "unknown field `{key}` in a debug adapter process" - ))); - } - serde_json::from_value(value) - .map(Self::Server) - .map_err(D::Error::custom) - } -} - -impl DebugProcess { - fn validate(&self, field: &str, context: &DeclarationContext<'_>) -> Validation<()> { - match self { - Self::Server(process) => process.validate( - field, - context.declared_settings, - ServerRole::DebugAdapter, - context.runtime_tools, - ), - Self::SelectedRuntime(process) => { - let tool = process.launch.tool.as_str(); - context - .runtime_tools - .check(tool, format!("{field}.launch.tool"))?; - validate_process_arguments( - &process.arguments, - &process.validation_arguments, - context.declared_settings, - field, - ) - } - } - } - - /// The runtime tool this adapter needs Run to have selected. - pub(crate) fn selected_runtime_tool(&self) -> Option<&str> { - match self { - Self::SelectedRuntime(process) => Some(process.launch.tool.as_str()), - Self::Server(_) => None, - } - } -} - -/// What validation needs from the manifest around the declarations. -pub(crate) struct DeclarationContext<'a> { - pub(crate) declared_settings: &'a DeclaredSettings, - /// Language IDs of the plugin's `languageServers[]`. - pub(crate) language_servers: &'a BTreeSet, - /// Whether tool names are host runtimes or tool-definition IDs. - pub(crate) runtime_tools: crate::plugins::declarative::RuntimeTools<'a>, -} - -/// Validates every declaration and sorts them by ID. -pub(crate) fn validate_declarations( - adapters: &mut [DebugAdapterDeclaration], - context: &DeclarationContext<'_>, -) -> Validation<()> { - if adapters.is_empty() { - return Ok(()); - } - if adapters.len() > MAX_ADAPTERS { - return Err(invalid( - "debugAdapters", - format!("A plugin declares at most {MAX_ADAPTERS} debug adapters."), - )); - } - let mut ids = BTreeSet::new(); - for (index, adapter) in adapters.iter_mut().enumerate() { - let field = format!("debugAdapters[{index}]"); - adapter.validate(&field, context)?; - if !ids.insert(adapter.id.clone()) { - return Err(invalid( - format!("{field}.id"), - "A debug adapter is declared more than once.", - )); - } - } - adapters.sort_by(|left, right| left.id.cmp(&right.id)); - Ok(()) -} - -/// Whether any adapter starts on the runtime Run selected. -pub(crate) fn uses_selected_runtime(adapters: &[DebugAdapterDeclaration]) -> bool { - adapters.iter().any(|adapter| { - adapter - .launch - .process - .as_ref() - .and_then(DebugProcess::selected_runtime_tool) - .is_some() - }) -} - -/// Whether any adapter serves `attach`, which needs its own permission. -pub(crate) fn serves_attach(adapters: &[DebugAdapterDeclaration]) -> bool { - adapters - .iter() - .any(|adapter| adapter.requests.contains(&DebugRequest::Attach)) -} - -impl DebugAdapterDeclaration { - fn validate(&mut self, field: &str, context: &DeclarationContext<'_>) -> Validation<()> { - if !is_local_id(&self.id) { - return Err(invalid( - format!("{field}.id"), - "A debug adapter ID is a lowercase letter followed by up to 63 lowercase letters, digits, dots, underscores, or hyphens.", - )); - } - validate_name(&self.display_name, &format!("{field}.displayName"))?; - if let Some(adapter_id) = &self.adapter_id { - validate_name(adapter_id, &format!("{field}.adapterID"))?; - } - - // `launch` is the default; an explicit empty list would serve nothing. - if self.requests.is_empty() { - self.requests = vec![DebugRequest::Launch]; - } - self.requests.sort(); - let before = self.requests.len(); - self.requests.dedup(); - if self.requests.len() != before { - return Err(invalid( - format!("{field}.requests"), - "A request is listed more than once.", - )); - } - - self.validate_selectors(field)?; - self.validate_configuration_types(field)?; - if let Some(configurations) = &self.configurations { - // A configuration for a request the adapter does not serve could - // never apply. - for (name, request, configuration) in [ - ("launch", DebugRequest::Launch, &configurations.launch), - ("attach", DebugRequest::Attach, &configurations.attach), - ] { - let Some(configuration) = configuration else { - continue; - }; - let configuration_field = format!("{field}.configurations.{name}"); - if !self.requests.contains(&request) { - return Err(invalid( - configuration_field, - "The configuration is for a request the adapter does not declare.", - )); - } - configuration.validate(&configuration_field, request, context)?; - } - } - // A connection target needs somewhere to put `host:port`; without it - // the selector would offer the adapter for a target it cannot start. - if self.selectors.iter().any(|selector| { - selector - .target_kinds - .iter() - .any(|kind| kind == TARGET_CONNECTION) - }) && self.connection_binding().is_none() - { - return Err(invalid( - format!("{field}.selectors"), - "A connection selector needs an attach configuration with a connection binding.", - )); - } - if let Some(capabilities) = &mut self.capabilities { - capabilities.validate(&format!("{field}.capabilities"))?; - } - if let Some(console) = &mut self.console { - console.validate(&format!("{field}.console"))?; - } - let declared = DeclaredAdapterBehavior { - features: self.features.clone(), - defaults: self.defaults.clone(), - }; - declared.check().map_err(|(declared_field, message)| { - invalid(format!("{field}.{declared_field}"), message) - })?; - self.launch.validate(&format!("{field}.launch"), context) - } - - /// The binding that receives a remote target, when the adapter attaches - /// to one. - pub(crate) fn connection_binding(&self) -> Option<&DebugConnectionBinding> { - self.configurations - .as_ref() - .and_then(|configurations| configurations.attach.as_ref()) - .and_then(|configuration| configuration.target_bindings.as_ref()) - .and_then(|bindings| bindings.connection.as_ref()) - } - - fn validate_selectors(&mut self, field: &str) -> Result<(), PluginRejection> { - if self.selectors.len() > MAX_SELECTORS { - return Err(invalid( - format!("{field}.selectors"), - format!("An adapter declares at most {MAX_SELECTORS} selectors."), - )); - } - for (index, selector) in self.selectors.iter_mut().enumerate() { - let selector_field = format!("{field}.selectors[{index}]"); - if selector.target_kinds.is_empty() && selector.language_ids.is_empty() { - return Err(invalid( - selector_field, - "A selector matches on at least one field.", - )); - } - if let Some(kind) = selector - .target_kinds - .iter() - .find(|kind| !TARGET_KINDS.contains(&kind.as_str())) - { - return Err(invalid( - format!("{selector_field}.targetKinds"), - format!("{kind} is not a target kind."), - )); - } - if selector.language_ids.iter().any(|id| !is_language_id(id)) { - return Err(invalid( - format!("{selector_field}.languageIDs"), - "A language ID is lowercase letters, digits, and + _ -.", - )); - } - sort_unique( - &mut selector.target_kinds, - &format!("{selector_field}.targetKinds"), - )?; - sort_unique( - &mut selector.language_ids, - &format!("{selector_field}.languageIDs"), - )?; - } - Ok(()) - } - - fn validate_configuration_types(&mut self, field: &str) -> Validation<()> { - let types_field = format!("{field}.configurationTypes"); - if self.configuration_types.len() > MAX_CONFIGURATION_TYPES - || self - .configuration_types - .iter() - .any(|name| name.trim().is_empty() || name.chars().any(char::is_control)) - { - return Err(invalid( - types_field, - format!("Configuration types are up to {MAX_CONFIGURATION_TYPES} non-empty names."), - )); - } - sort_unique(&mut self.configuration_types, &types_field) - } -} - -impl DebugConfiguration { - fn validate( - &self, - field: &str, - request: DebugRequest, - context: &DeclarationContext<'_>, - ) -> Validation<()> { - if self.arguments.is_none() - && self.parameters_schema.is_none() - && self.target_bindings.is_none() - && self.executable.is_none() - { - return Err(invalid( - field, - "A configuration declares at least one field.", - )); - } - if let Some(path) = &self.parameters_schema { - if !is_safe_relative_path(path) || path.contains(':') { - return Err(invalid( - format!("{field}.parametersSchema"), - "The parameters schema must be a file inside the plugin package.", - )); - } - } - let reads_executable = self - .target_bindings - .as_ref() - .is_some_and(|bindings| bindings.program.as_deref() == Some(TARGET_EXECUTABLE)); - match (&self.executable, reads_executable) { - (Some(executable), true) => { - executable.validate(&format!("{field}.executable"), request, context)? - } - (Some(_), false) => { - return Err(invalid( - format!("{field}.executable"), - "An executable lookup needs program bound to target.executable.", - )) - } - (None, true) => { - return Err(invalid( - format!("{field}.executable"), - "A program bound to target.executable needs an executable lookup.", - )) - } - (None, false) => {} - } - let Some(bindings) = &self.target_bindings else { - return Ok(()); - }; - let binding_field = format!("{field}.targetBindings"); - if bindings.program.is_none() - && bindings.process_id.is_none() - && bindings.cwd.is_none() - && bindings.args.is_none() - && bindings.env.is_none() - && bindings.connection.is_none() - { - return Err(invalid( - &binding_field, - "A binding list declares a binding.", - )); - } - for (name, source, allowed) in [ - ( - "program", - &bindings.program, - &["target.path", TARGET_EXECUTABLE][..], - ), - ("processId", &bindings.process_id, &["target.processID"][..]), - ("cwd", &bindings.cwd, &["target.workingDirectory"][..]), - ("args", &bindings.args, &["target.arguments"][..]), - ("env", &bindings.env, &["target.environment"][..]), - ] { - if source - .as_deref() - .is_some_and(|source| !allowed.contains(&source)) - { - return Err(invalid( - format!("{binding_field}.{name}"), - format!("{name} can only be bound to {}.", allowed.join(" or ")), - )); - } - } - // `processId` names a running process and `connection` a remote - // endpoint, which only attach has. - if ((bindings.process_id.is_some() || bindings.connection.is_some()) - && request != DebugRequest::Attach) - || (bindings.program.is_some() && request != DebugRequest::Launch) - { - return Err(invalid( - binding_field, - "The binding does not apply to this request.", - )); - } - if let Some(connection) = &bindings.connection { - let fixed_names: Vec<&str> = [ - ("processId", bindings.process_id.is_some()), - ("cwd", bindings.cwd.is_some()), - ] - .into_iter() - .filter_map(|(name, declared)| declared.then_some(name)) - .collect(); - connection.validate( - &format!("{binding_field}.connection"), - self.arguments.as_ref(), - &fixed_names, - )?; - } - Ok(()) - } -} - -impl DebugExecutable { - fn validate( - &self, - field: &str, - request: DebugRequest, - context: &DeclarationContext<'_>, - ) -> Validation<()> { - if request != DebugRequest::Launch { - return Err(invalid(field, "Only a launch looks for an executable.")); - } - if !context.declared_settings.is_file(&self.setting) { - return Err(invalid( - format!("{field}.setting"), - "The setting must be a declared file setting of this plugin.", - )); - } - if self.candidates.len() > MAX_EXECUTABLE_CANDIDATES { - return Err(invalid( - format!("{field}.candidates"), - format!("A lookup lists at most {MAX_EXECUTABLE_CANDIDATES} candidates."), - )); - } - for (index, candidate) in self.candidates.iter().enumerate() { - if !is_executable_candidate(candidate) { - return Err(invalid( - format!("{field}.candidates[{index}]"), - "A candidate starts with ${workspaceFolder}/ or ${fileDirectory}/, \ - names only known variables, and has no .. or control characters.", - )); - } - } - let mut seen = BTreeSet::new(); - if !self - .candidates - .iter() - .all(|candidate| seen.insert(candidate)) - { - return Err(invalid( - format!("{field}.candidates"), - "A candidate is listed more than once.", - )); - } - Ok(()) - } - - /// The candidates a host can look for with what it knows now, in order. - /// One that needs the source file is skipped when there is none, which is - /// how the settings page asks without a file open. - pub(crate) fn expanded_candidates( - &self, - workspace: Option<&str>, - target_path: Option<&str>, - ) -> Vec { - let file = target_path.map(std::path::Path::new); - let directory = file - .and_then(std::path::Path::parent) - .and_then(std::path::Path::to_str); - let stem = file - .and_then(std::path::Path::file_stem) - .and_then(std::ffi::OsStr::to_str); - let mut expanded = Vec::new(); - for candidate in &self.candidates { - let mut path = candidate.clone(); - for (name, value) in [ - ("workspaceFolder", workspace), - ("fileDirectory", directory), - ("fileBasenameNoExtension", stem), - ] { - let variable = format!("${{{name}}}"); - if !path.contains(&variable) { - continue; - } - let Some(value) = value.filter(|value| !value.is_empty()) else { - path.clear(); - break; - }; - path = path.replace(&variable, value.trim_end_matches('/')); - } - if !path.is_empty() && !expanded.contains(&path) { - expanded.push(path); - } - } - expanded - } -} - -/// Whether a candidate is a path template a host may look at. -fn is_executable_candidate(candidate: &str) -> bool { - if candidate.chars().count() > MAX_EXECUTABLE_CANDIDATE - || candidate - .chars() - .any(|character| character.is_control() || character == '\\') - || !EXECUTABLE_ROOTS - .iter() - .any(|root| candidate.starts_with(root)) - || candidate.split('/').any(|part| part == "..") - { - return false; - } - // Every `${` must close on a known variable; a stray one would reach the - // file system as part of a name. - let mut rest = candidate; - while let Some(start) = rest.find("${") { - let after = &rest[start + 2..]; - let Some(end) = after.find('}') else { - return false; - }; - if !EXECUTABLE_VARIABLES.contains(&&after[..end]) { - return false; - } - rest = &after[end + 1..]; - } - true -} - -impl DebugCapabilities { - fn validate(&mut self, field: &str) -> Validation<()> { - let disabled_field = format!("{field}.disabled"); - if self.disabled.is_empty() { - return Err(invalid( - disabled_field, - "Disabled capabilities list at least one name.", - )); - } - if let Some(index) = self - .disabled - .iter() - .position(|name| !DAP_CAPABILITIES.contains(&name.as_str())) - { - return Err(invalid( - format!("{disabled_field}[{index}]"), - "Only standard DAP capability names can be disabled.", - )); - } - sort_unique(&mut self.disabled, &disabled_field) - } -} - -impl DebugConsole { - fn validate(&mut self, field: &str) -> Validation<()> { - let commands_field = format!("{field}.commands"); - if let Some(prefix) = &self.command_prefix { - // The host concatenates the prefix and the line as written, so a - // space or control character would shift what the adapter reads. - if prefix.is_empty() - || prefix.chars().count() > MAX_CONSOLE_COMMAND_PREFIX - || prefix - .chars() - .any(|character| character.is_control() || character.is_whitespace()) - { - return Err(invalid( - format!("{field}.commandPrefix"), - "A command prefix is 1-8 characters without spaces.", - )); - } - } - if self.commands.is_empty() && self.command_prefix.is_none() { - return Err(invalid( - commands_field, - "A console declares at least one command or a command prefix.", - )); - } - if self.commands.len() > MAX_CONSOLE_COMMANDS { - return Err(invalid( - commands_field, - format!("An adapter declares at most {MAX_CONSOLE_COMMANDS} console commands."), - )); - } - for (index, command) in self.commands.iter().enumerate() { - let command_field = format!("{commands_field}[{index}]"); - // The name is inserted as written, so surrounding spaces would - // change what the user's line means. - if command.name.is_empty() - || command.name.trim() != command.name - || command.name.chars().count() > MAX_CONSOLE_COMMAND_NAME - || command.name.chars().any(char::is_control) - { - return Err(invalid( - format!("{command_field}.name"), - "A command name is one line of 1-128 characters without surrounding spaces.", - )); - } - if let Some(description) = &command.description { - if description.trim().is_empty() - || description.chars().count() > MAX_CONSOLE_COMMAND_DESCRIPTION - || description.chars().any(char::is_control) - { - return Err(invalid( - format!("{command_field}.description"), - "A description is one line of 1-240 characters.", - )); - } - } - } - self.commands - .sort_by(|left, right| left.name.cmp(&right.name)); - if let Some(pair) = self - .commands - .windows(2) - .find(|pair| pair[0].name == pair[1].name) - { - return Err(invalid( - commands_field, - format!("The command {} is listed more than once.", pair[0].name), - )); - } - Ok(()) - } -} - -impl DebugLaunch { - fn validate(&self, field: &str, context: &DeclarationContext<'_>) -> Validation<()> { - let endpoint_field = format!("{field}.endpoint"); - let hosted = self.transport == DebugTransport::TcpLanguageServer; - match (&self.process, hosted) { - (Some(process), false) => process.validate(&format!("{field}.process"), context)?, - (None, true) => {} - (None, false) => { - return Err(invalid( - format!("{field}.process"), - "An adapter process is required unless the transport is tcpLanguageServer.", - )); - } - (Some(_), true) => { - return Err(invalid( - format!("{field}.process"), - "A tcpLanguageServer adapter runs inside the language server and has no process.", - )); - } - } - match (self.transport, &self.endpoint) { - (DebugTransport::Stdio, None) => Ok(()), - (DebugTransport::Stdio, Some(_)) => Err(invalid( - endpoint_field, - "The stdio transport has no endpoint.", - )), - (_, None) => Err(invalid( - endpoint_field, - "A TCP transport needs an endpoint.", - )), - (DebugTransport::TcpAnnounced, Some(endpoint)) => { - let valid = endpoint.port.is_none() - && !endpoint.has_language_server_fields() - && matches!(endpoint.stream.as_deref(), Some("stdout" | "stderr")) - && matches!(endpoint.value.as_deref(), Some("port" | "hostPort")) - && endpoint.prefix.as_deref().is_some_and(|prefix| { - !prefix.is_empty() - && prefix.len() <= MAX_ENDPOINT_PREFIX - && !prefix.contains(['\r', '\n', '\0']) - }); - if valid { - Ok(()) - } else { - Err(invalid( - endpoint_field, - "An announced endpoint names a stream, a one-line prefix, and port or hostPort.", - )) - } - } - (DebugTransport::TcpPort, Some(endpoint)) => { - let valid = endpoint.stream.is_none() - && endpoint.prefix.is_none() - && endpoint.value.is_none() - && !endpoint.has_language_server_fields() - && endpoint.port.is_some_and(|port| port >= 1); - if valid { - Ok(()) - } else { - Err(invalid( - endpoint_field, - "A fixed endpoint is a loopback port from 1 to 65535.", - )) - } - } - (DebugTransport::TcpLanguageServer, Some(endpoint)) => { - if endpoint.stream.is_some() - || endpoint.prefix.is_some() - || endpoint.value.is_some() - || endpoint.port.is_some() - { - return Err(invalid( - endpoint_field, - "A language server endpoint names only a language and a command.", - )); - } - let language = endpoint.language_id.as_deref().unwrap_or_default(); - if !context.language_servers.contains(language) { - return Err(invalid( - format!("{endpoint_field}.languageID"), - "The language server is not declared in languageServers.", - )); - } - if !endpoint.command.as_deref().is_some_and(is_server_command) { - return Err(invalid( - format!("{endpoint_field}.command"), - "A command is 1-128 letters, digits, and . _ : -.", - )); - } - Ok(()) - } - } - } -} - -impl DebugEndpoint { - fn has_language_server_fields(&self) -> bool { - self.language_id.is_some() || self.command.is_some() - } -} - -/// Event that activates the plugin when a debug session needs the adapter. -pub(crate) fn activation_event(adapter: &DebugAdapterDeclaration) -> String { - format!("onDebug:{}", adapter.id) -} - -/// Install-review entries: the adapter process with the runtimes it needs. -/// Pointing an adapter at another executable or transport is a new program -/// the user must approve. A selected-runtime adapter adds no runtime entry: -/// the runtime is the one the user already chose for Run. -pub(crate) fn programs(adapter: &DebugAdapterDeclaration) -> Vec { - let mut program = match &adapter.launch.process { - Some(DebugProcess::Server(process)) => process.program(ServerRole::DebugAdapter), - Some(DebugProcess::SelectedRuntime(process)) => json!({ - "kind": "selectedRuntimeDebugAdapter", - "tool": process.launch.tool, - "arguments": process.arguments, - "validationArguments": process.validation_arguments, - }), - // The server is reviewed as the plugin's language server; what is new - // here is the command that makes it serve debugging. - None => json!({ "kind": "languageServerDebugAdapter" }), - }; - program["debugAdapter"] = json!(adapter.id); - program["transport"] = json!(adapter.launch.transport); - program["endpoint"] = json!(adapter.launch.endpoint); - let mut programs = vec![program]; - if let Some(DebugProcess::Server(process)) = &adapter.launch.process { - programs.extend(process.runtime_programs()); - } - programs -} - -// The install check has no caller until the plugin installer reads it; tests -// run it now. - -/// A file of the installed package, as the installer found it. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[cfg_attr(not(test), allow(dead_code))] -pub(crate) struct PackageResource { - pub(crate) path: String, - pub(crate) kind: PackageResourceKind, - /// Link target of a symlink, as stored in the package. - #[serde(default)] - pub(crate) target: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(not(test), allow(dead_code))] -pub(crate) enum PackageResourceKind { - File, - Directory, - Symlink, -} - -/// Checks that every packaged file an adapter refers to (its packaged -/// executable and parameter schemas) is a regular file or a link that stays -/// inside the package. Only the installer can see the files, so this is -/// separate from the manifest checks. -#[cfg_attr(not(test), allow(dead_code))] -pub(crate) fn check_package_resources( - adapters: &[DebugAdapterDeclaration], - resources: &[PackageResource], -) -> Validation<()> { - for (index, adapter) in adapters.iter().enumerate() { - if let Some(DebugProcess::Server(process)) = &adapter.launch.process { - for (key, path) in process.packaged_files() { - check_resource( - resources, - path, - format!("debugAdapters[{index}].launch.process.launch.{key}"), - "The adapter file", - )?; - } - } - let Some(configurations) = &adapter.configurations else { - continue; - }; - for (name, configuration) in [ - ("launch", &configurations.launch), - ("attach", &configurations.attach), - ] { - let Some(path) = configuration - .as_ref() - .and_then(|configuration| configuration.parameters_schema.as_ref()) - else { - continue; - }; - check_resource( - resources, - path, - format!("debugAdapters[{index}].configurations.{name}.parametersSchema"), - "The parameters schema", - )?; - } - } - Ok(()) -} - -/// Links followed before a chain counts as a loop. -const MAX_LINK_HOPS: usize = 8; - -/// One packaged path: present, and a regular file once its links are -/// followed. Every link on the way must stay inside the package. `what` -/// starts the message. -#[cfg_attr(not(test), allow(dead_code))] -fn check_resource( - resources: &[PackageResource], - path: &str, - field: String, - what: &str, -) -> Validation<()> { - let mut current = normalize(path); - for _ in 0..=MAX_LINK_HOPS { - let Some(resource) = resources.iter().find(|resource| resource.path == current) else { - return Err(invalid(field, format!("{what} is not in the package."))); - }; - match resource.kind { - PackageResourceKind::File => return Ok(()), - PackageResourceKind::Directory => { - return Err(invalid(field, format!("{what} is a directory."))); - } - PackageResourceKind::Symlink => { - let Some(next) = resolve_link(&resource.path, resource.target.as_deref()) else { - return Err(invalid( - field, - format!("{what} links outside the plugin package."), - )); - }; - current = next; - } - } - } - Err(invalid(field, format!("{what} is behind too many links."))) -} - -/// Drops empty and `.` components, so `./bin//x` names the file `bin/x`. -#[cfg_attr(not(test), allow(dead_code))] -fn normalize(path: &str) -> String { - path.split('/') - .filter(|component| !component.is_empty() && *component != ".") - .collect::>() - .join("/") -} - -/// Resolves a symlink target lexically from the link's directory to a -/// package-relative path. Only the final component is followed: a link in a -/// middle directory is the installer's to flatten into the resource list. A target that climbs above the package root, is -/// absolute, or resolves to the root itself has no such path. -#[cfg_attr(not(test), allow(dead_code))] -fn resolve_link(link: &str, target: Option<&str>) -> Option { - let target = target?; - if target.is_empty() || target.starts_with('/') || target.contains(['\\', '\0']) { - return None; - } - let mut parts: Vec<&str> = link.split('/').collect(); - parts.pop(); - for component in target.split('/') { - match component { - "" | "." => {} - ".." => { - parts.pop()?; - } - name => parts.push(name), - } - } - if parts.is_empty() { - return None; - } - Some(parts.join("/")) -} - -/// Facts of one debug start that the manifest cannot know. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct StartFacts { - pub(crate) registration: Generation, - #[serde(default)] - pub(crate) runtime_selection: Option, - pub(crate) target: TargetFacts, -} - -/// A generation the plan was made against and the one current at start. -#[derive(Debug, Clone, Copy, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct Generation { - pub(crate) generation: u64, - pub(crate) current_generation: u64, -} - -impl Generation { - fn is_stale(self) -> bool { - self.generation != self.current_generation - } -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct TargetFacts { - /// Host-validated process arguments and environment; absent differs from empty. - #[serde(default)] - pub(crate) arguments: Option>, - #[serde(default)] - pub(crate) environment: Option>, - // Selection by kind happens before a start; the host still states it so a - // start names what it was prepared for. A `connection` target is also - // checked against the adapter, see `plan`. - pub(crate) kind: String, - #[serde(default)] - pub(crate) path: Option, - #[serde(default, rename = "processID")] - pub(crate) process_id: Option, - #[serde(default)] - pub(crate) working_directory: Option, - /// The program the host found for a source file, an absolute path. - #[serde(default)] - pub(crate) executable: Option, - /// Where a remote debuggee listens: a host name or IP literal without - /// brackets, port, or scheme. - #[serde(default)] - pub(crate) host: Option, - #[serde(default)] - pub(crate) port: Option, - /// Local source roots and where the remote machine has them. - #[serde(default)] - pub(crate) path_mappings: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PathMapping { - pub(crate) local: String, - pub(crate) remote: String, -} - -/// What a start asks of the adapter: the request and the facts around it. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct StartRequest { - pub(crate) request: DebugRequest, - pub(crate) facts: StartFacts, - /// A plugin backend's own DAP arguments from `lithe/host/debug/start`, as - /// `debug.decodeBackendConfiguration` accepted them. They go on top of the - /// adapter's arguments, so the host sends the result unchanged. - #[serde(default)] - pub(crate) configuration: Option>, -} - -/// The remote endpoint a start connects to, as the adapter will receive it. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct StartConnection { - pub(crate) host: String, - pub(crate) port: u16, - /// False only when `host` is `localhost` or a loopback IP literal. A name - /// that resolves to loopback still counts as remote, so a host asks the - /// user about every endpoint Core cannot prove stays on this machine. - pub(crate) remote: bool, -} - -/// What a start gives the host: the DAP arguments and, for a connection -/// target, the endpoint they carry. -#[derive(Debug, Clone)] -pub(crate) struct StartArguments { - pub(crate) arguments: Map, - pub(crate) connection: Option, -} - -/// The default DAP arguments of a request with each binding filling its -/// argument from the target, unless the defaults already set it, and then a -/// backend's configuration replacing any argument it names. -pub(crate) fn start_arguments( - adapter: &DebugAdapterDeclaration, - start: &StartRequest, -) -> Validation { - let mut started = bound_arguments(adapter, start)?; - if let Some(configuration) = &start.configuration { - super::backend_start::reject_host_keys(configuration)?; - for (key, value) in configuration { - started.arguments.insert(key.clone(), value.clone()); - } - } - Ok(started) -} - -/// The adapter's own arguments for a start: its defaults and target bindings. -fn bound_arguments( - adapter: &DebugAdapterDeclaration, - start: &StartRequest, -) -> Validation { - plan(adapter, start.request, &start.facts)?; - let configuration = - adapter - .configurations - .as_ref() - .and_then(|configurations| match start.request { - DebugRequest::Launch => configurations.launch.as_ref(), - DebugRequest::Attach => configurations.attach.as_ref(), - }); - let mut arguments = configuration - .and_then(|configuration| configuration.arguments.clone()) - .unwrap_or_default(); - let Some(bindings) = - configuration.and_then(|configuration| configuration.target_bindings.as_ref()) - else { - return Ok(StartArguments { - arguments, - connection: None, - }); - }; - let target = &start.facts.target; - // A binding is the fact path that fills the argument of the same name. - let mut bind = |name: &str, binding: &Option, value: Option| { - if let (Some(_), Some(value)) = (binding, value) { - arguments.entry(name.to_string()).or_insert(value); - } - }; - let program = match bindings.program.as_deref() { - Some(TARGET_EXECUTABLE) => target.executable.clone(), - _ => target.path.clone(), - }; - bind("program", &bindings.program, program.map(Value::from)); - bind( - "processId", - &bindings.process_id, - target.process_id.map(Value::from), - ); - bind( - "cwd", - &bindings.cwd, - target.working_directory.clone().map(Value::from), - ); - bind( - "args", - &bindings.args, - target.arguments.as_ref().map(|args| json!(args)), - ); - bind( - "env", - &bindings.env, - target.environment.as_ref().map(|env| json!(env)), - ); - let connection = match &bindings.connection { - Some(binding) => Some(bind_connection(binding, target, &mut arguments)?), - None => None, - }; - Ok(StartArguments { - arguments, - connection, - }) -} - -/// Writes the target's endpoint, and its path mappings when the adapter takes -/// them, to the destinations the binding names. -fn bind_connection( - binding: &DebugConnectionBinding, - target: &TargetFacts, - arguments: &mut Map, -) -> Validation { - let endpoint = connection_endpoint(target)?; - if let (Some(host), Some(port)) = (&binding.host, &binding.port) { - set_argument(arguments, host, Value::from(endpoint.host.clone()))?; - set_argument(arguments, port, Value::from(endpoint.port))?; - } - if let Some(address) = &binding.address { - let joined = if endpoint.host.contains(':') { - format!("[{}]:{}", endpoint.host, endpoint.port) - } else { - format!("{}:{}", endpoint.host, endpoint.port) - }; - set_argument(arguments, address, Value::from(joined))?; - } - // `plan` has already checked the mappings against this shape: their number - // fits it, and a dictionary has no key twice. - if let (Some(mappings), false) = (&binding.path_mappings, target.path_mappings.is_empty()) { - let roots = |mapping: &PathMapping, order: MappingOrder| match order { - MappingOrder::RemoteLocal => (mapping.remote.clone(), mapping.local.clone()), - MappingOrder::LocalRemote => (mapping.local.clone(), mapping.remote.clone()), - }; - match mappings { - DebugPathMappingBinding::Objects { - argument, - local_key, - remote_key, - } => { - let entries = target - .path_mappings - .iter() - .map(|mapping| { - let mut entry = Map::new(); - entry.insert(local_key.clone(), Value::from(mapping.local.clone())); - entry.insert(remote_key.clone(), Value::from(mapping.remote.clone())); - Value::Object(entry) - }) - .collect(); - set_argument(arguments, argument, Value::Array(entries))?; - } - DebugPathMappingBinding::First { local, remote } => { - for first in target.path_mappings.iter().take(1) { - set_argument(arguments, local, Value::from(first.local.clone()))?; - set_argument(arguments, remote, Value::from(first.remote.clone()))?; - } - } - DebugPathMappingBinding::Dictionary { argument, keyed_by } => { - let order = match keyed_by { - MappingRoot::Remote => MappingOrder::RemoteLocal, - MappingRoot::Local => MappingOrder::LocalRemote, - }; - let entries = target - .path_mappings - .iter() - .map(|mapping| { - let (key, value) = roots(mapping, order); - (key, Value::from(value)) - }) - .collect(); - set_argument(arguments, argument, Value::Object(entries))?; - } - DebugPathMappingBinding::Pairs { argument, order } => { - let entries = target - .path_mappings - .iter() - .map(|mapping| { - let (first, second) = roots(mapping, *order); - Value::Array(vec![Value::from(first), Value::from(second)]) - }) - .collect(); - set_argument(arguments, argument, Value::Array(entries))?; - } - } - } - Ok(endpoint) -} - -/// Sets the argument at a dotted path, creating the objects on the way. A -/// declaration cannot leave anything at the path (see -/// `DebugConnectionBinding::validate`), so an occupied path is a bug in the -/// declaration that slipped past it and is refused rather than overwritten. -fn set_argument(arguments: &mut Map, path: &str, value: Value) -> Validation<()> { - let occupied = || { - PluginRejection::at( - "debug.arguments.invalid", - path, - "The adapter's arguments already hold something at this path.", - ) - }; - let mut current = arguments; - let mut names = path.split('.').peekable(); - while let Some(name) = names.next() { - if names.peek().is_none() { - return match current.entry(name.to_string()) { - serde_json::map::Entry::Vacant(slot) => { - slot.insert(value); - Ok(()) - } - serde_json::map::Entry::Occupied(_) => Err(occupied()), - }; - } - let next = current - .entry(name.to_string()) - .or_insert_with(|| Value::Object(Map::new())); - let Value::Object(next) = next else { - return Err(occupied()); - }; - current = next; - } - Ok(()) -} - -/// The endpoint a connection target names, checked. -fn connection_endpoint(target: &TargetFacts) -> Validation { - let invalid_target = |field: &'static str, message: &str| { - PluginRejection::at("debug.target.invalid", field, message) - }; - let Some(host) = target.host.as_deref() else { - return Err(invalid_target( - "target.host", - "The target has no host, which the adapter's configuration reads.", - )); - }; - if let Some(problem) = host_problem(host) { - return Err(invalid_target("target.host", problem)); - } - let port = target - .port - .ok_or_else(|| { - invalid_target( - "target.port", - "The target has no port, which the adapter's configuration reads.", - ) - }) - .and_then(|port| { - u16::try_from(port) - .ok() - .filter(|port| *port != 0) - .ok_or_else(|| invalid_target("target.port", "A port is 1-65535.")) - })?; - Ok(StartConnection { - host: host.to_string(), - port, - remote: !is_loopback_host(host), - }) -} - -/// Why a string is not a host to connect to, or `None` when it is one: a DNS -/// name or an IP literal, without scheme, credentials, port, path, brackets, -/// or whitespace. Non-ASCII names are written in punycode; an IPv6 zone is not -/// supported. -pub(super) fn host_problem(host: &str) -> Option<&'static str> { - if host.is_empty() || host.len() > MAX_HOST { - return Some("A host is 1-253 characters."); - } - if host.parse::().is_ok() { - return None; - } - if host.contains(':') { - return Some("A host is a name or an IP address, without a scheme, port, or brackets."); - } - if host - .bytes() - .all(|byte| byte.is_ascii_digit() || byte == b'.') - { - return Some("A numeric host is a dotted IPv4 address."); - } - let labels_valid = host.split('.').all(|label| { - !label.is_empty() - && label.len() <= MAX_HOST_LABEL - && !label.starts_with('-') - && !label.ends_with('-') - && label - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') - }); - if labels_valid { - None - } else { - Some("A host name is dot-separated labels of letters, digits, and hyphens.") - } -} - -/// Whether Core can tell from the text alone that the host is this machine. -pub(super) fn is_loopback_host(host: &str) -> bool { - host.eq_ignore_ascii_case("localhost") - || host - .parse::() - .is_ok_and(|address| address.is_loopback()) -} - -/// Checks the path mappings the host sends along with a remote target against -/// the shape the adapter takes them in. -fn check_path_mappings( - mappings: &[PathMapping], - shape: &DebugPathMappingBinding, -) -> Validation<()> { - let invalid_mapping = |message: String| { - PluginRejection::at("debug.target.invalid", "target.pathMappings", message) - }; - // A shape that holds fewer mappings than the user gave would start a - // session with some source lines silently unmapped. - if mappings.len() > shape.limit() { - return Err(invalid_mapping(match shape.limit() { - 1 => "The adapter takes one path mapping.".to_string(), - limit => format!("A target has up to {limit} path mappings."), - })); - } - for mapping in mappings { - let sound = |root: &str| { - !root.is_empty() - && root.chars().count() <= MAX_PATH_MAPPING_ROOT - && !root.chars().any(char::is_control) - }; - if !sound(&mapping.local) || !sound(&mapping.remote) { - return Err(invalid_mapping(format!( - "A path mapping root is one line of 1-{MAX_PATH_MAPPING_ROOT} characters." - ))); - } - // The local root is on this machine, so it must be a full path; the - // remote root is the other machine's and may follow other conventions. - if !std::path::Path::new(&mapping.local).is_absolute() { - return Err(invalid_mapping( - "A path mapping's local root is an absolute path.".to_string(), - )); - } - } - // A dictionary holds one value per key; two mappings from the same root - // cannot both be sent, and keeping either would drop the other unseen. - if let DebugPathMappingBinding::Dictionary { keyed_by, .. } = shape { - let mut keys = HashSet::new(); - for mapping in mappings { - let key = match keyed_by { - MappingRoot::Local => &mapping.local, - MappingRoot::Remote => &mapping.remote, - }; - if !keys.insert(key.as_str()) { - return Err(invalid_mapping(format!( - "The adapter takes one path mapping per {} root.", - match keyed_by { - MappingRoot::Local => "local", - MappingRoot::Remote => "remote", - } - ))); - } - } - } - Ok(()) -} - -impl DebugAdapterDeclaration { - /// What the host needs besides the process: how to reach the adapter and - /// what to show. The process itself is the launch plan's own part. - pub(crate) fn plan_descriptor(&self, plugin_id: &str) -> Value { - let mut descriptor = json!({ - "id": self.id, - "providerID": format!("{plugin_id}/{}", self.id), - "displayName": self.display_name, - "adapterID": self.adapter_id.as_deref().unwrap_or(&self.id), - "requests": self.requests, - "selectors": self.selectors, - "configurationTypes": self.configuration_types, - "transport": self.launch.transport, - "endpoint": self.launch.endpoint, - "disabledCapabilities": self.capabilities.as_ref().map(|c| c.disabled.clone()).unwrap_or_default(), - // Present exactly when the adapter attaches to a remote host and - // port; says whether the host may offer path mappings and how many - // the adapter can take (0 when it takes none). - "remoteAttach": self.connection_binding().map(|binding| json!({ - "pathMappings": binding.path_mappings.is_some(), - "maxPathMappings": binding.path_mappings.as_ref().map_or(0, DebugPathMappingBinding::limit), - })), - }); - // The host hands these to the debug engine with the session, which then follows the - // declaration instead of recognizing the adapter by its ID. - if let Some(features) = &self.features { - descriptor["features"] = json!(features); - } - if let Some(defaults) = &self.defaults { - descriptor["defaults"] = json!(defaults); - } - descriptor - } -} - -/// Checks one debug start against the registration, the selected runtime and -/// the target facts the request's bindings read. -pub(crate) fn plan( - adapter: &DebugAdapterDeclaration, - request: DebugRequest, - facts: &StartFacts, -) -> Validation<()> { - if facts.registration.is_stale() { - return Err(PluginRejection::at( - "debug.provider.stale", - "registration", - "The adapter registration changed after this start was prepared.", - )); - } - if !adapter.requests.contains(&request) { - return Err(PluginRejection::at( - "debug.request.unsupported", - "request", - "The adapter does not serve this request.", - )); - } - if uses_selected_runtime(std::slice::from_ref(adapter)) - && facts.runtime_selection.map_or(true, Generation::is_stale) - { - return Err(PluginRejection::at( - "debug.runtime.stale", - "runtimeSelection", - "The selected runtime changed after this start was prepared.", - )); - } - // A remote target is only ever attached to, through an adapter that says - // where its host and port go. - if facts.target.kind == TARGET_CONNECTION - && (request != DebugRequest::Attach || adapter.connection_binding().is_none()) - { - return Err(PluginRejection::at( - "debug.target.invalid", - "target.kind", - "The adapter does not attach to a remote host and port.", - )); - } - let bindings = adapter - .configurations - .as_ref() - .and_then(|configurations| match request { - DebugRequest::Launch => configurations.launch.as_ref(), - DebugRequest::Attach => configurations.attach.as_ref(), - }) - .and_then(|configuration| configuration.target_bindings.as_ref()); - let Some(bindings) = bindings else { - return Ok(()); - }; - let missing = |field: &'static str, fact: &str| { - PluginRejection::at( - "debug.target.invalid", - field, - format!("The target has no {fact}, which the adapter's configuration reads."), - ) - }; - let target = &facts.target; - match bindings.program.as_deref() { - Some(TARGET_EXECUTABLE) - if !target - .executable - .as_deref() - .is_some_and(|path| std::path::Path::new(path).is_absolute()) => - { - return Err(missing("target.executable", "executable")); - } - Some(source) - if source != TARGET_EXECUTABLE - && target.path.as_deref().map_or(true, str::is_empty) => - { - return Err(missing("target.path", "path")); - } - _ => {} - } - if bindings.args.is_some() - && target.arguments.as_ref().map_or(true, |args| { - args.len() > 1024 - || args - .iter() - .any(|arg| arg.len() > 65536 || arg.contains('\0')) - }) - { - return Err(missing("target.arguments", "bounded arguments")); - } - if bindings.env.is_some() - && target.environment.as_ref().map_or(true, |env| { - env.len() > 64 - || env.iter().any(|(key, value)| { - key.is_empty() - || key.len() > 128 - || !key.bytes().enumerate().all(|(index, byte)| { - byte == b'_' - || byte.is_ascii_alphabetic() - || index > 0 && byte.is_ascii_digit() - }) - || value.len() > 65536 - || value.contains('\0') - }) - }) - { - return Err(missing("target.environment", "bounded environment")); - } - if bindings.process_id.is_some() && target.process_id.is_none() { - return Err(missing("target.processID", "process ID")); - } - if bindings.cwd.is_some() - && target - .working_directory - .as_deref() - .map_or(true, str::is_empty) - { - return Err(missing("target.workingDirectory", "working directory")); - } - if let Some(connection) = &bindings.connection { - connection_endpoint(target)?; - if let Some(shape) = &connection.path_mappings { - check_path_mappings(&target.path_mappings, shape)?; - } else if !target.path_mappings.is_empty() { - // Dropping a mapping the user typed would let the session start - // with source lines that silently do not match. - return Err(PluginRejection::at( - "debug.target.invalid", - "target.pathMappings", - "The adapter takes no path mappings.", - )); - } - } - Ok(()) -} - -/// A name in an argument path: a DAP argument or a key of a nested object. -fn is_argument_name(name: &str) -> bool { - let mut characters = name.chars(); - name.len() <= MAX_ARGUMENT_NAME - && characters - .next() - .is_some_and(|first| first.is_ascii_alphabetic() || first == '_') - && characters.all(|character| character.is_ascii_alphanumeric() || "_-".contains(character)) -} - -/// A dotted path to an argument, such as `hostName` or `connect.host`. -fn is_argument_path(path: &str) -> bool { - let mut depth = 0; - path.split('.').all(|name| { - depth += 1; - depth <= MAX_ARGUMENT_PATH_DEPTH && is_argument_name(name) - }) -} - -/// Whether two argument paths are the same or one is inside the other. -fn paths_overlap(left: &str, right: &str) -> bool { - left.split('.') - .zip(right.split('.')) - .all(|(left, right)| left == right) -} - -/// Whether the arguments already hold something at the path, or a value that -/// is not an object on the way to it. -fn default_occupies(defaults: &Map, path: &str) -> bool { - let mut current = defaults; - let mut names = path.split('.').peekable(); - while let Some(name) = names.next() { - let Some(value) = current.get(name) else { - return false; - }; - match value { - Value::Object(next) if names.peek().is_some() => current = next, - _ => return true, - } - } - false -} - -fn validate_name(value: &str, field: &str) -> Validation<()> { - let trimmed = value.trim(); - if trimmed.is_empty() - || trimmed.chars().count() > MAX_NAME - || trimmed.chars().any(char::is_control) - { - return Err(invalid(field, "A name is one line of 1-128 characters.")); - } - Ok(()) -} - -fn is_local_id(value: &str) -> bool { - let mut characters = value.chars(); - value.len() <= MAX_LOCAL_ID - && characters - .next() - .is_some_and(|first| first.is_ascii_lowercase()) - && characters.all(|character| { - character.is_ascii_lowercase() - || character.is_ascii_digit() - || "._-".contains(character) - }) -} - -/// A `workspace/executeCommand` command ID, as language servers register them. -fn is_server_command(value: &str) -> bool { - !value.is_empty() - && value.len() <= MAX_ENDPOINT_COMMAND - && value - .chars() - .all(|character| character.is_ascii_alphanumeric() || "._:-".contains(character)) -} - -fn is_language_id(value: &str) -> bool { - !value.is_empty() - && value.len() <= MAX_LOCAL_ID - && value.chars().all(|character| { - character.is_ascii_lowercase() - || character.is_ascii_digit() - || "+_-".contains(character) - }) -} - -/// Sorts a list and rejects a repeated entry, so equal declarations compare -/// equal whatever order the author wrote. -fn sort_unique(values: &mut Vec, field: &str) -> Validation<()> { - values.sort(); - let before = values.len(); - values.dedup(); - if values.len() == before { - Ok(()) - } else { - Err(invalid(field, "An entry is listed more than once.")) - } -} diff --git a/rust/crates/core/src/debug/engine.rs b/rust/crates/core/src/debug/engine.rs deleted file mode 100644 index faf077b7..00000000 --- a/rust/crates/core/src/debug/engine.rs +++ /dev/null @@ -1,7339 +0,0 @@ -//! Stateful DAP reducer whose byte transport and process lifecycle remain platform owned. - -use super::capabilities::{ - capability_states, client_capabilities, effective_body, validate_disabled, HostFeatures, -}; -use super::projection_v2::{ - capabilities_projection, scopes_projection, stack_trace_projection, CapabilityState, -}; -use super::protocol::{frame_message, parse_messages}; -use super::types::*; -use crate::diagnostics::channel::identity_segment; -use crate::protocol::{CoreError, ErrorCode}; -use base64::engine::general_purpose::STANDARD as BASE64; -use base64::Engine as _; -use serde_json::{json, Map, Value}; -use std::collections::{BTreeMap, BTreeSet, HashMap}; -use std::sync::{Mutex, OnceLock}; - -static SESSIONS: OnceLock>> = OnceLock::new(); - -#[derive(Debug)] -struct DebugSession { - id: String, - adapter_id: String, - /// Catalog-safe adapter identity; never derived from DAP payload or user output. - log_adapter: String, - /// A failing launch operation already owns the session failure record. - logged_terminal_failure: bool, - root_path: String, - state: DebugSessionState, - next_request_sequence: i64, - next_event_sequence: u64, - read_buffer: Vec, - pending_requests: BTreeMap, - /// Pause generation each request was issued under; frame and variable references die with it. - request_stop_generations: BTreeMap, - stop_generation: u64, - parent_id: Option, - /// Live child sessions in creation order; the host owns their transports. - child_ids: Vec, - /// Children already asked to stop, so a retry or a later `terminated` does not repeat it. - stopping_children: BTreeSet, - breakpoints: BTreeMap>, - /// Rejects replies to superseded per-file breakpoint sets. - breakpoint_generations: BTreeMap, - /// Adapter IDs correlate location-free events with the original source request. - resolved_source_breakpoints: BTreeMap, - exception_breakpoints: Vec, - did_configure_exception_breakpoints: bool, - function_breakpoints: Vec, - instruction_breakpoints: Vec, - data_breakpoints: Vec, - /// Muted breakpoints stay stored but every kind is sent empty, so the adapter stops on - /// none of them; a child starts muted when its parent is. - breakpoints_muted: bool, - supports_run_in_terminal_request: bool, - pending_run_in_terminal_requests: BTreeMap, - supports_start_debugging_request: bool, - pending_start_debugging_requests: BTreeMap, - did_receive_initialized: bool, - supports_configuration_done: bool, - capabilities: DebugCapabilities, - /// Raw capability flags exactly as the adapter reported them (merged with `capabilities` events). - adapter_capabilities: Map, - disabled_capabilities: BTreeSet, - capability_states: BTreeMap, - capability_revision: u64, - stepping_filters: DebugSteppingFilters, - /// DAP extensions the adapter declared; empty for an undeclared adapter. - features: DebugAdapterFeatures, - /// Declared filters used when a launch names none. - default_stepping_filters: Option, - debug_request_kind: Option, - pending_launch: Option<(String, DebugLaunchConfiguration)>, - outbound_frames: Vec>, - events: Vec, -} - -#[derive(Debug)] -enum PendingRequest { - Initialize, - Launch { - operation_id: String, - }, - SetBreakpoints { - source_path: String, - requested: Vec, - generation: u64, - }, - SetExceptionBreakpoints, - SetFunctionBreakpoints { - requested: Vec, - }, - SetInstructionBreakpoints { - requested: Vec, - }, - DataBreakpointInfo { - operation_id: String, - }, - SetDataBreakpoints { - requested: Vec, - }, - SetVariable { - operation_id: String, - name: String, - }, - Cancel, - ConfigurationDone, - Execute { - operation_id: String, - command: DebugExecutionCommand, - single_thread: bool, - }, - Inspect { - operation_id: String, - kind: DebugInspectKind, - /// Identify the v2 projection of stack traces and scopes. - thread_id: Option, - frame_id: Option, - }, - Disconnect, -} - -/// Creates a session and returns the framed DAP `initialize` request to send. -pub(crate) fn create_session( - request: CreateSessionRequest, -) -> Result { - validate_identifier(&request.session_id, "sessionId")?; - validate_identifier(&request.adapter_id, "adapterId")?; - validate_path(&request.root_path, "rootPath")?; - let disabled_capabilities = validate_disabled(&request.disabled_capabilities)?; - request - .declared - .check() - .map_err(|(field, message)| invalid_request(message).with_details(field.to_string()))?; - let DeclaredAdapterBehavior { features, defaults } = request.declared; - let mut sessions = sessions_lock()?; - if sessions.contains_key(&request.session_id) { - return Err(invalid_request( - "A debug session with this sessionId already exists.", - )); - } - let mut breakpoints_muted = false; - if let Some(parent_id) = &request.parent_session_id { - validate_identifier(parent_id, "parentSessionId")?; - let parent = sessions - .get(parent_id) - .ok_or_else(|| session_not_found(parent_id))?; - if matches!( - parent.state, - DebugSessionState::Terminating | DebugSessionState::Terminated - ) { - return Err(invalid_request( - "A child debug session needs a running parent session.", - )); - } - breakpoints_muted = parent.breakpoints_muted; - } - let mut session = DebugSession { - id: request.session_id.clone(), - // Same mapping as plugin facts, so one adapter id names one channel. - log_adapter: identity_segment(&request.adapter_id), - logged_terminal_failure: false, - adapter_id: request.adapter_id, - root_path: request.root_path, - state: DebugSessionState::Idle, - next_request_sequence: 1, - next_event_sequence: 1, - read_buffer: Vec::new(), - pending_requests: BTreeMap::new(), - request_stop_generations: BTreeMap::new(), - stop_generation: 0, - parent_id: request.parent_session_id.clone(), - child_ids: Vec::new(), - stopping_children: BTreeSet::new(), - breakpoints: BTreeMap::new(), - breakpoint_generations: BTreeMap::new(), - resolved_source_breakpoints: BTreeMap::new(), - exception_breakpoints: Vec::new(), - did_configure_exception_breakpoints: false, - function_breakpoints: Vec::new(), - instruction_breakpoints: Vec::new(), - data_breakpoints: Vec::new(), - breakpoints_muted, - supports_run_in_terminal_request: request.supports_run_in_terminal_request, - pending_run_in_terminal_requests: BTreeMap::new(), - supports_start_debugging_request: request.supports_start_debugging_request, - pending_start_debugging_requests: BTreeMap::new(), - did_receive_initialized: false, - supports_configuration_done: false, - capabilities: DebugCapabilities::default(), - adapter_capabilities: Map::new(), - disabled_capabilities, - capability_states: BTreeMap::new(), - capability_revision: 0, - stepping_filters: DebugSteppingFilters::unfiltered(), - features: features.unwrap_or_default(), - default_stepping_filters: defaults - .and_then(|defaults| defaults.step_filters) - .map(|filters| DebugSteppingFilters::from(&filters)), - debug_request_kind: None, - pending_launch: None, - outbound_frames: Vec::new(), - events: Vec::new(), - }; - session.log_fact( - "debug.process.started", - BTreeMap::new(), - Some(session.id.clone()), - ); - session.transition(DebugSessionState::Initializing); - let mut initialize = json!({ - "clientID": "lithe", - "clientName": "Lithe", - "adapterID": session.adapter_id, - "linesStartAt1": true, - "columnsStartAt1": true, - "pathFormat": "path", - "supportsVariableType": true, - "supportsVariablePaging": true - }); - if let (Some(arguments), Value::Object(client)) = ( - initialize.as_object_mut(), - client_capabilities(HostFeatures { - run_in_terminal: session.supports_run_in_terminal_request, - start_debugging: session.supports_start_debugging_request, - }), - ) { - arguments.extend(client); - } - session.send_request("initialize", initialize, PendingRequest::Initialize)?; - let update = session.take_update(); - if let Some(parent) = session - .parent_id - .as_ref() - .and_then(|parent_id| sessions.get_mut(parent_id)) - { - // The host created the child, so it needs no attach event. - parent.child_ids.push(session.id.clone()); - } - sessions.insert(request.session_id, session); - Ok(update) -} - -/// Queues launch or attach now, or stores it until initialize completes. -pub(crate) fn launch(request: LaunchRequest) -> Result { - validate_identifier(&request.operation_id, "operationId")?; - with_session(&request.session_id, |session| { - match session.state { - DebugSessionState::Initializing => { - session.pending_launch = Some((request.operation_id, request.configuration)); - } - DebugSessionState::Ready => { - session.perform_launch(request.operation_id, request.configuration)?; - } - _ => { - return Err(invalid_request( - "The debug session is not ready to launch or attach.", - )) - } - } - Ok(session.take_update()) - }) -} - -/// Returns deterministic adapter defaults or a normalized native-client override. -pub(crate) fn stepping_filters( - request: DebugSteppingFiltersRequest, -) -> Result { - validate_identifier(&request.adapter_id, "adapterId")?; - normalize_stepping_filters( - request - .filters - .unwrap_or_else(DebugSteppingFilters::unfiltered), - ) -} - -/// Stores a deterministic breakpoint set and sends it after DAP initialization. -pub(crate) fn set_breakpoints( - mut request: SetBreakpointsRequest, -) -> Result { - validate_path(&request.source_path, "sourcePath")?; - for breakpoint in &request.breakpoints { - if breakpoint.line < 1 || breakpoint.column.is_some_and(|column| column < 1) { - return Err(invalid_request( - "Debug breakpoint line and column values must be one-based.", - )); - } - } - request.breakpoints.sort_by_key(|breakpoint| { - ( - breakpoint.line, - breakpoint.column.unwrap_or(0), - breakpoint.enabled, - breakpoint.condition.clone().unwrap_or_default(), - breakpoint.hit_condition.clone().unwrap_or_default(), - breakpoint.log_message.clone().unwrap_or_default(), - ) - }); - request.breakpoints.dedup(); - with_session(&request.session_id, |session| { - session - .resolved_source_breakpoints - .retain(|_, point| point.source_path.as_deref() != Some(request.source_path.as_str())); - session - .breakpoints - .insert(request.source_path.clone(), request.breakpoints); - if session.did_receive_initialized { - session.send_breakpoints(&request.source_path)?; - } - Ok(session.take_update()) - }) -} - -/// Stores deterministic exception filters and sends them after DAP initialization. -pub(crate) fn set_exception_breakpoints( - mut request: SetExceptionBreakpointsRequest, -) -> Result { - for breakpoint in &mut request.breakpoints { - breakpoint.filter = breakpoint.filter.trim().to_string(); - if breakpoint.filter.is_empty() { - return Err(invalid_request( - "Debug exception breakpoint filters cannot be empty.", - )); - } - breakpoint.condition = breakpoint - .condition - .take() - .map(|condition| condition.trim().to_string()) - .filter(|condition| !condition.is_empty()); - } - request.breakpoints.sort_by(|left, right| { - (&left.filter, left.enabled, &left.condition).cmp(&( - &right.filter, - right.enabled, - &right.condition, - )) - }); - request - .breakpoints - .dedup_by(|left, right| left.filter == right.filter); - with_session(&request.session_id, |session| { - session.exception_breakpoints = request.breakpoints; - session.did_configure_exception_breakpoints = true; - if session.did_receive_initialized { - session.send_exception_breakpoints()?; - } - Ok(session.take_update()) - }) -} - -/// Stores deterministic function breakpoints and sends them when supported. -pub(crate) fn set_function_breakpoints( - mut request: SetFunctionBreakpointsRequest, -) -> Result { - for breakpoint in &mut request.breakpoints { - breakpoint.name = breakpoint.name.trim().to_string(); - if breakpoint.name.is_empty() { - return Err(invalid_request( - "Debug function breakpoint names cannot be empty.", - )); - } - breakpoint.condition = normalize_optional_text(breakpoint.condition.take()); - breakpoint.hit_condition = normalize_optional_text(breakpoint.hit_condition.take()); - } - request.breakpoints.sort_by(|left, right| { - ( - &left.name, - left.enabled, - &left.condition, - &left.hit_condition, - ) - .cmp(&( - &right.name, - right.enabled, - &right.condition, - &right.hit_condition, - )) - }); - request - .breakpoints - .dedup_by(|left, right| left.name == right.name); - with_session(&request.session_id, |session| { - session.function_breakpoints = request.breakpoints; - if session.did_receive_initialized && session.capabilities.supports_function_breakpoints { - session.send_function_breakpoints()?; - } - Ok(session.take_update()) - }) -} - -/// Longest opaque memory or instruction reference the Core forwards to an adapter. -const MAXIMUM_MEMORY_REFERENCE_LENGTH: usize = 256; -/// Largest `readMemory` request, so one call cannot ask an adapter for unbounded bytes. -const MAXIMUM_READ_MEMORY_BYTES: i64 = 65_536; -/// Largest `disassemble` window, enough for a scrolling view without flooding the transport. -const MAXIMUM_DISASSEMBLE_INSTRUCTIONS: i64 = 1_000; - -/// Stores deterministic instruction breakpoints and sends them when supported. -/// -/// References are opaque address strings: they are only trimmed and bounded, never parsed, -/// so a 64-bit address survives untouched and no CPU-specific logic enters the Core. -pub(crate) fn set_instruction_breakpoints( - mut request: SetInstructionBreakpointsRequest, -) -> Result { - for breakpoint in &mut request.breakpoints { - breakpoint.instruction_reference = - validated_memory_reference(&breakpoint.instruction_reference)?; - breakpoint.condition = normalize_optional_text(breakpoint.condition.take()); - breakpoint.hit_condition = normalize_optional_text(breakpoint.hit_condition.take()); - } - request.breakpoints.sort_by(|left, right| { - ( - &left.instruction_reference, - left.offset, - left.enabled, - &left.condition, - &left.hit_condition, - ) - .cmp(&( - &right.instruction_reference, - right.offset, - right.enabled, - &right.condition, - &right.hit_condition, - )) - }); - request.breakpoints.dedup_by(|left, right| { - left.instruction_reference == right.instruction_reference && left.offset == right.offset - }); - with_session(&request.session_id, |session| { - session.instruction_breakpoints = request.breakpoints; - if session.did_receive_initialized && session.capabilities.supports_instruction_breakpoints - { - session.send_instruction_breakpoints()?; - } - Ok(session.take_update()) - }) -} - -/// Resolves one adapter-owned data breakpoint identity for the selected variable. -pub(crate) fn data_breakpoint_info( - mut request: DataBreakpointInfoRequest, -) -> Result { - validate_identifier(&request.operation_id, "operationId")?; - request.name = request.name.trim().to_string(); - if request.name.is_empty() { - return Err(invalid_request( - "Debug data breakpoint info requires a variable name.", - )); - } - if request - .variables_reference - .is_some_and(|reference| reference < 1) - { - return Err(invalid_request( - "Debug variablesReference must be positive.", - )); - } - if request.frame_id.is_some_and(|frame_id| frame_id < 0) { - return Err(invalid_request("Debug frameId cannot be negative.")); - } - if request.variables_reference.is_none() && request.frame_id.is_none() { - return Err(invalid_request( - "Debug data breakpoint info requires a variable reference or frame.", - )); - } - with_session(&request.session_id, |session| { - if !session.capabilities.supports_data_breakpoints { - return Err(invalid_request( - "The debug adapter does not support data breakpoints.", - )); - } - let mut arguments = Map::new(); - arguments.insert("name".to_string(), json!(request.name)); - insert_option( - &mut arguments, - "variablesReference", - request.variables_reference, - ); - insert_option(&mut arguments, "frameId", request.frame_id); - session.send_request( - "dataBreakpointInfo", - Value::Object(arguments), - PendingRequest::DataBreakpointInfo { - operation_id: request.operation_id, - }, - )?; - Ok(session.take_update()) - }) -} - -/// Stores deterministic adapter-resolved data breakpoints and sends them when supported. -pub(crate) fn set_data_breakpoints( - mut request: SetDataBreakpointsRequest, -) -> Result { - for breakpoint in &mut request.breakpoints { - breakpoint.data_id = breakpoint.data_id.trim().to_string(); - if breakpoint.data_id.is_empty() { - return Err(invalid_request( - "Debug data breakpoint identifiers cannot be empty.", - )); - } - breakpoint.label = normalize_optional_text(breakpoint.label.take()); - breakpoint.access_type = normalize_optional_text(breakpoint.access_type.take()); - breakpoint.condition = normalize_optional_text(breakpoint.condition.take()); - breakpoint.hit_condition = normalize_optional_text(breakpoint.hit_condition.take()); - } - request.breakpoints.sort_by(|left, right| { - ( - &left.data_id, - &left.access_type, - left.enabled, - &left.condition, - &left.hit_condition, - ) - .cmp(&( - &right.data_id, - &right.access_type, - right.enabled, - &right.condition, - &right.hit_condition, - )) - }); - request.breakpoints.dedup_by(|left, right| { - left.data_id == right.data_id && left.access_type == right.access_type - }); - with_session(&request.session_id, |session| { - session.data_breakpoints = request.breakpoints; - if session.did_receive_initialized && session.capabilities.supports_data_breakpoints { - session.send_data_breakpoints()?; - } - Ok(session.take_update()) - }) -} - -/// Mutes or unmutes every breakpoint of one session (`debug.setBreakpointsMuted`). -/// -/// Muting keeps the stored source, exception, function, instruction and data breakpoints -/// and sends each set empty; unmuting sends them as stored again. Only a set holding an -/// enabled breakpoint is resent, since muting does not change what any other set sends. -/// Before DAP initialization the state is only stored and applies to the first sets sent. -pub(crate) fn set_breakpoints_muted( - request: SetBreakpointsMutedRequest, -) -> Result { - with_session(&request.session_id, |session| { - if session.breakpoints_muted != request.muted { - session.breakpoints_muted = request.muted; - if session.did_receive_initialized { - session.resend_enabled_breakpoints()?; - } - } - Ok(session.take_update()) - }) -} - -/// Queues one capability-gated variable mutation while execution is paused. -pub(crate) fn set_variable( - mut request: SetVariableRequest, -) -> Result { - validate_identifier(&request.operation_id, "operationId")?; - if request.variables_reference < 1 { - return Err(invalid_request( - "Debug variablesReference must be positive.", - )); - } - request.name = request.name.trim().to_string(); - if request.name.is_empty() { - return Err(invalid_request( - "Debug setVariable requires a variable name.", - )); - } - with_session(&request.session_id, |session| { - if session.state != DebugSessionState::Paused { - return Err(invalid_request( - "Variable mutation requires a paused debug session.", - )); - } - if !session.capabilities.supports_set_variable { - return Err(invalid_request( - "The debug adapter does not support variable mutation.", - )); - } - session.send_request( - "setVariable", - json!({ - "variablesReference": request.variables_reference, - "name": request.name, - "value": request.value - }), - PendingRequest::SetVariable { - operation_id: request.operation_id, - name: request.name, - }, - )?; - Ok(session.take_update()) - }) -} - -/// Ends one caller-owned operation and ignores any later adapter response. -pub(crate) fn cancel_operation( - request: CancelOperationRequest, -) -> Result { - validate_identifier(&request.operation_id, "operationId")?; - with_session(&request.session_id, |session| { - let pending_sequence = session - .pending_requests - .iter() - .find_map(|(sequence, pending)| { - (pending.operation_id() == Some(request.operation_id.as_str())).then_some(*sequence) - }); - let Some(pending_sequence) = pending_sequence else { - return Ok(session.take_update()); - }; - let pending = session - .pending_requests - .remove(&pending_sequence) - .expect("located pending debug operation should still exist"); - let command = pending.command().to_string(); - let message = match request.reason { - DebugCancellationReason::Cancelled => "Debug operation was cancelled.", - DebugCancellationReason::TimedOut => "Debug operation timed out.", - }; - session.emit(DebugEventBody::OperationFailed { - operation_id: request.operation_id, - command, - code: match request.reason { - DebugCancellationReason::Cancelled => DebugOperationFailureCode::Cancelled, - DebugCancellationReason::TimedOut => DebugOperationFailureCode::TimedOut, - }, - message: message.to_string(), - }); - if matches!(pending, PendingRequest::Launch { .. }) { - session.transition(DebugSessionState::Failed); - } - if session.capabilities.supports_cancel_request { - session.send_request( - "cancel", - json!({"requestId": pending_sequence}), - PendingRequest::Cancel, - )?; - } - Ok(session.take_update()) - }) -} - -/// Queues one continue, pause, or stepping request. -pub(crate) fn execute(request: ExecuteRequest) -> Result { - validate_identifier(&request.operation_id, "operationId")?; - with_session(&request.session_id, |session| { - if !matches!( - session.state, - DebugSessionState::Running | DebugSessionState::Paused - ) { - return Err(invalid_request( - "Execution control requires a running or paused debug session.", - )); - } - if matches!( - request.command, - DebugExecutionCommand::Next - | DebugExecutionCommand::StepIn - | DebugExecutionCommand::StepOut - | DebugExecutionCommand::StepBack - | DebugExecutionCommand::Goto - ) && session.state != DebugSessionState::Paused - { - return Err(invalid_request("Stepping requires a paused debug session.")); - } - if request.command == DebugExecutionCommand::Pause - && session.state != DebugSessionState::Running - { - return Err(invalid_request("Pause requires a running debug session.")); - } - if request.command == DebugExecutionCommand::Continue - && session.state != DebugSessionState::Paused - { - return Err(invalid_request("Continue requires a paused debug session.")); - } - if request.single_thread - && !session - .capabilities - .supports_single_thread_execution_requests - { - return Err(invalid_request( - "The debug adapter does not support single-thread execution control.", - )); - } - if request.command == DebugExecutionCommand::StepBack - && !session.capabilities.supports_step_back - { - return Err(invalid_request( - "The debug adapter does not support stepping backwards.", - )); - } - if request.command == DebugExecutionCommand::Goto - && !session.capabilities.supports_goto_targets_request - { - return Err(invalid_request( - "The debug adapter does not support run to cursor.", - )); - } - if request.command == DebugExecutionCommand::Restart - && !session.capabilities.supports_restart_request - { - return Err(invalid_request( - "The debug adapter does not support restart requests.", - )); - } - if request.command == DebugExecutionCommand::Terminate - && !session.capabilities.supports_terminate_request - { - return Err(invalid_request( - "The debug adapter does not support terminate requests.", - )); - } - if matches!( - request.command, - DebugExecutionCommand::Next - | DebugExecutionCommand::StepIn - | DebugExecutionCommand::StepOut - | DebugExecutionCommand::StepBack - | DebugExecutionCommand::Goto - ) && request.thread_id.is_none() - { - return Err(invalid_request("Stepping requires a selected thread.")); - } - if let Some(granularity) = request.granularity { - if !matches!( - request.command, - DebugExecutionCommand::Next - | DebugExecutionCommand::StepIn - | DebugExecutionCommand::StepOut - | DebugExecutionCommand::StepBack - ) { - return Err(invalid_request( - "Debug granularity is only valid for next, stepIn, stepOut or stepBack.", - )); - } - if !session.capabilities.supports_stepping_granularity { - return Err(invalid_request( - "The debug adapter does not support choosing the stepping granularity.", - ) - .with_details(granularity.argument().to_string())); - } - } - let mut arguments = Map::new(); - if !matches!( - request.command, - DebugExecutionCommand::Restart | DebugExecutionCommand::Terminate - ) { - if let Some(thread_id) = request.thread_id { - arguments.insert("threadId".to_string(), json!(thread_id)); - } - } - if request.command == DebugExecutionCommand::Goto { - insert_option( - &mut arguments, - "targetId", - Some(required_positive(request.target_id, "targetId")?), - ); - } else if request.command == DebugExecutionCommand::StepIn { - if let Some(target_id) = request.target_id { - if target_id < 1 { - return Err(invalid_request("Debug targetId must be positive.")); - } - arguments.insert("targetId".to_string(), json!(target_id)); - } - } else if request.target_id.is_some() { - return Err(invalid_request( - "Debug targetId is only valid for stepIn or goto.", - )); - } - if matches!( - request.command, - DebugExecutionCommand::Continue - | DebugExecutionCommand::Next - | DebugExecutionCommand::StepIn - | DebugExecutionCommand::StepOut - | DebugExecutionCommand::StepBack - | DebugExecutionCommand::Goto - | DebugExecutionCommand::Pause - ) { - arguments.insert( - "singleThread".to_string(), - Value::Bool(request.single_thread), - ); - } - if let Some(granularity) = request.granularity { - arguments.insert("granularity".to_string(), json!(granularity.argument())); - } - session.send_request( - request.command.command(), - Value::Object(arguments), - PendingRequest::Execute { - operation_id: request.operation_id, - command: request.command, - single_thread: request.single_thread, - }, - )?; - Ok(session.take_update()) - }) -} - -/// Queues one typed thread, stack, scope, variable, or evaluation request. -pub(crate) fn inspect(request: InspectRequest) -> Result { - validate_identifier(&request.operation_id, "operationId")?; - let arguments = inspect_arguments(&request)?; - with_session(&request.session_id, |session| { - if !matches!( - session.state, - DebugSessionState::Running | DebugSessionState::Paused - ) { - return Err(invalid_request( - "Debugger inspection requires a running or paused session.", - )); - } - if request.kind == DebugInspectKind::RedefineClasses - && session.features.hot_code_replace.is_none() - { - return Err(invalid_request( - "The debug adapter does not declare hot code replacement.", - )); - } - if request.kind == DebugInspectKind::StepInTargets - && !session.capabilities.supports_step_in_targets_request - { - return Err(invalid_request( - "The debug adapter does not support smart step into.", - )); - } - if request.kind == DebugInspectKind::GotoTargets - && !session.capabilities.supports_goto_targets_request - { - return Err(invalid_request( - "The debug adapter does not support run to cursor.", - )); - } - if request.kind == DebugInspectKind::Completions - && !session.capabilities.supports_completions_request - { - return Err(invalid_request( - "The debug adapter does not support console completion.", - )); - } - if request.kind == DebugInspectKind::Disassemble { - if session.state != DebugSessionState::Paused { - return Err(invalid_request( - "Disassembly requires a paused debug session.", - )); - } - if !session.capabilities.supports_disassemble_request { - return Err(invalid_request( - "The debug adapter does not support disassembly.", - )); - } - } - if request.kind == DebugInspectKind::ReadMemory { - if session.state != DebugSessionState::Paused { - return Err(invalid_request( - "Reading memory requires a paused debug session.", - )); - } - if !session.capabilities.supports_read_memory_request { - return Err(invalid_request( - "The debug adapter does not support reading memory.", - )); - } - } - if request.kind == DebugInspectKind::ExceptionInfo { - if session.state != DebugSessionState::Paused { - return Err(invalid_request( - "Exception information requires a paused debug session.", - )); - } - if !session.capabilities.supports_exception_info_request { - return Err(invalid_request( - "The debug adapter does not support exception information.", - )); - } - } - let command = match (request.kind, &session.features.hot_code_replace) { - (DebugInspectKind::RedefineClasses, Some(feature)) => feature.request.clone(), - (kind, _) => kind.command().to_string(), - }; - session.send_request( - &command, - Value::Object(arguments), - PendingRequest::Inspect { - operation_id: request.operation_id, - kind: request.kind, - thread_id: request.thread_id, - frame_id: request.frame_id, - }, - )?; - Ok(session.take_update()) - }) -} - -/// Reduces one transport byte chunk and returns ordered writes and events. -pub(crate) fn receive(request: ReceiveRequest) -> Result { - let bytes = BASE64.decode(request.data_base64).map_err(|error| { - invalid_request("Debug transport dataBase64 was invalid.").with_details(error.to_string()) - })?; - with_session(&request.session_id, |session| { - let messages = match parse_messages(&mut session.read_buffer, &bytes) { - Ok(messages) => messages, - Err(error) => { - session.log_session_failure("protocol"); - session.transition(DebugSessionState::Failed); - return Err(error); - } - }; - for message in messages { - if let Err(error) = session.handle_message(message) { - session.log_session_failure("protocol"); - session.transition(DebugSessionState::Failed); - return Err(error); - } - } - Ok(session.take_update()) - }) -} - -/// Completes one adapter-originated terminal launch and emits its DAP response. -pub(crate) fn run_in_terminal_response( - request: DebugRunInTerminalResponseRequest, -) -> Result { - validate_identifier(&request.request_id, "requestId")?; - with_session(&request.session_id, |session| { - let Some(&request_sequence) = session - .pending_run_in_terminal_requests - .get(&request.request_id) - else { - // Native terminal creation is asynchronous. A response that races - // session shutdown, timeout, or a previous completion must not be - // applied to a later adapter request. - return Ok(session.take_update()); - }; - - if request.success { - validate_dap_process_id(request.process_id, "processId")?; - validate_dap_process_id(request.shell_process_id, "shellProcessId")?; - } - session - .pending_run_in_terminal_requests - .remove(&request.request_id); - - if request.success { - let mut body = Map::new(); - insert_option(&mut body, "processId", request.process_id); - insert_option(&mut body, "shellProcessId", request.shell_process_id); - session.send_response_with_body( - request_sequence, - "runInTerminal", - true, - None, - Some(Value::Object(body)), - )?; - } else { - let message = normalize_optional_text(request.message) - .unwrap_or_else(|| "Lithe could not start the debuggee terminal.".to_string()); - session.send_response_with_body( - request_sequence, - "runInTerminal", - false, - Some(&message), - None, - )?; - } - Ok(session.take_update()) - }) -} - -/// Completes one adapter-originated child session start and emits its DAP response. -pub(crate) fn start_debugging_response( - request: DebugStartDebuggingResponseRequest, -) -> Result { - validate_identifier(&request.request_id, "requestId")?; - with_session(&request.session_id, |session| { - let Some(request_sequence) = session - .pending_start_debugging_requests - .remove(&request.request_id) - else { - // The child start is asynchronous, so a reply can race shutdown or a - // previous completion and must not answer a later adapter request. - return Ok(session.take_update()); - }; - if request.success { - session.send_response(request_sequence, "startDebugging", true, None)?; - } else { - let message = normalize_optional_text(request.message) - .unwrap_or_else(|| "Lithe could not start the child debug session.".to_string()); - session.send_response(request_sequence, "startDebugging", false, Some(&message))?; - } - Ok(session.take_update()) - }) -} - -/// Begins a graceful DAP disconnect while the host keeps transport ownership. -pub(crate) fn disconnect(request: DisconnectRequest) -> Result { - with_session(&request.session_id, |session| { - if matches!( - session.state, - DebugSessionState::Terminating | DebugSessionState::Terminated - ) { - return Ok(session.take_update()); - } - session.fail_pending_run_in_terminal_requests( - "The debug session stopped before the terminal could start.", - )?; - session.fail_pending_start_debugging_requests( - "The debug session stopped before the child session could start.", - )?; - session.request_children_stop(); - // An attach session does not own the remote process, so disconnect must - // never terminate it. A launch session owns the local debuggee and - // follows the user's explicit choice, defaulting to termination. - let terminate_debuggee = match session.debug_request_kind { - Some(DebugRequestKind::Launch) => request.terminate_debuggee.unwrap_or(true), - Some(DebugRequestKind::Attach) | None => false, - }; - session.send_request( - "disconnect", - json!({"restart": false, "terminateDebuggee": terminate_debuggee}), - PendingRequest::Disconnect, - )?; - session.transition(DebugSessionState::Terminating); - Ok(session.take_update()) - }) -} - -/// Removes a session and its whole child tree, returning the removed descendant IDs. -/// -/// A child never outlives its parent, so destroying a parent also removes every -/// descendant (leaves first). The host owns the transports and must close the transport of -/// each returned ID; the list is empty for a session without children. -pub(crate) fn destroy_session(request: SessionRequest) -> Result, CoreError> { - let mut sessions = sessions_lock()?; - let Some(mut removed) = sessions.remove(&request.session_id) else { - return Err(session_not_found(&request.session_id)); - }; - removed.transition(DebugSessionState::Terminated); - if let Some(parent) = removed - .parent_id - .as_ref() - .and_then(|parent_id| sessions.get_mut(parent_id)) - { - parent.child_ids.retain(|id| id != &removed.id); - parent.stopping_children.remove(&removed.id); - } - let mut descendants = Vec::new(); - let mut pending = removed.child_ids; - while let Some(child_id) = pending.pop() { - if let Some(mut child) = sessions.remove(&child_id) { - child.transition(DebugSessionState::Terminated); - pending.extend(child.child_ids); - descendants.push(child_id); - } - } - descendants.reverse(); - Ok(descendants) -} - -impl DebugSession { - fn perform_launch( - &mut self, - operation_id: String, - configuration: DebugLaunchConfiguration, - ) -> Result<(), CoreError> { - let request_kind = configuration.request; - let mut arguments = configuration.arguments; - let filters = normalize_stepping_filters( - configuration - .stepping_filters - .or_else(|| self.default_stepping_filters.clone()) - .unwrap_or_else(DebugSteppingFilters::unfiltered), - )?; - // Only an adapter that declared the argument receives the filters; the - // engine never invents launch arguments for an undeclared adapter. - if let Some(feature) = &self.features.step_filters { - if !arguments.contains_key(&feature.argument) { - arguments.insert(feature.argument.clone(), step_filter_argument(&filters)); - } - } - self.stepping_filters = filters; - arguments - .entry("name".to_string()) - .or_insert(Value::String(configuration.name)); - arguments - .entry("cwd".to_string()) - .or_insert(Value::String(self.root_path.clone())); - self.debug_request_kind = Some(request_kind); - self.transition(DebugSessionState::Launching); - self.send_request( - request_kind.command(), - Value::Object(arguments), - PendingRequest::Launch { operation_id }, - ) - } - - /// Whether a stored breakpoint with this enabled state goes to the adapter. - fn sends(&self, enabled: bool) -> bool { - enabled && !self.breakpoints_muted - } - - /// Sends again every set holding an enabled breakpoint, after muting changed what - /// those sets send. An adapter is never sent a kind it did not declare. - fn resend_enabled_breakpoints(&mut self) -> Result<(), CoreError> { - if self - .exception_breakpoints - .iter() - .any(|breakpoint| breakpoint.enabled) - { - self.send_exception_breakpoints()?; - } - if self.capabilities.supports_function_breakpoints - && self - .function_breakpoints - .iter() - .any(|breakpoint| breakpoint.enabled) - { - self.send_function_breakpoints()?; - } - if self.capabilities.supports_instruction_breakpoints - && self - .instruction_breakpoints - .iter() - .any(|breakpoint| breakpoint.enabled) - { - self.send_instruction_breakpoints()?; - } - if self.capabilities.supports_data_breakpoints - && self - .data_breakpoints - .iter() - .any(|breakpoint| breakpoint.enabled) - { - self.send_data_breakpoints()?; - } - let sources: Vec = self - .breakpoints - .iter() - .filter(|(_, points)| points.iter().any(|breakpoint| breakpoint.enabled)) - .map(|(path, _)| path.clone()) - .collect(); - for source in sources { - self.send_breakpoints(&source)?; - } - Ok(()) - } - - fn send_breakpoints(&mut self, source_path: &str) -> Result<(), CoreError> { - let generation = self - .breakpoint_generations - .entry(source_path.to_string()) - .or_default(); - *generation += 1; - let generation = *generation; - let requested = self - .breakpoints - .get(source_path) - .cloned() - .unwrap_or_default(); - let active: Vec = requested - .iter() - .filter(|breakpoint| self.sends(breakpoint.enabled)) - .cloned() - .collect(); - let breakpoints: Vec = active - .iter() - .map(|breakpoint| { - let mut value = Map::new(); - value.insert("line".to_string(), json!(breakpoint.line)); - insert_option(&mut value, "column", breakpoint.column); - insert_nonempty(&mut value, "condition", breakpoint.condition.as_deref()); - insert_nonempty( - &mut value, - "hitCondition", - breakpoint.hit_condition.as_deref(), - ); - insert_nonempty(&mut value, "logMessage", breakpoint.log_message.as_deref()); - Value::Object(value) - }) - .collect(); - let source_name = source_path - .rsplit(['/', '\\']) - .next() - .unwrap_or(source_path); - self.send_request( - "setBreakpoints", - json!({ - "source": {"name": source_name, "path": source_path}, - "breakpoints": breakpoints, - "sourceModified": false - }), - PendingRequest::SetBreakpoints { - source_path: source_path.to_string(), - requested: active, - generation, - }, - ) - } - - fn send_exception_breakpoints(&mut self) -> Result<(), CoreError> { - let active: Vec<&ExceptionBreakpoint> = self - .exception_breakpoints - .iter() - .filter(|breakpoint| self.sends(breakpoint.enabled)) - .collect(); - let filters: Vec<&str> = active - .iter() - .map(|breakpoint| breakpoint.filter.as_str()) - .collect(); - let filter_options: Vec = if self.capabilities.supports_exception_filter_options { - active - .iter() - .filter_map(|breakpoint| { - breakpoint.condition.as_ref().map(|condition| { - json!({ - "filterId": breakpoint.filter, - "condition": condition - }) - }) - }) - .collect() - } else { - Vec::new() - }; - let mut arguments = Map::new(); - arguments.insert("filters".to_string(), json!(filters)); - if !filter_options.is_empty() { - arguments.insert("filterOptions".to_string(), Value::Array(filter_options)); - } - self.send_request( - "setExceptionBreakpoints", - Value::Object(arguments), - PendingRequest::SetExceptionBreakpoints, - ) - } - - fn send_function_breakpoints(&mut self) -> Result<(), CoreError> { - let requested: Vec = self - .function_breakpoints - .iter() - .filter(|breakpoint| self.sends(breakpoint.enabled)) - .cloned() - .collect(); - let breakpoints: Vec = requested - .iter() - .map(|breakpoint| { - let mut value = Map::new(); - value.insert("name".to_string(), json!(breakpoint.name)); - insert_nonempty(&mut value, "condition", breakpoint.condition.as_deref()); - insert_nonempty( - &mut value, - "hitCondition", - breakpoint.hit_condition.as_deref(), - ); - Value::Object(value) - }) - .collect(); - self.send_request( - "setFunctionBreakpoints", - json!({"breakpoints": breakpoints}), - PendingRequest::SetFunctionBreakpoints { requested }, - ) - } - - fn send_instruction_breakpoints(&mut self) -> Result<(), CoreError> { - let requested: Vec = self - .instruction_breakpoints - .iter() - .filter(|breakpoint| self.sends(breakpoint.enabled)) - .cloned() - .collect(); - let breakpoints: Vec = requested - .iter() - .map(|breakpoint| { - let mut value = Map::new(); - value.insert( - "instructionReference".to_string(), - json!(breakpoint.instruction_reference), - ); - insert_option(&mut value, "offset", breakpoint.offset); - insert_nonempty(&mut value, "condition", breakpoint.condition.as_deref()); - insert_nonempty( - &mut value, - "hitCondition", - breakpoint.hit_condition.as_deref(), - ); - Value::Object(value) - }) - .collect(); - self.send_request( - "setInstructionBreakpoints", - json!({"breakpoints": breakpoints}), - PendingRequest::SetInstructionBreakpoints { requested }, - ) - } - - fn send_data_breakpoints(&mut self) -> Result<(), CoreError> { - let requested: Vec = self - .data_breakpoints - .iter() - .filter(|breakpoint| self.sends(breakpoint.enabled)) - .cloned() - .collect(); - let breakpoints: Vec = requested - .iter() - .map(|breakpoint| { - let mut value = Map::new(); - value.insert("dataId".to_string(), json!(breakpoint.data_id)); - insert_nonempty(&mut value, "accessType", breakpoint.access_type.as_deref()); - insert_nonempty(&mut value, "condition", breakpoint.condition.as_deref()); - insert_nonempty( - &mut value, - "hitCondition", - breakpoint.hit_condition.as_deref(), - ); - Value::Object(value) - }) - .collect(); - self.send_request( - "setDataBreakpoints", - json!({"breakpoints": breakpoints}), - PendingRequest::SetDataBreakpoints { requested }, - ) - } - - fn send_request( - &mut self, - command: &str, - arguments: Value, - pending: PendingRequest, - ) -> Result<(), CoreError> { - let sequence = self.next_request_sequence; - self.next_request_sequence += 1; - let message = json!({ - "seq": sequence, - "type": "request", - "command": command, - "arguments": arguments - }); - self.outbound_frames.push(frame_message(&message)?); - self.request_stop_generations - .insert(sequence, self.stop_generation); - self.pending_requests.insert(sequence, pending); - Ok(()) - } - - fn send_response( - &mut self, - request_sequence: i64, - command: &str, - success: bool, - message: Option<&str>, - ) -> Result<(), CoreError> { - self.send_response_with_body(request_sequence, command, success, message, None) - } - - fn send_response_with_body( - &mut self, - request_sequence: i64, - command: &str, - success: bool, - message: Option<&str>, - body: Option, - ) -> Result<(), CoreError> { - let sequence = self.next_request_sequence; - self.next_request_sequence += 1; - let mut response = json!({ - "seq": sequence, - "type": "response", - "request_seq": request_sequence, - "success": success, - "command": command - }); - if let Some(message) = message { - response["message"] = Value::String(message.to_string()); - } - if let Some(body) = body { - response["body"] = body; - } - self.outbound_frames.push(frame_message(&response)?); - Ok(()) - } - - fn handle_message(&mut self, message: Value) -> Result<(), CoreError> { - match message.get("type").and_then(Value::as_str) { - Some("response") => self.handle_response(&message), - Some("event") => self.handle_event(&message), - Some("request") => self.handle_server_request(&message), - _ => Err(CoreError::new( - ErrorCode::ParseFailed, - "DAP message did not contain a supported type.", - )), - } - } - - fn handle_response(&mut self, message: &Value) -> Result<(), CoreError> { - let request_sequence = required_i64(message, "request_seq")?; - let Some(pending) = self.pending_requests.remove(&request_sequence) else { - return Ok(()); - }; - let issued_generation = self.request_stop_generations.remove(&request_sequence); - let success = message - .get("success") - .and_then(Value::as_bool) - .unwrap_or(false); - if !success { - let command = pending.command().to_string(); - let detail = adapter_failure_detail(message) - .unwrap_or_else(|| "The debug adapter rejected the request.".to_string()); - if let Some(operation_id) = pending.operation_id() { - self.emit(DebugEventBody::OperationFailed { - operation_id: operation_id.to_string(), - command, - code: DebugOperationFailureCode::AdapterRejected, - message: detail, - }); - } - if matches!( - pending, - PendingRequest::Initialize | PendingRequest::Launch { .. } - ) { - self.transition(DebugSessionState::Failed); - } - return Ok(()); - } - if pending.is_reference_bound() && issued_generation != Some(self.stop_generation) { - if let Some(operation_id) = pending.operation_id() { - self.emit(DebugEventBody::OperationFailed { - operation_id: operation_id.to_string(), - command: pending.command().to_string(), - code: DebugOperationFailureCode::StaleReference, - message: "The debuggee resumed or stopped again before the adapter replied." - .to_string(), - }); - } - return Ok(()); - } - let body = message.get("body").cloned().unwrap_or_else(|| json!({})); - match pending { - PendingRequest::Initialize => { - self.adapter_capabilities = body.as_object().cloned().unwrap_or_default(); - self.refresh_capabilities(false); - if !self.did_configure_exception_breakpoints { - self.exception_breakpoints = self.default_exception_breakpoints(); - } - self.supports_configuration_done = self.capabilities.supports_configuration_done; - self.transition(DebugSessionState::Ready); - if let Some((operation_id, configuration)) = self.pending_launch.take() { - self.perform_launch(operation_id, configuration)?; - } - } - PendingRequest::Launch { operation_id } => { - // Adapters may emit stopped or terminated before this acknowledgement. - // A late response completes the operation, but cannot resume or resurrect it. - if self.state == DebugSessionState::Launching { - self.transition(DebugSessionState::Running); - } - self.emit(DebugEventBody::OperationCompleted { - operation_id, - result: DebugOperationResult::Acknowledged { - command: "launch".to_string(), - }, - }); - } - PendingRequest::SetBreakpoints { - source_path, - requested, - generation, - } => { - if self.breakpoint_generations.get(&source_path) == Some(&generation) { - self.emit_breakpoint_results(&body, &source_path, &requested); - } - } - PendingRequest::SetExceptionBreakpoints => {} - PendingRequest::SetFunctionBreakpoints { requested } => { - self.emit_function_breakpoint_results(&body, &requested) - } - PendingRequest::SetInstructionBreakpoints { requested } => { - self.emit_instruction_breakpoint_results(&body, &requested) - } - PendingRequest::DataBreakpointInfo { operation_id } => { - self.emit(DebugEventBody::OperationCompleted { - operation_id, - result: DebugOperationResult::DataBreakpointInfo { - data_id: string_field(&body, "dataId"), - description: body - .get("description") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(), - access_types: body - .get("accessTypes") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(Value::as_str) - .map(str::to_string) - .collect(), - can_persist: bool_field(&body, "canPersist"), - }, - }); - } - PendingRequest::SetDataBreakpoints { requested } => { - self.emit_data_breakpoint_results(&body, &requested) - } - PendingRequest::SetVariable { operation_id, name } => { - self.emit(DebugEventBody::OperationCompleted { - operation_id, - result: DebugOperationResult::SetVariable { - variable: DebugVariable { - name, - value: required_str(&body, "value")?.to_string(), - r#type: string_field(&body, "type"), - evaluate_name: None, - variables_reference: body - .get("variablesReference") - .and_then(Value::as_i64) - .unwrap_or(0), - named_variables: nonnegative_count_field(&body, "namedVariables"), - indexed_variables: nonnegative_count_field(&body, "indexedVariables"), - }, - }, - }); - } - PendingRequest::Cancel => {} - PendingRequest::ConfigurationDone => {} - PendingRequest::Execute { - operation_id, - command, - single_thread, - } => { - // Some adapters send `stopped` before the reply to `next`; a newer generation - // means an event already advanced the state, so the reply must not undo it. - if command != DebugExecutionCommand::Pause - && command != DebugExecutionCommand::Terminate - && !(single_thread && command == DebugExecutionCommand::Continue) - && issued_generation == Some(self.stop_generation) - { - self.stop_generation += 1; - self.transition(DebugSessionState::Running); - } - self.emit(DebugEventBody::OperationCompleted { - operation_id, - result: DebugOperationResult::Acknowledged { - command: command.command().to_string(), - }, - }); - } - PendingRequest::Inspect { - operation_id, - kind, - thread_id, - frame_id, - } => { - self.emit_inspection_projection(kind, thread_id, frame_id, &body); - let result = match (kind, &self.features.hot_code_replace) { - (DebugInspectKind::RedefineClasses, Some(feature)) => { - declared_replacement(feature, &body) - } - _ => normalize_inspection(kind, &body, &self.stepping_filters, &self.root_path), - }; - // HCR rejection and unreadable memory are operation failures, not a broken - // transport/session. - if matches!( - kind, - DebugInspectKind::RedefineClasses - | DebugInspectKind::Disassemble - | DebugInspectKind::ReadMemory - ) { - match result { - Ok(result) => self.emit(DebugEventBody::OperationCompleted { - operation_id, - result, - }), - Err(error) => self.emit(DebugEventBody::OperationFailed { - operation_id, - command: kind.command().into(), - code: DebugOperationFailureCode::AdapterRejected, - message: error.message, - }), - } - } else { - self.emit(DebugEventBody::OperationCompleted { - operation_id, - result: result?, - }); - } - } - PendingRequest::Disconnect => {} - } - Ok(()) - } - - fn handle_event(&mut self, message: &Value) -> Result<(), CoreError> { - let event = required_str(message, "event")?; - let body = message.get("body").cloned().unwrap_or_else(|| json!({})); - match event { - "initialized" => { - self.did_receive_initialized = true; - self.emit(DebugEventBody::Initialized); - self.send_exception_breakpoints()?; - if self.capabilities.supports_function_breakpoints { - self.send_function_breakpoints()?; - } - if self.capabilities.supports_instruction_breakpoints { - self.send_instruction_breakpoints()?; - } - if self.capabilities.supports_data_breakpoints { - self.send_data_breakpoints()?; - } - let sources: Vec = self.breakpoints.keys().cloned().collect(); - for source in sources { - self.send_breakpoints(&source)?; - } - if self.supports_configuration_done { - self.send_request( - "configurationDone", - json!({}), - PendingRequest::ConfigurationDone, - )?; - } - } - "output" => self.emit(DebugEventBody::Output { - category: body - .get("category") - .and_then(Value::as_str) - .map(str::to_string), - output: body - .get("output") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(), - }), - "stopped" => { - self.stop_generation += 1; - self.transition(DebugSessionState::Paused); - self.emit(DebugEventBody::Stopped { - reason: body - .get("reason") - .and_then(Value::as_str) - .unwrap_or("pause") - .to_string(), - thread_id: body.get("threadId").and_then(Value::as_i64), - description: body - .get("description") - .and_then(Value::as_str) - .map(str::to_string), - stop_generation: self.stop_generation, - }); - } - "continued" => { - // Resuming one thread leaves the frames and variables of the others valid. - if body - .get("allThreadsContinued") - .and_then(Value::as_bool) - .unwrap_or(true) - { - self.stop_generation += 1; - self.transition(DebugSessionState::Running); - } - self.emit(DebugEventBody::Continued { - thread_id: body.get("threadId").and_then(Value::as_i64), - }); - } - "terminated" => { - self.stop_generation += 1; - self.transition(DebugSessionState::Terminated); - self.request_children_stop(); - self.emit(DebugEventBody::Terminated { exit_code: None }); - } - "exited" => { - self.stop_generation += 1; - self.transition(DebugSessionState::Terminated); - self.request_children_stop(); - self.emit(DebugEventBody::Terminated { - exit_code: body.get("exitCode").and_then(Value::as_i64), - }); - } - "capabilities" => { - if let Some(updated) = body.get("capabilities").and_then(Value::as_object) { - self.adapter_capabilities.extend(updated.clone()); - self.refresh_capabilities(true); - } - } - "breakpoint" => { - if let Some(value) = body.get("breakpoint") { - let mut breakpoint = parse_breakpoint(value, None, None, 0); - if let Some(previous) = self.resolved_source_breakpoints.get(&breakpoint.id) { - breakpoint.requested_line = previous.requested_line; - breakpoint.source_path = breakpoint - .source_path - .or_else(|| previous.source_path.clone()); - breakpoint.line = breakpoint.line.or(previous.line); - breakpoint.column = breakpoint.column.or(previous.column); - self.resolved_source_breakpoints - .insert(breakpoint.id, breakpoint.clone()); - } - self.emit(DebugEventBody::Breakpoint { breakpoint }); - } - } - declared - if self - .features - .hot_code_replace - .as_ref() - .and_then(|feature| feature.event.as_deref()) - == Some(declared) => - { - // The declared progress event shows its message in the Debug Console. - if let Some(message) = body - .get("message") - .and_then(Value::as_str) - .filter(|message| !message.is_empty()) - { - self.emit(DebugEventBody::Output { - category: Some("console".to_string()), - output: format!("{message}\n"), - }); - } - } - _ => {} - } - Ok(()) - } - - fn handle_server_request(&mut self, message: &Value) -> Result<(), CoreError> { - let request_sequence = required_i64(message, "seq")?; - let command = required_str(message, "command")?; - if command == "runInTerminal" && self.supports_run_in_terminal_request { - let arguments = message.get("arguments").unwrap_or(&Value::Null); - match parse_run_in_terminal_request(arguments) { - Ok(request) => { - let request_id = format!("runInTerminal-{request_sequence}"); - if self - .pending_run_in_terminal_requests - .contains_key(&request_id) - { - // One DAP sequence can have only one response. Keep the - // original native launch pending instead of replacing - // it or starting a second process for a malformed retry. - return Ok(()); - } - self.pending_run_in_terminal_requests - .insert(request_id.clone(), request_sequence); - self.emit(DebugEventBody::RunInTerminalRequested { - request_id, - request, - }); - return Ok(()); - } - Err(error) => { - return self.send_response( - request_sequence, - command, - false, - Some(&error.message), - ); - } - } - } - if command == "startDebugging" && self.supports_start_debugging_request { - let arguments = message.get("arguments").unwrap_or(&Value::Null); - return match parse_start_debugging_request(arguments) { - Ok(request) => { - let request_id = format!("startDebugging-{request_sequence}"); - if self - .pending_start_debugging_requests - .contains_key(&request_id) - { - // A DAP sequence has one response; a duplicate must not - // open a second child session. - return Ok(()); - } - self.pending_start_debugging_requests - .insert(request_id.clone(), request_sequence); - self.emit(DebugEventBody::StartDebuggingRequested { - request_id, - request, - }); - Ok(()) - } - Err(error) => { - self.send_response(request_sequence, command, false, Some(&error.message)) - } - }; - } - self.send_response( - request_sequence, - command, - false, - Some("This debug adapter request is not supported by Lithe."), - ) - } - - fn fail_pending_start_debugging_requests(&mut self, message: &str) -> Result<(), CoreError> { - let pending = std::mem::take(&mut self.pending_start_debugging_requests); - for request_sequence in pending.into_values() { - self.send_response(request_sequence, "startDebugging", false, Some(message))?; - } - Ok(()) - } - - fn fail_pending_run_in_terminal_requests(&mut self, message: &str) -> Result<(), CoreError> { - let pending = std::mem::take(&mut self.pending_run_in_terminal_requests); - for (_, request_sequence) in pending { - self.send_response(request_sequence, "runInTerminal", false, Some(message))?; - } - Ok(()) - } - - /// Emits the v2 projection of a stack trace or scopes reply beside the v1 result. - fn emit_inspection_projection( - &mut self, - kind: DebugInspectKind, - thread_id: Option, - frame_id: Option, - body: &Value, - ) { - let projection = match (kind, thread_id, frame_id) { - (DebugInspectKind::StackTrace, Some(thread_id), _) => stack_trace_projection( - &self.id, - self.stop_generation, - thread_id, - body, - &self.root_path, - |name, path, hint| { - stack_frame_matches_filters( - name, - path, - hint, - &self.stepping_filters, - &self.root_path, - ) - }, - ), - (DebugInspectKind::Scopes, _, Some(frame_id)) => scopes_projection( - &self.id, - self.stop_generation, - frame_id, - body, - &self.root_path, - ), - _ => None, - }; - if let Some(projection) = projection { - self.emit(DebugEventBody::Projection { projection }); - } - } - - fn emit_breakpoint_results( - &mut self, - body: &Value, - source_path: &str, - requested: &[SourceBreakpoint], - ) { - let values = body - .get("breakpoints") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - for (index, value) in values.iter().enumerate() { - let fallback = requested.get(index).map(|breakpoint| breakpoint.line); - let mut breakpoint = - parse_breakpoint(value, None, Some(source_path), fallback.unwrap_or(0)); - breakpoint.requested_line = fallback; - if value.get("id").and_then(Value::as_i64).is_some() { - self.resolved_source_breakpoints - .insert(breakpoint.id, breakpoint.clone()); - } - self.emit(DebugEventBody::Breakpoint { breakpoint }); - } - } - - fn emit_function_breakpoint_results(&mut self, body: &Value, requested: &[FunctionBreakpoint]) { - let values = body - .get("breakpoints") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - for (index, value) in values.iter().enumerate() { - let function_name = requested - .get(index) - .map(|breakpoint| breakpoint.name.as_str()); - self.emit(DebugEventBody::Breakpoint { - breakpoint: parse_breakpoint(value, function_name, None, 0), - }); - } - } - - fn emit_instruction_breakpoint_results( - &mut self, - body: &Value, - requested: &[InstructionBreakpoint], - ) { - let values = body - .get("breakpoints") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - for (index, value) in values.iter().enumerate() { - let mut breakpoint = parse_breakpoint(value, None, None, 0); - // Adapters may omit the reference; the request at the same index owns it. - if breakpoint.instruction_reference.is_none() { - breakpoint.instruction_reference = requested - .get(index) - .map(|item| item.instruction_reference.clone()); - } - self.emit(DebugEventBody::Breakpoint { breakpoint }); - } - } - - fn emit_data_breakpoint_results(&mut self, body: &Value, requested: &[DataBreakpoint]) { - let values = body - .get("breakpoints") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - for (index, value) in values.iter().enumerate() { - let mut breakpoint = parse_breakpoint(value, None, None, 0); - breakpoint.data_id = requested.get(index).map(|item| item.data_id.clone()); - self.emit(DebugEventBody::Breakpoint { breakpoint }); - } - } - - fn transition(&mut self, state: DebugSessionState) { - if self.state == state { - return; - } - self.state = state; - let event = match state { - DebugSessionState::Ready => "debug.session.initialized", - DebugSessionState::Terminated => "debug.session.finished", - DebugSessionState::Failed => { - self.log_session_failure("adapter_rejected"); - self.emit(DebugEventBody::StateChanged { state }); - return; - } - _ => "debug.session.state_changed", - }; - let fields = if event == "debug.session.state_changed" { - BTreeMap::from([( - "state".into(), - serde_json::to_value(state).expect("DAP state encodes"), - )]) - } else { - BTreeMap::new() - }; - self.log_fact(event, fields, Some(self.id.clone())); - self.emit(DebugEventBody::StateChanged { state }); - } - - fn default_exception_breakpoints(&self) -> Vec { - self.capabilities - .exception_breakpoint_filters - .iter() - .map(|filter| ExceptionBreakpoint { - filter: filter.filter.clone(), - enabled: filter.default, - condition: None, - }) - .collect() - } - - /// Asks the host to stop each child once; the host owns the child transports. - fn request_children_stop(&mut self) { - for child_session_id in self.child_ids.clone() { - if self.stopping_children.insert(child_session_id.clone()) { - self.emit(DebugEventBody::ChildSessionStopRequested { child_session_id }); - } - } - } - - /// Recomputes the effective capabilities and publishes them when any state changed. - /// - /// With `only_changes` false the first negotiation is always published. - fn refresh_capabilities(&mut self, only_changes: bool) { - let states = capability_states(&self.adapter_capabilities, &self.disabled_capabilities); - let capabilities = parse_capabilities(&effective_body(&self.adapter_capabilities, &states)); - // Non-boolean fields such as exception filters change without any flag flipping. - if only_changes && states == self.capability_states && capabilities == self.capabilities { - return; - } - self.capabilities = capabilities; - self.capability_states = states; - if only_changes && !self.did_configure_exception_breakpoints { - self.exception_breakpoints = self.default_exception_breakpoints(); - } - self.capability_revision += 1; - self.emit(DebugEventBody::Capabilities { - capabilities: self.capabilities.clone(), - }); - if let Some(projection) = capabilities_projection( - &self.id, - self.stop_generation, - self.capability_revision, - self.capability_states.clone(), - ) { - self.emit(DebugEventBody::Projection { projection }); - } - } - - fn log_fact(&self, event: &str, mut fields: BTreeMap, op: Option) { - fields.insert("adapter".into(), json!(self.log_adapter)); - if crate::diagnostics::core_events::record(event, fields, op).is_err() { - crate::diagnostics::core_events::note_dropped(); - } - } - - fn log_session_failure(&mut self, code: &str) { - if self.logged_terminal_failure { - return; - } - self.logged_terminal_failure = true; - self.log_fact( - "debug.session.failed", - BTreeMap::from([("failure_code".into(), json!(code))]), - Some(self.id.clone()), - ); - } - - fn emit(&mut self, body: DebugEventBody) { - if let DebugEventBody::OperationFailed { - operation_id, - command, - code, - .. - } = &body - { - let (event, failure) = match code { - DebugOperationFailureCode::Cancelled => ("debug.operation.cancelled", "cancelled"), - DebugOperationFailureCode::StaleReference => { - ("debug.operation.cancelled", "stale_reference") - } - DebugOperationFailureCode::TimedOut => ("debug.operation.failed", "timed_out"), - DebugOperationFailureCode::AdapterRejected => { - ("debug.operation.failed", "adapter_rejected") - } - }; - // A launch cancellation is an expected terminal too: the following - // Failed state must not turn it into a second product error. - self.logged_terminal_failure |= matches!(command.as_str(), "launch" | "attach"); - self.log_fact( - event, - BTreeMap::from([("failure_code".into(), json!(failure))]), - Some(operation_id.clone()), - ); - } - let sequence = self.next_event_sequence; - self.next_event_sequence += 1; - self.events.push(DebugEvent { sequence, body }); - } - - fn take_update(&mut self) -> DebugSessionUpdate { - DebugSessionUpdate { - session_id: self.id.clone(), - state: self.state, - outbound_frames: std::mem::take(&mut self.outbound_frames) - .into_iter() - .map(|frame| BASE64.encode(frame)) - .collect(), - events: std::mem::take(&mut self.events), - } - } -} - -impl PendingRequest { - fn command(&self) -> &'static str { - match self { - Self::Initialize => "initialize", - Self::Launch { .. } => "launch", - Self::SetBreakpoints { .. } => "setBreakpoints", - Self::SetExceptionBreakpoints => "setExceptionBreakpoints", - Self::SetFunctionBreakpoints { .. } => "setFunctionBreakpoints", - Self::SetInstructionBreakpoints { .. } => "setInstructionBreakpoints", - Self::DataBreakpointInfo { .. } => "dataBreakpointInfo", - Self::SetDataBreakpoints { .. } => "setDataBreakpoints", - Self::SetVariable { .. } => "setVariable", - Self::Cancel => "cancel", - Self::ConfigurationDone => "configurationDone", - Self::Execute { command, .. } => command.command(), - Self::Inspect { kind, .. } => kind.command(), - Self::Disconnect => "disconnect", - } - } - - /// Frame IDs and variables references are only valid until the next stop or resume. - fn is_reference_bound(&self) -> bool { - match self { - Self::SetVariable { .. } => true, - Self::Inspect { kind, .. } => matches!( - kind, - DebugInspectKind::StackTrace - | DebugInspectKind::Scopes - | DebugInspectKind::Variables - | DebugInspectKind::Evaluate - | DebugInspectKind::StepInTargets - | DebugInspectKind::GotoTargets - | DebugInspectKind::Completions - | DebugInspectKind::Disassemble - | DebugInspectKind::ReadMemory - ), - _ => false, - } - } - - fn operation_id(&self) -> Option<&str> { - match self { - Self::Launch { operation_id } - | Self::Execute { operation_id, .. } - | Self::Inspect { operation_id, .. } - | Self::DataBreakpointInfo { operation_id } - | Self::SetVariable { operation_id, .. } => Some(operation_id), - _ => None, - } - } -} - -fn inspect_arguments(request: &InspectRequest) -> Result, CoreError> { - // `readMemory` reuses `count` for its byte length; every other kind rejects paging fields. - let paging_allowed = request.kind == DebugInspectKind::Variables; - let count_allowed = paging_allowed || request.kind == DebugInspectKind::ReadMemory; - if (!paging_allowed && (request.variable_filter.is_some() || request.start.is_some())) - || (!count_allowed && request.count.is_some()) - { - return Err(invalid_request( - "Debug variable paging is only valid for variables inspection.", - )); - } - let is_memory_kind = matches!( - request.kind, - DebugInspectKind::Disassemble | DebugInspectKind::ReadMemory - ); - if !is_memory_kind - && (request.memory_reference.is_some() - || request.offset.is_some() - || request.instruction_offset.is_some() - || request.instruction_count.is_some() - || request.resolve_symbols.is_some()) - { - return Err(invalid_request( - "Debug memory fields are only valid for disassemble or readMemory.", - )); - } - let mut arguments = Map::new(); - match request.kind { - DebugInspectKind::Threads | DebugInspectKind::RedefineClasses => {} - DebugInspectKind::StackTrace => { - arguments.insert( - "threadId".to_string(), - json!(required_positive(request.thread_id, "threadId")?), - ); - } - DebugInspectKind::Scopes => { - arguments.insert( - "frameId".to_string(), - json!(required_nonnegative(request.frame_id, "frameId")?), - ); - } - DebugInspectKind::Variables => { - arguments.insert( - "variablesReference".to_string(), - json!(required_positive( - request.variables_reference, - "variablesReference" - )?), - ); - if let Some(filter) = request.variable_filter { - arguments.insert("filter".to_string(), json!(filter.argument())); - } - if let Some(start) = request.start { - arguments.insert( - "start".to_string(), - json!(required_nonnegative(Some(start), "start")?), - ); - } - if let Some(count) = request.count { - arguments.insert( - "count".to_string(), - json!(required_positive(Some(count), "count")?), - ); - } - } - DebugInspectKind::Evaluate => { - let expression = request.expression.as_deref().unwrap_or_default().trim(); - if expression.is_empty() { - return Err(invalid_request("Debug evaluate requires an expression.")); - } - arguments.insert("expression".to_string(), json!(expression)); - let context = request.context.as_deref().unwrap_or("watch"); - if !matches!( - context, - "watch" | "hover" | "repl" | "clipboard" | "variables" - ) { - return Err(invalid_request( - "Debug evaluate context must be watch, hover, repl, clipboard or variables.", - )); - } - arguments.insert("context".to_string(), json!(context)); - if let Some(frame_id) = request.frame_id { - if frame_id < 0 { - return Err(invalid_request("Debug frameId cannot be negative.")); - } - arguments.insert("frameId".to_string(), json!(frame_id)); - } - } - DebugInspectKind::ExceptionInfo => { - arguments.insert( - "threadId".to_string(), - json!(required_positive(request.thread_id, "threadId")?), - ); - } - DebugInspectKind::StepInTargets => { - arguments.insert( - "frameId".to_string(), - json!(required_nonnegative(request.frame_id, "frameId")?), - ); - } - DebugInspectKind::GotoTargets => { - let source_path = request.source_path.as_deref().unwrap_or_default().trim(); - if source_path.is_empty() { - return Err(invalid_request("Debug gotoTargets requires a source path.")); - } - arguments.insert("source".to_string(), json!({"path": source_path})); - arguments.insert( - "line".to_string(), - json!(required_positive(request.line, "line")?), - ); - if let Some(column) = request.column { - if column < 1 { - return Err(invalid_request("Debug column must be positive.")); - } - arguments.insert("column".to_string(), json!(column)); - } - } - DebugInspectKind::Disassemble => { - let reference = request.memory_reference.as_deref().unwrap_or_default(); - arguments.insert( - "memoryReference".to_string(), - json!(validated_memory_reference(reference)?), - ); - insert_option(&mut arguments, "offset", request.offset); - insert_option( - &mut arguments, - "instructionOffset", - request.instruction_offset, - ); - let count = required_positive(request.instruction_count, "instructionCount")?; - if count > MAXIMUM_DISASSEMBLE_INSTRUCTIONS { - return Err(invalid_request( - "Debug instructionCount cannot exceed 1000 instructions.", - )); - } - arguments.insert("instructionCount".to_string(), json!(count)); - arguments.insert( - "resolveSymbols".to_string(), - json!(request.resolve_symbols.unwrap_or(true)), - ); - } - DebugInspectKind::ReadMemory => { - let reference = request.memory_reference.as_deref().unwrap_or_default(); - arguments.insert( - "memoryReference".to_string(), - json!(validated_memory_reference(reference)?), - ); - insert_option(&mut arguments, "offset", request.offset); - let count = required_positive(request.count, "count")?; - if count > MAXIMUM_READ_MEMORY_BYTES { - return Err(invalid_request( - "Debug readMemory count cannot exceed 65536 bytes.", - )); - } - arguments.insert("count".to_string(), json!(count)); - } - DebugInspectKind::Completions => { - // Whitespace is significant: `break ` and `break` complete differently. - let text = request.expression.as_deref().unwrap_or_default(); - if text.is_empty() { - return Err(invalid_request("Debug completions require text.")); - } - arguments.insert("text".to_string(), json!(text)); - // The caret defaults to the end of the text. DAP columns count UTF-16 - // units from 1, matching the `columnsStartAt1` the session initialized with. - let end = text.encode_utf16().count() as i64 + 1; - let column = request.column.unwrap_or(end); - if column < 1 || column > end { - return Err(invalid_request( - "Debug completion column must lie within the text.", - )); - } - arguments.insert("column".to_string(), json!(column)); - if let Some(frame_id) = request.frame_id { - if frame_id < 0 { - return Err(invalid_request("Debug frameId cannot be negative.")); - } - arguments.insert("frameId".to_string(), json!(frame_id)); - } - } - } - Ok(arguments) -} - -fn normalize_inspection( - kind: DebugInspectKind, - body: &Value, - stepping_filters: &DebugSteppingFilters, - root_path: &str, -) -> Result { - match kind { - DebugInspectKind::Threads => Ok(DebugOperationResult::Threads { - threads: required_array(body, "threads")? - .iter() - .filter_map(parse_thread) - .collect(), - }), - DebugInspectKind::StackTrace => Ok(DebugOperationResult::StackTrace { - stack_frames: required_array(body, "stackFrames")? - .iter() - .filter_map(|value| parse_stack_frame(value, stepping_filters, root_path)) - .collect(), - }), - DebugInspectKind::Scopes => Ok(DebugOperationResult::Scopes { - scopes: required_array(body, "scopes")? - .iter() - .filter_map(parse_scope) - .collect(), - }), - DebugInspectKind::Variables => Ok(DebugOperationResult::Variables { - variables: required_array(body, "variables")? - .iter() - .filter_map(parse_variable) - .collect(), - }), - DebugInspectKind::Evaluate => Ok(DebugOperationResult::Evaluate { - variable: DebugVariable { - name: body - .get("evaluateName") - .and_then(Value::as_str) - .unwrap_or("Expression") - .to_string(), - value: required_str(body, "result")?.to_string(), - r#type: string_field(body, "type"), - evaluate_name: string_field(body, "evaluateName"), - variables_reference: body - .get("variablesReference") - .and_then(Value::as_i64) - .unwrap_or(0), - named_variables: nonnegative_count_field(body, "namedVariables"), - indexed_variables: nonnegative_count_field(body, "indexedVariables"), - }, - }), - DebugInspectKind::ExceptionInfo => Ok(DebugOperationResult::ExceptionInfo { - exception_info: DebugExceptionInfo { - exception_id: required_str(body, "exceptionId")?.to_string(), - description: string_field(body, "description"), - break_mode: required_str(body, "breakMode")?.to_string(), - details: body.get("details").and_then(parse_exception_details), - }, - }), - DebugInspectKind::RedefineClasses => { - // An adapter may return DAP success with an application-level error. - if let Some(message) = body - .get("errorMessage") - .and_then(Value::as_str) - .filter(|s| !s.is_empty()) - { - return Err(invalid_request(message)); - } - let mut changed_classes = required_array(body, "changedClasses")? - .iter() - .map(|value| { - value - .as_str() - .map(str::to_owned) - .ok_or_else(|| invalid_request("Invalid changed class name.")) - }) - .collect::, _>>()?; - changed_classes.sort(); - changed_classes.dedup(); - Ok(DebugOperationResult::RedefineClasses { changed_classes }) - } - DebugInspectKind::StepInTargets => Ok(DebugOperationResult::StepInTargets { - targets: required_array(body, "targets")? - .iter() - .filter_map(parse_step_in_target) - .collect(), - }), - DebugInspectKind::GotoTargets => Ok(DebugOperationResult::GotoTargets { - targets: required_array(body, "targets")? - .iter() - .filter_map(parse_goto_target) - .collect(), - }), - DebugInspectKind::Completions => Ok(DebugOperationResult::Completions { - items: required_array(body, "targets")? - .iter() - .filter_map(parse_completion_item) - .collect(), - }), - DebugInspectKind::Disassemble => Ok(DebugOperationResult::Disassemble { - instructions: required_array(body, "instructions")? - .iter() - .filter_map(parse_disassembled_instruction) - .collect(), - }), - DebugInspectKind::ReadMemory => { - // Memory that is entirely unreadable comes back without `data`. - let data_base64 = string_field(body, "data").unwrap_or_default(); - if BASE64.decode(&data_base64).is_err() { - return Err(CoreError::new( - ErrorCode::ParseFailed, - "DAP readMemory data was not valid base64.", - )); - } - Ok(DebugOperationResult::ReadMemory { - address: required_str(body, "address")?.to_string(), - unreadable_bytes: nonnegative_count_field(body, "unreadableBytes"), - data_base64, - }) - } - } -} - -/// Rejects empty, oversized or multi-line references; the value itself stays opaque. -fn validated_memory_reference(reference: &str) -> Result { - let reference = reference.trim(); - if reference.is_empty() - || reference.chars().count() > MAXIMUM_MEMORY_REFERENCE_LENGTH - || reference.chars().any(char::is_control) - { - return Err(invalid_request( - "Debug memory references must be short single-line adapter references.", - )); - } - Ok(reference.to_string()) -} - -fn parse_disassembled_instruction(value: &Value) -> Option { - let address = value.get("address")?.as_str()?; - let instruction = value.get("instruction")?.as_str()?; - if address.is_empty() { - return None; - } - Some(DebugDisassembledInstruction { - address: address.to_string(), - instruction_bytes: string_field(value, "instructionBytes").filter(|text| !text.is_empty()), - instruction: instruction.to_string(), - symbol: string_field(value, "symbol").filter(|text| !text.is_empty()), - source_path: value - .get("location") - .and_then(|source| source.get("path")) - .and_then(Value::as_str) - .map(str::to_string), - line: value - .get("line") - .and_then(Value::as_i64) - .filter(|line| *line > 0), - }) -} - -fn parse_exception_details(value: &Value) -> Option { - value.as_object()?; - Some(DebugExceptionDetails { - message: string_field(value, "message"), - type_name: string_field(value, "typeName"), - full_type_name: string_field(value, "fullTypeName"), - evaluate_name: string_field(value, "evaluateName"), - stack_trace: string_field(value, "stackTrace"), - inner_exceptions: value - .get("innerException") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(parse_exception_details) - .collect(), - }) -} - -fn parse_step_in_target(value: &Value) -> Option { - Some(DebugStepInTarget { - id: value.get("id")?.as_i64()?, - label: value.get("label")?.as_str()?.to_string(), - line: value.get("line").and_then(Value::as_i64), - column: value.get("column").and_then(Value::as_i64), - end_line: value.get("endLine").and_then(Value::as_i64), - end_column: value.get("endColumn").and_then(Value::as_i64), - }) -} - -fn parse_goto_target(value: &Value) -> Option { - Some(DebugGotoTarget { - id: value.get("id")?.as_i64()?, - label: value.get("label")?.as_str()?.to_string(), - line: value.get("line")?.as_i64()?, - column: value.get("column").and_then(Value::as_i64), - end_line: value.get("endLine").and_then(Value::as_i64), - end_column: value.get("endColumn").and_then(Value::as_i64), - instruction_pointer_reference: string_field(value, "instructionPointerReference"), - }) -} - -fn parse_completion_item(value: &Value) -> Option { - let label = value.get("label")?.as_str()?; - if label.is_empty() { - return None; - } - // The wire range counts from 1 with the session's `columnsStartAt1`; clients get 0. - let offset = |field: &str| { - value - .get(field) - .and_then(Value::as_i64) - .filter(|number| *number >= 0) - }; - Some(DebugCompletionItem { - label: label.to_string(), - text: string_field(value, "text") - .filter(|text| !text.is_empty()) - .unwrap_or_else(|| label.to_string()), - detail: string_field(value, "detail"), - kind: string_field(value, "type"), - start: offset("start").map(|start| (start - 1).max(0)), - length: offset("length"), - }) -} - -fn parse_thread(value: &Value) -> Option { - Some(DebugThread { - id: value.get("id")?.as_i64()?, - name: value.get("name")?.as_str()?.to_string(), - }) -} - -fn parse_stack_frame( - value: &Value, - stepping_filters: &DebugSteppingFilters, - root_path: &str, -) -> Option { - let name = value.get("name")?.as_str()?.to_string(); - let source_path = value - .get("source") - .and_then(|source| source.get("path")) - .and_then(Value::as_str) - .map(str::to_string); - let source_reference = value - .get("source") - .and_then(|source| source.get("sourceReference")) - .and_then(Value::as_i64) - .filter(|reference| *reference > 0); - let presentation_hint = value.get("presentationHint").and_then(Value::as_str); - // A frame in code without debug info has no file; the Core must not invent line 1 of - // nothing, so the UI can show the function and its instruction address instead. - let has_source = source_path.is_some() || source_reference.is_some(); - Some(DebugStackFrame { - id: value.get("id")?.as_i64()?, - is_filtered: stack_frame_matches_filters( - &name, - source_path.as_deref(), - presentation_hint, - stepping_filters, - root_path, - ), - name, - source_path, - source_reference, - line: value - .get("line") - .and_then(Value::as_i64) - .unwrap_or(if has_source { 1 } else { 0 }), - column: value.get("column").and_then(Value::as_i64).unwrap_or(1), - instruction_pointer_reference: string_field(value, "instructionPointerReference") - .filter(|reference| !reference.is_empty()), - }) -} - -fn normalize_stepping_filters( - mut filters: DebugSteppingFilters, -) -> Result { - const MAXIMUM_FILTER_COUNT: usize = 256; - const MAXIMUM_FILTER_LENGTH: usize = 256; - - let mut normalized = Vec::with_capacity(filters.class_name_filters.len()); - for filter in filters.class_name_filters { - let filter = filter.trim(); - if filter.is_empty() { - continue; - } - if filter.chars().count() > MAXIMUM_FILTER_LENGTH || filter.chars().any(char::is_control) { - return Err(invalid_request( - "Debug stepping filters must be short single-line class patterns.", - )); - } - normalized.push(filter.to_string()); - } - normalized.sort(); - normalized.dedup(); - if normalized.len() > MAXIMUM_FILTER_COUNT { - return Err(invalid_request( - "Debug stepping filters cannot contain more than 256 class patterns.", - )); - } - filters.class_name_filters = normalized; - Ok(filters) -} - -/// Reads a declared hot code replacement reply through the fields the -/// adapter named for its failure message and its replaced units. -fn declared_replacement( - feature: &HotCodeReplaceFeature, - body: &Value, -) -> Result { - if let Some(message) = feature - .error_field - .as_deref() - .and_then(|field| body.get(field)) - .and_then(Value::as_str) - .filter(|message| !message.is_empty()) - { - return Err(invalid_request(message)); - } - let mut changed = match feature.changed_field.as_deref() { - None => Vec::new(), - Some(field) => body - .get(field) - .and_then(Value::as_array) - .ok_or_else(|| invalid_request("The hot code replacement reply has no change list."))? - .iter() - .map(|value| { - value - .as_str() - .map(str::to_owned) - .ok_or_else(|| invalid_request("Invalid replaced unit name.")) - }) - .collect::, _>>()?, - }; - changed.sort(); - changed.dedup(); - Ok(DebugOperationResult::RedefineClasses { - changed_classes: changed, - }) -} - -/// The stepping filters in the launch-argument shape adapters that take -/// them as one argument read. -fn step_filter_argument(filters: &DebugSteppingFilters) -> Value { - json!({ - "skipClasses": filters.class_name_filters, - "skipSynthetics": filters.skip_synthetics, - "skipStaticInitializers": filters.skip_static_initializers, - "skipConstructors": filters.skip_constructors - }) -} - -fn stack_frame_matches_filters( - name: &str, - source_path: Option<&str>, - presentation_hint: Option<&str>, - filters: &DebugSteppingFilters, - root_path: &str, -) -> bool { - filters - .class_name_filters - .iter() - .any(|filter| match filter.as_str() { - "$Libraries" => is_library_frame(source_path, presentation_hint, root_path), - pattern => class_pattern_matches_frame(pattern, name, source_path), - }) -} - -fn class_pattern_matches_frame(pattern: &str, name: &str, source_path: Option<&str>) -> bool { - if wildcard_match(pattern, name) || name == pattern || name.starts_with(&format!("{pattern}.")) - { - return true; - } - - let simple_type = name.split('.').next().unwrap_or(name); - if source_path.is_none() - && !pattern.contains('*') - && pattern - .rsplit('.') - .next() - .is_some_and(|value| value == simple_type) - { - return true; - } - - source_path.is_some_and(|source_path| source_path_matches_pattern(source_path, pattern)) -} - -fn source_path_matches_pattern(source_path: &str, pattern: &str) -> bool { - let source_path = source_path.replace('\\', "/"); - let slash_pattern = format!("*{}*", pattern.replace('.', "/")); - let dotted_pattern = format!("*{pattern}*"); - wildcard_match(&slash_pattern, &source_path) || wildcard_match(&dotted_pattern, &source_path) -} - -fn is_library_frame( - source_path: Option<&str>, - presentation_hint: Option<&str>, - root_path: &str, -) -> bool { - if presentation_hint == Some("subtle") || source_path.is_none() { - return true; - } - let source_path = source_path.unwrap_or_default(); - !source_path_is_within_root(source_path, root_path) && path_is_absolute_or_uri(source_path) -} - -fn source_path_is_within_root(source_path: &str, root_path: &str) -> bool { - let source_path = normalized_comparison_path(source_path); - let root_path = normalized_comparison_path(root_path); - source_path == root_path - || source_path - .strip_prefix(&root_path) - .is_some_and(|suffix| suffix.starts_with('/')) -} - -fn normalized_comparison_path(path: &str) -> String { - path.strip_prefix("file://") - .unwrap_or(path) - .replace('\\', "/") - .trim_end_matches('/') - .to_ascii_lowercase() -} - -fn path_is_absolute_or_uri(path: &str) -> bool { - let bytes = path.as_bytes(); - path.starts_with('/') || path.contains("://") || (bytes.len() >= 2 && bytes[1] == b':') -} - -fn wildcard_match(pattern: &str, value: &str) -> bool { - let pattern = pattern.as_bytes(); - let value = value.as_bytes(); - let (mut pattern_index, mut value_index) = (0, 0); - let (mut star_index, mut star_value_index) = (None, 0); - - while value_index < value.len() { - if pattern_index < pattern.len() && pattern[pattern_index] == value[value_index] { - pattern_index += 1; - value_index += 1; - } else if pattern_index < pattern.len() && pattern[pattern_index] == b'*' { - star_index = Some(pattern_index); - pattern_index += 1; - star_value_index = value_index; - } else if let Some(star) = star_index { - pattern_index = star + 1; - star_value_index += 1; - value_index = star_value_index; - } else { - return false; - } - } - while pattern_index < pattern.len() && pattern[pattern_index] == b'*' { - pattern_index += 1; - } - pattern_index == pattern.len() -} - -fn parse_scope(value: &Value) -> Option { - Some(DebugScope { - name: value.get("name")?.as_str()?.to_string(), - variables_reference: value.get("variablesReference")?.as_i64()?, - expensive: value - .get("expensive") - .and_then(Value::as_bool) - .unwrap_or(false), - named_variables: nonnegative_count_field(value, "namedVariables"), - indexed_variables: nonnegative_count_field(value, "indexedVariables"), - presentation_hint: string_field(value, "presentationHint").filter(|hint| !hint.is_empty()), - }) -} - -fn parse_variable(value: &Value) -> Option { - Some(DebugVariable { - name: value.get("name")?.as_str()?.to_string(), - value: value.get("value")?.as_str()?.to_string(), - r#type: string_field(value, "type"), - evaluate_name: string_field(value, "evaluateName"), - variables_reference: value - .get("variablesReference") - .and_then(Value::as_i64) - .unwrap_or(0), - named_variables: nonnegative_count_field(value, "namedVariables"), - indexed_variables: nonnegative_count_field(value, "indexedVariables"), - }) -} - -fn nonnegative_count_field(value: &Value, key: &str) -> i64 { - value.get(key).and_then(Value::as_i64).unwrap_or(0).max(0) -} - -fn parse_breakpoint( - value: &Value, - function_name: Option<&str>, - source_path: Option<&str>, - fallback_line: i64, -) -> DebugBreakpoint { - DebugBreakpoint { - id: value.get("id").and_then(Value::as_i64).unwrap_or(0), - verified: value - .get("verified") - .and_then(Value::as_bool) - .unwrap_or(false), - message: string_field(value, "message"), - function_name: function_name.map(str::to_string), - data_id: None, - instruction_reference: string_field(value, "instructionReference"), - requested_line: None, - source_path: value - .get("source") - .and_then(|source| source.get("path")) - .and_then(Value::as_str) - .or(source_path) - .map(str::to_string), - line: value - .get("line") - .and_then(Value::as_i64) - .or((fallback_line > 0).then_some(fallback_line)), - column: value.get("column").and_then(Value::as_i64), - } -} - -fn parse_capabilities(value: &Value) -> DebugCapabilities { - DebugCapabilities { - supports_configuration_done: bool_field(value, "supportsConfigurationDoneRequest"), - supports_conditional_breakpoints: bool_field(value, "supportsConditionalBreakpoints"), - supports_hit_conditional_breakpoints: bool_field( - value, - "supportsHitConditionalBreakpoints", - ), - supports_log_points: bool_field(value, "supportsLogPoints"), - supports_function_breakpoints: bool_field(value, "supportsFunctionBreakpoints"), - supports_data_breakpoints: bool_field(value, "supportsDataBreakpoints"), - supports_exception_options: bool_field(value, "supportsExceptionOptions"), - supports_exception_filter_options: bool_field(value, "supportsExceptionFilterOptions"), - supports_set_variable: bool_field(value, "supportsSetVariable"), - supports_cancel_request: bool_field(value, "supportsCancelRequest"), - supports_single_thread_execution_requests: bool_field( - value, - "supportsSingleThreadExecutionRequests", - ), - supports_restart_request: bool_field(value, "supportsRestartRequest"), - supports_terminate_request: bool_field(value, "supportsTerminateRequest"), - supports_step_back: bool_field(value, "supportsStepBack"), - supports_exception_info_request: bool_field(value, "supportsExceptionInfoRequest"), - supports_step_in_targets_request: bool_field(value, "supportsStepInTargetsRequest"), - supports_goto_targets_request: bool_field(value, "supportsGotoTargetsRequest"), - supports_completions_request: bool_field(value, "supportsCompletionsRequest"), - supports_disassemble_request: bool_field(value, "supportsDisassembleRequest"), - supports_stepping_granularity: bool_field(value, "supportsSteppingGranularity"), - supports_instruction_breakpoints: bool_field(value, "supportsInstructionBreakpoints"), - supports_read_memory_request: bool_field(value, "supportsReadMemoryRequest"), - exception_breakpoint_filters: value - .get("exceptionBreakpointFilters") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(parse_exception_breakpoint_filter) - .collect(), - } -} - -fn parse_exception_breakpoint_filter(value: &Value) -> Option { - let filter = value.get("filter")?.as_str()?.trim(); - let label = value.get("label")?.as_str()?.trim(); - if filter.is_empty() || label.is_empty() { - return None; - } - Some(DebugExceptionBreakpointFilter { - filter: filter.to_string(), - label: label.to_string(), - description: string_field(value, "description"), - default: bool_field(value, "default"), - supports_condition: bool_field(value, "supportsCondition"), - condition_description: string_field(value, "conditionDescription"), - }) -} - -fn with_session( - session_id: &str, - operation: impl FnOnce(&mut DebugSession) -> Result, -) -> Result { - let mut sessions = sessions_lock()?; - let session = sessions - .get_mut(session_id) - .ok_or_else(|| session_not_found(session_id))?; - operation(session) -} - -fn sessions_lock( -) -> Result>, CoreError> { - SESSIONS - .get_or_init(|| Mutex::new(HashMap::new())) - .lock() - .map_err(|_| CoreError::new(ErrorCode::Unknown, "Debug session state is unavailable.")) -} - -fn parse_start_debugging_request(value: &Value) -> Result { - let arguments = value - .as_object() - .ok_or_else(|| invalid_request("Debug startDebugging arguments must be a JSON object."))?; - let mut configuration = arguments - .get("configuration") - .and_then(Value::as_object) - .cloned() - .ok_or_else(|| invalid_request("Debug startDebugging configuration must be an object."))?; - // DAP names the kind on the envelope; the configuration keeps the same word - // for adapters that read it, but Lithe routes on the typed field. - let kind = arguments - .get("request") - .or_else(|| configuration.get("request")) - .and_then(Value::as_str); - let request = match kind { - Some("launch") => DebugRequestKind::Launch, - Some("attach") => DebugRequestKind::Attach, - _ => { - return Err(invalid_request( - "Debug startDebugging request must be launch or attach.", - )) - } - }; - configuration.remove("request"); - Ok(DebugStartDebuggingRequest { - request, - configuration, - }) -} - -fn parse_run_in_terminal_request(value: &Value) -> Result { - const MAX_ARGUMENT_COUNT: usize = 4_096; - const MAX_ENVIRONMENT_COUNT: usize = 4_096; - - let object = value - .as_object() - .ok_or_else(|| invalid_request("Debug runInTerminal arguments must be a JSON object."))?; - let kind = match object.get("kind") { - None => DebugRunInTerminalKind::Integrated, - Some(Value::String(value)) if value == "integrated" => DebugRunInTerminalKind::Integrated, - Some(Value::String(value)) if value == "external" => DebugRunInTerminalKind::External, - _ => { - return Err(invalid_request( - "Debug runInTerminal kind must be integrated or external.", - )) - } - }; - let title = match object.get("title") { - None | Some(Value::Null) => None, - Some(Value::String(value)) => normalize_optional_text(Some(value.clone())), - _ => { - return Err(invalid_request( - "Debug runInTerminal title must be a string.", - )) - } - }; - let cwd = object - .get("cwd") - .and_then(Value::as_str) - .ok_or_else(|| invalid_request("Debug runInTerminal cwd must be a string."))? - .to_string(); - if cwd.contains('\0') { - return Err(invalid_request( - "Debug runInTerminal cwd contains an invalid null byte.", - )); - } - let argument_values = object - .get("args") - .and_then(Value::as_array) - .ok_or_else(|| invalid_request("Debug runInTerminal args must be an array."))?; - if argument_values.is_empty() || argument_values.len() > MAX_ARGUMENT_COUNT { - return Err(invalid_request( - "Debug runInTerminal args must contain between 1 and 4096 items.", - )); - } - let mut args = Vec::with_capacity(argument_values.len()); - for value in argument_values { - let argument = value.as_str().ok_or_else(|| { - invalid_request("Debug runInTerminal args must contain only strings.") - })?; - if argument.contains('\0') { - return Err(invalid_request( - "Debug runInTerminal args contain an invalid null byte.", - )); - } - args.push(argument.to_string()); - } - if args[0].trim().is_empty() { - return Err(invalid_request( - "Debug runInTerminal args must begin with an executable.", - )); - } - - let mut environment = Vec::new(); - if let Some(value) = object.get("env") { - let values = value - .as_object() - .ok_or_else(|| invalid_request("Debug runInTerminal env must be a JSON object."))?; - if values.len() > MAX_ENVIRONMENT_COUNT { - return Err(invalid_request( - "Debug runInTerminal env cannot exceed 4096 entries.", - )); - } - let mut names: Vec<&String> = values.keys().collect(); - names.sort(); - for name in names { - if name.is_empty() || name.contains(['=', '\0']) { - return Err(invalid_request( - "Debug runInTerminal env contains an invalid variable name.", - )); - } - let value = match &values[name] { - Value::Null => None, - Value::String(value) if !value.contains('\0') => Some(value.clone()), - _ => { - return Err(invalid_request( - "Debug runInTerminal env values must be strings or null.", - )) - } - }; - environment.push(DebugRunInTerminalEnvironmentVariable { - name: name.clone(), - value, - }); - } - } - let args_can_be_interpreted_by_shell = match object.get("argsCanBeInterpretedByShell") { - None => false, - Some(Value::Bool(value)) => *value, - _ => { - return Err(invalid_request( - "Debug runInTerminal argsCanBeInterpretedByShell must be a boolean.", - )) - } - }; - - Ok(DebugRunInTerminalRequest { - kind, - title, - cwd, - args, - environment, - args_can_be_interpreted_by_shell, - }) -} - -fn validate_dap_process_id(value: Option, field: &str) -> Result<(), CoreError> { - if value.is_some_and(|value| !(1..=i32::MAX as i64).contains(&value)) { - return Err(invalid_request(&format!( - "Debug runInTerminal {field} must be between 1 and {}.", - i32::MAX - ))); - } - Ok(()) -} - -fn validate_identifier(value: &str, field: &str) -> Result<(), CoreError> { - if value.trim().is_empty() || value.contains('\0') || value.len() > 512 { - return Err(invalid_request(&format!("Debug {field} was invalid."))); - } - Ok(()) -} - -fn validate_path(value: &str, field: &str) -> Result<(), CoreError> { - if value.trim().is_empty() || value.contains('\0') { - return Err(invalid_request(&format!("Debug {field} was invalid."))); - } - Ok(()) -} - -fn required_positive(value: Option, field: &str) -> Result { - value - .filter(|value| *value > 0) - .ok_or_else(|| invalid_request(&format!("Debug {field} must be positive."))) -} - -fn required_nonnegative(value: Option, field: &str) -> Result { - value - .filter(|value| *value >= 0) - .ok_or_else(|| invalid_request(&format!("Debug {field} cannot be negative."))) -} - -fn required_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, CoreError> { - value.get(field).and_then(Value::as_str).ok_or_else(|| { - CoreError::new( - ErrorCode::ParseFailed, - format!("DAP message did not contain a valid {field}."), - ) - }) -} - -fn required_i64(value: &Value, field: &str) -> Result { - value.get(field).and_then(Value::as_i64).ok_or_else(|| { - CoreError::new( - ErrorCode::ParseFailed, - format!("DAP message did not contain a valid {field}."), - ) - }) -} - -fn required_array<'a>(value: &'a Value, field: &str) -> Result<&'a Vec, CoreError> { - value.get(field).and_then(Value::as_array).ok_or_else(|| { - CoreError::new( - ErrorCode::ParseFailed, - format!("DAP response did not contain a valid {field} array."), - ) - }) -} - -fn string_field(value: &Value, field: &str) -> Option { - value.get(field).and_then(Value::as_str).map(str::to_string) -} - -/// The reason an adapter gave for rejecting a request, in the words meant for the user. -/// -/// DAP carries two texts: `message` is a short raw reason the client may interpret -/// (`cancelled`, `notStopped`), while `body.error.format` is the text to show, with -/// `{name}` placeholders filled from `body.error.variables`. Adapters such as -/// lldb-dap and debugpy put the useful explanation only in the latter. -fn adapter_failure_detail(response: &Value) -> Option { - let error = response.get("body").and_then(|body| body.get("error")); - let formatted = error - .and_then(|error| error.get("format")) - .and_then(Value::as_str) - .map(|format| { - let variables = error - .and_then(|error| error.get("variables")) - .and_then(Value::as_object); - fill_error_variables(format, variables) - }); - formatted - .or_else(|| string_field(response, "message")) - .map(|text| text.trim().to_string()) - .filter(|text| !text.is_empty()) -} - -/// Replaces `{name}` with the matching string variable; unknown placeholders stay as written. -fn fill_error_variables(format: &str, variables: Option<&Map>) -> String { - let Some(variables) = variables else { - return format.to_string(); - }; - let mut result = String::with_capacity(format.len()); - let mut rest = format; - while let Some(open) = rest.find('{') { - result.push_str(&rest[..open]); - let after = &rest[open + 1..]; - match after.find('}') { - Some(close) => { - let name = &after[..close]; - match variables.get(name).and_then(Value::as_str) { - Some(value) => result.push_str(value), - None => { - result.push('{'); - result.push_str(name); - result.push('}'); - } - } - rest = &after[close + 1..]; - } - None => { - result.push_str(&rest[open..]); - rest = ""; - } - } - } - result.push_str(rest); - result -} - -fn bool_field(value: &Value, field: &str) -> bool { - value.get(field).and_then(Value::as_bool).unwrap_or(false) -} - -fn insert_option(map: &mut Map, key: &str, value: Option) { - if let Some(value) = value { - map.insert(key.to_string(), json!(value)); - } -} - -fn insert_nonempty(map: &mut Map, key: &str, value: Option<&str>) { - if let Some(value) = value.filter(|value| !value.is_empty()) { - map.insert(key.to_string(), Value::String(value.to_string())); - } -} - -fn normalize_optional_text(value: Option) -> Option { - value - .map(|text| text.trim().to_string()) - .filter(|text| !text.is_empty()) -} - -pub(super) fn invalid_request(message: &str) -> CoreError { - CoreError::new(ErrorCode::InvalidRequest, message) -} - -fn session_not_found(session_id: &str) -> CoreError { - CoreError::new(ErrorCode::InvalidRequest, "Debug session was not found.") - .with_details(session_id.to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::debug::projection_v2::{Projection, SessionProjection, Source}; - - fn request_message(sequence: i64, command: &str, arguments: Value) -> Value { - json!({ - "seq": sequence, - "type": "request", - "command": command, - "arguments": arguments - }) - } - - fn response_message(request_sequence: i64, command: &str, body: Value) -> Value { - json!({ - "seq": 100 + request_sequence, - "type": "response", - "request_seq": request_sequence, - "success": true, - "command": command, - "body": body - }) - } - - fn receive_messages(session_id: &str, messages: Vec) -> DebugSessionUpdate { - let bytes = messages - .into_iter() - .flat_map(|message| frame_message(&message).unwrap()) - .collect::>(); - receive(ReceiveRequest { - session_id: session_id.to_string(), - data_base64: BASE64.encode(bytes), - }) - .unwrap() - } - - fn decode_frame(frame: &str) -> Value { - let bytes = BASE64.decode(frame).unwrap(); - let body_start = bytes - .windows(4) - .position(|value| value == b"\r\n\r\n") - .unwrap() - + 4; - serde_json::from_slice(&bytes[body_start..]).unwrap() - } - - #[test] - fn delayed_launch_response_preserves_a_newer_stopped_or_terminal_event() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../../fixtures/debug/launch-event-order.json" - )) - .unwrap(); - for case in fixture["cases"].as_array().unwrap() { - let session_id = case["id"].as_str().unwrap(); - let event = case["event"].clone(); - let expected = case["state"].clone(); - create_session(child_request(session_id, None)).unwrap(); - struct Cleanup<'a>(&'a str); - impl Drop for Cleanup<'_> { - fn drop(&mut self) { - // log-exempt: Expected absence if the test already disposed this process-free session. - let _ = destroy_session(SessionRequest { - session_id: self.0.into(), - }); - } - } - let _cleanup = Cleanup(session_id); - receive_messages( - session_id, - vec![response_message(1, "initialize", json!({}))], - ); - let launching = launch(LaunchRequest { - session_id: session_id.into(), - operation_id: "launch".into(), - configuration: DebugLaunchConfiguration { - name: "native target".into(), - request: DebugRequestKind::Launch, - arguments: Map::new(), - stepping_filters: None, - }, - }) - .unwrap(); - let frame = decode_frame(&launching.outbound_frames[0]); - let stopped = receive_messages(session_id, vec![event]); - assert_eq!(serde_json::to_value(stopped.state).unwrap(), expected); - // LLDB can report the first stop before acknowledging the launch request. - let acknowledged = receive_messages( - session_id, - vec![response_message( - frame["seq"].as_i64().unwrap(), - "launch", - json!({}), - )], - ); - assert_eq!( - serde_json::to_value(acknowledged.state).unwrap(), - expected, - "a response must not resume or resurrect the target" - ); - } - } - - #[test] - fn dap_logging_keeps_catalog_identity_and_cancellation_private() { - let adapter = "lldb+private-adapter"; - let subject = identity_segment(adapter); - assert!(subject.starts_with("invalid-")); - assert_eq!(subject, identity_segment(adapter)); - assert!(!subject.contains("private")); - let mut fact = crate::diagnostics::record::StructuredLogEvent { - ts: "2026-11-04T00:00:00.000Z".into(), - level: crate::diagnostics::record::LogLevel::Debug, - seq: 1, - event: "debug.operation.cancelled".into(), - op: Some("owned-operation".into()), - msg: String::new(), - fields: BTreeMap::from([ - ("adapter".into(), json!(subject)), - ("failure_code".into(), json!("cancelled")), - ]), - }; - let channel = - crate::diagnostics::record::prepare_catalog_event(&mut fact, &json!({})).unwrap(); - assert_eq!(fact.level, crate::diagnostics::record::LogLevel::Debug); - assert_eq!(fact.op.as_deref(), Some("owned-operation")); - assert_eq!(fact.fields.len(), 2); - assert!(channel.starts_with("debug/invalid-")); - fact.fields - .insert("message".into(), json!("forbidden protocol payload")); - assert!(crate::diagnostics::record::prepare_catalog_event(&mut fact, &json!({})).is_err()); - } - - #[test] - fn dap_logging_failed_launch_owns_one_terminal_and_cancel_is_not_error() { - let session_id = "debug-log-owner"; - create_session(child_request(session_id, None)).unwrap(); - let result = with_session(session_id, |session| { - session.state = DebugSessionState::Launching; - session.emit(DebugEventBody::OperationFailed { - operation_id: "cancelled-launch".into(), - command: "launch".into(), - code: DebugOperationFailureCode::Cancelled, - message: "private adapter payload must never be logged".into(), - }); - assert!(session.logged_terminal_failure); - session.transition(DebugSessionState::Failed); - Ok(()) - }); - destroy_session(SessionRequest { - session_id: session_id.into(), - }) - .unwrap(); - result.unwrap(); - } - - #[test] - fn stack_frame_reports_adapter_served_source_references() { - let filters = DebugSteppingFilters::unfiltered(); - let body = json!({"stackFrames": [ - {"id": 1, "name": "main", "line": 9, "column": 1, - "source": {"name": "main.c", "path": "/workspace/main.c"}}, - {"id": 2, "name": "start", "line": 1664, "column": 1, - "source": {"name": "start", "path": "/usr/lib/dyld`start", "sourceReference": 1}}, - {"id": 3, "name": "stale", "line": 1, "column": 1, - "source": {"name": "stale.c", "path": "/workspace/stale.c", "sourceReference": 0}}, - ]}); - let frames = - normalize_inspection(DebugInspectKind::StackTrace, &body, &filters, "/workspace") - .unwrap(); - let value = serde_json::to_value(frames).unwrap(); - let frames = &value["stackFrames"]; - // A path with a positive reference is adapter-served content, not a file on disk. - assert!(frames[0].get("sourceReference").is_none()); - assert_eq!(frames[1]["sourceReference"], 1); - assert_eq!(frames[1]["sourcePath"], "/usr/lib/dyld`start"); - assert!(frames[2].get("sourceReference").is_none()); - } - - #[test] - fn hot_replace_normalizes_success_empty_and_error_bodies() { - let fixture: Value = serde_json::from_str(include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../fixtures/debug/hot-code-replace-v1.json" - ))) - .unwrap(); - for case in fixture["cases"].as_array().unwrap() { - let result = normalize_inspection( - DebugInspectKind::RedefineClasses, - &case["body"], - &DebugSteppingFilters::unfiltered(), - "/workspace", - ); - if case["failure"].as_bool() == Some(true) { - assert!(result.is_err(), "{}", case["name"]); - } else { - let value = serde_json::to_value(result.unwrap()).unwrap(); - assert_eq!(value, case["result"], "{}", case["name"]); - } - } - } - - #[test] - fn hot_replace_requires_the_declaration_and_preserves_paused_state() { - let hot_swapping: DeclaredAdapterBehavior = serde_json::from_value(json!({ - "features": { - "hotCodeReplace": { - "request": "redefineClasses", - "changedField": "changedClasses", - "errorField": "errorMessage" - } - } - })) - .unwrap(); - for (id, adapter, declared) in [ - ("hcr-declared", "hotswap", hot_swapping), - ("hcr-other", "python", DeclaredAdapterBehavior::default()), - ] { - let declares_hot_replace = declared - .features - .as_ref() - .is_some_and(|features| features.hot_code_replace.is_some()); - create_session(CreateSessionRequest { - session_id: id.into(), - adapter_id: adapter.into(), - root_path: "/workspace".into(), - supports_run_in_terminal_request: false, - supports_start_debugging_request: false, - disabled_capabilities: Vec::new(), - parent_session_id: None, - declared, - }) - .unwrap(); - // The guard always removes the process-free session, including on assertion failure. - struct Cleanup(&'static str); - impl Drop for Cleanup { - fn drop(&mut self) { - let _ = destroy_session(SessionRequest { - session_id: self.0.into(), - }); - } - } - let _cleanup = Cleanup(id); - receive_messages(id, vec![response_message(1, "initialize", json!({}))]); - let launched = launch(LaunchRequest { - session_id: id.into(), - operation_id: "launch".into(), - configuration: DebugLaunchConfiguration { - name: "service".into(), - request: DebugRequestKind::Launch, - arguments: serde_json::from_value(json!({"vmArgs":"-Xmx256m"})).unwrap(), - stepping_filters: None, - }, - }) - .unwrap(); - let launch_frame = decode_frame(&launched.outbound_frames[0]); - // Launch arguments pass through untouched for every adapter. - assert_eq!(launch_frame["arguments"]["vmArgs"], "-Xmx256m"); - receive_messages( - id, - vec![response_message( - launch_frame["seq"].as_i64().unwrap(), - "launch", - json!({}), - )], - ); - receive_messages( - id, - vec![ - json!({"type":"event", "event":"stopped", "body":{"reason":"breakpoint","threadId":1}}), - ], - ); - let request: InspectRequest = serde_json::from_value(json!({ - "sessionId":id,"operationId":"apply","kind":"redefineClasses" - })) - .unwrap(); - let update = inspect(request); - if !declares_hot_replace { - assert!(update.is_err()); - continue; - } - let update = update.unwrap(); - let frame = decode_frame(&update.outbound_frames[0]); - assert_eq!(frame["command"], "redefineClasses"); - assert_eq!(frame["arguments"], json!({})); - let result = receive_messages( - id, - vec![response_message( - frame["seq"].as_i64().unwrap(), - "redefineClasses", - json!({"changedClasses":["example.Service"]}), - )], - ); - assert_eq!(result.state, DebugSessionState::Paused); - assert!(result.events.iter().any(|e| matches!(&e.body, - DebugEventBody::OperationCompleted { operation_id, result: DebugOperationResult::RedefineClasses { changed_classes } } - if operation_id == "apply" && changed_classes == &["example.Service".to_string()]))); - // A success envelope with a runtime error must not tear down the debug transport. - let rejected = inspect( - serde_json::from_value(json!({ - "sessionId":id,"operationId":"rejected","kind":"redefineClasses" - })) - .unwrap(), - ) - .unwrap(); - let frame = decode_frame(&rejected.outbound_frames[0]); - let result = receive_messages( - id, - vec![response_message( - frame["seq"].as_i64().unwrap(), - "redefineClasses", - json!({"changedClasses":[], "errorMessage":"Schema change not supported"}), - )], - ); - assert_eq!(result.state, DebugSessionState::Paused); - assert!(result.events.iter().any(|e| matches!(&e.body, - DebugEventBody::OperationFailed { operation_id, message, .. } - if operation_id == "rejected" && message == "Schema change not supported"))); - } - } - - #[test] - fn debug_update_serializes_variant_fields_with_contract_casing() { - let update = DebugSessionUpdate { - session_id: "debug-contract-casing".to_string(), - state: DebugSessionState::Paused, - outbound_frames: Vec::new(), - events: vec![ - DebugEvent { - sequence: 1, - body: DebugEventBody::Stopped { - reason: "breakpoint".to_string(), - thread_id: Some(13), - description: None, - stop_generation: 1, - }, - }, - DebugEvent { - sequence: 2, - body: DebugEventBody::OperationCompleted { - operation_id: "stack-1".to_string(), - result: DebugOperationResult::StackTrace { - stack_frames: vec![DebugStackFrame { - id: 7, - name: "example.Main.run".to_string(), - source_path: Some("/workspace/Main.c".to_string()), - source_reference: None, - line: 12, - column: 1, - is_filtered: false, - instruction_pointer_reference: None, - }], - }, - }, - }, - ], - }; - - let value = serde_json::to_value(update).unwrap(); - assert_eq!(value["events"][0]["threadId"], 13); - assert!(value["events"][0].get("thread_id").is_none()); - assert_eq!(value["events"][1]["operationId"], "stack-1"); - assert!(value["events"][1].get("operation_id").is_none()); - assert_eq!(value["events"][1]["result"]["kind"], "stackTrace"); - assert_eq!(value["events"][1]["result"]["stackFrames"][0]["id"], 7); - assert_eq!( - value["events"][1]["result"]["stackFrames"][0]["isFiltered"], - false - ); - assert!(value["events"][1]["result"].get("stack_frames").is_none()); - } - - #[test] - fn disconnect_policy_matches_shared_fixture() { - let fixture: Value = serde_json::from_str(include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../fixtures/debug/disconnect-policy-v1.json" - ))) - .unwrap(); - - for case in fixture["cases"].as_array().unwrap() { - let request_name = case["request"].as_str().unwrap_or("unstarted"); - let session_id = format!("debug-disconnect-{request_name}"); - create_session(CreateSessionRequest { - session_id: session_id.clone(), - adapter_id: "lldb".to_string(), - root_path: "/workspace".to_string(), - supports_run_in_terminal_request: false, - supports_start_debugging_request: false, - disabled_capabilities: Vec::new(), - parent_session_id: None, - declared: Default::default(), - }) - .unwrap(); - - if case["request"].is_string() { - let request_kind = serde_json::from_value(case["request"].clone()).unwrap(); - launch(LaunchRequest { - session_id: session_id.clone(), - operation_id: format!("{request_name}-main"), - configuration: DebugLaunchConfiguration { - name: "Main".to_string(), - request: request_kind, - arguments: Map::new(), - stepping_filters: None, - }, - }) - .unwrap(); - let initialized = receive_messages( - &session_id, - vec![response_message(1, "initialize", json!({}))], - ); - assert_eq!( - decode_frame(&initialized.outbound_frames[0])["command"], - request_name - ); - } - - let disconnected = disconnect(DisconnectRequest { - session_id: session_id.clone(), - terminate_debuggee: case["requestedTerminateDebuggee"].as_bool(), - }) - .unwrap(); - let request = decode_frame(&disconnected.outbound_frames[0]); - assert_eq!(request["command"], "disconnect"); - assert_eq!(request["arguments"], case["expectedArguments"]); - destroy_session(SessionRequest { session_id }).unwrap(); - } - } - - #[test] - fn variable_paging_arguments_reject_invalid_combinations() { - let base = InspectRequest { - session_id: "debug-variable-validation".to_string(), - operation_id: "variables".to_string(), - kind: DebugInspectKind::Variables, - thread_id: None, - frame_id: None, - variables_reference: Some(700), - variable_filter: Some(DebugVariableFilter::Indexed), - start: Some(0), - count: Some(100), - expression: None, - context: None, - source_path: None, - line: None, - column: None, - memory_reference: None, - offset: None, - instruction_offset: None, - instruction_count: None, - resolve_symbols: None, - }; - - let mut negative_start = base.clone(); - negative_start.start = Some(-1); - let error = inspect_arguments(&negative_start).unwrap_err(); - assert!(matches!(error.code, ErrorCode::InvalidRequest)); - - let mut zero_count = base.clone(); - zero_count.count = Some(0); - let error = inspect_arguments(&zero_count).unwrap_err(); - assert!(matches!(error.code, ErrorCode::InvalidRequest)); - - let mut non_variable_request = base; - non_variable_request.kind = DebugInspectKind::Threads; - non_variable_request.variables_reference = None; - non_variable_request.start = None; - non_variable_request.count = None; - let error = inspect_arguments(&non_variable_request).unwrap_err(); - assert!(matches!(error.code, ErrorCode::InvalidRequest)); - } - - #[test] - fn variable_child_counts_are_normalized_for_scopes_and_evaluation() { - let scopes = normalize_inspection( - DebugInspectKind::Scopes, - &json!({ - "scopes": [{ - "name": "Locals", - "variablesReference": 700, - "expensive": false, - "namedVariables": -2, - "indexedVariables": 250 - }] - }), - &DebugSteppingFilters::default(), - "/workspace", - ) - .unwrap(); - let scopes = serde_json::to_value(scopes).unwrap(); - assert_eq!(scopes["scopes"][0]["namedVariables"], 0); - assert_eq!(scopes["scopes"][0]["indexedVariables"], 250); - - let evaluation = normalize_inspection( - DebugInspectKind::Evaluate, - &json!({ - "result": "Customer[250]", - "variablesReference": 701, - "namedVariables": 4, - "indexedVariables": -1 - }), - &DebugSteppingFilters::default(), - "/workspace", - ) - .unwrap(); - let evaluation = serde_json::to_value(evaluation).unwrap(); - assert_eq!(evaluation["variable"]["namedVariables"], 4); - assert_eq!(evaluation["variable"]["indexedVariables"], 0); - } - - #[test] - fn stepping_filters_are_normalized_projected_and_mark_stack_frames() { - // Without a declaration there are no built-in defaults for any adapter. - let defaults = stepping_filters(DebugSteppingFiltersRequest { - adapter_id: "lldb".to_string(), - filters: None, - }) - .unwrap(); - assert_eq!(defaults, DebugSteppingFilters::unfiltered()); - - let filters = stepping_filters(DebugSteppingFiltersRequest { - adapter_id: "lldb".to_string(), - filters: Some(DebugSteppingFilters { - class_name_filters: vec![ - " org.mocking.* ".to_string(), - "lib.core.*".to_string(), - "org.mocking.*".to_string(), - String::new(), - ], - skip_synthetics: true, - skip_static_initializers: false, - skip_constructors: true, - hide_filtered_stack_frames: true, - }), - }) - .unwrap(); - assert_eq!(filters.class_name_filters, ["lib.core.*", "org.mocking.*"]); - - let multiline_error = stepping_filters(DebugSteppingFiltersRequest { - adapter_id: "lldb".to_string(), - filters: Some(DebugSteppingFilters { - class_name_filters: vec!["example.Valid\nexample.Invalid".to_string()], - skip_synthetics: false, - skip_static_initializers: false, - skip_constructors: false, - hide_filtered_stack_frames: false, - }), - }) - .unwrap_err(); - assert!(matches!(multiline_error.code, ErrorCode::InvalidRequest)); - - let excessive_filter_error = stepping_filters(DebugSteppingFiltersRequest { - adapter_id: "lldb".to_string(), - filters: Some(DebugSteppingFilters { - class_name_filters: (0..257) - .map(|index| format!("example.Type{index}")) - .collect(), - skip_synthetics: false, - skip_static_initializers: false, - skip_constructors: false, - hide_filtered_stack_frames: false, - }), - }) - .unwrap_err(); - assert!(matches!( - excessive_filter_error.code, - ErrorCode::InvalidRequest - )); - - let session_id = "debug-stepping-filters"; - create_session(CreateSessionRequest { - session_id: session_id.to_string(), - adapter_id: "lldb".to_string(), - root_path: "/workspace".to_string(), - supports_run_in_terminal_request: false, - supports_start_debugging_request: false, - disabled_capabilities: Vec::new(), - parent_session_id: None, - declared: serde_json::from_value(json!({ - "features": {"stepFilters": {"argument": "stepFilters"}} - })) - .unwrap(), - }) - .unwrap(); - launch(LaunchRequest { - session_id: session_id.to_string(), - operation_id: "launch".to_string(), - configuration: DebugLaunchConfiguration { - name: "Main".to_string(), - request: DebugRequestKind::Launch, - arguments: Map::new(), - stepping_filters: Some(filters), - }, - }) - .unwrap(); - let initialized = receive_messages( - session_id, - vec![response_message(1, "initialize", json!({}))], - ); - let launch_request = decode_frame(&initialized.outbound_frames[0]); - assert_eq!(launch_request["command"], "launch"); - assert_eq!( - launch_request["arguments"]["stepFilters"]["skipClasses"], - json!(["lib.core.*", "org.mocking.*"]) - ); - assert_eq!( - launch_request["arguments"]["stepFilters"]["skipConstructors"], - true - ); - receive_messages( - session_id, - vec![ - response_message(2, "launch", json!({})), - json!({ - "seq": 103, - "type": "event", - "event": "stopped", - "body": {"reason": "breakpoint", "threadId": 11} - }), - ], - ); - inspect(InspectRequest { - session_id: session_id.to_string(), - operation_id: "stack".to_string(), - kind: DebugInspectKind::StackTrace, - thread_id: Some(11), - frame_id: None, - variables_reference: None, - variable_filter: None, - start: None, - count: None, - expression: None, - context: None, - source_path: None, - line: None, - column: None, - memory_reference: None, - offset: None, - instruction_offset: None, - instruction_count: None, - resolve_symbols: None, - }) - .unwrap(); - let stack = receive_messages( - session_id, - vec![response_message( - 3, - "stackTrace", - json!({"stackFrames": [ - { - "id": 7, - "name": "lib.core.reflect.Method.invoke(Object,Object[])", - "source": { - "path": "/deps/lib/core/reflect/Method.class" - }, - "line": 1, - "column": 1 - }, - { - "id": 8, - "name": "MockMethodInterceptor.intercept(Object,Method,Object[],Invoker)", - "source": { - "path": "/deps/mocking-core/org/mocking/internal/MockMethodInterceptor.class" - }, - "line": 1, - "column": 1 - }, - { - "id": 9, - "name": "example.LoginService.authenticate", - "source": {"path": "/workspace/LoginService.c"}, - "line": 24, - "column": 5 - } - ]}), - )], - ); - assert!(stack.events.iter().any(|event| matches!( - &event.body, - DebugEventBody::OperationCompleted { - operation_id, - result: DebugOperationResult::StackTrace { stack_frames } - } if operation_id == "stack" - && stack_frames.len() == 3 - && stack_frames[0].is_filtered - && stack_frames[1].is_filtered - && !stack_frames[2].is_filtered - ))); - let library_filters = DebugSteppingFilters { - class_name_filters: vec!["$Libraries".to_string()], - skip_synthetics: false, - skip_static_initializers: false, - skip_constructors: false, - hide_filtered_stack_frames: true, - }; - assert!(stack_frame_matches_filters( - "Dependency.call()", - Some("/dependencies/example/Dependency.c"), - None, - &library_filters, - "/workspace" - )); - assert!(!stack_frame_matches_filters( - "LoginService.authenticate()", - Some("/workspace/LoginService.c"), - None, - &library_filters, - "/workspace" - )); - destroy_session(SessionRequest { - session_id: session_id.to_string(), - }) - .unwrap(); - } - - #[test] - fn initialize_launch_breakpoints_and_inspection_are_reduced_in_order() { - let session_id = "debug-engine-flow"; - let created = create_session(CreateSessionRequest { - session_id: session_id.to_string(), - adapter_id: "lldb".to_string(), - root_path: "/workspace".to_string(), - supports_run_in_terminal_request: false, - supports_start_debugging_request: false, - disabled_capabilities: Vec::new(), - parent_session_id: None, - declared: Default::default(), - }) - .unwrap(); - assert_eq!(created.state, DebugSessionState::Initializing); - assert_eq!( - decode_frame(&created.outbound_frames[0])["command"], - "initialize" - ); - - let queued = launch(LaunchRequest { - session_id: session_id.to_string(), - operation_id: "launch-1".to_string(), - configuration: DebugLaunchConfiguration { - name: "Main".to_string(), - request: DebugRequestKind::Launch, - arguments: Map::from_iter([("mainClass".to_string(), json!("example.Main"))]), - stepping_filters: None, - }, - }) - .unwrap(); - assert!(queued.outbound_frames.is_empty()); - set_breakpoints(SetBreakpointsRequest { - session_id: session_id.to_string(), - source_path: "/workspace/src/Main.c".to_string(), - breakpoints: vec![ - SourceBreakpoint { - line: 12, - column: None, - enabled: true, - condition: Some("value > 1".to_string()), - hit_condition: Some("3".to_string()), - log_message: Some("value = {value}".to_string()), - }, - SourceBreakpoint { - line: 14, - column: None, - enabled: false, - condition: None, - hit_condition: None, - log_message: None, - }, - ], - }) - .unwrap(); - set_exception_breakpoints(SetExceptionBreakpointsRequest { - session_id: session_id.to_string(), - breakpoints: vec![ - ExceptionBreakpoint { - filter: "uncaught".to_string(), - enabled: false, - condition: None, - }, - ExceptionBreakpoint { - filter: "caught".to_string(), - enabled: true, - condition: Some(" example.CustomException ".to_string()), - }, - ], - }) - .unwrap(); - set_function_breakpoints(SetFunctionBreakpointsRequest { - session_id: session_id.to_string(), - breakpoints: vec![ - FunctionBreakpoint { - name: " example.Main.run ".to_string(), - enabled: true, - condition: Some("ready".to_string()), - hit_condition: Some("2".to_string()), - }, - FunctionBreakpoint { - name: "example.Main.skip".to_string(), - enabled: false, - condition: None, - hit_condition: None, - }, - ], - }) - .unwrap(); - - let initialized = receive_messages( - session_id, - vec![response_message( - 1, - "initialize", - json!({ - "supportsConfigurationDoneRequest": true, - "supportsConditionalBreakpoints": true, - "supportsHitConditionalBreakpoints": true, - "supportsLogPoints": true, - "supportsFunctionBreakpoints": true, - "supportsDataBreakpoints": true, - "supportsSetVariable": true, - "supportsRestartRequest": true, - "supportsExceptionFilterOptions": true, - "exceptionBreakpointFilters": [{ - "filter": "caught", - "label": "Caught Exceptions", - "default": false, - "supportsCondition": true - }] - }), - )], - ); - assert_eq!(initialized.state, DebugSessionState::Launching); - assert!(initialized.events.iter().any(|event| matches!( - &event.body, - DebugEventBody::Capabilities { capabilities } - if capabilities.supports_conditional_breakpoints - && capabilities.supports_hit_conditional_breakpoints - && capabilities.supports_log_points - && capabilities.supports_function_breakpoints - && capabilities.supports_data_breakpoints - && capabilities.supports_set_variable - && capabilities.supports_restart_request - && capabilities.exception_breakpoint_filters.len() == 1 - ))); - assert_eq!( - decode_frame(&initialized.outbound_frames[0])["command"], - "launch" - ); - - let configured = receive_messages( - session_id, - vec![json!({"seq": 102, "type": "event", "event": "initialized"})], - ); - assert_eq!( - decode_frame(&configured.outbound_frames[0])["command"], - "setExceptionBreakpoints" - ); - assert_eq!( - decode_frame(&configured.outbound_frames[1])["command"], - "setFunctionBreakpoints" - ); - assert_eq!( - decode_frame(&configured.outbound_frames[2])["command"], - "setDataBreakpoints" - ); - assert_eq!( - decode_frame(&configured.outbound_frames[3])["command"], - "setBreakpoints" - ); - assert_eq!( - decode_frame(&configured.outbound_frames[4])["command"], - "configurationDone" - ); - assert_eq!( - decode_frame(&configured.outbound_frames[0])["arguments"]["filters"], - json!(["caught"]) - ); - assert_eq!( - decode_frame(&configured.outbound_frames[0])["arguments"]["filterOptions"][0] - ["condition"], - "example.CustomException" - ); - assert_eq!( - decode_frame(&configured.outbound_frames[1])["arguments"]["breakpoints"][0]["name"], - "example.Main.run" - ); - assert_eq!( - decode_frame(&configured.outbound_frames[1])["arguments"]["breakpoints"][0] - ["condition"], - "ready" - ); - assert_eq!( - decode_frame(&configured.outbound_frames[1])["arguments"]["breakpoints"][0] - ["hitCondition"], - "2" - ); - assert_eq!( - decode_frame(&configured.outbound_frames[1])["arguments"]["breakpoints"] - .as_array() - .unwrap() - .len(), - 1 - ); - assert_eq!( - decode_frame(&configured.outbound_frames[3])["arguments"]["breakpoints"][0] - ["condition"], - "value > 1" - ); - assert_eq!( - decode_frame(&configured.outbound_frames[3])["arguments"]["breakpoints"][0] - ["hitCondition"], - "3" - ); - assert_eq!( - decode_frame(&configured.outbound_frames[3])["arguments"]["breakpoints"][0] - ["logMessage"], - "value = {value}" - ); - assert_eq!( - decode_frame(&configured.outbound_frames[3])["arguments"]["breakpoints"] - .as_array() - .unwrap() - .len(), - 1 - ); - - let running = receive_messages( - session_id, - vec![ - response_message(2, "launch", json!({})), - response_message(3, "setExceptionBreakpoints", json!({})), - response_message( - 4, - "setFunctionBreakpoints", - json!({"breakpoints": [{"id": 8, "verified": true}]}), - ), - response_message(5, "setDataBreakpoints", json!({"breakpoints": []})), - response_message( - 6, - "setBreakpoints", - json!({"breakpoints": [{"id": 7, "verified": true, "line": 12}]}), - ), - response_message(7, "configurationDone", json!({})), - ], - ); - assert_eq!(running.state, DebugSessionState::Running); - assert!(running - .events - .iter() - .any(|event| matches!(event.body, DebugEventBody::Breakpoint { .. }))); - assert!(running.events.iter().any(|event| matches!( - &event.body, - DebugEventBody::Breakpoint { breakpoint } - if breakpoint.function_name.as_deref() == Some("example.Main.run") - && breakpoint.verified - ))); - - let inspection = inspect(InspectRequest { - session_id: session_id.to_string(), - operation_id: "threads-1".to_string(), - kind: DebugInspectKind::Threads, - thread_id: None, - frame_id: None, - variables_reference: None, - variable_filter: None, - start: None, - count: None, - expression: None, - context: None, - source_path: None, - line: None, - column: None, - memory_reference: None, - offset: None, - instruction_offset: None, - instruction_count: None, - resolve_symbols: None, - }) - .unwrap(); - assert_eq!( - decode_frame(&inspection.outbound_frames[0])["command"], - "threads" - ); - let completed = receive_messages( - session_id, - vec![response_message( - 8, - "threads", - json!({"threads": [{"id": 1, "name": "main"}]}), - )], - ); - assert!(completed.events.iter().any(|event| matches!( - &event.body, - DebugEventBody::OperationCompleted { operation_id, result: DebugOperationResult::Threads { threads } } - if operation_id == "threads-1" && threads.len() == 1 - ))); - - destroy_session(SessionRequest { - session_id: session_id.to_string(), - }) - .unwrap(); - } - - #[test] - fn source_breakpoint_binding_preserves_requested_identity_and_rejects_stale_replies() { - let fixture: Value = serde_json::from_str(include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../fixtures/debug/breakpoint-binding-v1.json" - ))) - .unwrap(); - let session_id = "source-breakpoint-binding"; - let source = fixture["sourcePath"].as_str().unwrap(); - create_session(CreateSessionRequest { - session_id: session_id.to_string(), - adapter_id: "lldb".to_string(), - root_path: "/workspace".to_string(), - supports_run_in_terminal_request: false, - supports_start_debugging_request: false, - disabled_capabilities: Vec::new(), - parent_session_id: None, - declared: Default::default(), - }) - .unwrap(); - // This reducer owns no processes; always destroy its registry entry below. - receive_messages( - session_id, - vec![response_message(1, "initialize", json!({}))], - ); - receive_messages( - session_id, - vec![json!({"type":"event", "event":"initialized"})], - ); - let set = || { - set_breakpoints(SetBreakpointsRequest { - session_id: session_id.to_string(), - source_path: source.to_string(), - breakpoints: vec![SourceBreakpoint { - line: fixture["requestedLine"].as_i64().unwrap(), - column: None, - enabled: true, - condition: None, - hit_condition: None, - log_message: None, - }], - }) - .unwrap() - }; - let sequence = |update: DebugSessionUpdate| { - decode_frame(&update.outbound_frames[0])["seq"] - .as_i64() - .unwrap() - }; - let old_sequence = sequence(set()); - let latest_sequence = sequence(set()); - let stale = receive_messages( - session_id, - vec![response_message( - old_sequence, - "setBreakpoints", - json!({"breakpoints":[{"id":40,"verified":true,"line":8}]}), - )], - ); - assert!(!stale - .events - .iter() - .any(|e| matches!(e.body, DebugEventBody::Breakpoint { .. }))); - receive_messages( - session_id, - vec![response_message( - latest_sequence, - "setBreakpoints", - json!({"breakpoints":[fixture["response"].clone()]}), - )], - ); - let changed = receive_messages( - session_id, - vec![json!({"type":"event", "event":"breakpoint", - "body":{"reason":"changed", "breakpoint":fixture["changed"]}})], - ); - let result = changed - .events - .iter() - .find_map(|event| match &event.body { - DebugEventBody::Breakpoint { breakpoint } => { - Some(serde_json::to_value(breakpoint).unwrap()) - } - _ => None, - }) - .unwrap(); - destroy_session(SessionRequest { - session_id: session_id.to_string(), - }) - .unwrap(); - for key in ["requestedLine", "line", "sourcePath", "verified"] { - assert_eq!(result[key], fixture["expected"][key], "{key}"); - } - } - - #[test] - fn start_debugging_request_is_normalized_completed_and_stale_safe() { - let fixture: Value = serde_json::from_str(include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../fixtures/debug/start-debugging-v1.json" - ))) - .unwrap(); - let session_id = "debug-start-debugging"; - create_session(CreateSessionRequest { - session_id: session_id.to_string(), - adapter_id: "node".to_string(), - root_path: "/workspace".to_string(), - supports_run_in_terminal_request: false, - supports_start_debugging_request: true, - disabled_capabilities: Vec::new(), - parent_session_id: None, - declared: Default::default(), - }) - .unwrap(); - // This reducer owns no processes; always destroy its registry entry below. - let requested = receive_messages(session_id, vec![fixture["adapterRequest"].clone()]); - let request_id = requested - .events - .iter() - .find_map(|event| match &event.body { - DebugEventBody::StartDebuggingRequested { - request_id, - request, - } => { - assert_eq!( - serde_json::to_value(request).unwrap(), - fixture["expectedRequest"] - ); - Some(request_id.clone()) - } - _ => None, - }) - .unwrap(); - - let duplicate = receive_messages(session_id, vec![fixture["adapterRequest"].clone()]); - assert!(duplicate.outbound_frames.is_empty() && duplicate.events.is_empty()); - - let success = start_debugging_response(DebugStartDebuggingResponseRequest { - session_id: session_id.to_string(), - request_id: request_id.clone(), - success: true, - message: None, - }) - .unwrap(); - let response = decode_frame(&success.outbound_frames[0]); - assert_eq!(response["request_seq"], fixture["adapterRequest"]["seq"]); - assert_eq!(response["command"], "startDebugging"); - assert_eq!(response["success"], true); - - let stale = start_debugging_response(DebugStartDebuggingResponseRequest { - session_id: session_id.to_string(), - request_id, - success: false, - message: Some(fixture["failureMessage"].as_str().unwrap().to_string()), - }) - .unwrap(); - assert!(stale.outbound_frames.is_empty()); - destroy_session(SessionRequest { - session_id: session_id.to_string(), - }) - .unwrap(); - } - - #[test] - fn start_debugging_failure_and_unsupported_hosts_answer_the_adapter() { - let fixture: Value = serde_json::from_str(include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../fixtures/debug/start-debugging-v1.json" - ))) - .unwrap(); - for (session_id, supported) in [ - ("debug-start-debugging-unsupported", false), - ("debug-start-debugging-failure", true), - ] { - create_session(CreateSessionRequest { - session_id: session_id.to_string(), - adapter_id: "node".to_string(), - root_path: "/workspace".to_string(), - supports_run_in_terminal_request: false, - supports_start_debugging_request: supported, - disabled_capabilities: Vec::new(), - parent_session_id: None, - declared: Default::default(), - }) - .unwrap(); - let update = receive_messages(session_id, vec![fixture["adapterRequest"].clone()]); - if supported { - let request_id = update - .events - .iter() - .find_map(|event| match &event.body { - DebugEventBody::StartDebuggingRequested { request_id, .. } => { - Some(request_id.clone()) - } - _ => None, - }) - .unwrap(); - let failed = start_debugging_response(DebugStartDebuggingResponseRequest { - session_id: session_id.to_string(), - request_id, - success: false, - message: Some(fixture["failureMessage"].as_str().unwrap().to_string()), - }) - .unwrap(); - let response = decode_frame(&failed.outbound_frames[0]); - assert_eq!(response["success"], false); - assert_eq!(response["message"], fixture["failureMessage"]); - } else { - let response = decode_frame(&update.outbound_frames[0]); - assert_eq!(response["success"], false); - assert!(update.events.is_empty()); - } - destroy_session(SessionRequest { - session_id: session_id.to_string(), - }) - .unwrap(); - } - } - - #[test] - fn disconnect_fails_a_pending_start_debugging_request() { - let fixture: Value = serde_json::from_str(include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../fixtures/debug/start-debugging-v1.json" - ))) - .unwrap(); - let session_id = "debug-start-debugging-disconnect"; - create_session(CreateSessionRequest { - session_id: session_id.to_string(), - adapter_id: "node".to_string(), - root_path: "/workspace".to_string(), - supports_run_in_terminal_request: false, - supports_start_debugging_request: true, - disabled_capabilities: Vec::new(), - parent_session_id: None, - declared: Default::default(), - }) - .unwrap(); - receive_messages(session_id, vec![fixture["adapterRequest"].clone()]); - let update = disconnect(DisconnectRequest { - session_id: session_id.to_string(), - terminate_debuggee: None, - }) - .unwrap(); - let response = decode_frame(&update.outbound_frames[0]); - assert_eq!(response["command"], "startDebugging"); - assert_eq!(response["success"], false); - destroy_session(SessionRequest { - session_id: session_id.to_string(), - }) - .unwrap(); - } - - #[test] - fn node_source_mapped_locations_pass_through_unchanged() { - let fixture: Value = serde_json::from_str(include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../fixtures/debug/node-source-map-v1.json" - ))) - .unwrap(); - let session_id = "debug-node-source-map"; - let source = fixture["sourcePath"].as_str().unwrap(); - create_session(CreateSessionRequest { - session_id: session_id.to_string(), - adapter_id: "node".to_string(), - root_path: "/workspace".to_string(), - supports_run_in_terminal_request: false, - supports_start_debugging_request: false, - disabled_capabilities: Vec::new(), - parent_session_id: None, - declared: Default::default(), - }) - .unwrap(); - // This reducer owns no processes; always destroy its registry entry below. - receive_messages( - session_id, - vec![response_message(1, "initialize", json!({}))], - ); - receive_messages( - session_id, - vec![json!({"type":"event", "event":"initialized"})], - ); - let sent = set_breakpoints(SetBreakpointsRequest { - session_id: session_id.to_string(), - source_path: source.to_string(), - breakpoints: vec![SourceBreakpoint { - line: fixture["requestedLine"].as_i64().unwrap(), - column: None, - enabled: true, - condition: None, - hit_condition: None, - log_message: None, - }], - }) - .unwrap(); - let request = decode_frame(&sent.outbound_frames[0]); - // Core never rewrites TypeScript to the generated file; the adapter owns mapping. - assert_eq!(request["arguments"]["source"]["path"], source); - let answered = receive_messages( - session_id, - vec![response_message( - request["seq"].as_i64().unwrap(), - "setBreakpoints", - json!({"breakpoints":[fixture["response"].clone()]}), - )], - ); - let breakpoint = answered - .events - .iter() - .find_map(|event| match &event.body { - DebugEventBody::Breakpoint { breakpoint } => { - Some(serde_json::to_value(breakpoint).unwrap()) - } - _ => None, - }) - .unwrap(); - for key in ["sourcePath", "requestedLine", "line", "verified"] { - assert_eq!(breakpoint[key], fixture["expectedBreakpoint"][key], "{key}"); - } - - receive_messages( - session_id, - vec![json!({"seq": 103, "type": "event", "event": "stopped", - "body": {"reason": "breakpoint", "threadId": 1}})], - ); - inspect(InspectRequest { - session_id: session_id.to_string(), - operation_id: "stack".to_string(), - kind: DebugInspectKind::StackTrace, - thread_id: Some(1), - frame_id: None, - variables_reference: None, - variable_filter: None, - start: None, - count: None, - expression: None, - context: None, - source_path: None, - line: None, - column: None, - memory_reference: None, - offset: None, - instruction_offset: None, - instruction_count: None, - resolve_symbols: None, - }) - .unwrap(); - let stack_sequence = 4; - let stack = receive_messages( - session_id, - vec![response_message( - stack_sequence, - "stackTrace", - json!({"stackFrames": fixture["stackFrames"].clone()}), - )], - ); - let frame = stack - .events - .iter() - .find_map(|event| match &event.body { - DebugEventBody::OperationCompleted { - result: DebugOperationResult::StackTrace { stack_frames }, - .. - } => Some(serde_json::to_value(&stack_frames[0]).unwrap()), - _ => None, - }) - .unwrap(); - assert_eq!(frame["sourcePath"], fixture["expectedFrame"]["sourcePath"]); - assert_eq!(frame["line"], fixture["expectedFrame"]["line"]); - assert_eq!(frame["column"], fixture["expectedFrame"]["column"]); - destroy_session(SessionRequest { - session_id: session_id.to_string(), - }) - .unwrap(); - } - - #[test] - fn data_breakpoint_identity_and_verification_are_correlated() { - let session_id = "debug-data-breakpoint"; - create_session(CreateSessionRequest { - session_id: session_id.to_string(), - adapter_id: "lldb".to_string(), - root_path: "/workspace".to_string(), - supports_run_in_terminal_request: false, - supports_start_debugging_request: false, - disabled_capabilities: Vec::new(), - parent_session_id: None, - declared: Default::default(), - }) - .unwrap(); - set_data_breakpoints(SetDataBreakpointsRequest { - session_id: session_id.to_string(), - breakpoints: vec![ - DataBreakpoint { - data_id: " field:count ".to_string(), - label: Some("count".to_string()), - enabled: true, - access_type: Some(" write ".to_string()), - condition: Some(" count > 1 ".to_string()), - hit_condition: Some(" 2 ".to_string()), - }, - DataBreakpoint { - data_id: "field:ignored".to_string(), - label: None, - enabled: false, - access_type: None, - condition: None, - hit_condition: None, - }, - ], - }) - .unwrap(); - receive_messages( - session_id, - vec![response_message( - 1, - "initialize", - json!({"supportsDataBreakpoints": true}), - )], - ); - let configured = receive_messages( - session_id, - vec![json!({"seq": 102, "type": "event", "event": "initialized"})], - ); - assert_eq!( - decode_frame(&configured.outbound_frames[1])["command"], - "setDataBreakpoints" - ); - let arguments = &decode_frame(&configured.outbound_frames[1])["arguments"]["breakpoints"]; - assert_eq!(arguments.as_array().unwrap().len(), 1); - assert_eq!(arguments[0]["dataId"], "field:count"); - assert_eq!(arguments[0]["accessType"], "write"); - assert_eq!(arguments[0]["condition"], "count > 1"); - assert_eq!(arguments[0]["hitCondition"], "2"); - - let verified = receive_messages( - session_id, - vec![ - response_message(2, "setExceptionBreakpoints", json!({})), - response_message( - 3, - "setDataBreakpoints", - json!({"breakpoints": [{"id": 9, "verified": true}]}), - ), - ], - ); - assert!(verified.events.iter().any(|event| matches!( - &event.body, - DebugEventBody::Breakpoint { breakpoint } - if breakpoint.data_id.as_deref() == Some("field:count") - && breakpoint.verified - ))); - - let info = data_breakpoint_info(DataBreakpointInfoRequest { - session_id: session_id.to_string(), - operation_id: "field-info".to_string(), - name: " count ".to_string(), - variables_reference: Some(42), - frame_id: Some(7), - }) - .unwrap(); - let request = decode_frame(&info.outbound_frames[0]); - assert_eq!(request["command"], "dataBreakpointInfo"); - assert_eq!(request["arguments"]["name"], "count"); - assert_eq!(request["arguments"]["variablesReference"], 42); - assert_eq!(request["arguments"]["frameId"], 7); - let completed = receive_messages( - session_id, - vec![response_message( - 4, - "dataBreakpointInfo", - json!({ - "dataId": "field:count", - "description": "Main.count", - "accessTypes": ["read", "write"], - "canPersist": true - }), - )], - ); - assert!(completed.events.iter().any(|event| matches!( - &event.body, - DebugEventBody::OperationCompleted { - operation_id, - result: DebugOperationResult::DataBreakpointInfo { - data_id, - description, - access_types, - can_persist - } - } if operation_id == "field-info" - && data_id.as_deref() == Some("field:count") - && description == "Main.count" - && access_types == &["read", "write"] - && *can_persist - ))); - destroy_session(SessionRequest { - session_id: session_id.to_string(), - }) - .unwrap(); - } - - #[test] - fn variable_mutation_is_capability_gated_and_correlated() { - let session_id = "debug-set-variable"; - create_session(CreateSessionRequest { - session_id: session_id.to_string(), - adapter_id: "lldb".to_string(), - root_path: "/workspace".to_string(), - supports_run_in_terminal_request: false, - supports_start_debugging_request: false, - disabled_capabilities: Vec::new(), - parent_session_id: None, - declared: Default::default(), - }) - .unwrap(); - launch(LaunchRequest { - session_id: session_id.to_string(), - operation_id: "launch".to_string(), - configuration: DebugLaunchConfiguration { - name: "Main".to_string(), - request: DebugRequestKind::Launch, - arguments: Map::new(), - stepping_filters: None, - }, - }) - .unwrap(); - receive_messages( - session_id, - vec![response_message( - 1, - "initialize", - json!({"supportsSetVariable": true}), - )], - ); - receive_messages(session_id, vec![response_message(2, "launch", json!({}))]); - receive_messages( - session_id, - vec![json!({ - "seq": 103, - "type": "event", - "event": "stopped", - "body": {"reason": "breakpoint", "threadId": 11} - })], - ); - - let update = set_variable(SetVariableRequest { - session_id: session_id.to_string(), - operation_id: "set-count".to_string(), - variables_reference: 42, - name: " count ".to_string(), - value: "7".to_string(), - }) - .unwrap(); - let request = decode_frame(&update.outbound_frames[0]); - assert_eq!(request["command"], "setVariable"); - assert_eq!(request["arguments"]["variablesReference"], 42); - assert_eq!(request["arguments"]["name"], "count"); - assert_eq!(request["arguments"]["value"], "7"); - - let completed = receive_messages( - session_id, - vec![response_message( - 3, - "setVariable", - json!({ - "value": "7", - "type": "int", - "variablesReference": 0, - "namedVariables": -1, - "indexedVariables": 2 - }), - )], - ); - assert!(completed.events.iter().any(|event| matches!( - &event.body, - DebugEventBody::OperationCompleted { - operation_id, - result: DebugOperationResult::SetVariable { variable } - } if operation_id == "set-count" - && variable.name == "count" - && variable.value == "7" - && variable.r#type.as_deref() == Some("int") - && variable.named_variables == 0 - && variable.indexed_variables == 2 - ))); - destroy_session(SessionRequest { - session_id: session_id.to_string(), - }) - .unwrap(); - } - - // Regression: adapters that explain a failure only in `body.error.format` used to surface - // as the generic "rejected the request" line, hiding the actionable reason from the user. - #[test] - fn adapter_failure_detail_prefers_the_user_visible_error_format() { - let response = json!({ - "success": false, - "message": "launch failed", - "body": {"error": { - "id": 2, - "format": "program '{path}' does not exist ({path})", - "variables": {"path": "/work/a.out"} - }} - }); - assert_eq!( - adapter_failure_detail(&response).as_deref(), - Some("program '/work/a.out' does not exist (/work/a.out)") - ); - } - - #[test] - fn adapter_failure_detail_falls_back_to_the_short_message_and_keeps_unknown_placeholders() { - let short_only = json!({"success": false, "message": " notStopped "}); - assert_eq!( - adapter_failure_detail(&short_only).as_deref(), - Some("notStopped") - ); - - // A placeholder without a matching variable must stay readable, not vanish. - let unknown = json!({ - "success": false, - "body": {"error": {"format": "cannot read {file} {", "variables": {"other": "x"}}} - }); - assert_eq!( - adapter_failure_detail(&unknown).as_deref(), - Some("cannot read {file} {") - ); - - let empty = json!({"success": false, "message": " ", "body": {"error": {"format": ""}}}); - assert_eq!(adapter_failure_detail(&empty), None); - assert_eq!(adapter_failure_detail(&json!({"success": false})), None); - } - - #[test] - fn rejected_launch_reports_the_adapters_own_explanation() { - let session_id = "debug-launch-rejected-detail"; - create_session(CreateSessionRequest { - session_id: session_id.to_string(), - adapter_id: "lldb".to_string(), - root_path: "/workspace".to_string(), - supports_run_in_terminal_request: false, - supports_start_debugging_request: false, - disabled_capabilities: Vec::new(), - parent_session_id: None, - declared: Default::default(), - }) - .unwrap(); - launch(LaunchRequest { - session_id: session_id.to_string(), - operation_id: "launch".to_string(), - configuration: DebugLaunchConfiguration { - name: "Main".to_string(), - request: DebugRequestKind::Launch, - arguments: Map::new(), - stepping_filters: None, - }, - }) - .unwrap(); - receive_messages( - session_id, - vec![response_message(1, "initialize", json!({}))], - ); - let update = receive_messages( - session_id, - vec![json!({ - "seq": 102, - "type": "response", - "request_seq": 2, - "success": false, - "command": "launch", - "message": "launch failed", - "body": {"error": {"id": 1, "format": "no such file: {path}", "variables": {"path": "/work/a.out"}}} - })], - ); - let failure = update.events.iter().find_map(|event| match &event.body { - DebugEventBody::OperationFailed { - operation_id, - code, - message, - .. - } => Some((operation_id.clone(), *code, message.clone())), - _ => None, - }); - assert_eq!( - failure, - Some(( - "launch".to_string(), - DebugOperationFailureCode::AdapterRejected, - "no such file: /work/a.out".to_string() - )) - ); - assert_eq!(session_state(session_id), DebugSessionState::Failed); - cleanup(session_id); - } - - #[test] - fn cancelled_operation_forwards_dap_cancel_and_ignores_late_response() { - let session_id = "debug-cancel-operation"; - create_session(CreateSessionRequest { - session_id: session_id.to_string(), - adapter_id: "lldb".to_string(), - root_path: "/workspace".to_string(), - supports_run_in_terminal_request: false, - supports_start_debugging_request: false, - disabled_capabilities: Vec::new(), - parent_session_id: None, - declared: Default::default(), - }) - .unwrap(); - launch(LaunchRequest { - session_id: session_id.to_string(), - operation_id: "launch".to_string(), - configuration: DebugLaunchConfiguration { - name: "Main".to_string(), - request: DebugRequestKind::Launch, - arguments: Map::new(), - stepping_filters: None, - }, - }) - .unwrap(); - receive_messages( - session_id, - vec![response_message( - 1, - "initialize", - json!({ - "supportsCancelRequest": true, - "supportsSingleThreadExecutionRequests": true - }), - )], - ); - receive_messages(session_id, vec![response_message(2, "launch", json!({}))]); - receive_messages( - session_id, - vec![json!({ - "seq": 103, - "type": "event", - "event": "stopped", - "body": {"reason": "breakpoint", "threadId": 11} - })], - ); - let pending = inspect(InspectRequest { - session_id: session_id.to_string(), - operation_id: "threads-timeout".to_string(), - kind: DebugInspectKind::Threads, - thread_id: None, - frame_id: None, - variables_reference: None, - variable_filter: None, - start: None, - count: None, - expression: None, - context: None, - source_path: None, - line: None, - column: None, - memory_reference: None, - offset: None, - instruction_offset: None, - instruction_count: None, - resolve_symbols: None, - }) - .unwrap(); - assert_eq!(decode_frame(&pending.outbound_frames[0])["seq"], 3); - - let cancelled = cancel_operation(CancelOperationRequest { - session_id: session_id.to_string(), - operation_id: "threads-timeout".to_string(), - reason: DebugCancellationReason::TimedOut, - }) - .unwrap(); - assert!(cancelled.events.iter().any(|event| matches!( - &event.body, - DebugEventBody::OperationFailed { operation_id, command, code, message } - if operation_id == "threads-timeout" - && command == "threads" - && *code == DebugOperationFailureCode::TimedOut - && message == "Debug operation timed out." - ))); - let cancel = decode_frame(&cancelled.outbound_frames[0]); - assert_eq!(cancel["command"], "cancel"); - assert_eq!(cancel["arguments"]["requestId"], 3); - - let late = receive_messages( - session_id, - vec![ - response_message(3, "threads", json!({"threads": []})), - response_message(4, "cancel", json!({})), - ], - ); - assert!(!late - .events - .iter() - .any(|event| matches!(event.body, DebugEventBody::OperationCompleted { .. }))); - let resumed = execute(ExecuteRequest { - session_id: session_id.to_string(), - operation_id: "resume-main-thread".to_string(), - command: DebugExecutionCommand::Continue, - thread_id: Some(11), - target_id: None, - single_thread: true, - granularity: None, - }) - .unwrap(); - let resumed_request = decode_frame(&resumed.outbound_frames[0]); - assert_eq!(resumed_request["command"], "continue"); - assert_eq!(resumed_request["arguments"]["threadId"], 11); - assert_eq!(resumed_request["arguments"]["singleThread"], true); - let resumed = receive_messages( - session_id, - vec![ - response_message(5, "continue", json!({})), - json!({ - "seq": 108, - "type": "event", - "event": "continued", - "body": {"threadId": 11, "allThreadsContinued": false} - }), - ], - ); - assert_eq!(resumed.state, DebugSessionState::Paused); - destroy_session(SessionRequest { - session_id: session_id.to_string(), - }) - .unwrap(); - } - - #[test] - fn advanced_execution_controls_are_capability_gated_and_correlated() { - let session_id = "debug-advanced-control"; - create_session(CreateSessionRequest { - session_id: session_id.to_string(), - adapter_id: "lldb".to_string(), - root_path: "/workspace".to_string(), - supports_run_in_terminal_request: false, - supports_start_debugging_request: false, - disabled_capabilities: Vec::new(), - parent_session_id: None, - declared: Default::default(), - }) - .unwrap(); - launch(LaunchRequest { - session_id: session_id.to_string(), - operation_id: "launch".to_string(), - configuration: DebugLaunchConfiguration { - name: "Main".to_string(), - request: DebugRequestKind::Launch, - arguments: Map::new(), - stepping_filters: None, - }, - }) - .unwrap(); - receive_messages( - session_id, - vec![response_message( - 1, - "initialize", - json!({ - "supportsStepBack": true, - "supportsRestartRequest": true, - "supportsTerminateRequest": true - }), - )], - ); - receive_messages(session_id, vec![response_message(2, "launch", json!({}))]); - receive_messages( - session_id, - vec![json!({ - "seq": 103, - "type": "event", - "event": "stopped", - "body": {"reason": "breakpoint", "threadId": 11} - })], - ); - - let step_back = execute(ExecuteRequest { - session_id: session_id.to_string(), - operation_id: "step-back".to_string(), - command: DebugExecutionCommand::StepBack, - thread_id: Some(11), - target_id: None, - single_thread: false, - granularity: None, - }) - .unwrap(); - let request = decode_frame(&step_back.outbound_frames[0]); - assert_eq!(request["command"], "stepBack"); - assert_eq!(request["arguments"]["threadId"], 11); - assert_eq!(request["arguments"]["singleThread"], false); - let stepped = - receive_messages(session_id, vec![response_message(3, "stepBack", json!({}))]); - assert!(stepped.events.iter().any(|event| matches!( - &event.body, - DebugEventBody::OperationCompleted { operation_id, result: DebugOperationResult::Acknowledged { command } } - if operation_id == "step-back" && command == "stepBack" - ))); - - let restart = execute(ExecuteRequest { - session_id: session_id.to_string(), - operation_id: "restart".to_string(), - command: DebugExecutionCommand::Restart, - thread_id: Some(11), - target_id: None, - single_thread: false, - granularity: None, - }) - .unwrap(); - let request = decode_frame(&restart.outbound_frames[0]); - assert_eq!(request["command"], "restart"); - assert!(request["arguments"].get("threadId").is_none()); - receive_messages(session_id, vec![response_message(4, "restart", json!({}))]); - - let terminate = execute(ExecuteRequest { - session_id: session_id.to_string(), - operation_id: "terminate".to_string(), - command: DebugExecutionCommand::Terminate, - thread_id: Some(11), - target_id: None, - single_thread: false, - granularity: None, - }) - .unwrap(); - let request = decode_frame(&terminate.outbound_frames[0]); - assert_eq!(request["command"], "terminate"); - assert!(request["arguments"].get("threadId").is_none()); - destroy_session(SessionRequest { - session_id: session_id.to_string(), - }) - .unwrap(); - } - - #[test] - fn smart_step_and_goto_targets_are_normalized_before_targeted_execution() { - let session_id = "debug-targeted-control"; - create_session(CreateSessionRequest { - session_id: session_id.to_string(), - adapter_id: "lldb".to_string(), - root_path: "/workspace".to_string(), - supports_run_in_terminal_request: false, - supports_start_debugging_request: false, - disabled_capabilities: Vec::new(), - parent_session_id: None, - declared: Default::default(), - }) - .unwrap(); - launch(LaunchRequest { - session_id: session_id.to_string(), - operation_id: "launch".to_string(), - configuration: DebugLaunchConfiguration { - name: "Main".to_string(), - request: DebugRequestKind::Launch, - arguments: Map::new(), - stepping_filters: None, - }, - }) - .unwrap(); - receive_messages( - session_id, - vec![response_message( - 1, - "initialize", - json!({ - "supportsStepInTargetsRequest": true, - "supportsGotoTargetsRequest": true - }), - )], - ); - receive_messages(session_id, vec![response_message(2, "launch", json!({}))]); - receive_messages( - session_id, - vec![json!({ - "seq": 103, - "type": "event", - "event": "stopped", - "body": {"reason": "breakpoint", "threadId": 11} - })], - ); - - let step_targets = inspect(InspectRequest { - session_id: session_id.to_string(), - operation_id: "step-targets".to_string(), - kind: DebugInspectKind::StepInTargets, - thread_id: None, - frame_id: Some(7), - variables_reference: None, - variable_filter: None, - start: None, - count: None, - expression: None, - context: None, - source_path: None, - line: None, - column: None, - memory_reference: None, - offset: None, - instruction_offset: None, - instruction_count: None, - resolve_symbols: None, - }) - .unwrap(); - let request = decode_frame(&step_targets.outbound_frames[0]); - assert_eq!(request["command"], "stepInTargets"); - assert_eq!(request["arguments"]["frameId"], 7); - let step_targets = receive_messages( - session_id, - vec![response_message( - 3, - "stepInTargets", - json!({"targets": [{ - "id": 21, - "label": "service.load()", - "line": 12, - "column": 9, - "endLine": 12, - "endColumn": 23 - }]}), - )], - ); - assert!(step_targets.events.iter().any(|event| matches!( - &event.body, - DebugEventBody::OperationCompleted { - operation_id, - result: DebugOperationResult::StepInTargets { targets } - } if operation_id == "step-targets" - && targets.first().map(|target| target.id) == Some(21) - ))); - let targeted_step = execute(ExecuteRequest { - session_id: session_id.to_string(), - operation_id: "targeted-step".to_string(), - command: DebugExecutionCommand::StepIn, - thread_id: Some(11), - target_id: Some(21), - single_thread: false, - granularity: None, - }) - .unwrap(); - assert_eq!( - decode_frame(&targeted_step.outbound_frames[0])["arguments"]["targetId"], - 21 - ); - receive_messages(session_id, vec![response_message(4, "stepIn", json!({}))]); - receive_messages( - session_id, - vec![json!({ - "seq": 105, - "type": "event", - "event": "stopped", - "body": {"reason": "step", "threadId": 11} - })], - ); - - let goto_targets = inspect(InspectRequest { - session_id: session_id.to_string(), - operation_id: "goto-targets".to_string(), - kind: DebugInspectKind::GotoTargets, - thread_id: None, - frame_id: None, - variables_reference: None, - variable_filter: None, - start: None, - count: None, - expression: None, - context: None, - source_path: Some("/workspace/src/Main.c".to_string()), - line: Some(20), - column: Some(5), - memory_reference: None, - offset: None, - instruction_offset: None, - instruction_count: None, - resolve_symbols: None, - }) - .unwrap(); - let request = decode_frame(&goto_targets.outbound_frames[0]); - assert_eq!(request["command"], "gotoTargets"); - assert_eq!( - request["arguments"]["source"]["path"], - "/workspace/src/Main.c" - ); - assert_eq!(request["arguments"]["line"], 20); - let goto_targets = receive_messages( - session_id, - vec![response_message( - 5, - "gotoTargets", - json!({"targets": [{"id": 31, "label": "main.c:20", "line": 20}]}), - )], - ); - assert!(goto_targets.events.iter().any(|event| matches!( - &event.body, - DebugEventBody::OperationCompleted { - operation_id, - result: DebugOperationResult::GotoTargets { targets } - } if operation_id == "goto-targets" - && targets.first().map(|target| target.id) == Some(31) - ))); - let goto = execute(ExecuteRequest { - session_id: session_id.to_string(), - operation_id: "goto".to_string(), - command: DebugExecutionCommand::Goto, - thread_id: Some(11), - target_id: Some(31), - single_thread: false, - granularity: None, - }) - .unwrap(); - let request = decode_frame(&goto.outbound_frames[0]); - assert_eq!(request["command"], "goto"); - assert_eq!(request["arguments"]["targetId"], 31); - destroy_session(SessionRequest { - session_id: session_id.to_string(), - }) - .unwrap(); - } - - fn completions_request(session_id: &str, text: &str, column: Option) -> InspectRequest { - InspectRequest { - session_id: session_id.to_string(), - operation_id: "complete".to_string(), - kind: DebugInspectKind::Completions, - thread_id: None, - frame_id: Some(7), - variables_reference: None, - variable_filter: None, - start: None, - count: None, - expression: Some(text.to_string()), - context: None, - source_path: None, - line: None, - column, - memory_reference: None, - offset: None, - instruction_offset: None, - instruction_count: None, - resolve_symbols: None, - } - } - - #[test] - fn console_completions_are_capability_gated_and_normalized() { - let unsupported = "debug-completions-unsupported"; - paused_session(unsupported); - assert!(inspect(completions_request(unsupported, "fra", None)).is_err()); - destroy_session(SessionRequest { - session_id: unsupported.to_string(), - }) - .unwrap(); - - let session_id = "debug-completions"; - paused_session_with_capabilities(session_id, json!({"supportsCompletionsRequest": true})); - // The caret must lie inside the typed text, counted in UTF-16 units. - assert!(inspect(completions_request(session_id, "fra", Some(5))).is_err()); - assert!(inspect(completions_request(session_id, "fra", Some(0))).is_err()); - assert!(inspect(completions_request(session_id, "", None)).is_err()); - - let update = inspect(completions_request(session_id, "p 😀fra", None)).unwrap(); - let request = decode_frame(&update.outbound_frames[0]); - assert_eq!(request["command"], "completions"); - assert_eq!(request["arguments"]["text"], "p 😀fra"); - // "p ", the emoji (two UTF-16 units) and "fra" make seven units; the caret is the eighth. - assert_eq!(request["arguments"]["column"], 8); - assert_eq!(request["arguments"]["frameId"], 7); - - let completed = receive_messages( - session_id, - vec![response_message( - 3, - "completions", - json!({"targets": [ - {"label": "frame -- Commands for stack frames.", "text": "frame"}, - {"label": "format", "start": 3, "length": 2, "type": "function"}, - {"label": ""}, - {"text": "no label"} - ]}), - )], - ); - let items = completed - .events - .iter() - .find_map(|event| match &event.body { - DebugEventBody::OperationCompleted { - operation_id, - result: DebugOperationResult::Completions { items }, - } if operation_id == "complete" => Some(items), - _ => None, - }) - .expect("completions result"); - assert_eq!(items.len(), 2); - assert_eq!(items[0].text, "frame"); - assert_eq!(items[0].start, None); - // Without `text` the label is inserted; the 1-based wire range becomes 0-based. - assert_eq!(items[1].text, "format"); - assert_eq!(items[1].start, Some(2)); - assert_eq!(items[1].length, Some(2)); - assert_eq!(items[1].kind.as_deref(), Some("function")); - destroy_session(SessionRequest { - session_id: session_id.to_string(), - }) - .unwrap(); - } - - #[test] - fn exception_information_is_capability_gated_and_normalized() { - let fixture: Value = serde_json::from_str(include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../fixtures/debug/exception-info-v1.json" - ))) - .unwrap(); - let session_id = "debug-exception-info"; - create_session(CreateSessionRequest { - session_id: session_id.to_string(), - adapter_id: "lldb".to_string(), - root_path: "/workspace".to_string(), - supports_run_in_terminal_request: false, - supports_start_debugging_request: false, - disabled_capabilities: Vec::new(), - parent_session_id: None, - declared: Default::default(), - }) - .unwrap(); - launch(LaunchRequest { - session_id: session_id.to_string(), - operation_id: "launch".to_string(), - configuration: DebugLaunchConfiguration { - name: "Main".to_string(), - request: DebugRequestKind::Launch, - arguments: Map::new(), - stepping_filters: None, - }, - }) - .unwrap(); - let initialized = receive_messages( - session_id, - vec![response_message( - 1, - "initialize", - json!({"supportsExceptionInfoRequest": true}), - )], - ); - assert!(initialized.events.iter().any(|event| matches!( - &event.body, - DebugEventBody::Capabilities { capabilities } - if capabilities.supports_exception_info_request - ))); - receive_messages(session_id, vec![response_message(2, "launch", json!({}))]); - receive_messages( - session_id, - vec![json!({ - "seq": 103, - "type": "event", - "event": "stopped", - "body": {"reason": "exception", "threadId": fixture["request"]["threadId"]} - })], - ); - - let inspection = inspect(InspectRequest { - session_id: session_id.to_string(), - operation_id: fixture["request"]["operationId"] - .as_str() - .unwrap() - .to_string(), - kind: DebugInspectKind::ExceptionInfo, - thread_id: fixture["request"]["threadId"].as_i64(), - frame_id: None, - variables_reference: None, - variable_filter: None, - start: None, - count: None, - expression: None, - context: None, - source_path: None, - line: None, - column: None, - memory_reference: None, - offset: None, - instruction_offset: None, - instruction_count: None, - resolve_symbols: None, - }) - .unwrap(); - let request = decode_frame(&inspection.outbound_frames[0]); - assert_eq!(request["command"], "exceptionInfo"); - assert_eq!( - request["arguments"]["threadId"], - fixture["request"]["threadId"] - ); - - let completed = receive_messages( - session_id, - vec![response_message( - 3, - "exceptionInfo", - fixture["adapterResponse"].clone(), - )], - ); - let result = completed.events.iter().find_map(|event| match &event.body { - DebugEventBody::OperationCompleted { - operation_id, - result, - } if operation_id == fixture["request"]["operationId"].as_str().unwrap() => { - Some(result) - } - _ => None, - }); - assert_eq!( - serde_json::to_value(result.unwrap()).unwrap(), - fixture["expected"] - ); - destroy_session(SessionRequest { - session_id: session_id.to_string(), - }) - .unwrap(); - } - - #[test] - fn variable_paging_is_forwarded_and_normalized_from_shared_fixture() { - let fixture: Value = serde_json::from_str(include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../fixtures/debug/variable-paging-v1.json" - ))) - .unwrap(); - let session_id = "debug-variable-paging"; - create_session(CreateSessionRequest { - session_id: session_id.to_string(), - adapter_id: "lldb".to_string(), - root_path: "/workspace".to_string(), - supports_run_in_terminal_request: false, - supports_start_debugging_request: false, - disabled_capabilities: Vec::new(), - parent_session_id: None, - declared: Default::default(), - }) - .unwrap(); - launch(LaunchRequest { - session_id: session_id.to_string(), - operation_id: "launch".to_string(), - configuration: DebugLaunchConfiguration { - name: "Main".to_string(), - request: DebugRequestKind::Launch, - arguments: Map::new(), - stepping_filters: None, - }, - }) - .unwrap(); - receive_messages( - session_id, - vec![response_message(1, "initialize", json!({}))], - ); - receive_messages(session_id, vec![response_message(2, "launch", json!({}))]); - receive_messages( - session_id, - vec![json!({ - "seq": 103, - "type": "event", - "event": "stopped", - "body": {"reason": "breakpoint", "threadId": 11} - })], - ); - - let inspection = inspect(InspectRequest { - session_id: session_id.to_string(), - operation_id: fixture["request"]["operationId"] - .as_str() - .unwrap() - .to_string(), - kind: DebugInspectKind::Variables, - thread_id: None, - frame_id: None, - variables_reference: fixture["request"]["variablesReference"].as_i64(), - variable_filter: serde_json::from_value(fixture["request"]["variableFilter"].clone()) - .unwrap(), - start: fixture["request"]["start"].as_i64(), - count: fixture["request"]["count"].as_i64(), - expression: None, - context: None, - source_path: None, - line: None, - column: None, - memory_reference: None, - offset: None, - instruction_offset: None, - instruction_count: None, - resolve_symbols: None, - }) - .unwrap(); - let request = decode_frame(&inspection.outbound_frames[0]); - assert_eq!(request["command"], "variables"); - assert_eq!(request["arguments"]["variablesReference"], 700); - assert_eq!(request["arguments"]["filter"], "indexed"); - assert_eq!(request["arguments"]["start"], 100); - assert_eq!(request["arguments"]["count"], 2); - - let completed = receive_messages( - session_id, - vec![response_message( - 3, - "variables", - fixture["adapterResponse"].clone(), - )], - ); - let result = completed.events.iter().find_map(|event| match &event.body { - DebugEventBody::OperationCompleted { - operation_id, - result, - } if operation_id == fixture["request"]["operationId"].as_str().unwrap() => { - Some(result) - } - _ => None, - }); - assert_eq!( - serde_json::to_value(result.unwrap()).unwrap(), - fixture["expected"] - ); - destroy_session(SessionRequest { - session_id: session_id.to_string(), - }) - .unwrap(); - } - - #[test] - fn exception_information_is_rejected_when_the_adapter_does_not_support_it() { - let session_id = "debug-exception-info-unsupported"; - create_session(CreateSessionRequest { - session_id: session_id.to_string(), - adapter_id: "lldb".to_string(), - root_path: "/workspace".to_string(), - supports_run_in_terminal_request: false, - supports_start_debugging_request: false, - disabled_capabilities: Vec::new(), - parent_session_id: None, - declared: Default::default(), - }) - .unwrap(); - launch(LaunchRequest { - session_id: session_id.to_string(), - operation_id: "launch".to_string(), - configuration: DebugLaunchConfiguration { - name: "Main".to_string(), - request: DebugRequestKind::Launch, - arguments: Map::new(), - stepping_filters: None, - }, - }) - .unwrap(); - receive_messages( - session_id, - vec![response_message(1, "initialize", json!({}))], - ); - receive_messages(session_id, vec![response_message(2, "launch", json!({}))]); - receive_messages( - session_id, - vec![json!({ - "seq": 103, - "type": "event", - "event": "stopped", - "body": {"reason": "exception", "threadId": 13} - })], - ); - - let error = inspect(InspectRequest { - session_id: session_id.to_string(), - operation_id: "exception-main".to_string(), - kind: DebugInspectKind::ExceptionInfo, - thread_id: Some(13), - frame_id: None, - variables_reference: None, - variable_filter: None, - start: None, - count: None, - expression: None, - context: None, - source_path: None, - line: None, - column: None, - memory_reference: None, - offset: None, - instruction_offset: None, - instruction_count: None, - resolve_symbols: None, - }) - .unwrap_err(); - assert!(matches!(error.code, ErrorCode::InvalidRequest)); - destroy_session(SessionRequest { - session_id: session_id.to_string(), - }) - .unwrap(); - } - - #[test] - fn run_in_terminal_request_is_normalized_completed_and_stale_safe() { - let fixture: Value = serde_json::from_str(include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../fixtures/debug/run-in-terminal-v1.json" - ))) - .unwrap(); - let session_id = "debug-run-in-terminal"; - let created = create_session(CreateSessionRequest { - session_id: session_id.to_string(), - adapter_id: "lldb".to_string(), - root_path: "/workspace".to_string(), - supports_run_in_terminal_request: true, - supports_start_debugging_request: false, - disabled_capabilities: Vec::new(), - parent_session_id: None, - declared: Default::default(), - }) - .unwrap(); - assert_eq!( - decode_frame(&created.outbound_frames[0])["arguments"]["supportsRunInTerminalRequest"], - true - ); - - let requested = receive_messages(session_id, vec![fixture["adapterRequest"].clone()]); - assert!(requested.outbound_frames.is_empty()); - let request_id = requested - .events - .iter() - .find_map(|event| match &event.body { - DebugEventBody::RunInTerminalRequested { - request_id, - request, - } => { - assert_eq!( - serde_json::to_value(request).unwrap(), - fixture["expectedRequest"] - ); - Some(request_id.clone()) - } - _ => None, - }) - .unwrap(); - - let duplicate = receive_messages(session_id, vec![fixture["adapterRequest"].clone()]); - assert!(duplicate.outbound_frames.is_empty()); - assert!(duplicate.events.is_empty()); - - let invalid = run_in_terminal_response(DebugRunInTerminalResponseRequest { - session_id: session_id.to_string(), - request_id: request_id.clone(), - success: true, - process_id: Some(0), - shell_process_id: None, - message: None, - }) - .unwrap_err(); - assert!(matches!(invalid.code, ErrorCode::InvalidRequest)); - - let success = run_in_terminal_response(DebugRunInTerminalResponseRequest { - session_id: session_id.to_string(), - request_id: request_id.clone(), - success: true, - process_id: fixture["successResponse"]["processId"].as_i64(), - shell_process_id: fixture["successResponse"]["shellProcessId"].as_i64(), - message: None, - }) - .unwrap(); - let response = decode_frame(&success.outbound_frames[0]); - assert_eq!(response["request_seq"], fixture["adapterRequest"]["seq"]); - assert_eq!(response["success"], true); - assert_eq!(response["body"], fixture["expectedSuccessBody"]); - - let stale = run_in_terminal_response(DebugRunInTerminalResponseRequest { - session_id: session_id.to_string(), - request_id, - success: false, - process_id: None, - shell_process_id: None, - message: Some(fixture["failureMessage"].as_str().unwrap().to_string()), - }) - .unwrap(); - assert!(stale.outbound_frames.is_empty()); - destroy_session(SessionRequest { - session_id: session_id.to_string(), - }) - .unwrap(); - } - - #[test] - fn malformed_run_in_terminal_request_gets_an_explicit_failure_response() { - let session_id = "debug-run-in-terminal-invalid"; - create_session(CreateSessionRequest { - session_id: session_id.to_string(), - adapter_id: "lldb".to_string(), - root_path: "/workspace".to_string(), - supports_run_in_terminal_request: true, - supports_start_debugging_request: false, - disabled_capabilities: Vec::new(), - parent_session_id: None, - declared: Default::default(), - }) - .unwrap(); - - let update = receive_messages( - session_id, - vec![request_message( - 45, - "runInTerminal", - json!({"cwd": "/workspace", "args": []}), - )], - ); - - let response = decode_frame(&update.outbound_frames[0]); - assert_eq!(response["request_seq"], 45); - assert_eq!(response["success"], false); - assert!(response["message"] - .as_str() - .unwrap() - .contains("between 1 and 4096")); - destroy_session(SessionRequest { - session_id: session_id.to_string(), - }) - .unwrap(); - } - - #[test] - fn unknown_server_request_gets_an_explicit_failure_response() { - let session_id = "debug-server-request"; - create_session(CreateSessionRequest { - session_id: session_id.to_string(), - adapter_id: "lldb".to_string(), - root_path: "/workspace".to_string(), - supports_run_in_terminal_request: false, - supports_start_debugging_request: false, - disabled_capabilities: Vec::new(), - parent_session_id: None, - declared: Default::default(), - }) - .unwrap(); - - let update = receive_messages( - session_id, - vec![request_message(44, "runInTerminal", json!({}))], - ); - - let response = decode_frame(&update.outbound_frames[0]); - assert_eq!(response["request_seq"], 44); - assert_eq!(response["success"], false); - destroy_session(SessionRequest { - session_id: session_id.to_string(), - }) - .unwrap(); - } - - fn snapshot_of( - update: &DebugSessionUpdate, - ) -> Option<(u64, BTreeMap)> { - update.events.iter().find_map(|event| match &event.body { - DebugEventBody::Projection { - projection: - SessionProjection { - projection: Projection::Capabilities(payload), - .. - }, - } => Some((payload.revision, payload.capabilities.clone())), - _ => None, - }) - } - - #[test] - fn initialize_publishes_a_snapshot_and_forces_disabled_capabilities_off() { - let session_id = "debug-capability-snapshot"; - create_session(CreateSessionRequest { - session_id: session_id.to_string(), - adapter_id: "lldb".to_string(), - root_path: "/workspace".to_string(), - supports_run_in_terminal_request: false, - supports_start_debugging_request: false, - disabled_capabilities: vec!["supportsRestartRequest".to_string()], - parent_session_id: None, - declared: Default::default(), - }) - .unwrap(); - launch(LaunchRequest { - session_id: session_id.to_string(), - operation_id: "launch".to_string(), - configuration: DebugLaunchConfiguration { - name: "Main".to_string(), - request: DebugRequestKind::Launch, - arguments: Map::new(), - stepping_filters: None, - }, - }) - .unwrap(); - - let update = receive_messages( - session_id, - vec![response_message( - 1, - "initialize", - json!({"supportsRestartRequest": true, "supportsWriteMemoryRequest": true}), - )], - ); - let (revision, states) = snapshot_of(&update).expect("initialize publishes a snapshot"); - assert_eq!(revision, 1); - let restart = &states["supportsRestartRequest"]; - assert!(restart.adapter && restart.extension && !restart.authorized && !restart.supported); - // The adapter reports memory reads, but no extension in this build handles it. - let step_back = &states["supportsWriteMemoryRequest"]; - assert!(step_back.adapter && !step_back.extension && !step_back.supported); - - let late = receive_messages( - session_id, - vec![json!({ - "seq": 90, - "type": "event", - "event": "capabilities", - "body": {"capabilities": {"supportsRestartRequest": true}} - })], - ); - assert!( - snapshot_of(&late).is_none(), - "an unchanged state must not bump the revision" - ); - destroy_session(SessionRequest { - session_id: session_id.to_string(), - }) - .unwrap(); - } - - fn child_request(session_id: &str, parent: Option<&str>) -> CreateSessionRequest { - CreateSessionRequest { - session_id: session_id.to_string(), - adapter_id: "lldb".to_string(), - root_path: "/workspace".to_string(), - supports_run_in_terminal_request: false, - supports_start_debugging_request: false, - disabled_capabilities: Vec::new(), - parent_session_id: parent.map(str::to_string), - declared: Default::default(), - } - } - - fn child_events(update: &DebugSessionUpdate) -> Vec { - update - .events - .iter() - .filter_map(|event| match &event.body { - DebugEventBody::ChildSessionStopRequested { child_session_id } => { - Some(format!("stop:{child_session_id}")) - } - _ => None, - }) - .collect() - } - - fn destroy(session_id: &str) -> Vec { - destroy_session(SessionRequest { - session_id: session_id.to_string(), - }) - .unwrap() - } - - #[test] - fn child_sessions_stop_with_the_parent_and_leave_the_tree_when_destroyed() { - let parent = "debug-tree-parent"; - let child = "debug-tree-child"; - create_session(child_request(parent, None)).unwrap(); - assert!(create_session(child_request("debug-tree-orphan", Some("missing"))).is_err()); - create_session(child_request(child, Some(parent))).unwrap(); - - let stopping = disconnect(DisconnectRequest { - session_id: parent.to_string(), - terminate_debuggee: None, - }) - .unwrap(); - assert_eq!(child_events(&stopping), vec![format!("stop:{child}")]); - let again = disconnect(DisconnectRequest { - session_id: parent.to_string(), - terminate_debuggee: None, - }) - .unwrap(); - assert!(child_events(&again).is_empty(), "stop is requested once"); - assert!( - create_session(child_request("debug-tree-late", Some(parent))).is_err(), - "a stopping parent accepts no new children" - ); - - assert!(destroy(child).is_empty()); - with_session(parent, |session| { - assert!(session.child_ids.is_empty()); - Ok(()) - }) - .unwrap(); - assert!(destroy(parent).is_empty()); - } - - #[test] - fn destroying_a_parent_destroys_its_whole_child_tree() { - let (root, child, grandchild) = ( - "debug-cascade-root", - "debug-cascade-child", - "debug-cascade-leaf", - ); - create_session(child_request(root, None)).unwrap(); - create_session(child_request(child, Some(root))).unwrap(); - create_session(child_request(grandchild, Some(child))).unwrap(); - - assert_eq!( - destroy(root), - vec![grandchild.to_string(), child.to_string()] - ); - for id in [root, child, grandchild] { - assert!( - with_session(id, |_| Ok(())).is_err(), - "{id} must not outlive the root" - ); - } - } - - #[test] - fn stack_trace_and_scopes_emit_v2_projections_with_one_source_identity() { - let session_id = "debug-v2-projection"; - paused_session(session_id); - let mut request = evaluate_request(session_id, "stack", None); - request.kind = DebugInspectKind::StackTrace; - request.thread_id = Some(11); - request.expression = None; - inspect(request).unwrap(); - let update = receive_messages( - session_id, - vec![response_message( - 3, - "stackTrace", - json!({"totalFrames": 3, "stackFrames": [ - {"id": 1, "name": "main", "line": 4, "column": 2, - "source": {"path": "/workspace/src/Main.c"}}, - {"id": 2, "name": "decompiled", "line": 9, - "source": {"name": "Lib.class", "sourceReference": 77}}, - {"id": 3, "name": "jit", "line": 5, - "source": {"path": "/elsewhere/Lib.c"}, - "instructionPointerReference": "0xFFFFFFFFFFFFFFFF"} - ]}), - )], - ); - let projection = update - .events - .iter() - .find_map(|event| match &event.body { - DebugEventBody::Projection { projection } => Some(projection.clone()), - _ => None, - }) - .expect("stack trace emits a v2 projection"); - assert_eq!(projection.stop_generation, 1); - let Projection::StackTrace(payload) = projection.projection else { - panic!("expected a stack trace projection"); - }; - assert_eq!(payload.thread_id, 11); - assert!(matches!( - &payload.frames[0].source, - Some(Source::Workspace(source)) if source.path == "src/Main.c" - )); - assert!(matches!( - &payload.frames[1].source, - Some(Source::Session(source)) if source.source_reference == 77 - )); - // A path outside the workspace has no grant, so the frame carries no location. - assert!(payload.frames[2].source.is_none() && payload.frames[2].line.is_none()); - assert_eq!( - payload.frames[2].instruction_pointer_reference.as_deref(), - Some("0xFFFFFFFFFFFFFFFF") - ); - destroy_session(SessionRequest { - session_id: session_id.to_string(), - }) - .unwrap(); - } - - /// Creates a paused session so reference-bound inspections can be issued. - fn paused_session(session_id: &str) { - paused_session_with_capabilities(session_id, json!({})); - } - - /// A paused session whose adapter answered `initialize` with `capabilities`. - fn paused_session_with_capabilities(session_id: &str, capabilities: Value) { - create_session(CreateSessionRequest { - session_id: session_id.to_string(), - adapter_id: "lldb".to_string(), - root_path: "/workspace".to_string(), - supports_run_in_terminal_request: false, - supports_start_debugging_request: false, - disabled_capabilities: Vec::new(), - parent_session_id: None, - declared: Default::default(), - }) - .unwrap(); - launch(LaunchRequest { - session_id: session_id.to_string(), - operation_id: "launch".to_string(), - configuration: DebugLaunchConfiguration { - name: "Main".to_string(), - request: DebugRequestKind::Launch, - arguments: Map::new(), - stepping_filters: None, - }, - }) - .unwrap(); - receive_messages( - session_id, - vec![response_message(1, "initialize", capabilities)], - ); - receive_messages(session_id, vec![response_message(2, "launch", json!({}))]); - receive_messages(session_id, vec![stopped_event(103)]); - } - - fn stopped_event(sequence: i64) -> Value { - json!({ - "seq": sequence, - "type": "event", - "event": "stopped", - "body": {"reason": "breakpoint", "threadId": 11} - }) - } - - fn evaluate_request( - session_id: &str, - operation_id: &str, - context: Option<&str>, - ) -> InspectRequest { - InspectRequest { - session_id: session_id.to_string(), - operation_id: operation_id.to_string(), - kind: DebugInspectKind::Evaluate, - thread_id: None, - frame_id: Some(7), - variables_reference: None, - variable_filter: None, - start: None, - count: None, - expression: Some("a + b".to_string()), - context: context.map(str::to_string), - source_path: None, - line: None, - column: None, - memory_reference: None, - offset: None, - instruction_offset: None, - instruction_count: None, - resolve_symbols: None, - } - } - - #[test] - fn evaluate_forwards_each_supported_context_and_rejects_unknown_ones() { - let session_id = "debug-evaluate-context"; - paused_session(session_id); - let update = inspect(evaluate_request(session_id, "default", None)).unwrap(); - assert_eq!( - decode_frame(&update.outbound_frames[0])["arguments"]["context"], - "watch" - ); - for context in ["watch", "hover", "repl", "clipboard", "variables"] { - let update = inspect(evaluate_request(session_id, context, Some(context))).unwrap(); - assert_eq!( - decode_frame(&update.outbound_frames[0])["arguments"]["context"], - context - ); - } - assert!(inspect(evaluate_request(session_id, "bad", Some("debugger"))).is_err()); - destroy_session(SessionRequest { - session_id: session_id.to_string(), - }) - .unwrap(); - } - - #[test] - fn reference_bound_replies_after_a_new_stop_fail_as_stale() { - let session_id = "debug-stale-reference"; - paused_session(session_id); - let update = inspect(evaluate_request(session_id, "old", None)).unwrap(); - let request_seq = decode_frame(&update.outbound_frames[0])["seq"] - .as_i64() - .unwrap(); - receive_messages(session_id, vec![stopped_event(104)]); - let update = receive_messages( - session_id, - vec![response_message( - request_seq, - "evaluate", - json!({"result": "3"}), - )], - ); - assert!(update.events.iter().any(|event| matches!( - &event.body, - DebugEventBody::OperationFailed { operation_id, code: DebugOperationFailureCode::StaleReference, .. } - if operation_id == "old" - ))); - assert!(!update - .events - .iter() - .any(|event| matches!(event.body, DebugEventBody::OperationCompleted { .. }))); - - let update = inspect(evaluate_request(session_id, "fresh", None)).unwrap(); - let request_seq = decode_frame(&update.outbound_frames[0])["seq"] - .as_i64() - .unwrap(); - let update = receive_messages( - session_id, - vec![response_message( - request_seq, - "evaluate", - json!({"result": "3"}), - )], - ); - assert!(update.events.iter().any(|event| matches!( - &event.body, - DebugEventBody::OperationCompleted { operation_id, .. } if operation_id == "fresh" - ))); - destroy_session(SessionRequest { - session_id: session_id.to_string(), - }) - .unwrap(); - } - - #[test] - fn stale_reference_fixture_matches_core_behavior() { - let fixture: Value = serde_json::from_str(include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../fixtures/debug/stale-reference-v1.json" - ))) - .unwrap(); - let session_id = "debug-stale-fixture"; - paused_session(session_id); - let stale_id = fixture["staleOperationId"].as_str().unwrap(); - let update = inspect(evaluate_request(session_id, stale_id, None)).unwrap(); - let request_seq = decode_frame(&update.outbound_frames[0])["seq"] - .as_i64() - .unwrap(); - receive_messages(session_id, vec![stopped_event(104)]); - let update = receive_messages( - session_id, - vec![response_message( - request_seq, - "evaluate", - fixture["evaluateResponseBody"].clone(), - )], - ); - let failed = update - .events - .iter() - .find_map(|event| match &event.body { - DebugEventBody::OperationFailed { - operation_id, code, .. - } => Some((operation_id.clone(), serde_json::to_value(code).unwrap())), - _ => None, - }) - .expect("stale reply must fail"); - assert_eq!(failed.0, stale_id); - assert_eq!(failed.1, fixture["expectedFailureCode"]); - destroy_session(SessionRequest { - session_id: session_id.to_string(), - }) - .unwrap(); - } - - fn session_state(session_id: &str) -> DebugSessionState { - with_session(session_id, |session| Ok(session.state)).unwrap() - } - - fn cleanup(session_id: &str) { - destroy_session(SessionRequest { - session_id: session_id.to_string(), - }) - .unwrap(); - } - - #[test] - fn step_reply_arriving_after_the_stopped_event_keeps_the_session_paused() { - let session_id = "debug-step-race"; - paused_session(session_id); - let update = execute(ExecuteRequest { - session_id: session_id.to_string(), - operation_id: "step".to_string(), - command: DebugExecutionCommand::Next, - thread_id: Some(11), - target_id: None, - single_thread: false, - granularity: None, - }) - .unwrap(); - let request_seq = decode_frame(&update.outbound_frames[0])["seq"] - .as_i64() - .unwrap(); - let stopped = receive_messages(session_id, vec![stopped_event(104)]); - let generation = stopped - .events - .iter() - .find_map(|event| match event.body { - DebugEventBody::Stopped { - stop_generation, .. - } => Some(stop_generation), - _ => None, - }) - .unwrap(); - receive_messages( - session_id, - vec![response_message(request_seq, "next", json!({}))], - ); - assert_eq!(session_state(session_id), DebugSessionState::Paused); - let current = with_session(session_id, |session| Ok(session.stop_generation)).unwrap(); - assert_eq!(current, generation); - cleanup(session_id); - } - - #[test] - fn single_thread_continue_event_keeps_other_threads_references_valid() { - let session_id = "debug-single-thread-continue"; - paused_session(session_id); - let update = inspect(evaluate_request(session_id, "other", None)).unwrap(); - let request_seq = decode_frame(&update.outbound_frames[0])["seq"] - .as_i64() - .unwrap(); - receive_messages( - session_id, - vec![json!({"seq": 105, "type": "event", "event": "continued", - "body": {"threadId": 12, "allThreadsContinued": false}})], - ); - let update = receive_messages( - session_id, - vec![response_message( - request_seq, - "evaluate", - json!({"result": "1"}), - )], - ); - assert!(update.events.iter().any(|event| matches!( - &event.body, - DebugEventBody::OperationCompleted { operation_id, .. } if operation_id == "other" - ))); - assert_eq!(session_state(session_id), DebugSessionState::Paused); - cleanup(session_id); - } - - #[test] - fn stale_stack_trace_reply_is_not_published_under_the_new_generation() { - let session_id = "debug-stale-stack"; - paused_session(session_id); - let mut request = evaluate_request(session_id, "stack", None); - request.kind = DebugInspectKind::StackTrace; - request.thread_id = Some(11); - request.expression = None; - let update = inspect(request).unwrap(); - let request_seq = decode_frame(&update.outbound_frames[0])["seq"] - .as_i64() - .unwrap(); - receive_messages(session_id, vec![stopped_event(104)]); - let update = receive_messages( - session_id, - vec![response_message( - request_seq, - "stackTrace", - json!({"stackFrames": [{"id": 1, "name": "main", "line": 1}]}), - )], - ); - assert!(update.events.iter().any(|event| matches!( - &event.body, - DebugEventBody::OperationFailed { - code: DebugOperationFailureCode::StaleReference, - .. - } - ))); - assert!(!update - .events - .iter() - .any(|event| matches!(event.body, DebugEventBody::Projection { .. }))); - cleanup(session_id); - } - - #[test] - fn capabilities_event_with_new_exception_filters_is_published() { - let session_id = "debug-late-filters"; - paused_session(session_id); - let update = receive_messages( - session_id, - vec![json!({"seq": 106, "type": "event", "event": "capabilities", - "body": {"capabilities": {"exceptionBreakpointFilters": [ - {"filter": "uncaught", "label": "Uncaught", "default": true} - ]}}})], - ); - let filters = update.events.iter().find_map(|event| match &event.body { - DebugEventBody::Capabilities { capabilities } => { - Some(capabilities.exception_breakpoint_filters.len()) - } - _ => None, - }); - assert_eq!(filters, Some(1)); - cleanup(session_id); - } - - #[test] - fn terminated_parent_asks_each_child_to_stop_once() { - let parent = "debug-term-parent"; - let child = "debug-term-child"; - create_session(child_request(parent, None)).unwrap(); - create_session(child_request(child, Some(parent))).unwrap(); - let update = receive_messages( - parent, - vec![json!({"seq": 9, "type": "event", "event": "terminated", "body": {}})], - ); - assert_eq!(child_events(&update), vec![format!("stop:{child}")]); - let update = disconnect(DisconnectRequest { - session_id: parent.to_string(), - terminate_debuggee: None, - }) - .unwrap(); - assert!(child_events(&update).is_empty()); - cleanup(child); - cleanup(parent); - } - - #[test] - fn stop_generation_increments_on_every_stop() { - let session_id = "debug-stop-generation"; - paused_session(session_id); - let update = receive_messages(session_id, vec![stopped_event(104)]); - assert!(update.events.iter().any(|event| matches!( - event.body, - DebugEventBody::Stopped { - stop_generation: 2, - .. - } - ))); - destroy_session(SessionRequest { - session_id: session_id.to_string(), - }) - .unwrap(); - } - - /// Removes the process-free session even when an assertion fails first. - struct SessionGuard(&'static str); - - impl Drop for SessionGuard { - fn drop(&mut self) { - let _ = destroy_session(SessionRequest { - session_id: self.0.to_string(), - }); - } - } - - fn memory_request( - session_id: &str, - operation_id: &str, - kind: DebugInspectKind, - ) -> InspectRequest { - InspectRequest { - session_id: session_id.to_string(), - operation_id: operation_id.to_string(), - kind, - thread_id: Some(11), - frame_id: Some(7), - variables_reference: None, - variable_filter: None, - start: None, - count: None, - expression: None, - context: None, - source_path: None, - line: None, - column: None, - memory_reference: Some("0x100003f50".to_string()), - offset: None, - instruction_offset: None, - instruction_count: None, - resolve_symbols: None, - } - } - - fn step_request( - session_id: &str, - command: DebugExecutionCommand, - granularity: Option, - ) -> ExecuteRequest { - ExecuteRequest { - session_id: session_id.to_string(), - operation_id: "step".to_string(), - command, - thread_id: Some(11), - target_id: None, - single_thread: false, - granularity, - } - } - - fn instruction_breakpoint( - reference: &str, - offset: Option, - enabled: bool, - condition: Option<&str>, - ) -> InstructionBreakpoint { - InstructionBreakpoint { - instruction_reference: reference.to_string(), - offset, - enabled, - condition: condition.map(str::to_string), - hit_condition: None, - } - } - - fn request_sequence(update: &DebugSessionUpdate) -> i64 { - decode_frame(&update.outbound_frames[0])["seq"] - .as_i64() - .unwrap() - } - - #[test] - fn sourceless_frames_keep_the_instruction_pointer_without_a_fake_line() { - let filters = DebugSteppingFilters::unfiltered(); - let body = json!({"stackFrames": [ - {"id": 1, "name": "main", "line": 9, "column": 1, - "instructionPointerReference": "0x100003f50", - "source": {"name": "main.c", "path": "/workspace/main.c"}}, - {"id": 2, "name": "start", - "instructionPointerReference": "0xffffffffffffffff"}, - {"id": 3, "name": "no-line", - "source": {"name": "a.c", "path": "/workspace/a.c"}}, - {"id": 4, "name": "empty-reference", "instructionPointerReference": ""}, - ]}); - let frames = - normalize_inspection(DebugInspectKind::StackTrace, &body, &filters, "/workspace") - .unwrap(); - let value = serde_json::to_value(frames).unwrap(); - let frames = &value["stackFrames"]; - assert_eq!(frames[0]["line"], 9); - assert_eq!(frames[0]["instructionPointerReference"], "0x100003f50"); - // No file means no line: the UI shows the function and its address instead. - assert_eq!(frames[1]["line"], 0); - assert!(frames[1]["sourcePath"].is_null()); - // A 64-bit address stays an opaque string; it never passes through a JSON number. - assert_eq!( - frames[1]["instructionPointerReference"], - "0xffffffffffffffff" - ); - // A frame that does name a file keeps the historical line-1 fallback. - assert_eq!(frames[2]["line"], 1); - assert!(frames[2].get("instructionPointerReference").is_none()); - assert!(frames[3].get("instructionPointerReference").is_none()); - } - - #[test] - fn registers_scope_keeps_its_presentation_hint() { - let filters = DebugSteppingFilters::unfiltered(); - let body = json!({"scopes": [ - {"name": "Locals", "variablesReference": 3}, - {"name": "Registers", "variablesReference": 4, - "presentationHint": "registers", "expensive": true}, - {"name": "Blank", "variablesReference": 5, "presentationHint": ""}, - ]}); - let scopes = - normalize_inspection(DebugInspectKind::Scopes, &body, &filters, "/workspace").unwrap(); - let value = serde_json::to_value(scopes).unwrap(); - let scopes = &value["scopes"]; - assert!(scopes[0].get("presentationHint").is_none()); - assert_eq!(scopes[1]["presentationHint"], "registers"); - assert!(scopes[2].get("presentationHint").is_none()); - } - - #[test] - fn stepping_granularity_is_gated_and_forwarded() { - let unsupported = "debug-granularity-unsupported"; - let _unsupported_guard = SessionGuard(unsupported); - paused_session(unsupported); - let error = execute(step_request( - unsupported, - DebugExecutionCommand::StepIn, - Some(DebugSteppingGranularity::Instruction), - )) - .unwrap_err(); - assert!(matches!(error.code, ErrorCode::InvalidRequest)); - // The rejected request left the session paused, so a plain step still works. - let update = execute(step_request(unsupported, DebugExecutionCommand::Next, None)).unwrap(); - let request = decode_frame(&update.outbound_frames[0]); - assert_eq!(request["command"], "next"); - assert!(request["arguments"].get("granularity").is_none()); - - let supported = "debug-granularity-supported"; - let _supported_guard = SessionGuard(supported); - paused_session_with_capabilities(supported, json!({"supportsSteppingGranularity": true})); - // Granularity only describes how far a step moves. - for command in [ - DebugExecutionCommand::Continue, - DebugExecutionCommand::Pause, - DebugExecutionCommand::Restart, - ] { - assert!(execute(step_request( - supported, - command, - Some(DebugSteppingGranularity::Instruction) - )) - .is_err()); - } - let update = execute(step_request( - supported, - DebugExecutionCommand::StepIn, - Some(DebugSteppingGranularity::Instruction), - )) - .unwrap(); - let request = decode_frame(&update.outbound_frames[0]); - assert_eq!(request["command"], "stepIn"); - assert_eq!(request["arguments"]["granularity"], "instruction"); - assert_eq!(request["arguments"]["threadId"], 11); - } - - #[test] - fn instruction_breakpoints_are_validated_deduplicated_and_sent_after_initialized() { - let session_id = "debug-instruction-breakpoints"; - let _guard = SessionGuard(session_id); - create_session(CreateSessionRequest { - session_id: session_id.to_string(), - adapter_id: "lldb".to_string(), - root_path: "/workspace".to_string(), - supports_run_in_terminal_request: false, - supports_start_debugging_request: false, - disabled_capabilities: Vec::new(), - parent_session_id: None, - declared: Default::default(), - }) - .unwrap(); - launch(LaunchRequest { - session_id: session_id.to_string(), - operation_id: "launch".to_string(), - configuration: DebugLaunchConfiguration { - name: "Main".to_string(), - request: DebugRequestKind::Launch, - arguments: Map::new(), - stepping_filters: None, - }, - }) - .unwrap(); - for invalid in [" ", "0x1000\n0x2000", &"9".repeat(300)] { - assert!( - set_instruction_breakpoints(SetInstructionBreakpointsRequest { - session_id: session_id.to_string(), - breakpoints: vec![instruction_breakpoint(invalid, None, true, None)], - }) - .is_err(), - "{invalid:?}" - ); - } - let queued = set_instruction_breakpoints(SetInstructionBreakpointsRequest { - session_id: session_id.to_string(), - breakpoints: vec![ - instruction_breakpoint(" 0x100003f60 ", Some(4), true, None), - instruction_breakpoint(" 0x100003f50 ", None, true, Some(" x > 1 ")), - instruction_breakpoint("0x100003f50", None, true, Some("x > 1")), - instruction_breakpoint("0x100003f70", None, false, None), - ], - }) - .unwrap(); - // The session has not seen `initialized`, so nothing is sent yet. - assert!(queued.outbound_frames.is_empty()); - - receive_messages( - session_id, - vec![response_message( - 1, - "initialize", - json!({ - "supportsConfigurationDoneRequest": true, - "supportsInstructionBreakpoints": true - }), - )], - ); - let configured = receive_messages( - session_id, - vec![json!({"seq": 102, "type": "event", "event": "initialized"})], - ); - let frames: Vec = configured - .outbound_frames - .iter() - .map(|frame| decode_frame(frame)) - .collect(); - let sent = frames - .iter() - .find(|frame| frame["command"] == "setInstructionBreakpoints") - .expect("instruction breakpoints are sent after initialized"); - let breakpoints = sent["arguments"]["breakpoints"].as_array().unwrap(); - // The duplicate address collapsed and the disabled one is not sent. - assert_eq!(breakpoints.len(), 2); - assert_eq!(breakpoints[0]["instructionReference"], "0x100003f50"); - assert_eq!(breakpoints[0]["condition"], "x > 1"); - assert!(breakpoints[0].get("offset").is_none()); - assert_eq!(breakpoints[1]["instructionReference"], "0x100003f60"); - assert_eq!(breakpoints[1]["offset"], 4); - - let results = receive_messages( - session_id, - vec![response_message( - sent["seq"].as_i64().unwrap(), - "setInstructionBreakpoints", - json!({"breakpoints": [ - {"id": 5, "verified": true, "instructionReference": "0x100003f50"}, - {"verified": false, "message": "no code at this address"} - ]}), - )], - ); - let verified: Vec<(Option, bool)> = results - .events - .iter() - .filter_map(|event| match &event.body { - DebugEventBody::Breakpoint { breakpoint } => Some(( - breakpoint.instruction_reference.clone(), - breakpoint.verified, - )), - _ => None, - }) - .collect(); - // The second reply omitted the address, so the request at the same index names it. - assert_eq!( - verified, - vec![ - (Some("0x100003f50".to_string()), true), - (Some("0x100003f60".to_string()), false) - ] - ); - - // Once initialized, a change is sent immediately and an empty list clears the set. - let cleared = set_instruction_breakpoints(SetInstructionBreakpointsRequest { - session_id: session_id.to_string(), - breakpoints: Vec::new(), - }) - .unwrap(); - let request = decode_frame(&cleared.outbound_frames[0]); - assert_eq!(request["command"], "setInstructionBreakpoints"); - assert_eq!(request["arguments"]["breakpoints"], json!([])); - } - - #[test] - fn instruction_breakpoints_are_not_sent_without_adapter_support() { - let session_id = "debug-instruction-breakpoints-unsupported"; - let _guard = SessionGuard(session_id); - paused_session(session_id); - let update = set_instruction_breakpoints(SetInstructionBreakpointsRequest { - session_id: session_id.to_string(), - breakpoints: vec![instruction_breakpoint("0x100003f50", None, true, None)], - }) - .unwrap(); - assert!(update.outbound_frames.is_empty()); - } - - #[test] - fn disassemble_is_gated_bounded_and_normalized() { - let unsupported = "debug-disassemble-unsupported"; - let _unsupported_guard = SessionGuard(unsupported); - paused_session(unsupported); - let mut request = memory_request(unsupported, "dis", DebugInspectKind::Disassemble); - request.instruction_count = Some(4); - assert!(inspect(request).is_err()); - - let session_id = "debug-disassemble"; - let _guard = SessionGuard(session_id); - paused_session_with_capabilities(session_id, json!({"supportsDisassembleRequest": true})); - let valid = |count: Option| { - let mut request = memory_request(session_id, "dis", DebugInspectKind::Disassemble); - request.instruction_count = count; - request - }; - assert!(inspect(valid(None)).is_err()); - assert!(inspect(valid(Some(0))).is_err()); - assert!(inspect(valid(Some(1001))).is_err()); - let mut no_reference = valid(Some(4)); - no_reference.memory_reference = None; - assert!(inspect(no_reference).is_err()); - // Memory fields belong to memory requests only. - let mut misplaced = memory_request(session_id, "threads", DebugInspectKind::Threads); - misplaced.memory_reference = Some("0x100003f50".to_string()); - assert!(inspect(misplaced).is_err()); - - let mut request = valid(Some(5)); - request.instruction_offset = Some(-2); - request.offset = Some(0); - let update = inspect(request).unwrap(); - let sent = decode_frame(&update.outbound_frames[0]); - assert_eq!(sent["command"], "disassemble"); - assert_eq!(sent["arguments"]["memoryReference"], "0x100003f50"); - assert_eq!(sent["arguments"]["instructionOffset"], -2); - assert_eq!(sent["arguments"]["instructionCount"], 5); - assert_eq!(sent["arguments"]["resolveSymbols"], true); - - let completed = receive_messages( - session_id, - vec![response_message( - sent["seq"].as_i64().unwrap(), - "disassemble", - json!({"instructions": [ - {"address": "0x100003f48", "instructionBytes": "fd 7b bf a9", - "instruction": "stp x29, x30, [sp, #-0x10]!", "symbol": "main"}, - {"address": "0xffffffffffffff00", "instruction": "mov x29, sp", - "location": {"path": "/workspace/main.c"}, "line": 9}, - {"instruction": "no address is dropped"}, - {"address": "0x100003f50"} - ]}), - )], - ); - let instructions = completed - .events - .iter() - .find_map(|event| match &event.body { - DebugEventBody::OperationCompleted { - operation_id, - result: DebugOperationResult::Disassemble { instructions }, - } if operation_id == "dis" => Some(instructions), - _ => None, - }) - .expect("disassemble result"); - assert_eq!(instructions.len(), 2); - assert_eq!(instructions[0].address, "0x100003f48"); - assert_eq!( - instructions[0].instruction_bytes.as_deref(), - Some("fd 7b bf a9") - ); - assert_eq!(instructions[0].symbol.as_deref(), Some("main")); - assert_eq!(instructions[1].address, "0xffffffffffffff00"); - assert_eq!( - instructions[1].source_path.as_deref(), - Some("/workspace/main.c") - ); - assert_eq!(instructions[1].line, Some(9)); - } - - #[test] - fn disassemble_failure_and_stale_replies_are_operation_failures() { - let session_id = "debug-disassemble-failure"; - let _guard = SessionGuard(session_id); - paused_session_with_capabilities(session_id, json!({"supportsDisassembleRequest": true})); - let mut request = memory_request(session_id, "bad", DebugInspectKind::Disassemble); - request.instruction_count = Some(3); - // A body without `instructions` is a rejected request, not a broken session. - let update = inspect(request.clone()).unwrap(); - let failed = receive_messages( - session_id, - vec![response_message( - request_sequence(&update), - "disassemble", - json!({}), - )], - ); - assert!(failed.events.iter().any(|event| matches!( - &event.body, - DebugEventBody::OperationFailed { operation_id, code: DebugOperationFailureCode::AdapterRejected, .. } - if operation_id == "bad" - ))); - assert_eq!(session_state(session_id), DebugSessionState::Paused); - - // Addresses belong to one stop: a reply after the next stop is stale. - request.operation_id = "old".to_string(); - let update = inspect(request).unwrap(); - let sequence = request_sequence(&update); - receive_messages(session_id, vec![stopped_event(104)]); - let stale = receive_messages( - session_id, - vec![response_message( - sequence, - "disassemble", - json!({"instructions": [{"address": "0x1", "instruction": "nop"}]}), - )], - ); - assert!(stale.events.iter().any(|event| matches!( - &event.body, - DebugEventBody::OperationFailed { operation_id, code: DebugOperationFailureCode::StaleReference, .. } - if operation_id == "old" - ))); - } - - #[test] - fn read_memory_is_gated_bounded_and_returns_validated_bytes() { - let unsupported = "debug-read-memory-unsupported"; - let _unsupported_guard = SessionGuard(unsupported); - paused_session(unsupported); - let mut request = memory_request(unsupported, "mem", DebugInspectKind::ReadMemory); - request.count = Some(16); - assert!(inspect(request).is_err()); - - let session_id = "debug-read-memory"; - let _guard = SessionGuard(session_id); - paused_session_with_capabilities(session_id, json!({"supportsReadMemoryRequest": true})); - let sized = |count: Option| { - let mut request = memory_request(session_id, "mem", DebugInspectKind::ReadMemory); - request.count = count; - request - }; - assert!(inspect(sized(None)).is_err()); - assert!(inspect(sized(Some(0))).is_err()); - assert!(inspect(sized(Some(65_537))).is_err()); - - let mut request = sized(Some(16)); - request.offset = Some(8); - let update = inspect(request).unwrap(); - let sent = decode_frame(&update.outbound_frames[0]); - assert_eq!(sent["command"], "readMemory"); - assert_eq!(sent["arguments"]["memoryReference"], "0x100003f50"); - assert_eq!(sent["arguments"]["offset"], 8); - assert_eq!(sent["arguments"]["count"], 16); - - let completed = receive_messages( - session_id, - vec![response_message( - sent["seq"].as_i64().unwrap(), - "readMemory", - json!({"address": "0x100003f58", "unreadableBytes": 8, "data": "3q2+7w=="}), - )], - ); - assert!(completed.events.iter().any(|event| matches!( - &event.body, - DebugEventBody::OperationCompleted { - operation_id, - result: DebugOperationResult::ReadMemory { address, unreadable_bytes: 8, data_base64 } - } if operation_id == "mem" && address == "0x100003f58" && data_base64 == "3q2+7w==" - ))); - - // Entirely unreadable memory has no `data`; invalid base64 is rejected, not forwarded. - let update = inspect(sized(Some(4))).unwrap(); - let unreadable = receive_messages( - session_id, - vec![response_message( - request_sequence(&update), - "readMemory", - json!({"address": "0x100003f50", "unreadableBytes": 4}), - )], - ); - assert!(unreadable.events.iter().any(|event| matches!( - &event.body, - DebugEventBody::OperationCompleted { - result: DebugOperationResult::ReadMemory { unreadable_bytes: 4, data_base64, .. }, - .. - } if data_base64.is_empty() - ))); - let update = inspect(sized(Some(4))).unwrap(); - let malformed = receive_messages( - session_id, - vec![response_message( - request_sequence(&update), - "readMemory", - json!({"address": "0x100003f50", "data": "***not base64***"}), - )], - ); - assert!(malformed.events.iter().any(|event| matches!( - &event.body, - DebugEventBody::OperationFailed { - code: DebugOperationFailureCode::AdapterRejected, - .. - } - ))); - assert_eq!(session_state(session_id), DebugSessionState::Paused); - } - - /// A scripting-language adapter that declares the DAP extensions it implements. - fn scripting_adapter() -> DeclaredAdapterBehavior { - serde_json::from_value(json!({ - "features": { - "hotCodeReplace": { - "request": "script.reload", - "event": "script.reloaded", - "changedField": "modules", - "errorField": "error" - }, - "stepFilters": {"argument": "skipFilters"} - }, - "defaults": { - "stepFilters": { - "classNameFilters": ["site-packages/*"], - "skipSynthetics": true - } - } - })) - .unwrap() - } - - /// Creates a session, answers `initialize`, and launches with `arguments`. - /// Returns the launch request the adapter received. - fn launch_session( - session_id: &str, - adapter_id: &str, - declared: DeclaredAdapterBehavior, - arguments: Value, - ) -> Value { - create_session(CreateSessionRequest { - session_id: session_id.into(), - adapter_id: adapter_id.into(), - root_path: "/workspace".into(), - supports_run_in_terminal_request: false, - supports_start_debugging_request: false, - disabled_capabilities: Vec::new(), - parent_session_id: None, - declared, - }) - .unwrap(); - receive_messages( - session_id, - vec![response_message(1, "initialize", json!({}))], - ); - let launched = launch(LaunchRequest { - session_id: session_id.into(), - operation_id: "launch".into(), - configuration: DebugLaunchConfiguration { - name: "script".into(), - request: DebugRequestKind::Launch, - arguments: serde_json::from_value(arguments).unwrap(), - stepping_filters: None, - }, - }) - .unwrap(); - decode_frame(&launched.outbound_frames[0]) - } - - /// Answers the launch request and stops the debuggee at a breakpoint. - fn pause_session(session_id: &str, launch_frame: &Value) { - receive_messages( - session_id, - vec![ - response_message(launch_frame["seq"].as_i64().unwrap(), "launch", json!({})), - json!({"type": "event", "event": "stopped", - "body": {"reason": "breakpoint", "threadId": 1}}), - ], - ); - } - - fn redefine_request(session_id: &str, operation_id: &str) -> InspectRequest { - serde_json::from_value(json!({ - "sessionId": session_id, "operationId": operation_id, "kind": "redefineClasses" - })) - .unwrap() - } - - #[test] - fn declared_hot_code_replace_sends_the_declared_request_and_reads_its_fields() { - let session_id = "debug-declared-hot-replace"; - let _guard = SessionGuard(session_id); - let launched = launch_session(session_id, "debugpy", scripting_adapter(), json!({})); - pause_session(session_id, &launched); - - let update = inspect(redefine_request(session_id, "apply")).unwrap(); - let frame = decode_frame(&update.outbound_frames[0]); - assert_eq!(frame["command"], "script.reload"); - let replaced = receive_messages( - session_id, - vec![response_message( - frame["seq"].as_i64().unwrap(), - "script.reload", - json!({"modules": ["pkg.util", "pkg.app", "pkg.util"]}), - )], - ); - assert_eq!(replaced.state, DebugSessionState::Paused); - assert!(replaced.events.iter().any(|event| matches!( - &event.body, - DebugEventBody::OperationCompleted { - operation_id, - result: DebugOperationResult::RedefineClasses { changed_classes }, - } if operation_id == "apply" - && changed_classes == &["pkg.app".to_string(), "pkg.util".to_string()] - ))); - - // A failure the adapter reports inside a successful response fails the - // operation without breaking the session. - let update = inspect(redefine_request(session_id, "rejected")).unwrap(); - let rejected = receive_messages( - session_id, - vec![response_message( - request_sequence(&update), - "script.reload", - json!({"modules": [], "error": "Syntax error in pkg.app"}), - )], - ); - assert_eq!(rejected.state, DebugSessionState::Paused); - assert!(rejected.events.iter().any(|event| matches!( - &event.body, - DebugEventBody::OperationFailed { operation_id, message, .. } - if operation_id == "rejected" && message == "Syntax error in pkg.app" - ))); - } - - #[test] - fn declared_hot_code_replace_event_message_reaches_the_debug_console() { - let session_id = "debug-declared-hot-replace-event"; - let _guard = SessionGuard(session_id); - let launched = launch_session(session_id, "debugpy", scripting_adapter(), json!({})); - pause_session(session_id, &launched); - - let update = receive_messages( - session_id, - vec![json!({"type": "event", "event": "script.reloaded", - "body": {"message": "Reloaded 2 modules"}})], - ); - assert!(update.events.iter().any(|event| matches!( - &event.body, - DebugEventBody::Output { category: Some(category), output } - if category == "console" && output == "Reloaded 2 modules\n" - ))); - } - - #[test] - fn an_adapter_without_the_declaration_has_no_hot_code_replace() { - let session_id = "debug-undeclared-hot-replace"; - let _guard = SessionGuard(session_id); - let launched = launch_session( - session_id, - "debugpy", - DeclaredAdapterBehavior::default(), - json!({}), - ); - pause_session(session_id, &launched); - - assert!(inspect(redefine_request(session_id, "apply")).is_err()); - } - - #[test] - fn declared_step_filters_use_the_declared_argument_and_defaults() { - let session_id = "debug-declared-step-filters"; - let _guard = SessionGuard(session_id); - let launched = launch_session(session_id, "debugpy", scripting_adapter(), json!({})); - - let filters = &launched["arguments"]["skipFilters"]; - assert_eq!(filters["skipClasses"], json!(["site-packages/*"])); - assert_eq!(filters["skipSynthetics"], true); - assert_eq!(filters["skipConstructors"], false); - // The declaration names the argument; no other argument is invented. - assert!(launched["arguments"].get("stepFilters").is_none()); - assert!(launched["arguments"].get("vmArgs").is_none()); - } - - #[test] - fn declared_step_filters_keep_what_the_launch_configuration_sets() { - let session_id = "debug-declared-step-filters-explicit"; - let _guard = SessionGuard(session_id); - let launched = launch_session( - session_id, - "debugpy", - scripting_adapter(), - json!({"skipFilters": {"skipClasses": ["mine/*"]}}), - ); - - assert_eq!( - launched["arguments"]["skipFilters"], - json!({"skipClasses": ["mine/*"]}) - ); - } - - #[test] - fn invalid_declarations_are_refused_when_the_session_is_created() { - let declared = serde_json::from_value(json!({ - "features": {"hotCodeReplace": {"request": "not a command"}} - })) - .unwrap(); - let error = create_session(CreateSessionRequest { - session_id: "debug-invalid-declaration".into(), - adapter_id: "debugpy".into(), - root_path: "/workspace".into(), - supports_run_in_terminal_request: false, - supports_start_debugging_request: false, - disabled_capabilities: Vec::new(), - parent_session_id: None, - declared, - }) - .unwrap_err(); - assert!(matches!(error.code, ErrorCode::InvalidRequest)); - assert_eq!( - error.details.as_deref(), - Some("features.hotCodeReplace.request") - ); - } - - fn sent(update: &DebugSessionUpdate) -> Vec<(String, Value)> { - update - .outbound_frames - .iter() - .map(|frame| { - let message = decode_frame(frame); - ( - message["command"].as_str().unwrap_or_default().to_string(), - message["arguments"].clone(), - ) - }) - .collect() - } - - // Muting sends every kind of stored breakpoint empty, the adapter's default exception - // filters included, and unmuting sends them as stored; a child starts muted. - #[test] - fn muting_empties_every_breakpoint_kind_and_children_inherit_it() { - let parent = "debug-mute-parent"; - let child = "debug-mute-child"; - struct Cleanup; - impl Drop for Cleanup { - fn drop(&mut self) { - for session_id in ["debug-mute-child", "debug-mute-parent"] { - // log-exempt: Expected absence if the test already disposed this process-free session. - let _ = destroy_session(SessionRequest { - session_id: session_id.into(), - }); - } - } - } - let _cleanup = Cleanup; - let capabilities = json!({ - "supportsFunctionBreakpoints": true, - "supportsInstructionBreakpoints": true, - "supportsDataBreakpoints": true, - "exceptionBreakpointFilters": [{ - "filter": "uncaught", - "label": "Uncaught Exceptions", - "default": true - }] - }); - create_session(child_request(parent, None)).unwrap(); - receive_messages( - parent, - vec![response_message(1, "initialize", capabilities.clone())], - ); - let line = |line: i64, enabled: bool| SourceBreakpoint { - line, - column: None, - enabled, - condition: None, - hit_condition: None, - log_message: None, - }; - set_breakpoints(SetBreakpointsRequest { - session_id: parent.into(), - source_path: "/workspace/main.c".into(), - breakpoints: vec![line(4, true), line(9, false)], - }) - .unwrap(); - set_breakpoints(SetBreakpointsRequest { - session_id: parent.into(), - source_path: "/workspace/off.c".into(), - breakpoints: vec![line(2, false)], - }) - .unwrap(); - set_function_breakpoints(SetFunctionBreakpointsRequest { - session_id: parent.into(), - breakpoints: vec![FunctionBreakpoint { - name: "main".into(), - enabled: true, - condition: None, - hit_condition: None, - }], - }) - .unwrap(); - set_instruction_breakpoints(SetInstructionBreakpointsRequest { - session_id: parent.into(), - breakpoints: vec![InstructionBreakpoint { - instruction_reference: "0x1000".into(), - offset: None, - enabled: true, - condition: None, - hit_condition: None, - }], - }) - .unwrap(); - let configured = receive_messages( - parent, - vec![json!({"seq": 102, "type": "event", "event": "initialized"})], - ); - assert_eq!(sent(&configured)[0].1["filters"], json!(["uncaught"])); - - let muted = set_breakpoints_muted(SetBreakpointsMutedRequest { - session_id: parent.into(), - muted: true, - }) - .unwrap(); - // The data set and the file holding only a disabled line send the same either way. - assert_eq!( - sent(&muted), - vec![ - ("setExceptionBreakpoints".into(), json!({"filters": []})), - ("setFunctionBreakpoints".into(), json!({"breakpoints": []})), - ( - "setInstructionBreakpoints".into(), - json!({"breakpoints": []}) - ), - ( - "setBreakpoints".into(), - json!({ - "source": {"name": "main.c", "path": "/workspace/main.c"}, - "breakpoints": [], - "sourceModified": false - }) - ), - ] - ); - let again = set_breakpoints_muted(SetBreakpointsMutedRequest { - session_id: parent.into(), - muted: true, - }) - .unwrap(); - assert!(again.outbound_frames.is_empty()); - // A set replaced while muted is stored and sent empty. - let replaced = set_function_breakpoints(SetFunctionBreakpointsRequest { - session_id: parent.into(), - breakpoints: vec![FunctionBreakpoint { - name: "worker".into(), - enabled: true, - condition: None, - hit_condition: None, - }], - }) - .unwrap(); - assert_eq!(sent(&replaced)[0].1, json!({"breakpoints": []})); - - create_session(child_request(child, Some(parent))).unwrap(); - receive_messages(child, vec![response_message(1, "initialize", capabilities)]); - set_breakpoints(SetBreakpointsRequest { - session_id: child.into(), - source_path: "/workspace/main.c".into(), - breakpoints: vec![line(4, true)], - }) - .unwrap(); - let child_configured = receive_messages( - child, - vec![json!({"seq": 102, "type": "event", "event": "initialized"})], - ); - let child_sent = sent(&child_configured); - assert_eq!( - child_sent[0], - ("setExceptionBreakpoints".into(), json!({"filters": []})) - ); - assert!(child_sent - .iter() - .filter(|(command, _)| command == "setBreakpoints") - .all(|(_, arguments)| arguments["breakpoints"] == json!([]))); - - let unmuted = set_breakpoints_muted(SetBreakpointsMutedRequest { - session_id: parent.into(), - muted: false, - }) - .unwrap(); - let unmuted = sent(&unmuted); - assert_eq!(unmuted[0].1["filters"], json!(["uncaught"])); - assert_eq!(unmuted[1].1["breakpoints"][0]["name"], "worker"); - assert_eq!( - unmuted[2].1["breakpoints"][0]["instructionReference"], - "0x1000" - ); - assert_eq!(unmuted[3].1["breakpoints"], json!([{"line": 4}])); - assert_eq!(unmuted.len(), 4); - } -} diff --git a/rust/crates/core/src/debug/mod.rs b/rust/crates/core/src/debug/mod.rs deleted file mode 100644 index 130bdfa5..00000000 --- a/rust/crates/core/src/debug/mod.rs +++ /dev/null @@ -1,27 +0,0 @@ -//! Transport-neutral Debug Adapter Protocol state and normalized debugger models. - -pub(crate) mod adapters; -mod backend_start; -mod breakpoint_relocation; -mod capabilities; -mod disassembly_paging; -mod endpoint; -mod engine; -mod memory_window; -mod projection_v2; -mod protocol; -mod shortcut; -mod transport_lifecycle; -mod types; -mod variable_paging; - -pub(crate) use backend_start::decode_backend_configuration_command; -pub(crate) use breakpoint_relocation::*; -pub(crate) use disassembly_paging::{plan_disassembly_window, reduce_disassembly_page}; -pub(crate) use endpoint::*; -pub(crate) use engine::*; -pub(crate) use memory_window::{locate_memory_window, plan_memory_window}; -pub(crate) use shortcut::*; -pub(crate) use transport_lifecycle::*; -pub(crate) use types::*; -pub(crate) use variable_paging::{plan_variable_page, reduce_variable_page}; diff --git a/rust/crates/core/src/diagnostics/mod.rs b/rust/crates/core/src/diagnostics/mod.rs deleted file mode 100644 index 4694d65f..00000000 --- a/rust/crates/core/src/diagnostics/mod.rs +++ /dev/null @@ -1,30 +0,0 @@ -//! Diagnostic-bundle redaction and manifest shaping shared by every host. -//! -//! Hosts gather log files, panic reports, and environment facts natively; -//! this module only contains pure, deterministic logic: scrubbing secrets -//! from text and shaping the manifest that lists what a bundle contains. -//! The bounded Core event handoff owns observation timestamps but no file writer. -//! No filesystem or process access happens here; native collection stays in -//! each platform's own adapters per `develop-lithe`'s platform-adapter -//! boundary. - -pub mod catalog; -pub mod channel; -pub mod collection; -pub mod core_events; -pub mod filter; -pub mod format; -pub mod logging; -pub mod query_v2; -pub mod record; -pub mod retention; - -pub(crate) mod helper_failures; -pub(crate) mod plugin_operations; -pub(crate) mod request_trace; - -mod manifest; -mod redaction; - -pub(crate) use manifest::*; -pub(crate) use redaction::*; diff --git a/rust/crates/core/src/editor/mod.rs b/rust/crates/core/src/editor/mod.rs deleted file mode 100644 index ddb2259f..00000000 --- a/rust/crates/core/src/editor/mod.rs +++ /dev/null @@ -1,23 +0,0 @@ -//! Editor text-transform commands owned by Rust Core. -//! -//! macOS routes deterministic line-level editing through this package and -//! applies the returned replacement through its native text engine. Windows -//! still uses its local TypeScript implementation and is expected to adopt -//! the same contract in a follow-up change; the contract and fixtures here -//! are the canonical reference for that migration. - -pub(crate) mod find; -pub(crate) mod line_edit; -pub(crate) mod navigation_history; -pub(crate) mod navigation_input; -pub(crate) mod preferences; -pub(crate) mod recent; -pub(crate) mod recovery; -pub(crate) mod text_edit_plan; - -pub(crate) use line_edit::{ - line_comment_token, line_edit, LineCommentTokenRequest, LineEditRequest, -}; - -#[cfg(test)] -mod tests; diff --git a/rust/crates/core/src/execution/configuration.rs b/rust/crates/core/src/execution/configuration.rs deleted file mode 100644 index ff25f603..00000000 --- a/rust/crates/core/src/execution/configuration.rs +++ /dev/null @@ -1,2430 +0,0 @@ -//! Run-configuration schemas, layered overrides, and deterministic generation. - -mod project_toolchain; -pub(crate) use project_toolchain::{project_toolchain, ProjectToolchainRequest}; - -use super::generated_layer::{self, GeneratedLayer}; -use super::types::{Confidence, Execution, RunCategory}; -use crate::protocol::{invalid_relative_path, CoreError, ErrorCode}; -use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; -use sha2::{Digest, Sha256}; -use std::collections::{BTreeMap, BTreeSet}; -use std::fs; -use std::path::{Path, PathBuf}; - -const VERSION: u32 = 2; -const LEGACY_VERSION: u32 = 1; -// Bumped when the detected layer changes; stale cached facts must regenerate. -const GENERATOR_REVISION: &str = "20"; - -/// Toolchain requirements and `project.json` are separate documents that happen -/// to live under `.lithe`. Their schema did not change with run-config v2, so -/// they keep their own version and must not be validated against `VERSION`. -const SIDECAR_VERSION: u32 = 1; - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Request to validate the layered configuration documents for a workspace. -pub struct InspectRequest { - pub root: String, - /// Settings-only reads may skip source hashing; omitted preserves full inspection. - #[serde(default)] - pub check_fingerprint: Option, - /// Host-owned local layer. When present, Core validates it instead of `.lithe/run/local.json`. - #[serde(default)] - pub local_document: Option, - /// Complete generic run selectors; omission preserves older callers. - #[serde(default)] - pub provider_runs: Option>, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Request to regenerate detected configurations from the current project tree. -pub struct GenerateRequest { - pub root: String, - /// Complete generic run selectors; omission preserves older callers. - #[serde(default)] - pub provider_runs: Option>, - /// Application cache directory, outside the workspace. Generation records - /// its result there so the next session can list provider entries before - /// the providers answer again; omitted, only this process remembers it. - #[serde(default)] - pub cache_directory: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Request to merge configuration layers and resolve host toolchains. -pub struct ResolveRequest { - pub root: String, - #[serde(default)] - pub toolchain_candidates: Vec, - /// Host-owned local layer. When present, Core uses it instead of `.lithe/run/local.json`. - #[serde(default)] - pub local_document: Option, - /// Optional prepared team layer for a host transaction preflight; never persisted by Core. - #[serde(default)] - pub team_document: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Host-discovered executable that may satisfy a configuration requirement. -pub struct ToolchainCandidate { - /// Host-stable candidate identifier referenced by resolved configurations. - pub id: String, - /// Toolchain role such as `node`, not a display label. - #[serde(rename = "type")] - pub kind: String, - #[serde(default)] - pub version: String, - #[serde(default)] - pub vendor: String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Request to turn one resolved configuration into a process launch plan. -pub struct LaunchPlanRequest { - pub root: String, - pub configuration_id: String, - /// Host-owned local layer. When present, Core uses it instead of `.lithe/run/local.json`. - #[serde(default)] - pub local_document: Option, -} - -/// Resolves saved Run choices without assembling a build tool's command line. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ProviderLaunchInputsRequest { - /// Canonical workspace owned by the native host. - pub root: String, - /// Stable identity resolved through the generated/team/local document layers. - pub configuration_id: String, - /// Current provider instance, supplied by the owning build model. - pub provider_root: String, - /// Optional host-owned replacement for the machine-local document. - #[serde(default)] - pub local_document: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Global machine toolchain stored at document level in the local layer. -pub struct ToolchainPaths { - /// Explicit executables for generic runtime toolchain IDs such as `project-node`. - #[serde(default)] - pub runtime_executable_paths: BTreeMap, - /// Generic consumer preferences in the same machine-local document. - /// Absent preserves saved choices; an empty v1 document resets them. - #[serde(default)] - pub preferences: Option, -} - -/// Replaces only generic preferences in a captured machine-local Run document. -/// Other toolchain fields are preserved untouched for their owners. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct UpdateToolPreferencesRequest { - /// Required captured local layer; this command never reads native files. - pub local_document: Value, - /// Inert v1 preferences already edited by the shared preference command. - pub preferences: Value, -} - -/// Produces a Run-document replacement without materializing legacy defaults. -pub fn update_tool_preferences(request: UpdateToolPreferencesRequest) -> Result { - let mut document = request.local_document; - // A preference edit must not trigger legacy Run migration or silently - // narrow an unknown 64-bit version into a supported document version. - if !matches!(document["version"].as_u64(), Some(1 | 2)) { - return Err(CoreError::new( - ErrorCode::NotSupported, - "Unsupported run configuration version", - )); - } - configuration_ids(&document)?; - validate_tool_preferences(&document)?; - crate::toolchain::preferences::validate(&request.preferences) - .map_err(|error| CoreError::new(ErrorCode::InvalidRequest, error.message))?; - let object = document - .as_object_mut() - .ok_or_else(|| CoreError::new(ErrorCode::InvalidRequest, "Invalid local Run document"))?; - let toolchain = object.entry("toolchain").or_insert_with(|| json!({})); - let toolchain = toolchain.as_object_mut().ok_or_else(|| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid local tool preferences") - })?; - toolchain.insert("preferences".into(), request.preferences); - Ok(json!({"document":serde_json::to_string_pretty(&document).expect("validated Run document")})) -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Editable options applied to a team or machine-local configuration layer. -pub struct UpdateOptionsRequest { - pub root: String, - /// Persistence layer: `project` for shared configuration or `local` for this host. - pub scope: String, - pub configuration_id: String, - #[serde(default)] - pub working_directory: String, - /// Common program arguments. `programArguments` remains a wire alias for - /// clients from before the generic run-options migration. - #[serde(default, alias = "programArguments")] - pub arguments: String, - #[serde(default)] - pub environment: BTreeMap, - /// Portable provider choices. Omission preserves the existing namespace. - #[serde( - default, - deserialize_with = "super::provider_run_options::optional_request_options" - )] - pub provider_run_options: Option, - /// When present, `updateOptions` writes the document-level global toolchain - /// into the local layer instead of patching one configuration. - #[serde(default)] - pub toolchain: Option, - /// Host-owned local layer used when `scope` is `local` and when resolving first. - #[serde(default)] - pub local_document: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Request to add an explicitly user-authored run configuration. -pub struct CreateUserConfigurationRequest { - pub root: String, - /// Persistence layer: `project` for shared configuration or `local` for this host. - pub scope: String, - pub name: String, - /// UI configuration kind mapped to a namespaced provider during creation. - #[serde(rename = "type")] - pub kind: String, - /// A typed selector, never a cached executable or resolved launch payload. - #[serde(default)] - pub build: Option, -} - -/// Persisted workspace identity for re-preparing a build-provider application. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct BuildProviderRunReference { - #[serde(rename = "pluginID")] - pub plugin_id: String, - #[serde(rename = "buildSystemID")] - pub build_system_id: String, - pub root: String, - pub run: String, - #[serde(rename = "projectID")] - pub project_id: String, - #[serde(rename = "targetID")] - pub target_id: String, - /// Omitted older references are applications; the current declaration must match at launch. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub execution: Option, - #[serde(default)] - pub user_arguments: Vec, -} - -impl BuildProviderRunReference { - /// Stable generated identity excludes mutable display names, execution kind and arguments. - pub(super) fn generated_id(&self) -> String { - use sha2::{Digest, Sha256}; - let selector = json!([ - self.plugin_id, - self.build_system_id, - self.root, - self.run, - self.project_id, - self.target_id - ]); - let bytes = serde_json::to_vec(&selector).expect("selector contains only strings"); - format!("build.provider:{:x}", Sha256::digest(bytes)) - } - - /// Persistence validates identities only; current model membership is checked at launch. - pub(super) fn validate(&self) -> Result<(), CoreError> { - let identifier = |value: &str| { - !value.is_empty() - && value.len() <= 200 - && value - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte)) - }; - if self.execution == Some(Execution::Group) - || !identifier(&self.plugin_id) - || !identifier(&self.build_system_id) - || !identifier(&self.run) - || [&self.project_id, &self.target_id] - .iter() - .any(|id| id.is_empty() || id.len() > 2048 || id.contains('\0')) - || self.user_arguments.len() > 64 - || self - .user_arguments - .iter() - .any(|arg| arg.len() > 4096 || arg.contains('\0')) - || invalid_relative_path(&self.root) - || self.root.is_empty() - || self.root.contains('\\') - || self.root.contains('\0') - { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Invalid build provider run reference", - )); - } - Ok(()) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -/// Versioned run-configuration document stored below `.lithe/run`. -pub struct RunConfigurationDocument { - pub version: u32, - #[serde(default)] - pub generator: Option, - #[serde(default)] - pub configurations: Vec, -} - -/// Rewrites a v1 document in place into the v2 shape. -/// -/// Ids are preserved verbatim. The three-layer merge matches configurations by -/// id, so rewriting them would silently detach every override a user wrote in -/// the team or local layer -- a failure with no error message. -pub fn migrate_document_value(document: &mut Value) -> bool { - if document.get("version").and_then(Value::as_u64) != Some(LEGACY_VERSION as u64) { - return false; - } - if let Some(items) = document - .get_mut("configurations") - .and_then(Value::as_array_mut) - { - for item in items { - migrate_configuration_value(item); - } - } - document["version"] = json!(VERSION); - true -} - -fn migrate_configuration_value(item: &mut Value) { - let Some(object) = item.as_object_mut() else { - return; - }; - // v1 entries kept tool-specific launch fields at the top level. Core no - // longer interprets any of them; they stay inert because RunConfiguration - // ignores unknown keys, and the owning provider regenerates its payload. - let legacy_type = object - .remove("type") - .and_then(|value| value.as_str().map(str::to_string)) - .filter(|value| valid_provider(value)) - .unwrap_or_else(|| "legacy.unknown".to_string()); - if let Some(working_directory) = object.remove("workingDirectory") { - object.insert("cwd".to_string(), working_directory); - } - object.insert("provider".to_string(), json!(legacy_type)); - object.insert("execution".to_string(), json!("task")); - object.insert("confidence".to_string(), json!("native")); -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Fingerprint and inputs used to decide whether generated output is stale. -/// -/// `inputs` maps each workspace-relative input to `sha256:` of its bytes. -pub struct GeneratorMetadata { - pub fingerprint: String, - /// Identity of generic run declarations and opaque targets, separate from - /// the file inputs so a provider answer alone can mark the layer stale. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub provider_run_fingerprint: Option, - /// Unreadable inputs retained as unknown; a partial baseline cannot prove freshness. - #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] - pub unreadable_paths: BTreeSet, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub inputs: BTreeMap, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Debug adapter supported by a runnable configuration. -pub struct DebugCapability { - /// Stable adapter identifier declared by the provider that owns the entry. - pub adapter: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Ecosystem-neutral command, identity, and launch metadata for one runnable item. -pub struct RunConfiguration { - pub id: String, - pub name: String, - /// Open namespaced discriminator, e.g. `npm.script`, `cargo.run`. - /// Deliberately not an enum: new ecosystems must not require a contract change. - pub provider: String, - #[serde(default)] - pub execution: Execution, - /// Whether the entry runs this project or the infrastructure it depends on. - /// Omitted for project entries so existing documents stay byte-identical. - #[serde(default, skip_serializing_if = "RunCategory::is_project")] - pub category: RunCategory, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub command: Option, - #[serde(default)] - pub args: Vec, - #[serde(default = "dot")] - pub cwd: String, - #[serde(default)] - pub env: BTreeMap, - #[serde(default)] - pub confidence: Confidence, - #[serde(default)] - pub toolchains: BTreeMap, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub debug: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub members: Vec, - /// Ecosystem-specific payload keyed by namespace (`npm`, `build`, ...). - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub extensions: BTreeMap, - #[serde(default)] - pub disabled: bool, - /// Workspace-relative manifest or source file that produced the configuration. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub source: Option, -} - -fn dot() -> String { - ".".to_string() -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Versioned requirements written separately from run configurations. -pub struct ToolchainRequirementsDocument { - /// Document version the reader must accept before trusting any requirement. - pub version: u32, - /// Requirement per toolchain kind, keyed by the kind the caller asks about. - #[serde(default)] - pub toolchains: BTreeMap, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Constraints the host uses when selecting one toolchain candidate. -pub struct ToolchainRequirement { - /// Toolchain role matched against [`ToolchainCandidate::kind`]. - #[serde(rename = "type")] - pub kind: String, - #[serde(default)] - pub minimum_version: Option, - #[serde(default)] - pub preferred_vendor: Option, - /// Exact version prefix; [`Self::minimum_version`] accepts newer releases. - #[serde(default)] - pub version: Option, -} - -/// Validates configuration and sidecar documents without mutating the workspace. -pub fn inspect(request: InspectRequest) -> Result { - let root = existing_root(&request.root)?; - let layer = generated_layer::get(&root); - let generated = layer.as_ref().map(|layer| &layer.document); - let requirements = layer.as_ref().map(|layer| &layer.requirements); - let local_toolchains = read_local_toolchains(&root)?; - for relative in [ - "run/configurations.json", - "run/local.json", - "toolchains/local.json", - "project.json", - ] { - if relative == "run/local.json" { - if let Some(document) = request.local_document.as_ref() { - let mut migrated = document.clone(); - migrate_document_value(&mut migrated); - validate_version_value(&migrated)?; - configuration_ids(&migrated)?; - continue; - } - } - if let Some(document) = read_document_value(&root, relative)? { - if relative.starts_with("run/") { - validate_version_value(&document)?; - configuration_ids(&document)?; - } else { - validate_sidecar_version_value(&document)?; - if relative == "toolchains/local.json" - && document - .get("toolchains") - .and_then(Value::as_object) - .is_none() - { - return Err(CoreError::new( - ErrorCode::ParseFailed, - "Local toolchains must contain a toolchains object", - )); - } - } - } - } - let mut diagnostics = Vec::new(); - if request.check_fingerprint == Some(false) { - if let Some(metadata) = generated.and_then(|document| document.generator.as_ref()) { - if !metadata.unreadable_paths.is_empty() { - diagnostics.push(incomplete_scan_diagnostic(&metadata.unreadable_paths)); - } - } - } - let local = local_layer_document(&root, request.local_document.clone())?; - let team = read_document_value(&root, "run/configurations.json")? - .unwrap_or_else(|| json!({"version": VERSION, "configurations": []})); - if let Some(metadata) = generated - .and_then(|document| document.generator.as_ref()) - .filter(|_| request.check_fingerprint != Some(false)) - { - let current_inputs = project_inputs(&root)?; - if !current_inputs.unreadable_paths.is_empty() || !metadata.unreadable_paths.is_empty() { - let unknown = current_inputs - .unreadable_paths - .union(&metadata.unreadable_paths) - .cloned() - .collect(); - diagnostics.push(incomplete_scan_diagnostic(&unknown)); - } else if metadata.fingerprint != fingerprint_from_inputs(¤t_inputs.inputs) { - // Stored inputs that no longer reproduce the stored fingerprint were - // recorded by another generator revision, whose input rules (such - // as which manifests are hashed) may differ from today's. - let generator_changed = - fingerprint_from_inputs(&metadata.inputs) != metadata.fingerprint; - let message = if metadata.inputs.is_empty() { - "Project inputs changed after run configuration generation".to_string() - } else if generator_changed || metadata.inputs == current_inputs.inputs { - "Run configuration generator changed; regenerate configurations".to_string() - } else { - input_change_summary(&metadata.inputs, ¤t_inputs.inputs) - }; - diagnostics.push(json!({ - "code": "staleFingerprint", - "message": message - })); - } - } - if let Some(runs) = &request.provider_runs { - super::provider_runs::validate(runs)?; - if let Some(document) = generated { - let expected = super::provider_runs::fingerprint(runs); - if document - .generator - .as_ref() - .and_then(|metadata| metadata.provider_run_fingerprint.as_deref()) - != Some(expected.as_str()) - { - diagnostics.push(json!({"code":"staleProviderModel", "message":"Build provider model changed; regenerate Run configurations"})); - } - } - } - Ok(json!({ - "status": if generated.is_some() || has_saved_build_run(&local) || has_saved_build_run(&team) { "ready" } else { "missing" }, - // Saved provider tasks remain runnable before detection, but must not - // suppress discovery of the other entries after a process restart. - "requiresGeneration": generated.is_none(), - "generated": generated, - "toolchainRequirements": requirements, - "localToolchains": local_toolchains, - "toolchain": local.get("toolchain"), - "diagnostics": diagnostics, - "paths": { "configurations": ".lithe/run/configurations.json", "local": ".lithe/run/local.json" } - })) -} - -/// Detects runnable project entries and replaces the workspace's detected layer. -/// -/// The layer is held in memory (and in the host's cache directory when given), -/// never written into the workspace. -pub fn generate(request: GenerateRequest) -> Result { - let root = existing_root(&request.root)?; - let cache_directory = request.cache_directory.map(PathBuf::from); - let previous = previous_generation(&root, cache_directory.as_deref()); - let mut configurations = detected_configurations(&root)?; - if let Some(runs) = &request.provider_runs { - super::provider_runs::validate(runs)?; - let claimed = configurations - .iter() - .map(|item| item.id.clone()) - .collect::>(); - configurations.extend( - super::provider_runs::configurations(runs) - .into_iter() - .filter(|item| !claimed.contains(&item.id)), - ); - } - let mut snapshot = project_inputs(&root)?; - // Unknown subtrees cannot delete known configurations or baseline inputs. - // Invalid required documents still fail instead of being silently overwritten. - let authoritative_service_ids = request.provider_runs.as_ref().map(|_| { - configurations - .iter() - .filter(|item| item.provider == "build.provider") - .map(|item| item.id.clone()) - .collect::>() - }); - - if !snapshot.unreadable_paths.is_empty() { - if let Some(previous) = previous { - retain_unknown_inputs(&mut configurations, &mut snapshot, previous); - } - } - if let Some(ids) = authoritative_service_ids { - configurations.retain(|item| item.provider != "build.provider" || ids.contains(&item.id)); - } - disambiguate_configuration_names(&mut configurations); - let entry_count = configurations.len(); - let requirements = detect_requirements(&root, &configurations)?; - let generated = RunConfigurationDocument { - version: VERSION, - generator: Some(GeneratorMetadata { - provider_run_fingerprint: request - .provider_runs - .as_deref() - .map(super::provider_runs::fingerprint), - fingerprint: fingerprint_from_inputs(&snapshot.inputs), - inputs: snapshot.inputs, - unreadable_paths: snapshot.unreadable_paths, - }), - configurations, - }; - let response = json!({ - "generated": generated, - "toolchainRequirements": requirements, - "entryCount": entry_count - }); - if let Some(cache_directory) = cache_directory.as_deref() { - generated_layer::cache_document(cache_directory, &root, &generated); - } - generated_layer::store( - &root, - GeneratedLayer { - document: generated, - requirements, - }, - ); - Ok(response) -} - -/// The detected layer the previous generation produced: this session's, else -/// the one cached by an earlier session, else the `.lithe/run/generated.json` -/// older Lithe versions wrote into the workspace (read, never written). -/// -/// Generation uses it only to keep entries under a subtree it cannot read: -/// replaying them keeps ids and user overrides attached until the subtree is -/// readable again. An unreadable or invalid previous document contributes -/// nothing, because this generation replaces it. -fn previous_generation( - root: &Path, - cache_directory: Option<&Path>, -) -> Option { - if let Some(layer) = generated_layer::get(root) { - return Some(layer.document.clone()); - } - let mut document = cache_directory - .and_then(|directory| generated_layer::cached_document(directory, root)) - .or_else(|| { - read_document_value(root, "run/generated.json") - .ok() - .flatten() - })?; - migrate_document_value(&mut document); - serde_json::from_value::(document) - .ok() - .filter(|document| validate_version(document.version).is_ok()) -} - -/// Qualifies display names that repeat across directories or modules. -/// -/// Ids already carry the directory, but the Run list shows only the name: three -/// Compose files each contributing `compose up` are otherwise -/// indistinguishable. The qualifier is the first -/// candidate that separates every entry in the group, so the shortest useful -/// label wins and unique names are never decorated. -fn disambiguate_configuration_names(configurations: &mut [RunConfiguration]) { - let mut groups: BTreeMap> = BTreeMap::new(); - for (index, configuration) in configurations.iter().enumerate() { - groups - .entry(configuration.name.to_lowercase()) - .or_default() - .push(index); - } - for indexes in groups.into_values().filter(|indexes| indexes.len() > 1) { - let candidates: [fn(&RunConfiguration) -> Option; 2] = [ - |configuration| Some(configuration.cwd.clone()).filter(|cwd| cwd != "."), - |configuration| configuration.source.clone(), - ]; - let Some(qualifier) = candidates.into_iter().find(|candidate| { - let values = indexes - .iter() - .map(|index| candidate(&configurations[*index])) - .collect::>>(); - values.is_some_and(|values| values.len() == indexes.len()) - }) else { - continue; - }; - for index in indexes { - if let Some(value) = qualifier(&configurations[index]) { - configurations[index].name = format!("{} ({value})", configurations[index].name); - } - } - } -} - -/// Translates detector output into the run-configuration contract. -/// -/// Nearly every detection is process-based: the detector resolved the command, -/// so there is no ecosystem-specific launch assembly. A process may still name -/// a runtime requirement for scoped compatibility diagnostics; a detection that -/// transfers executable ownership to toolchains carries no command. -fn detected_configurations(root: &Path) -> Result, CoreError> { - Ok(super::detectors::detect_all(root)? - .into_iter() - .map(|item| RunConfiguration { - id: item.id(), - name: item.name, - provider: item.provider, - execution: item.execution, - category: item.category, - command: item.command, - args: item.args, - cwd: item.cwd, - env: item.env, - confidence: item.confidence, - toolchains: item.toolchains, - debug: None, - members: Vec::new(), - extensions: item.extensions, - disabled: false, - source: Some(item.source), - }) - .filter(|item| validate_configuration(item).is_ok()) - .collect()) -} - -/// Merges generated, team, and local layers and selects compatible toolchains. -pub fn resolve(request: ResolveRequest) -> Result { - let root = existing_root(&request.root)?; - let team = match request.team_document { - Some(mut document) => { - migrate_document_value(&mut document); - document - } - None => read_document_value(&root, "run/configurations.json")? - .unwrap_or_else(|| json!({"version": VERSION, "configurations": []})), - }; - let local = local_layer_document(&root, request.local_document)?; - let layer = generated_layer::get(&root); - let generated = match layer.as_ref() { - Some(layer) => serde_json::to_value(&layer.document).expect("run configurations encode"), - None if has_saved_build_run(&local) || has_saved_build_run(&team) => { - json!({"version": VERSION, "configurations": []}) - } - None => { - return Err(CoreError::new( - ErrorCode::WorkspaceNotFound, - "Run configuration has not been generated", - )) - } - }; - let local_toolchains = read_local_toolchains(&root)?; - let manifest = read_document_value(&root, "project.json")?; - validate_version_value(&generated)?; - validate_version_value(&team)?; - validate_version_value(&local)?; - if let Some(manifest) = manifest.as_ref() { - validate_sidecar_version_value(manifest)?; - } - configuration_ids(&generated)?; - let mut configurations = merge_values(&generated, &team, &local)?; - let resolved_ids = configurations - .iter() - .map(|item| item.id.as_str()) - .collect::>(); - let mut diagnostics = Vec::new(); - for source in [&team, &local] { - for id in configuration_ids(source)?.keys() { - if !resolved_ids.contains(id.as_str()) && !id.starts_with("user:") { - diagnostics.push(json!({ - "id": id, - "code": "orphanedOverride", - "message": "Override no longer matches an automatically generated configuration" - })); - } - } - } - normalize_runtime_consumption(&mut configurations); - let global_toolchain = local.get("toolchain").cloned(); - diagnostics.extend(toolchain_diagnostics( - &root, - &request.toolchain_candidates, - &configurations, - )?); - for configuration in &mut configurations { - validate_configuration(configuration)?; - if configuration.provider == "build.provider" { - // Materialize optional defaults in the shared output so native - // consumers receive the same shape as a newly saved selector. - let reference: BuildProviderRunReference = serde_json::from_value( - configuration.extensions["build"].clone(), - ) - .map_err(|_| { - CoreError::new( - ErrorCode::ParseFailed, - "Invalid build provider run reference", - ) - })?; - configuration.extensions.insert( - "build".to_string(), - serde_json::to_value(reference).expect("reference encodes"), - ); - } - if configuration.disabled { - diagnostics.push(json!({ - "id": configuration.id, - "code": "disabled", - "message": "Run configuration is disabled" - })); - continue; - } - if !project_directory_exists(&root, &configuration.cwd) { - configuration.disabled = true; - diagnostics.push(json!({ - "id": configuration.id, - "code": "missingWorkingDirectory", - "message": format!("Working directory does not exist: {}", configuration.cwd) - })); - } - } - configurations.retain(|configuration| !configuration.disabled); - let mut default_run_configuration = manifest - .as_ref() - .and_then(|value| value.get("defaultRunConfiguration")) - .and_then(Value::as_str) - .map(str::to_string); - if let Some(default_id) = default_run_configuration.as_deref() { - if !configurations - .iter() - .any(|configuration| configuration.id == default_id) - { - diagnostics.push(json!({ - "code": "missingDefaultConfiguration", - "message": format!("Default run configuration is unavailable: {default_id}") - })); - default_run_configuration = None; - } - } - if default_run_configuration.is_none() { - default_run_configuration = layer - .as_ref() - .and_then(|layer| detected_default(&layer.document, &configurations)); - } - Ok(json!({ - "version": VERSION, - "configurations": configurations, - "diagnostics": diagnostics, - "defaultRunConfiguration": default_run_configuration, - "toolchain": global_toolchain, - "localToolchains": local_toolchains - })) -} - -/// Prefer a detected service when the project names no default, otherwise -/// select the first available entry. -fn detected_default( - generated: &RunConfigurationDocument, - available: &[RunConfiguration], -) -> Option { - let by_id = available - .iter() - .map(|item| (item.id.as_str(), item)) - .collect::>(); - let mut by_selector = BTreeMap::new(); - for configuration in available { - if let Some(selector) = super::provider_runs::selector( - &serde_json::to_value(configuration).expect("configuration encodes"), - ) { - by_selector.entry(selector).or_insert(configuration); - } - } - let detected = generated.configurations.iter().filter_map(|configuration| { - by_id.get(configuration.id.as_str()).copied().or_else(|| { - let selector = super::provider_runs::selector( - &serde_json::to_value(configuration).expect("configuration encodes"), - )?; - by_selector.get(&selector).copied() - }) - }); - detected - .clone() - .find(|configuration| configuration.execution == Execution::Service) - .or_else(|| detected.clone().next()) - .map(|configuration| configuration.id.clone()) -} - -/// Persists editable configuration options in the requested ownership layer. -pub fn update_options(mut request: UpdateOptionsRequest) -> Result { - let root = existing_root(&request.root)?; - if let Some(toolchain) = request.toolchain.take() { - if request.scope != "local" { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Toolchain paths can only be saved in the local layer", - )); - } - let document = update_toolchain_document(&root, request.local_document, toolchain)?; - return Ok(json!({ - "document": serde_json::to_string_pretty(&document).expect("document should encode") - })); - } - let document = update_configuration_options(&root, request)?; - Ok(json!({ - "document": serde_json::to_string_pretty(&document).expect("document should encode") - })) -} - -/// Produces every document needed to save the run-configuration editor as one operation. -pub fn save_editor_changes(mut request: UpdateOptionsRequest) -> Result { - let root = existing_root(&request.root)?; - let toolchain = request.toolchain.take().ok_or_else(|| { - CoreError::new( - ErrorCode::InvalidRequest, - "Run configuration editor changes require a project toolchain", - ) - })?; - let toolchain_document = - update_local_toolchains_document(&root, &toolchain.runtime_executable_paths)?; - let mut local_document = - update_toolchain_document(&root, request.local_document.take(), toolchain)?; - request.local_document = Some(local_document.clone()); - let scope = request.scope.clone(); - let options_document = update_configuration_options(&root, request)?; - let project_document = if scope == "project" { - Some(serde_json::to_string_pretty(&options_document).expect("document should encode")) - } else { - local_document = options_document; - None - }; - Ok(json!({ - "localDocument": serde_json::to_string_pretty(&local_document).expect("document should encode"), - "projectDocument": project_document, - "toolchainDocument": toolchain_document.map(|document| { - serde_json::to_string_pretty(&document).expect("document should encode") - }) - })) -} - -/// Replaces only the generic preferences. Other document-level toolchain -/// fields belong to whichever host or plugin wrote them, so a Run editor save -/// must not reset them. -fn update_toolchain_document( - root: &Path, - local_document: Option, - toolchain: ToolchainPaths, -) -> Result { - let mut document = local_layer_document(root, local_document)?; - validate_version_value(&document)?; - let mut saved = document["toolchain"] - .as_object() - .cloned() - .unwrap_or_default(); - if let Some(preferences) = toolchain.preferences { - saved.insert("preferences".to_string(), preferences); - } - document["toolchain"] = Value::Object(saved); - validate_tool_preferences(&document)?; - Ok(document) -} - -fn update_local_toolchains_document( - root: &Path, - runtime_executable_paths: &BTreeMap, -) -> Result, CoreError> { - if runtime_executable_paths.is_empty() { - return Ok(None); - } - let mut document = read_local_toolchains(root)? - .unwrap_or_else(|| json!({"version": SIDECAR_VERSION, "toolchains": {}})); - validate_sidecar_version_value(&document)?; - let toolchains = document - .get_mut("toolchains") - .and_then(Value::as_object_mut) - .ok_or_else(|| { - CoreError::new( - ErrorCode::ParseFailed, - "Local toolchains must contain a toolchains object", - ) - })?; - for (id, executable_path) in runtime_executable_paths { - if executable_path.trim().is_empty() { - toolchains.remove(id); - } else { - toolchains.insert(id.clone(), json!({ "executable": executable_path.trim() })); - } - } - Ok(Some(document)) -} - -fn update_configuration_options( - root: &Path, - request: UpdateOptionsRequest, -) -> Result { - let relative = scope_document(&request.scope)?; - let resolved = resolve(ResolveRequest { - team_document: None, - root: request.root.clone(), - toolchain_candidates: Vec::new(), - local_document: request.local_document.clone(), - })?; - let provider = resolved["configurations"] - .as_array() - .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") - })?; - if let Some(options) = &request.provider_run_options { - if provider != "build.provider" { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Provider run options require a build provider reference", - )); - } - options.validate()?; - } - let working_directory = if request.working_directory.trim().is_empty() { - None - } else { - Some(normalize_working_directory( - &root, - request.working_directory.trim(), - )?) - }; - let mut document = if request.scope == "local" { - local_layer_document(&root, request.local_document)? - } else { - read_document_value(&root, relative)? - .unwrap_or_else(|| json!({"version": VERSION, "configurations": []})) - }; - validate_version_value(&document)?; - let configurations = document["configurations"] - .as_array_mut() - .ok_or_else(|| CoreError::new(ErrorCode::ParseFailed, "configurations must be an array"))?; - let mut patch = json!({ - "id": request.configuration_id, - "env": request.environment - }); - if let Some(working_directory) = working_directory.as_ref() { - patch["cwd"] = json!(working_directory); - } - patch["args"] = json!(split_arguments(&request.arguments)); - if provider == "build.provider" { - if let Some(options) = &request.provider_run_options { - patch["extensions"] = json!({"build-run":options}); - } - } - if let Some(existing) = configurations - .iter_mut() - .find(|value| value["id"] == patch["id"]) - { - let target = existing.as_object_mut().ok_or_else(|| { - CoreError::new( - ErrorCode::ParseFailed, - "Run configuration must be an object", - ) - })?; - if working_directory.is_none() { - target.remove("cwd"); - } - for (key, value) in patch.as_object_mut().expect("patch is an object") { - if key == "extensions" { - merge_extensions(target, value)?; - continue; - } - target.insert(key.clone(), value.take()); - } - } else { - configurations.push(patch); - } - configurations.sort_by(|left, right| { - left["id"] - .as_str() - .unwrap_or("") - .cmp(right["id"].as_str().unwrap_or("")) - }); - Ok(document) -} - -/// Only explicit provider references make an ungenerated workspace runnable. -fn has_saved_build_run(document: &Value) -> bool { - document["configurations"] - .as_array() - .is_some_and(|items| items.iter().any(super::provider_runs::is_explicit)) -} - -/// Upserts a local reference while preserving unrelated settings and stable IDs. -fn create_build_provider_configuration( - root: &Path, - request: &CreateUserConfigurationRequest, -) -> Result { - if request.scope != "local" { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Build provider references must be saved locally", - )); - } - let reference = request.build.as_ref().ok_or_else(|| { - CoreError::new( - ErrorCode::InvalidRequest, - "Missing build provider run reference", - ) - })?; - reference.validate()?; - normalize_project_directory(root, &reference.root)?; - let name = request.name.trim(); - if name.is_empty() || name.len() > 256 || name.contains('\0') { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Invalid run configuration name", - )); - } - let mut document = read_document_value(root, "run/local.json")? - .unwrap_or_else(|| json!({"version":VERSION,"configurations":[]})); - validate_version_value(&document)?; - let mut ids: std::collections::BTreeSet = - configuration_ids(&document)?.into_keys().collect(); - ids.extend(detected_ids(root)); - if let Some(other) = read_document_value(root, "run/configurations.json")? { - validate_version_value(&other)?; - ids.extend(configuration_ids(&other)?.into_keys()); - } - let encoded = serde_json::to_value(reference).expect("reference encodes"); - let mut identity = encoded.clone(); - identity.as_object_mut().unwrap().remove("userArguments"); - identity.as_object_mut().unwrap().remove("execution"); - let items = document["configurations"] - .as_array_mut() - .ok_or_else(|| CoreError::new(ErrorCode::ParseFailed, "configurations must be an array"))?; - let existing = items.iter().position(|item| { - if item["provider"] != "build.provider" { - return false; - } - let mut saved = item["extensions"]["build"].clone(); - if let Some(object) = saved.as_object_mut() { - object.remove("userArguments"); - object.remove("execution"); - } - saved == identity - }); - let id = existing - .and_then(|index| items[index]["id"].as_str()) - .map(str::to_string) - .unwrap_or_else(|| unique_user_configuration_id(name, &ids)); - let mut configuration = existing - .map(|index| items[index].clone()) - .unwrap_or_else(|| json!({})); - let object = configuration.as_object_mut().ok_or_else(|| { - CoreError::new( - ErrorCode::ParseFailed, - "Run configuration must be an object", - ) - })?; - let managed = json!({"id":id,"name":name,"provider":"build.provider","execution":reference.execution.unwrap_or_default(), - "confidence":"declared"}); - object.entry("cwd").or_insert_with(|| json!(reference.root)); - object.extend( - managed - .as_object() - .expect("managed fields are an object") - .clone(), - ); - // Other namespaces may hold user/plugin metadata; replacing the whole - // entry would erase data unrelated to this selector update. - let extensions = object - .entry("extensions") - .or_insert_with(|| json!({})) - .as_object_mut() - .ok_or_else(|| { - CoreError::new( - ErrorCode::ParseFailed, - "Run configuration extensions must be an object", - ) - })?; - extensions.insert("build".to_string(), encoded); - let typed: RunConfiguration = serde_json::from_value(configuration.clone()) - .map_err(|_| CoreError::new(ErrorCode::ParseFailed, "Invalid saved run configuration"))?; - validate_configuration(&typed)?; - if let Some(index) = existing { - items[index] = configuration; - } else { - items.push(configuration); - } - items.sort_by(|a, b| a["id"].as_str().cmp(&b["id"].as_str())); - Ok( - json!({"id":id,"document":serde_json::to_string_pretty(&document).expect("document encodes")}), - ) -} - -/// Creates a user configuration while preserving stable IDs in existing layers. -/// -/// Only build provider references can be authored: every other runnable entry -/// is declared by the plugin that owns its launch semantics. -pub fn create_user_configuration( - request: CreateUserConfigurationRequest, -) -> Result { - let root = existing_root(&request.root)?; - if request.kind == "buildProvider" { - return create_build_provider_configuration(&root, &request); - } - Err(CoreError::new( - ErrorCode::NotSupported, - "Only build provider configurations can be created", - )) -} - -/// Extracts existing Run document values; framework goals and runtime semantics stay in providers. -pub fn provider_launch_inputs(request: ProviderLaunchInputsRequest) -> Result { - let root = existing_root(&request.root)?; - if invalid_relative_path(&request.provider_root) - || !project_directory_exists(&root, &request.provider_root) - { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "The provider instance leaves the workspace.", - )); - } - let explicit_cwd = configuration_override_has_key( - &root, - request.local_document.as_ref(), - &request.configuration_id, - "cwd", - )?; - let explicit_arguments = configuration_override_has_key( - &root, - request.local_document.as_ref(), - &request.configuration_id, - "args", - )?; - let resolved = resolve(ResolveRequest { - team_document: None, - root: request.root, - toolchain_candidates: Vec::new(), - local_document: request.local_document, - })?; - let config = resolved["configurations"] - .as_array() - .and_then(|items| { - items - .iter() - .find(|value| value["id"] == request.configuration_id) - }) - .ok_or_else(|| { - CoreError::new( - ErrorCode::InvalidRequest, - "Run configuration is unavailable.", - ) - })?; - if config["provider"] == "build.provider" { - let reference: BuildProviderRunReference = - serde_json::from_value(config["extensions"]["build"].clone()).map_err(|_| { - CoreError::new( - ErrorCode::ParseFailed, - "Invalid build provider run reference", - ) - })?; - reference.validate()?; - if reference.root != request.provider_root || config["disabled"] == true { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Run configuration belongs to a different provider instance or is disabled.", - )); - } - let arguments = if explicit_arguments { - config["args"].clone() - } else { - json!(reference.user_arguments) - }; - let choices = super::provider_run_options::BuildProviderRunOptions::from_extensions( - &serde_json::from_value(config["extensions"].clone()).map_err(|_| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid provider run options") - })?, - )?; - let mut options = json!({"execution":reference.execution.unwrap_or_default(),"runtimeArguments":choices.runtime_arguments, - "arguments":arguments,"environment":config["env"]}); - if !choices.entrypoint.is_empty() { - options["entrypoint"] = json!(choices.entrypoint); - } - // Saving an older selector materializes its root as cwd. Keep that - // default opaque for v4 applications; only an actual override is v10. - if explicit_cwd - && (config["cwd"] != reference.root - || request.configuration_id == reference.generated_id()) - { - options["workingDirectory"] = config["cwd"].clone(); - } - return Ok( - json!({"buildSystemID":reference.build_system_id,"provider":reference.run,"projectPath":reference.root, - "launchOptions":options,"optionOverrides":choices.options,"tools":choices.active_tools(),"reference":reference}), - ); - } - Err(CoreError::new( - ErrorCode::InvalidRequest, - "Run configuration has no provider-owned launch inputs.", - )) -} - -/// Resolves one configuration into the exact executable, arguments, and environment. -/// -/// Core only assembles process-based plans: a detector or provider either named -/// the command, or transferred executable ownership to a runtime toolchain. -/// Ecosystem launch semantics stay in the plugin that declared the run. -pub fn create_launch_plan(request: LaunchPlanRequest) -> Result { - existing_root(&request.root)?; - let resolved = resolve(ResolveRequest { - team_document: None, - root: request.root, - toolchain_candidates: Vec::new(), - local_document: request.local_document, - })?; - let config = resolved["configurations"] - .as_array() - .and_then(|items| items.iter().find(|v| v["id"] == request.configuration_id)) - .ok_or_else(|| { - CoreError::new(ErrorCode::InvalidRequest, "Run configuration was not found") - })?; - if config["disabled"].as_bool().unwrap_or(false) { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Run configuration is disabled", - )); - } - if let Some(command) = config["command"].as_str().filter(|value| !value.is_empty()) { - return process_launch_plan(config, command); - } - if let Some(toolchain) = config["toolchains"]["runtime"] - .as_str() - .filter(|value| !value.is_empty()) - { - return toolchain_process_launch_plan(config, toolchain); - } - Err(CoreError::new( - ErrorCode::InvalidRequest, - "Run provider must declare a command or runtime toolchain", - ) - .with_details(config["provider"].as_str().unwrap_or(""))) -} - -/// Returns whether a user-owned layer explicitly sets one configuration key. -/// -/// Generated `cwd: "."` is the schema default and may inherit the provider -/// root. A project or local `cwd: "."`, however, is an intentional override -/// and must keep the workspace root. -fn configuration_override_has_key( - root: &Path, - provided_local: Option<&Value>, - configuration_id: &str, - key: &str, -) -> Result { - let team = read_document_value(root, "run/configurations.json")? - .unwrap_or_else(|| json!({"version": VERSION, "configurations": []})); - let local = local_layer_document(root, provided_local.cloned())?; - for document in [&team, &local] { - validate_version_value(document)?; - if document["configurations"] - .as_array() - .and_then(|items| items.iter().find(|item| item["id"] == configuration_id)) - .is_some_and(|item| item.get(key).is_some()) - { - return Ok(true); - } - } - Ok(false) -} - -/// Launch plan for configurations that name their own executable. -/// -/// `executable.command` is a bare program name resolved on PATH by the host, as -/// opposed to `executable.toolchain` which the host resolves from its toolchain -/// registry. The two are mutually exclusive and the host branches on which key -/// is present. No toolchain home variable is injected: the plan carries only -/// the configuration's own environment. -fn process_launch_plan(config: &Value, command: &str) -> Result { - if command.contains('/') || command.contains('\\') { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Run configuration command must be a bare program name", - ) - .with_details(command)); - } - let arguments = config["args"].as_array().cloned().unwrap_or_default(); - if arguments.iter().any(|value| !value.is_string()) { - return Err(CoreError::new( - ErrorCode::ParseFailed, - "Run configuration arguments must be strings", - )); - } - Ok(json!({ - "executable": { "command": command }, - "arguments": arguments, - "workingDirectory": config["cwd"].as_str().unwrap_or("."), - // `environment` is reserved for toolchain-derived values the host must - // resolve. Literal key/value pairs stay in `env` so the two never - // collide in one field. - "environment": {}, - "env": config["env"].as_object().cloned().unwrap_or_default() - })) -} - -/// Launch plan for a language provider whose executable is supplied by the -/// host Toolchain Registry. The shared contract uses the stable `runtime` key; -/// provider-specific SDK metadata stays in other toolchain/extension entries. -fn toolchain_process_launch_plan(config: &Value, toolchain: &str) -> Result { - let arguments = config["args"].as_array().cloned().unwrap_or_default(); - if arguments.iter().any(|value| !value.is_string()) { - return Err(CoreError::new( - ErrorCode::ParseFailed, - "Run configuration arguments must be strings", - )); - } - Ok(json!({ - "executable": { "toolchain": toolchain }, - "arguments": arguments, - "workingDirectory": config["cwd"].as_str().unwrap_or("."), - "environment": {}, - "env": config["env"].as_object().cloned().unwrap_or_default() - })) -} - -fn validate_version_value(document: &Value) -> Result<(), CoreError> { - let version = document.get("version").and_then(Value::as_u64).unwrap_or(0) as u32; - validate_version(version)?; - validate_tool_preferences(document) -} - -fn validate_tool_preferences(document: &Value) -> Result<(), CoreError> { - if let Some(preferences) = document - .get("toolchain") - .and_then(|value| value.get("preferences")) - { - crate::toolchain::preferences::validate(preferences) - .map_err(|error| CoreError::new(ErrorCode::InvalidRequest, error.message))?; - } - Ok(()) -} - -fn validate_version(version: u32) -> Result<(), CoreError> { - if version != VERSION { - return Err(CoreError::new( - ErrorCode::NotSupported, - "Unsupported run configuration version", - ) - .with_details(format!("expected {VERSION}, found {version}"))); - } - Ok(()) -} - -/// Rejects a sidecar document written for a version this Core does not read. -pub fn validate_sidecar_version(version: u32) -> Result<(), CoreError> { - if version != SIDECAR_VERSION { - return Err( - CoreError::new(ErrorCode::NotSupported, "Unsupported document version") - .with_details(format!("expected {SIDECAR_VERSION}, found {version}")), - ); - } - Ok(()) -} - -fn validate_sidecar_version_value(document: &Value) -> Result<(), CoreError> { - let version = document.get("version").and_then(Value::as_u64).unwrap_or(0) as u32; - validate_sidecar_version(version) -} - -fn merge_values( - base: &Value, - team: &Value, - local: &Value, -) -> Result, CoreError> { - let mut result: BTreeMap = BTreeMap::new(); - let mut saved = BTreeMap::::new(); - let mut complete_ids = BTreeSet::new(); - for source in [team, local] { - for item in source["configurations"].as_array().into_iter().flatten() { - let Some(id) = item["id"].as_str() else { - continue; - }; - if super::provider_runs::is_explicit(item) { - complete_ids.insert(id.to_string()); - } - if let Some(previous) = saved.get_mut(id) { - merge_configuration_value(previous, item)?; - } else { - saved.insert(id.to_string(), item.clone()); - } - } - } - let explicit_selectors = saved - .values() - .filter(|item| { - super::provider_runs::is_explicit(item) - && item["id"] - .as_str() - .is_some_and(|id| complete_ids.contains(id)) - }) - .filter_map(super::provider_runs::selector) - .collect::>(); - for (source_index, source) in [base, team, local].into_iter().enumerate() { - let Some(items) = source.get("configurations").and_then(Value::as_array) else { - return Err(CoreError::new( - ErrorCode::ParseFailed, - "configurations must be an array", - )); - }; - for item in items { - let Some(id) = item.get("id").and_then(Value::as_str) else { - return Err(CoreError::new( - ErrorCode::ParseFailed, - "Run configuration id is required", - )); - }; - // A complete saved selector supersedes only its automatically generated - // counterpart. Distinct user configurations for that target remain distinct. - if source_index == 0 - && super::provider_runs::selector(item) - .is_some_and(|selector| explicit_selectors.contains(&selector)) - { - continue; - } - if source_index > 0 - && !result.contains_key(id) - && !id.starts_with("user:") - && !super::provider_runs::is_explicit(item) - { - continue; - } - if let Some(existing) = result.get_mut(id) { - merge_configuration_value(existing, item)?; - let target = existing - .as_object_mut() - .expect("merged configuration is an object"); - target.insert( - "source".to_string(), - json!(["generated", "project", "local"][source_index]), - ); - } else { - let mut value = item.clone(); - if let Some(object) = value.as_object_mut() { - object.insert( - "source".to_string(), - json!(["generated", "project", "local"][source_index]), - ); - } - result.insert(id.to_string(), value); - } - } - } - result - .into_values() - .map(|item| { - serde_json::from_value::(item).map_err(|e| { - CoreError::new(ErrorCode::ParseFailed, "Invalid merged run configuration") - .with_details(e.to_string()) - }) - }) - .collect() -} - -/// Applies one sparse configuration patch using the same rules for claim discovery and resolution. -fn merge_configuration_value(existing: &mut Value, item: &Value) -> Result<(), CoreError> { - let (Some(target), Some(patch)) = (existing.as_object_mut(), item.as_object()) else { - return Err(CoreError::new( - ErrorCode::ParseFailed, - "Run configuration must be an object", - )); - }; - for (key, value) in patch { - if key == "source" { - continue; - } - if key == "extensions" { - merge_extensions(target, &mut value.clone())?; - continue; - } - if key == "toolchains" { - let target_map = target - .entry(key.clone()) - .or_insert_with(|| json!({})) - .as_object_mut() - .ok_or_else(|| { - CoreError::new( - ErrorCode::ParseFailed, - "Run configuration toolchains must be an object", - ) - })?; - let patch_map = value.as_object().ok_or_else(|| { - CoreError::new( - ErrorCode::ParseFailed, - "Run configuration toolchains must be an object", - ) - })?; - for (toolchain_kind, toolchain_id) in patch_map { - target_map.insert(toolchain_kind.clone(), toolchain_id.clone()); - } - } else { - target.insert(key.clone(), value.clone()); - } - } - Ok(()) -} - -/// Reconciles runtime bindings from the effective command for generated v2 -/// documents written before detectors declared their consumption explicitly. -fn normalize_runtime_consumption(configurations: &mut [RunConfiguration]) { - for configuration in configurations { - let command = configuration - .command - .as_deref() - .unwrap_or("") - .to_ascii_lowercase(); - if matches!( - command.as_str(), - "npm" | "npm.cmd" | "pnpm" | "pnpm.cmd" | "yarn" | "yarn.cmd" - ) { - configuration - .toolchains - .entry("runtime".to_string()) - .or_insert_with(|| "project-node".to_string()); - } else if matches!(command.as_str(), "bun" | "bun.exe") - && configuration.toolchains.get("runtime").map(String::as_str) == Some("project-node") - { - configuration.toolchains.remove("runtime"); - } - } -} - -/// Deep-merges the `extensions` object one namespace at a time. -/// -/// A shallow insert would let a layer that touches a single `build-run` key -/// drop the whole `npm` namespace it never mentioned. -fn merge_extensions( - target: &mut serde_json::Map, - patch: &mut Value, -) -> Result<(), CoreError> { - let patch_map = patch.as_object().ok_or_else(|| { - CoreError::new( - ErrorCode::ParseFailed, - "Run configuration extensions must be an object", - ) - })?; - let target_map = target - .entry("extensions".to_string()) - .or_insert_with(|| json!({})) - .as_object_mut() - .ok_or_else(|| { - CoreError::new( - ErrorCode::ParseFailed, - "Run configuration extensions must be an object", - ) - })?; - for (namespace, value) in patch_map { - let slot = target_map - .entry(namespace.clone()) - .or_insert_with(|| json!({})); - match (slot.as_object_mut(), value.as_object()) { - (Some(existing), Some(incoming)) => { - for (key, item) in incoming { - if namespace == "build-run" && key == "tools" { - let tools = item.as_object().ok_or_else(|| { - CoreError::new( - ErrorCode::ParseFailed, - "Run tool selections must be an object", - ) - })?; - let slot = existing.entry(key.clone()).or_insert_with(|| json!({})); - let destination = slot.as_object_mut().ok_or_else(|| { - CoreError::new( - ErrorCode::ParseFailed, - "Run tool selections must be an object", - ) - })?; - destination.extend(tools.clone()); - continue; - } - existing.insert(key.clone(), item.clone()); - } - } - _ => { - *slot = value.clone(); - } - } - } - Ok(()) -} - -fn configuration_ids(document: &Value) -> Result, CoreError> { - let Some(items) = document.get("configurations").and_then(Value::as_array) else { - return Err(CoreError::new( - ErrorCode::ParseFailed, - "configurations must be an array", - )); - }; - let mut result = BTreeMap::new(); - for item in items { - let Some(id) = item.get("id").and_then(Value::as_str) else { - return Err(CoreError::new( - ErrorCode::ParseFailed, - "Run configuration id is required", - )); - }; - result.insert(id.to_string(), ()); - } - Ok(result) -} - -fn validate_configuration(configuration: &RunConfiguration) -> Result<(), CoreError> { - if configuration.id.trim().is_empty() || configuration.name.trim().is_empty() { - return Err(CoreError::new( - ErrorCode::ParseFailed, - "Run configuration id and name are required", - )); - } - if !valid_provider(&configuration.provider) { - return Err(CoreError::new( - ErrorCode::NotSupported, - "Unsupported run configuration provider", - ) - .with_details(configuration.provider.clone())); - } - if configuration.provider == "build.provider" { - let reference: BuildProviderRunReference = serde_json::from_value( - configuration - .extensions - .get("build") - .cloned() - .unwrap_or(Value::Null), - ) - .map_err(|_| { - CoreError::new( - ErrorCode::ParseFailed, - "Invalid build provider run reference", - ) - })?; - reference.validate()?; - super::provider_run_options::BuildProviderRunOptions::from_extensions( - &configuration.extensions, - )?; - if configuration.command.is_some() - || !configuration.toolchains.is_empty() - || configuration.debug.is_some() - || configuration.execution != reference.execution.unwrap_or_default() - { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Build provider configurations cannot store commands, tool bindings or debug adapters", - )); - } - } - if invalid_relative_path(&configuration.cwd) { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Run configuration contains an invalid project-relative path", - ) - .with_details(format!("workingDirectory: {}", configuration.cwd))); - } - Ok(()) -} - -fn local_layer_document(root: &Path, provided: Option) -> Result { - if let Some(mut document) = provided { - // A host-owned local layer may still carry the v1 shape, mirroring the - // migration applied to the on-disk document below. - migrate_document_value(&mut document); - validate_version_value(&document)?; - configuration_ids(&document)?; - return Ok(document); - } - Ok(read_document_value(root, "run/local.json")? - .unwrap_or_else(|| json!({"version": VERSION, "configurations": []}))) -} - -fn scope_document(scope: &str) -> Result<&'static str, CoreError> { - match scope { - "local" => Ok("run/local.json"), - "project" => Ok("run/configurations.json"), - _ => Err(CoreError::new( - ErrorCode::InvalidRequest, - "Run configuration scope must be local or project", - )), - } -} - -/// Validates the literal value of a `cwd` override before it is persisted. -/// -/// Resolution disables a configuration whose `cwd` is missing and hides it from -/// the run list, so a directory that does not exist must fail here, where the -/// editor can show the error, instead of making the saved entry disappear. -/// Values are not expanded: `${workspaceFolder}` style variables from other -/// editors would otherwise be stored as a literal directory name. -fn normalize_working_directory(root: &Path, value: &str) -> Result { - normalize_project_directory(root, value).map_err(|error| { - if value.contains('$') && !root.join(value).is_dir() { - CoreError::new( - ErrorCode::InvalidRequest, - "Working directory variables are not supported; use a path relative to the project", - ) - .with_details(value) - } else { - error - } - }) -} - -fn normalize_project_directory(root: &Path, value: &str) -> Result { - let candidate = Path::new(value); - if (!candidate.is_absolute() && invalid_relative_path(value)) - || candidate - .components() - .any(|component| matches!(component, std::path::Component::ParentDir)) - { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Project configuration paths must stay inside the project", - )); - } - let canonical_root = fs::canonicalize(root)?; - let target = if candidate.is_absolute() { - candidate.to_path_buf() - } else { - root.join(candidate) - }; - let canonical_target = fs::canonicalize(&target).map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Project configuration directory does not exist", - ) - .with_details(format!("{}: {error}", target.display())) - })?; - let relative = canonical_target - .strip_prefix(&canonical_root) - .map_err(|_| { - CoreError::new( - ErrorCode::InvalidRequest, - "Project configuration paths must stay inside the project", - ) - })?; - if relative.as_os_str().is_empty() { - Ok(".".to_string()) - } else { - Ok(relative.to_string_lossy().replace('\\', "/")) - } -} - -fn project_directory_exists(root: &Path, value: &str) -> bool { - if invalid_relative_path(value) { - return false; - } - let Ok(canonical_root) = fs::canonicalize(root) else { - return false; - }; - let Ok(canonical_target) = fs::canonicalize(root.join(value)) else { - return false; - }; - canonical_target.is_dir() && canonical_target.starts_with(canonical_root) -} - -fn unique_user_configuration_id( - name: &str, - existing_ids: &std::collections::BTreeSet, -) -> String { - let mut slug = String::new(); - let mut separator = false; - for character in name.to_lowercase().chars() { - if character.is_alphanumeric() { - slug.push(character); - separator = false; - } else if !slug.is_empty() { - separator = true; - } - if separator && !slug.ends_with('-') { - slug.push('-'); - } - } - let slug = slug.trim_matches('-'); - let base = format!( - "user:{}", - if slug.is_empty() { - "configuration" - } else { - slug - } - ); - if !existing_ids.contains(&base) { - return base; - } - let mut suffix = 2; - loop { - let candidate = format!("{base}-{suffix}"); - if !existing_ids.contains(&candidate) { - return candidate; - } - suffix += 1; - } -} - -fn split_arguments(input: &str) -> Vec { - let mut result = Vec::new(); - let mut current = String::new(); - let mut started = false; - let mut quote = None; - let mut escaped = false; - for character in input.chars() { - // Quoting can deliberately start an empty argv item. - if !character.is_whitespace() || quote.is_some() || escaped { - started = true; - } - if escaped { - current.push(character); - escaped = false; - } else if character == '\\' && quote != Some('\'') { - escaped = true; - } else if matches!(character, '\'' | '"') { - if quote == Some(character) { - quote = None; - } else if quote.is_none() { - quote = Some(character); - } else { - current.push(character); - } - } else if character.is_whitespace() && quote.is_none() { - if started { - result.push(std::mem::take(&mut current)); - started = false; - } - } else { - current.push(character); - } - } - if escaped { - current.push('\\'); - } - if started { - result.push(current); - } - result -} - -fn read_document_value(root: &Path, relative: &str) -> Result, CoreError> { - let path = root.join(".lithe").join(relative); - if !path.exists() { - return Ok(None); - } - let text = fs::read_to_string(&path).map_err(|e| { - CoreError::new( - ErrorCode::PermissionDenied, - "Could not read run configuration", - ) - .with_details(e.to_string()) - })?; - let mut value: Value = serde_json::from_str(&text).map_err(|e| { - CoreError::new( - ErrorCode::ParseFailed, - format!("Configuration JSON is invalid: .lithe/{relative}"), - ) - .with_details(e.to_string()) - })?; - // Only run/* documents carry the run-configuration schema. project.json and - // the toolchain files share the .lithe root and a version field, but their - // schema is unrelated -- migrating them would bump a version they never own. - if relative.starts_with("run/") { - migrate_document_value(&mut value); - } - Ok(Some(value)) -} - -/// Ids of the configurations this session detected for `root`; a user -/// configuration must not reuse one. -fn detected_ids(root: &Path) -> BTreeSet { - generated_layer::get(root) - .map(|layer| { - layer - .document - .configurations - .iter() - .map(|configuration| configuration.id.clone()) - .collect() - }) - .unwrap_or_default() -} - -/// Toolchain requirements of `root`: those of this session's latest -/// generation, else those derived from the project's own declarations, so a -/// runtime can be checked before the Run list has been generated. -pub fn read_requirements(root: &Path) -> Result, CoreError> { - if let Some(layer) = generated_layer::get(root) { - return Ok(Some(layer.requirements.clone())); - } - if !root.is_dir() { - return Ok(None); - } - detect_requirements(root, &[]).map(Some) -} - -fn read_local_toolchains(root: &Path) -> Result, CoreError> { - let Some(document) = read_document_value(root, "toolchains/local.json")? else { - return Ok(None); - }; - validate_sidecar_version_value(&document)?; - if document - .get("toolchains") - .and_then(Value::as_object) - .is_none() - { - return Err(CoreError::new( - ErrorCode::ParseFailed, - "Local toolchains must contain a toolchains object", - )); - } - Ok(Some(document)) -} - -/// Only roles a Run actually consumes become requirements; Core reads the -/// generic declarations it understands and leaves tool-specific version files to -/// the plugin that owns the toolchain. -fn detect_requirements( - root: &Path, - configurations: &[RunConfiguration], -) -> Result { - let mut toolchains = BTreeMap::new(); - let consumes = |toolchain: &str| { - configurations.iter().any(|configuration| { - configuration - .toolchains - .values() - .any(|candidate| candidate == toolchain) - }) - }; - if consumes("project-node") { - toolchains.insert( - "project-node".to_string(), - generic_requirement("node", declared_node_version(root)), - ); - } - Ok(ToolchainRequirementsDocument { - version: SIDECAR_VERSION, - toolchains, - }) -} - -fn generic_requirement(kind: &str, minimum_version: Option) -> ToolchainRequirement { - ToolchainRequirement { - kind: kind.to_string(), - minimum_version, - preferred_vendor: None, - version: None, - } -} - -fn toolchain_diagnostics( - root: &Path, - candidates: &[ToolchainCandidate], - configurations: &[RunConfiguration], -) -> Result, CoreError> { - let Some(requirements) = read_requirements(root)? else { - return Ok(Vec::new()); - }; - validate_sidecar_version(requirements.version)?; - let mut diagnostics = Vec::new(); - for (id, requirement) in requirements.toolchains { - let mut consumer_ids = configurations - .iter() - .filter(|configuration| { - configuration - .toolchains - .values() - .any(|toolchain| toolchain == &id) - }) - .map(|configuration| configuration.id.clone()) - .collect::>(); - consumer_ids.sort(); - consumer_ids.dedup(); - let Some(candidate) = candidates - .iter() - .find(|candidate| candidate.id == id && candidate.kind == requirement.kind) - else { - append_toolchain_diagnostics( - &mut diagnostics, - &consumer_ids, - json!({ - "code": "missingToolchain", - "toolchain": id, - "message": format!("No local {} toolchain is selected", requirement.kind) - }), - ); - continue; - }; - let required_version = requirement - .minimum_version - .as_deref() - .or(requirement.version.as_deref()); - if let Some(required) = required_version { - // `version` is an exact prefix match; only `minimumVersion` accepts - // newer releases. - let treat_as_minimum = requirement.minimum_version.is_some(); - if !version_satisfies(&candidate.version, required, treat_as_minimum) { - append_toolchain_diagnostics( - &mut diagnostics, - &consumer_ids, - json!({ - "code": "toolchainVersionMismatch", - "toolchain": id, - "message": format!( - "{} {} does not satisfy required version {}", - requirement.kind, candidate.version, required - ) - }), - ); - } - } - if let Some(vendor) = requirement.preferred_vendor.as_deref() { - if !candidate - .vendor - .to_lowercase() - .contains(&vendor.to_lowercase()) - { - append_toolchain_diagnostics( - &mut diagnostics, - &consumer_ids, - json!({ - "code": "toolchainVendorMismatch", - "toolchain": id, - "message": format!("Preferred {} vendor is {vendor}", requirement.kind) - }), - ); - } - } - } - Ok(diagnostics) -} - -fn append_toolchain_diagnostics( - diagnostics: &mut Vec, - consumer_ids: &[String], - diagnostic: Value, -) { - if consumer_ids.is_empty() { - return; - } - for configuration_id in consumer_ids { - let mut scoped = diagnostic.clone(); - scoped["id"] = json!(configuration_id); - diagnostics.push(scoped); - } -} - -/// Compares numeric version components. A minimum accepts any later version; -/// otherwise `required` must be a component prefix of `actual` (`20` matches -/// `20.11.1`). Tool-specific version schemes are normalized by their owner first. -pub fn version_satisfies(actual: &str, required: &str, minimum: bool) -> bool { - let actual_parts = version_parts(actual); - let required_parts = version_parts(required); - if actual_parts.is_empty() || required_parts.is_empty() { - return false; - } - if minimum { - actual_parts >= required_parts - } else { - actual_parts.starts_with(&required_parts) - } -} - -/// Splits a version into its numeric parts, ignoring separators and suffixes. -/// -/// An input with no digits yields an empty list, which callers treat as "not a -/// version" rather than as zero. -pub fn version_parts(value: &str) -> Vec { - value - .split(|character: char| !character.is_ascii_digit()) - .filter(|part| !part.is_empty()) - .filter_map(|part| part.parse().ok()) - .collect() -} - -/// Collects generation inputs; every input is hashed by content. -struct ProjectInputs { - inputs: BTreeMap, - unreadable_paths: BTreeSet, -} - -// Retention is independent of filesystem permissions so both hosts preserve the -// same last-known configuration when an optional subtree becomes unreadable. -fn retain_unknown_inputs( - configurations: &mut Vec, - snapshot: &mut ProjectInputs, - previous: RunConfigurationDocument, -) { - for configuration in previous.configurations { - let source = configuration.source.as_deref(); - if source.is_some_and(|path| under_unknown(path, &snapshot.unreadable_paths)) - && !configurations - .iter() - .any(|current| current.id == configuration.id) - { - configurations.push(configuration); - } - } - if let Some(metadata) = previous.generator { - for (path, hash) in metadata.inputs { - if under_unknown(&path, &snapshot.unreadable_paths) { - snapshot.inputs.entry(path).or_insert(hash); - } - } - } -} - -fn under_unknown(path: &str, unknown: &BTreeSet) -> bool { - unknown - .iter() - .any(|prefix| path == prefix || path.starts_with(&format!("{prefix}/"))) -} - -fn incomplete_scan_diagnostic(paths: &BTreeSet) -> Value { - json!({"code": "incompleteProjectScan", "message": format!( - "Project scan is incomplete; freshness and removals could not be verified. Unreadable inputs: {}", - paths.iter().cloned().collect::>().join(", "))}) -} - -fn project_inputs(root: &Path) -> Result { - let scan = super::workspace_scan::scan_run_workspace(root, None)?; - let mut result = ProjectInputs { - inputs: BTreeMap::new(), - unreadable_paths: scan.unreadable_paths, - }; - for directory in scan.directories { - for name in directory.files { - let path = directory.path.join(name); - if !fingerprint_input(&path) { - continue; - } - let relative = path - .strip_prefix(root) - .expect("scan stays within root") - .to_string_lossy() - .replace('\\', "/"); - crate::protocol::cancellation::check()?; - // The scan lists files through the workspace adapter, so content reads - // must use the same adapter or remote projects look permanently unreadable. - match crate::remote::workspace_io::read(&path) { - Ok(bytes) => { - result - .inputs - .insert(relative, format!("sha256:{:x}", Sha256::digest(bytes))); - } - Err(_) => { - result.unreadable_paths.insert(relative); - } - } - } - } - Ok(result) -} - -fn fingerprint_from_inputs(inputs: &BTreeMap) -> String { - let mut digest = Sha256::new(); - // Detection changes invalidate persisted output even when project files are - // unchanged, so an application upgrade cannot keep launching a stale plan. - digest.update(GENERATOR_REVISION.as_bytes()); - digest.update([0]); - for (relative, content_hash) in inputs { - digest.update(relative.as_bytes()); - digest.update([0]); - digest.update(content_hash.as_bytes()); - digest.update([0]); - } - format!("sha256:{:x}", digest.finalize()) -} - -fn input_change_summary( - previous: &BTreeMap, - current: &BTreeMap, -) -> String { - let added = current - .keys() - .filter(|path| !previous.contains_key(*path)) - .count(); - let removed = previous - .keys() - .filter(|path| !current.contains_key(*path)) - .count(); - let changed = current - .iter() - .filter(|(path, hash)| previous.get(*path).is_some_and(|old| old != *hash)) - .count(); - format!("Project inputs changed: {added} added, {removed} removed, {changed} modified") -} - -fn fingerprint_input(path: &Path) -> bool { - matches!( - path.file_name().and_then(|name| name.to_str()), - Some( - "mise.toml" - | "package.json" - | "package-lock.json" - | "pnpm-lock.yaml" - | "yarn.lock" - | "bun.lock" - | "bun.lockb" - | "docker-compose.yml" - | "docker-compose.yaml" - | "compose.yml" - | "compose.yaml" - | "pyproject.toml" - | "manage.py" - | "Cargo.toml" - | "rust-toolchain" - | "rust-toolchain.toml" - | "go.mod" - | "Procfile" - | "Procfile.dev" - | "Procfile.local" - | "Makefile" - | "makefile" - | "GNUmakefile" - | "justfile" - | "Justfile" - | ".justfile" - ) - ) -} - -fn declared_node_version(root: &Path) -> Option { - highest_version( - project_manifest_paths(root, &["package.json"]) - .into_iter() - .filter_map(|path| crate::remote::workspace_io::read_to_string(path).ok()) - .filter_map(|text| serde_json::from_str::(&text).ok()) - .filter_map(|document| { - document - .get("engines")? - .get("node")? - .as_str() - .and_then(first_numeric_version) - }) - .collect(), - ) -} - -fn first_numeric_version(value: &str) -> Option { - regex::Regex::new(r"[0-9]+(?:\.[0-9]+)+") - .ok()? - .find(value) - .map(|value| value.as_str().to_string()) -} - -fn highest_version(versions: Vec) -> Option { - versions - .into_iter() - .max_by(|left, right| version_parts(left).cmp(&version_parts(right))) -} - -fn project_manifest_paths(root: &Path, names: &[&str]) -> Vec { - // This heuristic only supplements declared toolchain requirements. Fingerprint - // scanning separately reports unknown subtrees to both product hosts. - let Ok(scan) = super::workspace_scan::scan_run_workspace(root, None) else { - return Vec::new(); - }; - scan.directories - .into_iter() - .flat_map(|directory| { - directory - .files - .into_iter() - .filter(|name| names.contains(&name.as_str())) - .map(move |name| directory.path.join(name)) - }) - .collect() -} - -fn existing_root(value: &str) -> Result { - let path = PathBuf::from(value); - if !path.is_dir() { - return Err(CoreError::new( - ErrorCode::WorkspaceNotFound, - "Project root does not exist", - )); - } - Ok(path) -} - -#[allow(dead_code)] -fn valid_relative(value: &str) -> bool { - !invalid_relative_path(value) -} - -/// `namespace.name`, both segments lowercase kebab starting with a letter. -/// Shape is checked, membership is not: an unknown ecosystem must be able to -/// register a provider without touching this file. -fn valid_provider(value: &str) -> bool { - let Some((namespace, name)) = value.split_once('.') else { - return false; - }; - [namespace, name].iter().all(|segment| { - segment - .chars() - .next() - .is_some_and(|first| first.is_ascii_lowercase()) - && segment - .chars() - .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') - }) -} - -#[cfg(test)] -mod incomplete_scan_tests { - use super::*; - - #[test] - fn unknown_inputs_retain_only_affected_configurations_and_hashes() { - let previous: RunConfigurationDocument = serde_json::from_value(json!({ - "version": 2, - "generator": {"fingerprint": "old", "inputs": { - "blocked/package.json": "old-blocked", "removed/package.json": "old-removed" - }}, - "configurations": [ - {"id":"blocked", "name":"Blocked", "provider":"npm.script", "source":"blocked/package.json"}, - {"id":"removed", "name":"Removed", "provider":"npm.script", "source":"removed/package.json"} - ] - })).unwrap(); - let mut configurations = Vec::new(); - let mut snapshot = ProjectInputs { - inputs: BTreeMap::from([("visible/package.json".into(), "new".into())]), - unreadable_paths: BTreeSet::from(["blocked".into()]), - }; - retain_unknown_inputs(&mut configurations, &mut snapshot, previous); - assert_eq!(configurations.len(), 1); - assert_eq!(configurations[0].id, "blocked"); - assert_eq!( - snapshot - .inputs - .get("blocked/package.json") - .map(String::as_str), - Some("old-blocked") - ); - assert!(!snapshot.inputs.contains_key("removed/package.json")); - assert!(!under_unknown( - "blocked-sibling/package.json", - &snapshot.unreadable_paths - )); - } -} diff --git a/rust/crates/core/src/git/mod.rs b/rust/crates/core/src/git/mod.rs deleted file mode 100644 index 798a9c6b..00000000 --- a/rust/crates/core/src/git/mod.rs +++ /dev/null @@ -1,9872 +0,0 @@ -//! Deterministic Git inspection and mutation behind the shared command contract. - -// The initial projection/routing IR is deliberately not command- or host-facing -// until both native products can consume the same versioned contract. -mod authentication_failure; -mod branch_names; -pub(crate) mod catalog; -mod change_targets; -mod changelists; -mod command_trace; -mod commit_message_context; -mod commit_selection; -mod commit_state; -mod deleted_selection; -mod diff_blocks; -pub(crate) mod workspace_commit; -pub(crate) mod workspace_status; -pub use commit_state::{inspect as commit_state, GitCommitGitlink, GitCommitState}; - -pub(crate) mod configuration; -pub(crate) mod console; -pub(crate) mod execution_events; -pub(crate) mod execution_policy; -mod fetch; -mod fetch_execution; -pub(crate) mod file_context; -mod github_checkout; -mod graph; -mod history; -mod history_query; -mod history_window; -mod metadata_watch; -mod mutations; -mod patch_exchange; -mod progress; -mod rebase_session; -mod reference_tree; -mod remote_history; -mod remote_process; -mod repository_colors; -mod rewrite; -mod rollback; -mod setup; -mod shelf; -mod untracked_directories; -mod update_plan; - -// The response and reference types stay owned by the module; callers consume -// them through the command contract instead of naming more shared types. -pub use branch_names::{preview as branch_name_preview, GitBranchNamePreviewRequest}; -pub use fetch::{plan as fetch_plan, GitFetchOptions, GitFetchPlanRequest}; -// Re-exported for the cfg(test) integration tests only; production code reads the -// threshold inside `untracked_directories` itself. -#[cfg(test)] -pub(crate) use untracked_directories::UNTRACKED_DIRECTORY_SUMMARY_THRESHOLD; -pub use untracked_directories::{untracked_directory, GitUntrackedDirectoryRequest}; - -pub use setup::{ - configure_identity, initialize as initialize_repository, inspect as repository_setup, - GitConfigureIdentityRequest, GitSetupRequest, -}; - -pub(crate) use change_targets::command as normalize_change_targets_command; -pub(crate) use changelists::changelists_command; -pub(crate) use commit_message_context::commit_message_context_command; -pub(crate) use commit_selection::prepare_commit_selection_command; -pub(crate) use deleted_selection::reconcile_deleted_selection_command; -#[cfg(test)] -pub(crate) use diff_blocks::fixture_support as diff_blocks_fixture; -pub(crate) use diff_blocks::{ - apply_diff_block_command, plan_diff_blocks_command, stage_gutter_change_command, -}; -pub use github_checkout::{ - checkout as checkout_pull_request, preview as checkout_pull_request_preview, - PullRequestCheckoutPreviewRequest, PullRequestCheckoutRequest, -}; -pub(crate) use graph::command as graph_projection_command; -pub use history::{ - close_history_cursor, history, history_locate, history_page, references, - GitHistoryCursorCloseRequest, GitHistoryLocateRequest, GitHistoryPageRequest, - GitHistoryRequest, GitReferencesRequest, -}; -pub(crate) use history_query::query_command as history_query_command; -pub(crate) use history_window::command as history_window_command; -pub(crate) use metadata_watch::classify_command as metadata_classification_command; -pub use metadata_watch::{ - classify_git_metadata_change, GitMetadataChangeKind, GitMetadataEventClassification, - GitMetadataWatchContext, -}; -pub use patch_exchange::{ - apply_reviewed as apply_patch_reviewed, export as export_patch, preview as preview_patch, - PatchApplyRequest, PatchExportRequest, PatchPreviewRequest, -}; -pub(crate) use rebase_session::plan_preview_command as rebase_plan_preview_command; -pub use rebase_session::{ - control as rebase_control, preview as rebase_preview, session as rebase_session, - start as rebase_start, GitRebaseControlRequest, GitRebasePreviewRequest, - GitRebaseSessionRequest, GitRebaseStartRequest, -}; -pub(crate) use reference_tree::command as reference_tree_command; -pub(crate) use repository_colors::assign_command as repository_color_assignments_command; -pub(crate) use rollback::{rollback_plan_command, rollback_prepare_command}; -pub(crate) use shelf::shelf_command; -pub(crate) use update_plan::command as update_plan_command; - -pub(crate) use rewrite::history_action_capabilities_command; -pub use rewrite::{ - history_rewrite_preview, GitHistoryRewriteExpectation, GitHistoryRewritePreviewRequest, - GitHistoryRewriteResult, -}; - -use crate::protocol::{CoreError, ErrorCode}; -use crate::protocol::{ - GitBlameLineResponse, GitBlameResponse, GitChange, GitCheckoutPreflightResponse, - GitCommitLookupResponse, GitCommitResponse, GitComparisonResponse, GitConflictMarkerResponse, - GitDiffFileResponse, GitDiffHunkResponse, GitDiffResponse, GitDiffRowResponse, - GitDiffUnifiedLineResponse, GitFileResponse, GitFilesResponse, GitIntegrationPreflightResponse, - GitLineHistoryCommit, GitLineHistoryRange, GitLineHistoryResponse, GitLineHistoryUnmappedLine, - GitOperationStateResponse, GitPullPreflightResponse, GitPushPreviewResponse, - GitPushTagResponse, GitPushTargetKind, GitReferenceResponse, GitStashResponse, - GitStashesResponse, GitStatusResponse, GitWatchContextResponse, GitWorktreeResponse, - GitWorktreesResponse, WorkspaceRepositoriesResponse, WorkspaceRepositoryResponse, -}; -use crate::remote::workspace_io as fs; -use serde::{Deserialize, Serialize}; -use std::cell::RefCell; -use std::collections::{HashSet, VecDeque}; -use std::io::Read; -#[cfg(target_os = "windows")] -use std::os::windows::process::CommandExt; -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::thread; -use std::time::Duration; - -const DEFAULT_PUSH_PREVIEW_LIMIT: usize = 500; -const INTERNAL_REF_PREFIX: &str = "refs/lithe/"; -/// Commit date argument for every `parse_commit` producer. The trailing `%z` -/// carries the author's UTC offset, which `parse_commit` splits off so the -/// displayed `date` keeps its `%Y/%m/%d %H:%M` shape. -const GIT_COMMIT_DATE_ARGUMENT: &str = "--date=format:%Y/%m/%d %H:%M %z"; -const DEFAULT_REPOSITORY_SCAN_MAX_DIRECTORIES: usize = usize::MAX; -const DEFAULT_REPOSITORY_SCAN_MAX_DEPTH: usize = usize::MAX; -const FULL_FILE_CONTEXT_LINES: usize = 1_073_741_823; -static TEMPORARY_INDEX_SEQUENCE: AtomicU64 = AtomicU64::new(0); -static AUTO_STASH_SEQUENCE: AtomicU64 = AtomicU64::new(0); - -fn append_full_file_context_arguments(arguments: &mut Vec, context_lines: usize) { - if context_lines >= FULL_FILE_CONTEXT_LINES { - // Git xdiff combines nearby hunks using this value. Pin it to zero for - // whole-file reviews so user configuration cannot reintroduce the - // signed 32-bit overflow present in older Git for Windows releases. - arguments.push("--inter-hunk-context=0".into()); - } -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Request to discover Git repositories belonging to one opened workspace. -pub struct WorkspaceRepositoriesRequest { - pub root: String, - /// Optional traversal budget; defaults to all eligible directories below the workspace. - #[serde(default = "default_repository_scan_max_directories")] - pub max_directories: usize, - /// Optional traversal depth; defaults to the complete eligible workspace tree. - #[serde(default = "default_repository_scan_max_depth")] - pub max_depth: usize, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Request for a deterministic porcelain status snapshot. -pub struct GitStatusRequest { - pub root: String, - /// Staging-based UIs must retain additions deleted only from the worktree. - /// Omission preserves the Windows final-worktree projection. - #[serde(default)] - pub include_index_only_changes: bool, - /// Discovered native root bindings used to exclude independently owned untracked paths. - #[serde(default)] - pub repository_roots: Vec, - /// Report large wholly untracked directories as one entry with - /// `untrackedFileCount` instead of one entry per file. Omission preserves - /// the file-level `--untracked-files=all` projection. - #[serde(default)] - pub summarize_untracked_directories: bool, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Request for the directories and files a host watcher should observe. -pub struct GitWatchContextRequest { - pub root: String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Request for all worktrees registered in the current repository. -pub struct GitWorktreesRequest { - pub root: String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Request for branch and publication state used by pull request creation. -pub struct GitPullRequestContextRequest { - pub root: String, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -/// Worktree-aware branch defaults and publication requirements for a pull request. -pub struct GitPullRequestContextResponse { - /// Checked-out local branch, or `None` when HEAD is detached. - pub current_branch: Option, - /// Best-effort branch that should receive the pull request. - pub suggested_base_branch: Option, - /// Branch name shown when the current commit must be published first. - pub suggested_publish_branch: Option, - /// Whether GitHub cannot yet see the current local HEAD. - pub requires_publish: bool, - /// Whether the worktree has no checked-out local branch. - pub detached: bool, - /// Whether tracked or untracked working-tree changes are not part of HEAD. - pub has_uncommitted_changes: bool, -} - -/// Executes one Git operation without invoking a shell. -/// -/// The command boundary is intentionally argument-based. This keeps command -/// construction in the application layer while making process execution -/// available to every UI binding through the same Rust core. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GitCommandRequest { - pub root: String, - #[serde(default)] - pub arguments: Vec, - #[serde(default)] - pub input: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Request to resolve the repository root containing a workspace path. -pub struct GitRepositoryRootRequest { - pub root: String, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -/// One Git subprocess executed while fulfilling a shared Git command. -pub struct GitCommandInvocation { - /// Exact arguments passed to the Git executable, excluding the executable name. - pub arguments: Vec, - /// Text captured from the Git process standard output stream. - pub stdout: String, - /// Text captured from the Git process standard error stream. - pub stderr: String, - /// Exit status returned by the Git process. - pub exit_code: i32, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -/// Stable output returned by argument-based Git execution. -pub struct GitCommandResponse { - /// Exact arguments for the final Git subprocess, retained for compatibility. - pub arguments: Vec, - /// Backward-compatible concatenation of standard output followed by standard error. - pub output: String, - /// Text captured from the final Git process standard output stream. - pub stdout: String, - /// Text captured from the final Git process standard error stream. - pub stderr: String, - /// Exit status returned by the final Git process. - pub exit_code: i32, - /// Every Git subprocess executed for the operation, in execution order. - pub invocations: Vec, - /// Failure discovered after one or more subprocesses were recorded. - #[serde(skip_serializing_if = "Option::is_none")] - pub operation_error: Option, - /// Present when a stash restore kept its entry because the working tree - /// contains an unresolved merge. Keeping this out of the prose response - /// lets bindings offer recovery actions without matching localized Git - /// output. - #[serde(skip_serializing_if = "Option::is_none")] - pub stash_restore: Option, - /// Present when a tag deletion succeeded, carrying everything a host needs - /// to offer a restore without re-querying the repository. - #[serde(skip_serializing_if = "Option::is_none")] - pub tag_deletion: Option, - /// Present when a local branch deletion succeeded, carrying the commit the - /// branch pointed at so the host can offer to recreate it. - #[serde(skip_serializing_if = "Option::is_none")] - pub branch_deletion: Option, - /// Durable recovery point and authoritative outcome for a reviewed history action. - #[serde(skip_serializing_if = "Option::is_none")] - pub history_rewrite: Option, - /// Internal composite-operation guard; subsequent probes must not change its outcome. - #[serde(skip)] - outcome_authoritative: bool, - /// Non-fatal follow-up failures after the requested repository mutation succeeded. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub warnings: Vec, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -/// Stable non-fatal outcome emitted after a Git mutation has already succeeded. -pub struct GitOperationWarning { - /// Machine-readable warning category interpreted by platform presentation. - pub code: String, - /// Safe English fallback for clients without a localized presentation. - pub message: String, - /// Optional Git diagnostic retained for troubleshooting. - #[serde(skip_serializing_if = "Option::is_none")] - pub details: Option, -} - -impl GitOperationWarning { - fn new(code: &str, message: &str, details: Option) -> Self { - Self { - code: code.to_string(), - message: message.to_string(), - details, - } - } -} - -/// Raw Git process streams kept separate for machine-readable consumers. -struct GitProcessOutput { - stdout: Vec, - stderr: Vec, - exit_code: i32, -} - -impl GitProcessOutput { - fn into_command_response(self, arguments: &[String]) -> GitCommandResponse { - let stdout = String::from_utf8_lossy(&self.stdout).to_string(); - let stderr = String::from_utf8_lossy(&self.stderr).to_string(); - let invocation = GitCommandInvocation { - arguments: arguments.to_vec(), - stdout: stdout.clone(), - stderr: stderr.clone(), - exit_code: self.exit_code, - }; - let output = format!("{stdout}{stderr}"); - GitCommandResponse { - arguments: arguments.to_vec(), - output, - stdout, - stderr, - exit_code: self.exit_code, - invocations: vec![invocation], - operation_error: None, - stash_restore: None, - tag_deletion: None, - branch_deletion: None, - history_rewrite: None, - outcome_authoritative: false, - warnings: Vec::new(), - } - } -} - -thread_local! { - static GIT_INVOCATION_TRACE: RefCell>> = const { - RefCell::new(None) - }; -} - -fn with_git_invocation_trace( - operation: impl FnOnce() -> Result, -) -> Result { - let previous = GIT_INVOCATION_TRACE.with(|trace| trace.replace(Some(Vec::new()))); - let result = operation(); - let invocations = GIT_INVOCATION_TRACE - .with(|trace| trace.replace(previous)) - .unwrap_or_default(); - - match result { - Ok(mut response) => { - response.invocations = invocations; - synchronize_final_invocation(&mut response); - Ok(response) - } - Err(error) if !invocations.is_empty() => { - // A composite Git operation may complete subprocesses before a - // follow-up probe fails. Preserve those diagnostics as command data - // instead of replacing them with an empty error result. - let mut response = failed_git_result(error); - response.invocations = invocations; - synchronize_final_invocation(&mut response); - Ok(response) - } - Err(error) => Err(error), - } -} - -fn synchronize_final_invocation(response: &mut GitCommandResponse) { - // Recovery creation and post-mutation cleanup are traced subprocesses, but - // must not replace the authoritative history mutation's success or failure. - if response.history_rewrite.is_some() || response.outcome_authoritative { - return; - } - if response.exit_code == 0 && !response.warnings.is_empty() { - // The mutation already succeeded and a later reconciliation step only - // produced a warning. Keep the authoritative success summary while the - // failed follow-up remains available in `invocations` and `warnings`. - return; - } - let Some(final_invocation) = response.invocations.last() else { - return; - }; - response.arguments = final_invocation.arguments.clone(); - response.stdout = final_invocation.stdout.clone(); - response.stderr = final_invocation.stderr.clone(); - response.output = format!("{}{}", response.stdout, response.stderr); - response.exit_code = final_invocation.exit_code; -} - -fn record_git_invocation(response: &GitCommandResponse) { - let Some(invocation) = response.invocations.first().cloned() else { - return; - }; - GIT_INVOCATION_TRACE.with(|trace| { - if let Some(invocations) = trace.borrow_mut().as_mut() { - invocations.push(invocation); - } - }); -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -/// Recovery context when restoring a stash produces conflicts. -pub struct GitStashRestoreResponse { - pub stash_reference: String, - pub conflicted_paths: Vec, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -/// Deletion record that lets a host rebuild the deleted tag later. -/// -/// `deleted_target` is the commit the deleted ref resolved to (peeled for -/// annotated tags), so a restore can re-point a new tag at the same commit. -pub struct GitTagDeletionResponse { - /// Short name of the deleted tag, without the `refs/tags/` prefix. - pub name: String, - pub deleted_target: String, - /// `lightweight` or `annotated`, taken from the tag object type. - pub kind: String, - /// Annotation message; `None` only for lightweight tags. Empty annotated - /// messages remain `Some` so a restore does not change the tag form. - #[serde(skip_serializing_if = "Option::is_none")] - pub message: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -/// Deletion record that lets a host recreate the deleted local branch later. -pub struct GitBranchDeletionResponse { - /// Short branch name, without the `refs/heads/` prefix. - pub name: String, - /// Commit the deleted branch pointed at when it was removed. - pub deleted_target: String, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Complete identity of a Git reference supplied by a platform client. -pub struct GitReferenceRequest { - /// Fully qualified reference, such as `refs/remotes/origin/main`. - pub full_name: String, - /// User-facing short name, such as `origin/main`. - pub short_name: String, - /// Reference namespace: `local`, `remote`, or `tag`. - pub kind: String, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Repository snapshot that a previously reviewed push preview was based on. -pub struct GitPushExpectationRequest { - /// Local branch resolved when the preview was created. - pub local_branch: String, - /// Commit at the tip of the local branch when the preview was created. - pub local_head: String, - /// Push remote resolved from the repository configuration. - pub remote: String, - /// Destination branch name on the resolved remote. - pub remote_branch: String, - /// Destination relationship recorded by the preview. Optional so an older - /// caller that predates target kinds keeps its previous behaviour. - #[serde(default)] - pub target_kind: Option, - /// Locally observed destination OID, or `None` when the remote branch was absent. - #[serde(default)] - pub remote_tracking_oid: Option, - /// Exact reviewed tags that may be sent with the branch. - #[serde(default)] - pub tags: Vec, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// One tag identity copied from a push preview into the mutation request. -pub struct GitPushTagExpectationRequest { - /// Fully qualified tag reference under `refs/tags/`. - pub full_name: String, - /// Tag object ID observed while the preview was created. - pub object_id: String, -} - -#[derive(Debug, Default, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Typed mutation request translated into a controlled Git invocation. -pub struct GitWriteRequest { - pub root: String, - /// Stable mutation discriminator interpreted by [`write`]. - pub operation: String, - /// Optional Fetch choices; omitted values preserve the legacy scope and pruning policy. - #[serde(default)] - pub fetch_options: Option, - #[serde(default)] - pub paths: Vec, - #[serde(default)] - pub reference: Option, - /// Preferred typed reference. Legacy callers may still use `reference` and - /// `referenceKind`, but new cross-platform workflows send all identity fields. - #[serde(default)] - pub git_reference: Option, - /// Reference category used by checkout: `local`, `remote`, or `tag`. - #[serde(default)] - pub reference_kind: Option, - #[serde(default)] - pub revision: Option, - /// Commit revisions selected by an operation that rewrites a contiguous range. - #[serde(default)] - pub revisions: Vec, - #[serde(default)] - pub name: Option, - #[serde(default)] - pub message: Option, - /// `createTag`: create an explicitly lightweight tag, overriding `tag.gpgSign`. - /// Omission keeps the legacy message-based annotated/lightweight choice. - #[serde(default)] - pub lightweight: Option, - /// `createTag`: sign the annotated tag with the configured GPG key. - /// Omission keeps the legacy unsigned form. - #[serde(default)] - pub signed: Option, - #[serde(default)] - pub remote: Option, - #[serde(default)] - pub destination: Option, - /// Operation-specific strategy, such as reset mode or pull reconciliation. - #[serde(default)] - pub mode: Option, - /// Complete local branch ref for a background update after Fetch. The guarded - /// path integrates its fetched upstream without a second network wait. - /// Omission preserves interactive Pull compatibility. - #[serde(default)] - pub expected_branch: Option, - #[serde(default)] - pub include_untracked: bool, - #[serde(default)] - pub checkout: bool, - /// Worktree creation mode; omitted values retain `newBranch` compatibility. - #[serde(default)] - pub worktree_mode: Option, - /// Whether worktree creation should leave tracked files unpopulated. - /// Independent of `checkout`, which belongs to branch creation workflows. - #[serde(default)] - pub no_checkout: bool, - #[serde(default)] - pub amend: bool, - /// `commit`: the author as `Name `, in place of the configured identity. - /// The committer stays the configured identity. - #[serde(default)] - pub author: Option, - /// `commit`: append a `Signed-off-by` trailer for the committer. - #[serde(default)] - pub sign_off: bool, - /// Reviewed HEAD/index required by a planned workspace commit. - #[serde(default)] - pub expected_commit_state: Option, - /// Exact child commits to record; requires expected_commit_state. - #[serde(default)] - pub gitlink_updates: Vec, - #[serde(default)] - pub force: bool, - /// Tag scope for push: `none`, `all`, or `reachable`. - #[serde(default)] - pub push_tags: Option, - /// Optional reviewed preview snapshot that must still match before pushing. - #[serde(default)] - pub expected_push: Option, - /// Verify submodule commits are published before a workspace branch push. - #[serde(default)] - pub check_submodules: bool, - /// Destination the user explicitly confirmed. Required only when the branch - /// tracks a divergent upstream; every other destination stays implicit. - #[serde(default)] - pub push_target: Option, - #[serde(default)] - pub auto_stash: bool, - /// Mandatory immutable preview for undo, reword, squash, and drop. - #[serde(default)] - pub expected_state: Option, -} - -/// Owns an isolated Git administration directory for commits or read-only diffs. -struct TemporaryGitCommitContext { - directory: PathBuf, - index_path: PathBuf, - temporary_reference: Option, -} - -impl TemporaryGitCommitContext { - fn prepare(root: &str, head: Option<&str>) -> Result { - let common_directory = git_resolved_path( - root, - &["rev-parse", "--path-format=absolute", "--git-common-dir"], - "Git common directory", - )?; - Self::prepare_in(&common_directory, "lithe-commit", head) - } - - fn prepare_snapshot(head: Option<&str>) -> Result { - if let Some(directory) = - fs::remote_temp_directory("lithe-git-snapshot").map_err(line_history_io_error)? - { - let context = Self { - index_path: directory.join("index"), - directory, - temporary_reference: None, - }; - let contents = head - .map(|head| format!("{head}\n")) - .unwrap_or_else(|| "ref: refs/heads/lithe-snapshot\n".into()); - fs::write(context.directory.join("HEAD"), contents).map_err(line_history_io_error)?; - return Ok(context); - } - // Creating even an ignored child inside .git changes its parent directory - // on Windows. A read-only diff must not invalidate the Git watcher that - // requested it; keep its disposable index outside repository metadata. - Self::prepare_in(&std::env::temp_dir(), "lithe-git-snapshot", head) - } - - fn prepare_in(parent: &Path, prefix: &str, head: Option<&str>) -> Result { - let (directory, sequence) = loop { - let sequence = TEMPORARY_INDEX_SEQUENCE.fetch_add(1, Ordering::Relaxed); - let candidate = parent.join(format!("{prefix}-{}-{sequence}", std::process::id())); - // Public POSIX temp roots must not expose tracked paths/object IDs. - // Set permissions at atomic creation, before writing HEAD or index. - match fs::create_dir_mode(&candidate, false, Some(0o700)) { - Ok(()) => break (candidate, sequence), - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, - Err(error) => { - return Err(CoreError::new( - ErrorCode::Unknown, - "Could not create an isolated Git commit context", - ) - .with_details(error.to_string())); - } - } - }; - let temporary_reference = head.is_none().then(|| { - format!( - "refs/lithe/selected-commit-{}-{sequence}", - std::process::id() - ) - }); - let head_contents = head.map(|head| format!("{head}\n")).unwrap_or_else(|| { - format!( - "ref: {}\n", - temporary_reference - .as_deref() - .expect("an unborn repository needs a temporary reference") - ) - }); - // Establish ownership before initializing HEAD so errors also remove - // the temporary directory through Drop. - let context = Self { - index_path: directory.join("index"), - directory, - temporary_reference, - }; - fs::write(context.directory.join("HEAD"), head_contents).map_err(|error| { - CoreError::new( - ErrorCode::Unknown, - "Could not initialize an isolated Git commit context", - ) - .with_details(error.to_string()) - })?; - Ok(context) - } - - fn environment(&self, root: &str, common_directory: &Path) -> Vec<(String, String)> { - vec![ - ( - "GIT_DIR".to_string(), - self.directory.to_string_lossy().into_owned(), - ), - ( - "GIT_COMMON_DIR".to_string(), - common_directory.to_string_lossy().into_owned(), - ), - ("GIT_WORK_TREE".to_string(), root.to_string()), - ( - "GIT_INDEX_FILE".to_string(), - self.index_path.to_string_lossy().into_owned(), - ), - ] - } - - fn cleanup_reference(&self, root: &str) -> Result<(), CoreError> { - let Some(reference) = self.temporary_reference.as_ref() else { - return Ok(()); - }; - let arguments = vec!["update-ref".into(), "-d".into(), reference.clone()]; - let removed = capture_git_with_options(root, &arguments, None, false)?; - if removed.exit_code != 0 { - return Err(CoreError::new( - ErrorCode::ProcessFailed, - "Could not remove temporary Git reference", - ) - .with_details(String::from_utf8_lossy(&removed.stderr))); - } - Ok(()) - } -} - -impl Drop for TemporaryGitCommitContext { - fn drop(&mut self) { - fs::cleanup_directory(&self.directory); - } -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Request to resolve the exact destination and commits for a branch push. -pub struct GitPushPreviewRequest { - pub root: String, - #[serde(default)] - pub reference: Option, - /// Preferred complete local branch identity. - #[serde(default)] - pub git_reference: Option, - #[serde(default = "default_push_preview_limit")] - pub limit: usize, - /// Tag scope to resolve into an immutable preview: `none`, `all`, or `reachable`. - #[serde(default)] - pub push_tags: Option, - /// Destination the user picked for a divergent upstream. The preview then - /// describes that exact remote branch, so a reviewed push can be bound to it. - /// Ignored when the branch is not divergent. - #[serde(default)] - pub push_target: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Request for a structured diff suitable for side-by-side rendering. -pub struct GitDiffRequest { - pub root: String, - /// Inspect a saved stash, including its untracked parent, independently of the worktree. - #[serde(default)] - pub stash_reference: Option, - /// Return validated per-file sections instead of a flattened diff. - #[serde(default)] - pub include_file_sections: bool, - /// Omit the redundant whole-file patch when individual hunks suffice. - #[serde(default)] - pub omit_file_patch: bool, - pub pathspecs: Vec, - #[serde(default)] - pub reference: Option, - /// Compare the supplied legacy `reference` target against Git's empty tree. - /// This is used for cumulative review ranges that begin with a root commit. - #[serde(default)] - pub empty_tree_base: bool, - #[serde(default)] - pub git_reference: Option, - /// Optional typed comparison target. When present, `gitReference` is the - /// base and Core constructs the validated two-reference range. - #[serde(default)] - pub target_git_reference: Option, - #[serde(default)] - pub commit: Option, - #[serde(default)] - pub staged: bool, - #[serde(default)] - pub untracked: bool, - /// Compare HEAD with the complete worktree state that a selected-path - /// commit would stage, independently of the real index. - #[serde(default)] - pub worktree_snapshot: bool, - #[serde(default = "default_review_context_lines")] - pub context_lines: usize, - #[serde(default)] - pub ignore_all_whitespace: bool, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Request to apply a patch to the index or working tree. -pub struct GitApplyRequest { - pub root: String, - pub patch: String, - /// Patch target or validation mode, including `stage`, `unstage`, and `worktree`. - pub mode: String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Request for metadata and parent information about one commit. -pub struct GitCommitRequest { - pub root: String, - pub commit: String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Request for the paths changed by one commit. -pub struct GitCommitFilesRequest { - pub root: String, - pub commit: String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Request to compare a reference with the current checkout. -pub struct GitComparisonRequest { - pub root: String, - #[serde(default)] - pub reference: Option, - #[serde(default)] - pub git_reference: Option, - /// Optional typed target for a comparison between two references. - #[serde(default)] - pub target_git_reference: Option, - /// Include untracked worktree files; incompatible with reference-to-reference comparisons. - #[serde(default)] - pub include_untracked: bool, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Request for the repository's ordered stash list. -pub struct GitStashesRequest { - pub root: String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Request to identify local edits that would block switching references. -pub struct GitCheckoutPreflightRequest { - pub root: String, - #[serde(default)] - pub reference: Option, - #[serde(default)] - pub git_reference: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Request to find staged files that still contain conflict markers. -pub struct GitConflictMarkerRequest { - pub root: String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Request to determine whether a merge or rebase can start safely. -pub struct GitIntegrationPreflightRequest { - pub root: String, - #[serde(default)] - pub reference: Option, - #[serde(default)] - pub git_reference: Option, - /// Either "merge" or "rebase"; the two have different tolerances for a dirty tree. - pub operation: String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Request to determine whether the tracked branch can fast-forward. -pub struct GitPullPreflightRequest { - pub root: String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Request to inspect an interrupted merge, rebase, cherry-pick, or revert. -pub struct GitOperationStateRequest { - pub root: String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Request for line attribution on one workspace-relative file. -pub struct GitBlameRequest { - pub root: String, - pub path: String, -} - -/// Largest editor snapshot accepted by `git.lineHistory`; larger documents must -/// use committed state instead of an unsaved buffer. -const LINE_HISTORY_MAX_SNAPSHOT_BYTES: usize = 8 * 1024 * 1024; -/// Default entry limit for `git.lineHistory` before truncation is reported. -const LINE_HISTORY_DEFAULT_LIMIT: usize = 50; -/// Hard ceiling for a client-requested `git.lineHistory` limit. -const LINE_HISTORY_MAX_LIMIT: usize = 200; - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Request for the committed history of one one-based snapshot line range. -pub struct GitLineHistoryRequest { - pub root: String, - /// Workspace-relative file path validated as a safe pathspec. - pub path: String, - /// Editor buffer content at query time; the line range addresses this - /// snapshot, not the on-disk file, so unsaved edits stay answerable. - pub content: String, - /// First one-based snapshot line to trace, inclusive. - pub start_line: usize, - /// Last one-based snapshot line to trace, inclusive. - pub end_line: usize, - /// Maximum deduplicated entries to return; defaults to 50, capped at 200. - #[serde(default)] - pub limit: Option, -} - -fn default_review_context_lines() -> usize { - 80 -} - -fn default_push_preview_limit() -> usize { - DEFAULT_PUSH_PREVIEW_LIMIT -} - -fn default_repository_scan_max_directories() -> usize { - DEFAULT_REPOSITORY_SCAN_MAX_DIRECTORIES -} - -fn default_repository_scan_max_depth() -> usize { - DEFAULT_REPOSITORY_SCAN_MAX_DEPTH -} - -/// Discovers Git repositories for an opened workspace using shared traversal rules. -pub fn workspace_repositories( - request: WorkspaceRepositoriesRequest, -) -> Result { - let workspace_root = PathBuf::from(validate_root(&request.root)?); - let max_directories = request - .max_directories - .min(DEFAULT_REPOSITORY_SCAN_MAX_DIRECTORIES); - let max_depth = request.max_depth.min(DEFAULT_REPOSITORY_SCAN_MAX_DEPTH); - let mut discovered_repositories = HashSet::new(); - let containing_repository = discover_containing_repository(&workspace_root)?; - - if let Some(repository) = &containing_repository { - discovered_repositories.insert(repository.clone()); - } - - let mut queue = VecDeque::from([(workspace_root.clone(), 0usize)]); - let mut visited_directories = HashSet::new(); - - while let Some((directory, depth)) = queue.pop_front() { - crate::protocol::cancellation::check()?; - if visited_directories.len() >= max_directories { - break; - } - - let canonical_directory = match canonicalize_simplified(&directory) { - Ok(path) => path, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, - Err(error) => return Err(repository_scan_error(error)), - }; - if !canonical_directory.starts_with(&workspace_root) { - continue; - } - if !visited_directories.insert(canonical_directory.clone()) { - continue; - } - - let entries = fs::read_dir(&canonical_directory).map_err(repository_scan_error)?; - let mut child_directories = Vec::new(); - for entry in entries { - crate::protocol::cancellation::check()?; - let entry = entry.map_err(repository_scan_error)?; - let name = entry.file_name().to_string_lossy().to_string(); - if name == ".git" { - // Only openable repositories join the workspace list; a dangling gitfile - // entry must not poison status and reference loads for sibling roots. - if git_entry_opens_as_repository(&entry.path()) { - discovered_repositories.insert(canonical_directory.clone()); - } - continue; - } - if !repository_scan_directory_is_visible(&name) { - continue; - } - let file_type = entry.file_type().map_err(repository_scan_error)?; - if file_type.is_dir() { - child_directories.push(entry.path()); - } - } - child_directories.sort_by(|left, right| { - path_sort_key(left) - .cmp(&path_sort_key(right)) - .then_with(|| left.cmp(right)) - }); - - if depth >= max_depth { - continue; - } - for child_directory in child_directories { - queue.push_back((child_directory, depth + 1)); - } - } - - let mut repositories = discovered_repositories - .into_iter() - .collect::>(); - sort_workspace_repository_paths(&mut repositories, &workspace_root); - if let Some(repository) = containing_repository { - repositories.retain(|path| path != &repository); - repositories.insert(0, repository); - } - - Ok(WorkspaceRepositoriesResponse { - repositories: repositories - .into_iter() - .map(|path| WorkspaceRepositoryResponse { - path: path.to_string_lossy().replace('\\', "/"), - }) - .collect(), - }) -} - -fn repository_scan_directory_is_visible(name: &str) -> bool { - // These intentional checkout containers are hidden in the file tree but - // remain discoverable in Git. Apply exclusions to their children as usual. - if name.eq_ignore_ascii_case(".worktree") || name.eq_ignore_ascii_case(".worktrees") { - return true; - } - // Filter descendants, never the explicitly opened root or its containing - // repository. Git ignore rules govern tracked files, not repository ownership. - !name.starts_with('.') - && !crate::project::BUILT_IN_HIDDEN_DIRECTORIES - .iter() - .any(|hidden| name.eq_ignore_ascii_case(hidden)) -} - -/// Executes an argument-based Git command after validating the workspace root. -pub fn command(request: GitCommandRequest) -> Result { - with_git_invocation_trace(|| { - let root = validate_root(&request.root)?; - // Raw compatibility commands can also mutate refs and configuration. - // Share the typed writers' fail-fast lease instead of waiting on a mutex - // that cannot observe the request's cancellation or deadline. - let _lease = rewrite::RewriteLease::acquire(&root)?; - execute_git(&root, &request.arguments, request.input) - }) -} - -fn readonly_command(request: GitCommandRequest) -> Result { - let root = validate_root(&request.root)?; - execute_git_readonly(&root, &request.arguments, request.input) -} - -/// Resolves the containing repository root without participating in write coordination. -pub fn discover_repository_root( - request: GitRepositoryRootRequest, -) -> Result, CoreError> { - let root = validate_root(&request.root)?; - Ok(discover_containing_repository(Path::new(&root))? - .map(|path| path.to_string_lossy().replace('\\', "/"))) -} - -// Shared by ordinary staging and reviewed commit selection under its existing lease. -fn stage_paths(root: &str, paths: &[String]) -> Result { - let paths = validate_paths(paths)?; - let arguments = vec![ - "add".into(), - "-A".into(), - "--pathspec-from-file=-".into(), - "--pathspec-file-nul".into(), - ]; - let literal = [("GIT_LITERAL_PATHSPECS".to_string(), "1".to_string())]; - execute_git_with_environment( - root, - &arguments, - Some(nul_pathspec_input(&paths)), - false, - &literal, - ) -} - -fn unstage_paths(root: &str, paths: &[String]) -> Result { - let paths = validate_paths(paths)?; - let literal = [("GIT_LITERAL_PATHSPECS".to_string(), "1".to_string())]; - let input = nul_pathspec_input(&paths); - let head = execute_git( - root, - &["rev-parse".into(), "--verify".into(), "HEAD".into()], - None, - )?; - if head.exit_code != 0 && commit_state::is_unborn(root)? { - let arguments = vec![ - "rm".into(), - "--cached".into(), - "--force".into(), - "--ignore-unmatch".into(), - "--pathspec-from-file=-".into(), - "--pathspec-file-nul".into(), - ]; - return execute_git_with_environment(root, &arguments, Some(input), false, &literal); - } - let restore = vec![ - "restore".into(), - "--staged".into(), - "--pathspec-from-file=-".into(), - "--pathspec-file-nul".into(), - ]; - let response = - execute_git_with_environment(root, &restore, Some(input.clone()), false, &literal)?; - if response.exit_code == 0 { - return Ok(response); - } - let reset = vec![ - "reset".into(), - "HEAD".into(), - "--pathspec-from-file=-".into(), - "--pathspec-file-nul".into(), - ]; - execute_git_with_environment(root, &reset, Some(input), false, &literal) -} - -/// Executes one supported repository mutation without invoking a shell. -pub fn write(request: GitWriteRequest) -> Result { - let mut response = with_git_invocation_trace(|| write_with_trace(request))?; - authentication_failure::classify(&mut response); - Ok(response) -} - -fn write_with_trace(request: GitWriteRequest) -> Result { - if request.fetch_options.is_some() && request.operation != "fetch" { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Fetch options require a fetch operation", - )); - } - if request.check_submodules && request.operation != "push" { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Submodule publication checks require a push operation", - )); - } - if request.expected_branch.is_some() && request.operation != "pull" { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Branch preconditions require a pull operation", - )); - } - if request.operation != "commit" - && (!request.gitlink_updates.is_empty() - || (request.expected_commit_state.is_some() && request.operation != "push")) - { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Commit state preconditions require a commit or push operation", - )); - } - let root = validate_root(&request.root)?; - // Every typed writer shares the same repository lease, including linked - // worktrees. Clone has no existing repository whose state it could race. - let _lease = if request.operation == "clone" { - None - } else { - Some(rewrite::RewriteLease::acquire(&root)?) - }; - let mut arguments: Vec; - - match request.operation.as_str() { - "stage" => return stage_paths(&root, &request.paths), - "unstage" => return unstage_paths(&root, &request.paths), - "discard" => { - let paths = validate_paths(&request.paths)?; - let literal_paths = [("GIT_LITERAL_PATHSPECS".to_string(), "1".to_string())]; - let restore_arguments = ["restore", "--worktree", "--"] - .into_iter() - .map(String::from) - .chain(paths.clone()) - .collect::>(); - let restore = execute_git_with_environment( - &root, - &restore_arguments, - None, - false, - &literal_paths, - )?; - if restore.exit_code == 0 { - return Ok(restore); - } - let mut status_arguments = vec![ - "status".to_string(), - "--porcelain".to_string(), - "--".to_string(), - ]; - status_arguments.extend(paths.clone()); - let status = - execute_git_with_environment(&root, &status_arguments, None, true, &literal_paths)?; - let is_untracked = status.exit_code == 0 - && status - .output - .lines() - .any(|line| line.starts_with("??") || line.starts_with("!!")); - if !is_untracked { - return Ok(restore); - } - arguments = ["clean", "-f", "-d", "--"] - .into_iter() - .map(String::from) - .chain(paths) - .collect(); - return execute_git_with_environment(&root, &arguments, None, false, &literal_paths); - } - "discardAll" => { - let paths = validate_paths(&request.paths)?; - return discard_all(&root, &paths); - } - "stageAll" => arguments = vec!["add".into(), "--all".into()], - "commit" => { - let message = required_text(request.message.as_deref(), "commit message")?; - if request.expected_commit_state.is_some() && !request.paths.is_empty() { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Planned commits use the reviewed index", - )); - } - if let Some(expected) = &request.expected_commit_state { - commit_state::prepare(&root, expected, &request.gitlink_updates)?; - } else if !request.gitlink_updates.is_empty() { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Submodule updates require a reviewed commit state", - )); - } - let identity = commit_identity(request.author.as_deref(), request.sign_off)?; - if !request.paths.is_empty() { - let paths = validate_paths(&request.paths)?; - return commit_selected_paths(&root, paths, message, request.amend, identity); - } - arguments = vec!["commit".into()]; - if request.amend { - arguments.push("--amend".into()); - } - arguments.extend(identity); - arguments.extend(["-m".into(), message]); - } - "ignore" => { - return append_git_ignore_patterns(&root, &request.paths, GitIgnoreTarget::Repository) - } - "exclude" => { - return append_git_ignore_patterns(&root, &request.paths, GitIgnoreTarget::LocalExclude) - } - "cherryPick" => { - arguments = vec![ - "cherry-pick".into(), - validated_revision(request.revision.as_deref())?, - ]; - } - "revert" => { - arguments = vec![ - "revert".into(), - "--no-edit".into(), - validated_revision(request.revision.as_deref())?, - ]; - } - "reset" => { - let mode = request.mode.as_deref().unwrap_or("--mixed"); - if !["--soft", "--mixed", "--hard"].contains(&mode) { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Unsupported reset mode", - )); - } - arguments = vec![ - mode.into(), - validated_revision(request.revision.as_deref())?, - ]; - arguments.insert(0, "reset".into()); - } - "undoCommit" | "editCommitMessage" | "deleteCommit" | "squashCommits" => { - return rewrite::execute(&root, &request); - } - "createBranch" => { - let name = validated_branch_name(&root, request.name.as_deref())?; - let reference = write_request_reference(&root, &request)?; - // Creating a branch is not "checking out a remote branch": it must not - // inherit an upstream. With Git's default `branch.autoSetupMerge`, a - // branch started at `origin/main` would otherwise track `refs/heads/main`, - // and the next push would carry the explicit `refs/heads/:refs/heads/main` - // refspec straight over the remote main branch (issue #1272). Freezing the - // result with `--no-track` keeps it independent of user Git preferences, - // matching the worktree creation path. Deliberate upstream setup stays - // available through `setUpstream` and the `checkout` path. - arguments = if request.checkout { - vec![ - "switch".into(), - "-c".into(), - name, - "--no-track".into(), - reference, - ] - } else { - vec!["branch".into(), "--no-track".into(), name, reference] - }; - } - "publishBranch" => { - return publish_branch(&root, request.name.as_deref()); - } - "renameBranch" => { - let name = validated_branch_name(&root, request.name.as_deref())?; - let reference = write_request_reference(&root, &request)?; - let current = current_branch(&root)?; - let current_reference = format!("refs/heads/{current}"); - arguments = if reference == current || reference == current_reference { - vec!["branch".into(), "-m".into(), name] - } else { - vec!["branch".into(), "-m".into(), reference, name] - }; - } - "setUpstream" => { - let branch = validated_branch_name(&root, request.name.as_deref())?; - let upstream = request - .git_reference - .as_ref() - .ok_or_else(invalid_git_reference) - .and_then(|reference| validated_git_reference(&root, reference))?; - if upstream.kind != "remote" { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "A branch upstream must be a remote Git reference", - )); - } - arguments = vec![ - "branch".into(), - format!("--set-upstream-to={}", upstream.full_name), - branch, - ]; - } - "unsetUpstream" => { - let branch = validated_branch_name(&root, request.name.as_deref())?; - arguments = vec!["branch".into(), "--unset-upstream".into(), branch]; - } - "deleteBranch" => { - let reference = write_request_reference(&root, &request)?; - let branch = local_branch_name(&reference)?; - if current_branch(&root)?.as_str() == branch { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "The current branch cannot be deleted", - )); - } - return delete_branch(&root, &branch); - } - "merge" => { - let reference = write_request_reference(&root, &request)?; - if is_current_reference(&root, &reference)? { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "The current branch cannot be merged into itself", - )); - } - arguments = vec!["merge".into(), "--no-edit".into(), reference]; - } - "rebase" => { - let reference = write_request_reference(&root, &request)?; - if is_current_reference(&root, &reference)? { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "The current branch cannot be rebased onto itself", - )); - } - arguments = vec!["rebase".into(), reference]; - } - "checkoutAndRebase" => return mutations::checkout_and_rebase(&root, request), - "createWorktree" => return create_worktree(&root, &request), - "removeWorktree" | "lockWorktree" | "unlockWorktree" => { - return mutate_worktree(&root, &request) - } - "pruneWorktrees" => { - arguments = vec![ - "worktree".into(), - "prune".into(), - "--verbose".into(), - "--expire=now".into(), - ] - } - "repairWorktrees" => arguments = vec!["worktree".into(), "repair".into()], - "updateBranch" => return update_local_branch(&root, &request), - "fetch" => { - if execution_policy::current().detailed_fetch { - return fetch_execution::execute(&root, request.fetch_options); - } - let options = configuration::fetch_options(&root, request.fetch_options)?; - arguments = fetch::arguments(&options)?; - if let Some(remote) = &options.remote { - if read_git_config_value(&root, &format!("remote.{remote}.url"))?.is_none() { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Fetch remote is not configured", - )); - } - } - } - // Strategy comes from the caller because only the user can decide whether a - // divergent history should be merged or replayed. Absent a choice we stay on - // `--ff-only`, which refuses rather than inventing a merge commit. - "pull" => { - if let Some(expected) = request.expected_branch.as_deref() { - if !expected.starts_with("refs/heads/") || expected == "refs/heads/" { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Expected branch must be a complete local reference", - )); - } - // Repeat the frontend's post-Fetch check under the shared writer - // lease. A changed checkout must never update its replacement. - let current = execute_git( - &root, - &["symbolic-ref".into(), "--quiet".into(), "HEAD".into()], - None, - )?; - if current.exit_code != 0 || current.output.trim() != expected { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "The selected branch changed; review and retry", - )); - } - if request.auto_stash - || request.reference.is_some() - || request.git_reference.is_some() - { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Guarded background updates use the fetched upstream without auto-stash", - )); - } - // The host already fetched. Freeze the selected branch's upstream - // commit and integrate locally; `git pull` would fetch again and - // reopen a long window for an external client to switch HEAD. - let upstream = execute_git( - &root, - &[ - "rev-parse".into(), - "--verify".into(), - // Git's upstream selector takes a branch name, while the - // checkout guards above/below compare complete refs. - format!( - "{}@{{upstream}}^{{commit}}", - expected.strip_prefix("refs/heads/").unwrap() - ), - ], - None, - )?; - if upstream.exit_code != 0 { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "The selected branch has no fetched upstream commit", - )); - } - let revision = upstream.stdout.trim().to_owned(); - arguments = match request.mode.as_deref() { - None | Some("ffOnly") => vec!["merge".into(), "--ff-only".into(), revision], - Some("merge") => vec!["merge".into(), "--no-edit".into(), revision], - Some("rebase") => vec!["rebase".into(), revision], - Some(other) => { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - format!("Unknown pull strategy '{other}'"), - )) - } - }; - let current = execute_git( - &root, - &["symbolic-ref".into(), "--quiet".into(), "HEAD".into()], - None, - )?; - if current.exit_code != 0 || current.output.trim() != expected { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "The selected branch changed; review and retry", - )); - } - return execute_git(&root, &arguments, None); - } - arguments = match request.mode.as_deref() { - None | Some("ffOnly") => vec!["pull".into(), "--ff-only".into()], - Some("merge") => vec!["pull".into(), "--no-rebase".into(), "--no-edit".into()], - Some("rebase") => vec!["pull".into(), "--rebase".into()], - Some(other) => { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - format!("Unknown pull strategy '{other}'"), - )) - } - }; - let explicit_reference = if let Some(reference) = request.git_reference.as_ref() { - let reference = validated_git_reference(&root, reference)?; - if reference.kind != "remote" { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Explicit pull requires a remote Git reference", - )); - } - Some(reference.full_name) - } else if let Some(reference) = request.reference.as_deref() { - if request.reference_kind.as_deref() != Some("remote") { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Explicit pull requires a remote Git reference", - )); - } - Some(validated_reference(Some(reference))?) - } else { - None - }; - if let Some(reference) = explicit_reference { - let (remote, branch) = mutations::remote_branch_components(&root, &reference)?; - arguments.extend(["--".into(), remote, branch]); - } - if request.auto_stash { - return pull_with_auto_stash(&root, &arguments); - } - } - "deleteRemoteBranch" => { - let reference = request - .git_reference - .as_ref() - .ok_or_else(|| invalid_git_reference())?; - let reference = validated_git_reference(&root, reference)?; - if reference.kind != "remote" { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Remote branch deletion requires a remote Git reference", - )); - } - let (remote, branch) = - mutations::remote_branch_components(&root, &reference.full_name)?; - arguments = vec![ - "push".into(), - "--delete".into(), - "--".into(), - remote, - format!("refs/heads/{branch}"), - ]; - } - "push" => { - if let Some(expected) = &request.expected_commit_state { - if commit_state::inspect_root(&root)? != *expected { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Push state changed; review the commit plan again", - )); - } - } - let reference = optional_write_request_reference(&root, &request)?; - return push( - &root, - reference.as_deref(), - request.force, - request.push_tags.as_deref(), - request.expected_push.as_ref(), - request.check_submodules, - request.push_target, - ); - } - "checkout" => return checkout(&root, request), - "checkoutRevision" => { - arguments = vec![ - "switch".into(), - "--detach".into(), - validated_revision(request.revision.as_deref())?, - ]; - } - "createTag" => { - let name = validated_tag_name(request.name.as_deref())?; - let requested_target = validated_revision(request.revision.as_deref())?; - // Reject an impossible tag form before probing the repository; the - // typed fields only refine the legacy message-based tag choice. - validate_create_tag_form( - request.message.as_deref(), - request.lightweight, - request.signed, - )?; - // Existence and resolvability probes run before Git so a duplicate - // or unresolvable target fails with a stable message instead of - // leaving the caller to parse localized `git tag` stderr. - if tag_exists(&root, &name)? { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - format!("A tag named '{name}' already exists"), - )); - } - // Git can tag trees and blobs, but the deletion/restore contract - // promises a commit target, so anything else is rejected here and - // the tag is created against the resolved commit id. - let Some(target) = resolved_commit_target(&root, &requested_target)? else { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - format!("Could not resolve tag target '{requested_target}'"), - )); - }; - arguments = create_tag_arguments( - &name, - &target, - request.message.as_deref(), - request.lightweight, - request.signed, - ); - } - "deleteTag" => return delete_tag(&root, request.name.as_deref()), - "clone" => { - let remote = required_text(request.remote.as_deref(), "clone source")?; - let destination = required_text(request.destination.as_deref(), "clone destination")?; - if destination.starts_with('-') || destination.contains('\0') { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Invalid clone destination", - )); - } - let destination = crate::remote::workspace::remote_path(Path::new(&destination)) - .to_string_lossy() - .into_owned(); - arguments = vec!["clone".into(), "--".into(), remote, destination]; - } - "stashPush" => { - arguments = vec!["stash".into(), "push".into()]; - if request.include_untracked { - arguments.push("--include-untracked".into()); - } - if let Some(message) = request.message.filter(|value| !value.trim().is_empty()) { - arguments.extend(["-m".into(), message]); - } - if !request.paths.is_empty() { - let paths = validate_paths(&request.paths)?; - arguments.extend([ - "--pathspec-from-file=-".into(), - "--pathspec-file-nul".into(), - ]); - return execute_git(&root, &arguments, Some(nul_pathspec_input(&paths))); - } - } - "operationContinue" | "operationAbort" | "operationSkip" => { - return resolve_operation(&root, &request.operation) - } - "stashApply" | "stashDrop" => { - let requested = validated_stash_reference(request.reference.as_deref())?; - // Resolve immutable selections under the repository write lease. Older - // callers may still supply a positional stash reference. - let reference = if matches!(requested.len(), 40 | 64) - && requested.bytes().all(|byte| byte.is_ascii_hexdigit()) - { - stash_reference_for_oid(&root, &requested)?.ok_or_else(|| { - CoreError::new( - ErrorCode::InvalidRequest, - "The selected stash no longer exists", - ) - })? - } else { - requested - }; - if request.operation == "stashApply" { - return apply_stash(&root, &reference); - } - let action = match request.operation.as_str() { - _ => "drop", - }; - arguments = vec!["stash".into(), action.into(), reference]; - } - "stashPop" => { - let requested = validated_stash_reference(request.reference.as_deref())?; - // Resolve immutable selections under the repository write lease. Older - // callers may still supply a positional stash reference. - let reference = if matches!(requested.len(), 40 | 64) - && requested.bytes().all(|byte| byte.is_ascii_hexdigit()) - { - stash_reference_for_oid(&root, &requested)?.ok_or_else(|| { - CoreError::new( - ErrorCode::InvalidRequest, - "The selected stash no longer exists", - ) - })? - } else { - requested - }; - return pop_stash(&root, &reference); - } - _ => { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Unsupported Git write operation", - )) - } - } - - execute_git(&root, &arguments, None) -} - -pub(super) fn execute_git( - root: &str, - arguments: &[String], - input: Option, -) -> Result { - execute_git_with_options(root, arguments, input, false) -} - -pub(super) fn execute_git_readonly( - root: &str, - arguments: &[String], - input: Option, -) -> Result { - execute_git_readonly_with_environment(root, arguments, input, &[]) -} - -fn execute_git_readonly_with_environment( - root: &str, - arguments: &[String], - input: Option, - environment: &[(String, String)], -) -> Result { - // Internal queries follow IDEA's silent handler policy. Keep their result for - // the owning workflow without filling the user's console with plumbing output. - let arguments = execution_policy::arguments(arguments); - let response = capture_git_with_environment(root, &arguments, input, true, environment)? - .into_command_response(&arguments); - record_git_invocation(&response); - Ok(response) -} - -fn execute_git_with_options( - root: &str, - arguments: &[String], - input: Option, - disable_optional_locks: bool, -) -> Result { - execute_git_with_environment(root, arguments, input, disable_optional_locks, &[]) -} - -fn execute_git_with_environment( - root: &str, - arguments: &[String], - input: Option, - disable_optional_locks: bool, - environment: &[(String, String)], -) -> Result { - let arguments = execution_policy::arguments(arguments); - let mut retry_scope = None; - for attempt in 0..3 { - let output = capture_git_process( - root, - &arguments, - input.clone(), - disable_optional_locks, - environment, - true, - )?; - let response = output.into_command_response(&arguments); - record_git_invocation(&response); - let authentication_failed = [ - "Authentication failed", - "could not read Username", - "could not read Password", - "Permission denied (publickey", - ] - .iter() - .any(|marker| response.stderr.contains(marker)); - let transfer = execution_policy::command_index(&arguments).is_some_and(|index| { - matches!( - arguments[index].as_str(), - "fetch" | "pull" | "push" | "clone" - ) - }); - if response.exit_code == 0 - || !authentication_failed - || !transfer - || !execution_policy::current().interactive - || attempt == 2 - { - return Ok(response); - } - let confirmation = - lithe_git_host::authentication::Confirmation::new().map_err(|error| { - CoreError::new( - ErrorCode::ProcessFailed, - "Could not request Git authentication retry", - ) - .with_details(error.to_string()) - })?; - execution_events::emit( - serde_json::json!({ "type": "authentication", "requestId": confirmation.request_id, - "prompt": "Git authentication failed. Retry with credentials entered in Lithe, bypassing the credential helper for this retry?", - "secret": false, "retry": true, "attempt": attempt + 2, "workingDirectory": root }), - ); - let retry = confirmation - .wait(|| crate::protocol::cancellation::check().is_err()) - .map_err(|_| CoreError::new(ErrorCode::TimedOut, "Git authentication retry expired"))?; - crate::protocol::cancellation::check()?; - if !retry { - return Ok(response); - } - drop(retry_scope.take()); - let mut options = execution_policy::current(); - options.use_credential_helper = false; - retry_scope = Some(execution_policy::Scope::begin(Some(options))?); - } - unreachable!("bounded Git attempts always return a response") -} - -fn capture_git_with_options( - root: &str, - arguments: &[String], - input: Option, - disable_optional_locks: bool, -) -> Result { - capture_git_with_environment(root, arguments, input, disable_optional_locks, &[]) -} - -fn capture_git_with_environment( - root: &str, - arguments: &[String], - input: Option, - disable_optional_locks: bool, - environment: &[(String, String)], -) -> Result { - capture_git_process( - root, - arguments, - input, - disable_optional_locks, - environment, - false, - ) -} - -fn capture_git_process( - root: &str, - arguments: &[String], - input: Option, - disable_optional_locks: bool, - environment: &[(String, String)], - visible: bool, -) -> Result { - validate_windows_git_path(Path::new(root)).map_err(git_path_error)?; - crate::protocol::cancellation::check()?; - let mut trace = command_trace::Trace::begin(root, arguments, visible); - let result = match remote_process::capture( - root, - arguments, - input.as_deref(), - disable_optional_locks, - environment, - visible, - &mut trace, - ) { - Some(result) => result, - None => capture_local_git_process( - root, - arguments, - input, - disable_optional_locks, - environment, - visible, - &mut trace, - ), - }; - trace.finish(result.as_ref().err()); - result -} - -fn capture_local_git_process( - root: &str, - arguments: &[String], - input: Option, - disable_optional_locks: bool, - environment: &[(String, String)], - visible: bool, - trace: &mut command_trace::Trace, -) -> Result { - let mut process = git_process(); - let version = lithe_git_host::configuration::version(process.get_program(), || { - crate::protocol::cancellation::check().is_err() - }); - crate::protocol::cancellation::check()?; - execution_policy::validate_version(&version.map_err(|error| { - CoreError::new( - ErrorCode::ProcessStartFailed, - "Could not inspect Git executable", - ) - .with_details(error.to_string()) - })?)?; - if visible && execution_policy::current().interactive && !execution_events::has_sink() { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Interactive Git requires an execution event receiver", - )); - } - process - .args(arguments) - .current_dir(root) - .envs(environment.iter().map(|(key, value)| (key, value))); - if disable_optional_locks { - process.env("GIT_OPTIONAL_LOCKS", "0"); - } - if execution_policy::command_index(arguments).is_some_and(|index| arguments[index] != "config") - { - lithe_git_host::configuration::configure( - &mut process, - &execution_policy::temporary_config(), - ) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Could not apply temporary Git configuration", - ) - .with_details(error.to_string()) - })?; - } - let invocation = RefCell::new(execution_events::Invocation::reported(visible)); - let mut authentication = if visible && execution_policy::current().interactive { - let session = lithe_git_host::authentication::Session::new().map_err(|error| { - CoreError::new( - ErrorCode::ProcessStartFailed, - "Could not prepare Git authentication", - ) - .with_details(error.to_string()) - })?; - session.configure(&mut process).map_err(|error| { - CoreError::new( - ErrorCode::ProcessStartFailed, - "Could not configure Git authentication", - ) - .with_details(error.to_string()) - })?; - Some(session) - } else { - None - }; - let mut cancellation_error = None; - let output = lithe_git_host::run( - &mut process, - input.as_deref().map(str::as_bytes), - || { - cancellation_error = crate::protocol::cancellation::check().err(); - if cancellation_error.is_none() { - if let Some(session) = &mut authentication { - match session.poll() { - Ok(challenges) => { - for challenge in challenges { - execution_events::emit( - serde_json::json!({ "type": "authentication", "requestId": challenge.request_id, - "prompt": execution_events::redact(&challenge.prompt), "secret": challenge.secret, - "attempt": challenge.attempt, "workingDirectory": root }), - ); - } - } - Err(error) => { - cancellation_error = Some( - CoreError::new( - ErrorCode::ProcessFailed, - "Git authentication transport failed", - ) - .with_details(error.to_string()), - ) - } - } - } - } - cancellation_error.is_some() - }, - || { - if let Some(invocation) = invocation.borrow().as_ref() { - invocation.started(root, arguments); - } - }, - |stream, bytes| { - if let Some(invocation) = invocation.borrow_mut().as_mut() { - invocation.output(stream, bytes); - } - }, - ); - let error = output.failure.map(|failure| match failure { - lithe_git_host::Failure::Start(error) => { - CoreError::new(ErrorCode::ProcessStartFailed, "Could not start Git") - .with_details(error.to_string()) - } - lithe_git_host::Failure::Io(error) => { - CoreError::new(ErrorCode::ProcessFailed, "Could not read Git output") - .with_details(error.to_string()) - } - lithe_git_host::Failure::Cancelled => cancellation_error - .unwrap_or_else(|| CoreError::new(ErrorCode::Cancelled, "Operation was cancelled")), - lithe_git_host::Failure::OutputLimit => CoreError::new( - ErrorCode::ProcessFailed, - "Git output exceeded the capture limit", - ), - lithe_git_host::Failure::Cleanup => CoreError::new( - ErrorCode::ProcessFailed, - "Git process cleanup did not complete within its deadline", - ), - }); - let exit_code = output - .status - .as_ref() - .and_then(std::process::ExitStatus::code); - trace.observe(exit_code, &output.stderr); - if let Some(invocation) = invocation.into_inner() { - // A query that answers with a nonzero exit, such as a missing config key, keeps - // its actual code without presenting a normal result as failure. - let expected_exit = error.is_none() - && execution_events::Invocation::answered(visible, arguments, exit_code); - invocation.finished_with_expected_exit(exit_code, error.as_ref(), expected_exit); - } - if let Some(error) = error { - return Err(error); - } - Ok(GitProcessOutput { - stdout: output.stdout, - stderr: output.stderr, - exit_code: exit_code.unwrap_or(1), - }) -} - -pub(super) fn git_process() -> Command { - let options = execution_policy::current(); - let executable = lithe_git_host::configuration::executable(options.executable.as_deref()) - .unwrap_or_else(|| options.executable.unwrap_or_else(|| "git".into()).into()); - #[cfg(target_os = "windows")] - { - let mut process = Command::new(executable); - process.creation_flags(git_process_creation_flags()); - process - } - - #[cfg(not(target_os = "windows"))] - { - Command::new(executable) - } -} - -#[cfg(target_os = "windows")] -fn git_process_creation_flags() -> u32 { - // Git runs as an IDE background task; attaching a console can briefly open - // the user's default terminal whenever status or repository data refreshes. - const CREATE_NO_WINDOW: u32 = 0x08000000; - CREATE_NO_WINDOW -} - -/// Builds a structured working-tree, staged, untracked, or commit diff. -pub fn diff(request: GitDiffRequest) -> Result { - let omit_file_patch = request.include_file_sections && request.omit_file_patch; - // A complete-file review prints one hunk per file. Staging that hunk would - // write the whole file, so Core splits it into Git-default hunks. - let complete_file = request.include_file_sections - && request.context_lines >= FULL_FILE_CONTEXT_LINES - && request.stash_reference.is_none(); - let mut response = diff_impl(request)?; - if let Some(files) = &mut response.files { - for file in files { - if complete_file && !file.is_binary && !file.hunks.is_empty() { - // Fail closed: an unsplittable section offers no writable hunk. - file.hunks = diff_blocks::stageable_hunks(&file.patch).unwrap_or_default(); - if file.status == "R" { - attach_rename_content_patches(&file.patch, &mut file.hunks); - } - } - if omit_file_patch { - file.patch = String::new(); - } - } - } - Ok(response) -} - -fn diff_impl(request: GitDiffRequest) -> Result { - if request.pathspecs.is_empty() || request.pathspecs.iter().any(|path| !is_safe_pathspec(path)) - { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Git diff contains an invalid path", - )); - } - - if request.include_file_sections - && request.stash_reference.is_some() - && request.pathspecs != ["."] - { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Per-file stash output requires the whole repository", - )); - } - let root = validate_root(&request.root)?; - if let Some(reference) = request.stash_reference.as_deref() { - let oid = resolve_commit_revision(&root, &validated_stash_reference(Some(reference))?)?; - let mut arguments = vec![ - "-c".into(), - "diff.noprefix=false".into(), - "-c".into(), - "diff.mnemonicprefix=false".into(), - "stash".into(), - "show".into(), - "--include-untracked".into(), - "--format=".into(), - "--binary".into(), - "--full-index".into(), - "--no-ext-diff".into(), - "--no-textconv".into(), - "--no-color".into(), - format!("--unified={}", request.context_lines), - oid, - ]; - if request.include_file_sections { - // Stash already has the safe prefix configuration at the front. - arguments.splice(6..6, ["--numstat".into(), "-z".into(), "--patch".into()]); - } - let output = capture_git_with_options(&root, &arguments, None, true)?; - if output.exit_code != 0 { - return Err( - CoreError::new(ErrorCode::ProcessFailed, "Could not inspect saved stash") - .with_details(String::from_utf8_lossy(&output.stderr).to_string()), - ); - } - if request.include_file_sections { - return structured_numstat_output(output, true); - } - return Ok(structured_diff_from_output(output)); - } - if request.worktree_snapshot { - if request.reference.is_some() - || request.empty_tree_base - || request.git_reference.is_some() - || request.target_git_reference.is_some() - || request.commit.is_some() - || request.staged - || request.untracked - { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Git worktree snapshot diff cannot combine other diff modes", - )); - } - return worktree_snapshot_diff(&root, &request); - } - let reference = if request.empty_tree_base { - if request.git_reference.is_some() - || request.target_git_reference.is_some() - || request.commit.is_some() - { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Git empty-tree diff cannot combine reference forms", - )); - } - let target = request.reference.as_deref().ok_or_else(|| { - CoreError::new( - ErrorCode::InvalidRequest, - "Git empty-tree diff requires a target reference", - ) - })?; - validate_revision(target)?; - Some(format!("{}..{target}", empty_tree_oid(&root)?)) - } else { - typed_reference_range( - &root, - request.git_reference.as_ref(), - request.target_git_reference.as_ref(), - request.reference.as_deref(), - )? - }; - if reference.is_some() && request.commit.is_some() { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Git diff cannot combine a reference and a commit", - )); - } - - let include_untracked_with_reference = reference.is_some() && request.untracked; - // `diff --no-index` compares one file with another, so an untracked - // directory summary is diffed as the untracked files below it. - let expands_untracked_directories = reference.is_none() - && request.commit.is_none() - && request.untracked - && request.pathspecs.iter().any(|path| path.ends_with('/')); - let lists_untracked_paths = include_untracked_with_reference || expands_untracked_directories; - let mut arguments = if let Some(commit) = request.commit { - validate_revision(&commit)?; - vec![ - "show".to_string(), - "--format=".to_string(), - "--no-ext-diff".to_string(), - "--binary".to_string(), - format!("--unified={}", request.context_lines), - commit, - ] - } else if let Some(reference) = reference { - validate_revision(&reference)?; - vec![ - "diff".to_string(), - "--no-ext-diff".to_string(), - "--binary".to_string(), - format!("--unified={}", request.context_lines), - reference, - ] - } else { - let mut arguments = vec![ - "diff".to_string(), - "--no-ext-diff".to_string(), - "--binary".to_string(), - ]; - if request.untracked { - arguments.push("--no-index".to_string()); - } - arguments.push(format!("--unified={}", request.context_lines)); - if request.staged && !request.untracked { - arguments.push("--cached".to_string()); - } - arguments - }; - append_full_file_context_arguments(&mut arguments, request.context_lines); - if request.ignore_all_whitespace { - arguments.push("--ignore-all-space".to_string()); - } - arguments.push("--".to_string()); - if request.untracked && !include_untracked_with_reference { - arguments.push(null_device().to_string()); - } - arguments.extend(request.pathspecs.clone()); - - if request.include_file_sections { - configure_structured_diff(&mut arguments); - } - let root = validate_root(&request.root)?; - let mut output = if expands_untracked_directories { - GitProcessOutput { - stdout: Vec::new(), - stderr: Vec::new(), - exit_code: 0, - } - } else { - capture_git_with_options(&root, &arguments, None, true)? - }; - let mut sections = if request.include_file_sections { - Some(structured_numstat_output( - std::mem::replace( - &mut output, - GitProcessOutput { - stdout: Vec::new(), - stderr: Vec::new(), - exit_code: 0, - }, - ), - false, - )?) - } else { - None - }; - let mut section_bytes = sections.as_ref().map(structured_file_bytes).unwrap_or(0); - if lists_untracked_paths { - let untracked_paths = readonly_command(GitCommandRequest { - root: root.clone(), - arguments: vec![ - "ls-files".into(), - "--others".into(), - "--exclude-standard".into(), - "-z".into(), - "--".into(), - ] - .into_iter() - .chain(request.pathspecs.clone()) - .collect(), - input: None, - })?; - if untracked_paths.exit_code != 0 { - return Err(CoreError::new( - ErrorCode::ProcessFailed, - "Git untracked file lookup failed", - ) - .with_details(untracked_paths.output)); - } - for path in untracked_paths.stdout.split('\0') { - if path.is_empty() { - continue; - } - if !is_safe_pathspec(path) { - // Shelve cleans a whole summarized directory after saving this - // patch, so a file it cannot export must fail the diff rather - // than be dropped from the patch and then deleted. - if expands_untracked_directories { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Git diff cannot export an untracked file with an unsupported name", - )); - } - continue; - } - let mut untracked_arguments = vec![ - "diff".into(), - "--no-ext-diff".into(), - "--binary".into(), - "--no-index".into(), - format!("--unified={}", request.context_lines), - ]; - append_full_file_context_arguments(&mut untracked_arguments, request.context_lines); - if request.ignore_all_whitespace { - untracked_arguments.push("--ignore-all-space".into()); - } - untracked_arguments.extend(["--".into(), null_device().into(), path.into()]); - if request.include_file_sections { - configure_structured_diff(&mut untracked_arguments); - } - let untracked = capture_git_with_options(&root, &untracked_arguments, None, true)?; - if let Some(ref mut combined) = sections { - append_structured_files( - combined, - structured_numstat_output(untracked, false)?, - &mut section_bytes, - )?; - } else { - output.stdout.extend(untracked.stdout); - output.stderr.extend(untracked.stderr); - } - } - } - if let Some(sections) = sections { - Ok(sections) - } else { - Ok(structured_diff_from_output(output)) - } -} - -fn worktree_snapshot_diff( - root: &str, - request: &GitDiffRequest, -) -> Result { - let head = execute_git_readonly( - root, - &[ - "rev-parse".into(), - "--verify".into(), - "--quiet".into(), - "HEAD".into(), - ], - None, - )?; - if head.exit_code > 1 { - return Err( - CoreError::new(ErrorCode::ProcessFailed, "Could not resolve Git HEAD") - .with_details(head.output), - ); - } - let head = (head.exit_code == 0).then(|| head.stdout.trim().to_string()); - let common_directory = git_resolved_path( - root, - &["rev-parse", "--path-format=absolute", "--git-common-dir"], - "Git common directory", - )?; - let temporary_context = TemporaryGitCommitContext::prepare_snapshot(head.as_deref())?; - let environment = temporary_context.environment(root, &common_directory); - let initialize_arguments = if let Some(head) = head.as_ref() { - vec!["read-tree".to_string(), head.clone()] - } else { - vec!["read-tree".to_string(), "--empty".to_string()] - }; - let initialized = - execute_git_with_environment(root, &initialize_arguments, None, true, &environment)?; - if initialized.exit_code != 0 { - return Err(CoreError::new( - ErrorCode::ProcessFailed, - "Could not prepare Git snapshot diff", - ) - .with_details(initialized.output)); - } - - let mut arguments = vec![ - "diff-files".to_string(), - "--no-ext-diff".to_string(), - "--binary".to_string(), - format!("--unified={}", request.context_lines), - ]; - append_full_file_context_arguments(&mut arguments, request.context_lines); - if request.ignore_all_whitespace { - arguments.push("--ignore-all-space".to_string()); - } - arguments.push("--".to_string()); - arguments.extend(request.pathspecs.clone()); - if request.include_file_sections { - configure_structured_diff(&mut arguments); - } - let mut output = capture_git_with_environment(root, &arguments, None, true, &environment)?; - if output.exit_code != 0 { - return Err( - CoreError::new(ErrorCode::ProcessFailed, "Git snapshot diff failed") - .with_details(String::from_utf8_lossy(&output.stderr)), - ); - } - - let mut sections = if request.include_file_sections { - Some(structured_numstat_output( - std::mem::replace( - &mut output, - GitProcessOutput { - stdout: Vec::new(), - stderr: Vec::new(), - exit_code: 0, - }, - ), - false, - )?) - } else { - None - }; - let mut section_bytes = sections.as_ref().map(structured_file_bytes).unwrap_or(0); - let untracked_arguments = vec![ - "ls-files".into(), - "--others".into(), - "--exclude-standard".into(), - "-z".into(), - "--".into(), - ] - .into_iter() - .chain(request.pathspecs.clone()) - .collect::>(); - let untracked = - capture_git_with_environment(root, &untracked_arguments, None, true, &environment)?; - if untracked.exit_code != 0 { - return Err( - CoreError::new(ErrorCode::ProcessFailed, "Git snapshot file lookup failed") - .with_details(String::from_utf8_lossy(&untracked.stderr)), - ); - } - for path in untracked - .stdout - .split(|byte| *byte == 0) - .filter(|path| !path.is_empty()) - { - let path = String::from_utf8_lossy(path).to_string(); - if !is_safe_pathspec(&path) { - continue; - } - let mut untracked_arguments = vec![ - "diff".into(), - "--no-ext-diff".into(), - "--binary".into(), - "--no-index".into(), - format!("--unified={}", request.context_lines), - ]; - append_full_file_context_arguments(&mut untracked_arguments, request.context_lines); - if request.ignore_all_whitespace { - untracked_arguments.push("--ignore-all-space".into()); - } - untracked_arguments.extend(["--".into(), null_device().into(), path]); - if request.include_file_sections { - configure_structured_diff(&mut untracked_arguments); - } - let added = - capture_git_with_environment(root, &untracked_arguments, None, true, &environment)?; - if let Some(ref mut combined) = sections { - append_structured_files( - combined, - structured_numstat_output(added, false)?, - &mut section_bytes, - )?; - } else { - output.stdout.extend(added.stdout); - output.stderr.extend(added.stderr); - } - } - if let Some(sections) = sections { - Ok(sections) - } else { - Ok(structured_diff_from_output(output)) - } -} - -// Request Git's NUL-delimited identities and its patch in the same process. -fn configure_structured_diff(arguments: &mut Vec) { - arguments.splice( - 1..1, - [ - "--numstat".into(), - "-z".into(), - "--patch".into(), - "--no-color".into(), - "--no-textconv".into(), - ], - ); - arguments.splice( - 0..0, - [ - "-c".into(), - "diff.noprefix=false".into(), - "-c".into(), - "diff.mnemonicprefix=false".into(), - ], - ); -} - -fn structured_file_bytes(response: &GitDiffResponse) -> usize { - response - .files - .as_ref() - .map(|files| files.iter().map(|file| file.patch.len()).sum()) - .unwrap_or(0) -} - -fn append_structured_files( - target: &mut GitDiffResponse, - source: GitDiffResponse, - bytes: &mut usize, -) -> Result<(), CoreError> { - *bytes = bytes - .checked_add(structured_file_bytes(&source)) - .ok_or_else(|| CoreError::new(ErrorCode::InvalidRequest, "Git diff exceeds 32 MiB"))?; - if *bytes > patch_exchange::MAX_PATCH_BYTES { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Git diff exceeds 32 MiB", - )); - } - target.has_decoding_loss |= source.has_decoding_loss; - if let (Some(target), Some(source)) = (&mut target.files, source.files) { - target.extend(source); - } - Ok(()) -} - -fn structured_diff_from_output(output: GitProcessOutput) -> GitDiffResponse { - // Diff is a machine-readable stdout protocol. Git diagnostics on stderr - // must never become synthetic file lines in the parsed patch. - let decoded = String::from_utf8_lossy(&output.stdout); - // A Cow::Owned means invalid UTF-8 bytes were replaced with U+FFFD; the - // patch cannot be used as byte-faithful write material. - let has_decoding_loss = matches!(decoded, std::borrow::Cow::Owned(_)); - let patch = decoded.to_string(); - let document = parse_diff(&patch); - GitDiffResponse { - patch, - rows: document.0, - hunks: document.1, - has_decoding_loss, - files: None, - } -} - -/// Adds a content-only patch to every hunk of a rename section, so applying -/// one hunk changes the target file's text without reversing the rename. -fn attach_rename_content_patches(section: &str, hunks: &mut [GitDiffHunkResponse]) { - let target = section - .lines() - .take_while(|line| !line.starts_with("@@")) - .find(|line| line.starts_with("+++ b/") || line.starts_with("+++ \"b/")); - let Some(target) = target else { - return; - }; - let old = target - .strip_prefix("+++ b/") - .map(|path| format!("--- a/{path}")) - .or_else(|| { - target - .strip_prefix("+++ \"b/") - .map(|path| format!("--- \"a/{path}")) - }); - let Some(old) = old else { - return; - }; - for hunk in hunks { - let marker = format!("\n{}", hunk.header); - if let Some(offset) = hunk.patch.find(&marker) { - hunk.content_patch = Some(format!("{old}\n{target}\n{}", &hunk.patch[offset + 1..])); - } - } -} - -// Git owns filename decoding. Pair its ordered NUL-delimited metadata with -// byte-preserving section boundaries, then parse each document once. -fn structured_patch_sections( - patch: &str, - identities: Vec, - has_decoding_loss: bool, -) -> Result { - let mut files = Vec::new(); - if !patch.is_empty() { - let mut starts = Vec::new(); - let mut offset = 0; - for line in patch.split_inclusive('\n') { - if line.starts_with("diff --git ") { - starts.push(offset); - } - offset += line.len(); - } - if starts.first() != Some(&0) || starts.len() != identities.len() { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Patch sections do not match Git metadata", - )); - } - starts.push(patch.len()); - for (identity, range) in identities.into_iter().zip(starts.windows(2)) { - let section = &patch[range[0]..range[1]]; - let status = if section - .lines() - .any(|line| line.starts_with("new file mode ")) - { - "A" - } else if section - .lines() - .any(|line| line.starts_with("deleted file mode ")) - { - "D" - } else if identity.original_path.is_some() { - "R" - } else { - "M" - }; - let is_binary = identity.additions.is_none() && identity.deletions.is_none(); - let has_lossy_line_endings = has_decoding_loss - || section.split('\n').any(|line| { - line.ends_with('\r') || line.starts_with("\\ No newline at end of file") - }); - let (rows, mut hunks, lines) = parse_diff(section); - if status == "R" { - attach_rename_content_patches(section, &mut hunks); - } - files.push(GitDiffFileResponse { - path: identity.path, - original_path: identity.original_path, - status: status.into(), - patch: section.into(), - rows, - hunks, - lines, - is_binary, - has_lossy_line_endings, - additions: identity.additions, - deletions: identity.deletions, - }); - } - } else if !identities.is_empty() { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Patch sections do not match Git metadata", - )); - } - Ok(GitDiffResponse { - patch: String::new(), - rows: Vec::new(), - hunks: Vec::new(), - has_decoding_loss, - files: Some(files), - }) -} - -// Git writes all `--numstat -z` records first, followed by one extra NUL and -// the patch. NUL cannot occur inside a Git path, including quoted path names. -fn structured_numstat_output( - output: GitProcessOutput, - require_utf8: bool, -) -> Result { - if output.stdout.is_empty() { - return structured_patch_sections("", Vec::new(), false); - } - let separator = output - .stdout - .windows(2) - .position(|bytes| bytes == [0, 0]) - .ok_or_else(|| { - CoreError::new(ErrorCode::InvalidRequest, "Git diff metadata is incomplete") - })?; - let identities = patch_exchange::parse_diff_statistics_in_order(&output.stdout[..=separator])?; - let bytes = &output.stdout[separator + 2..]; - if bytes.len() > patch_exchange::MAX_PATCH_BYTES { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Saved patch exceeds 32 MiB", - )); - } - let decoded = String::from_utf8_lossy(bytes); - let has_decoding_loss = matches!(decoded, std::borrow::Cow::Owned(_)); - if require_utf8 && has_decoding_loss { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Saved patch must contain valid UTF-8", - )); - } - structured_patch_sections(&decoded, identities, has_decoding_loss) -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct ParsePatchRequest { - root: String, - patch: String, -} - -pub(crate) fn parse_patch_command( - payload: serde_json::Value, -) -> Result { - let request: ParsePatchRequest = serde_json::from_value(payload).map_err(|_| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid Git patch parse request") - })?; - let root = validate_root(&request.root)?; - if request.patch.len() > patch_exchange::MAX_PATCH_BYTES { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Saved patch exceeds 32 MiB", - )); - } - let identities = if request.patch.is_empty() { - Vec::new() - } else { - patch_exchange::ordered_patch_files(&root, &request.patch)? - }; - serde_json::to_value(structured_patch_sections( - &request.patch, - identities, - false, - )?) - .map_err(|_| CoreError::new(ErrorCode::Unknown, "Could not encode parsed Git patch")) -} - -/// Applies a validated patch using the requested index or working-tree mode. -pub fn apply(request: GitApplyRequest) -> Result { - let root = validate_root(&request.root)?; - let _lease = rewrite::RewriteLease::acquire(&root)?; - let arguments = match request.mode.as_str() { - "stage" => vec![ - "apply".to_string(), - "--cached".to_string(), - "--whitespace=nowarn".to_string(), - ], - "unstage" => vec![ - "apply".to_string(), - "--cached".to_string(), - "--reverse".to_string(), - "--whitespace=nowarn".to_string(), - ], - "discard" => { - // A hunk rollback rewrites worktree bytes; a clean/smudge filter or - // working-tree encoding could change bytes the patch never saw. - let mut paths = Vec::new(); - for file in patch_exchange::ordered_patch_files(&root, &request.patch)? { - paths.extend(file.original_path); - paths.push(file.path); - } - if !diff_blocks::worktree_attributes_safe(&root, &paths)? { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - diff_blocks::HUNK_ROLLBACK_UNSAFE, - )); - } - vec![ - "apply".to_string(), - "--reverse".to_string(), - "--whitespace=nowarn".to_string(), - ] - } - // Reconstruct a saved index snapshot and its worktree in one step. - // `--cached` alone would leave the worktree at HEAD, which makes a - // subsequent unstaged patch fail for files with both staged and - // unstaged edits. - "restoreIndex" => vec![ - "apply".to_string(), - "--index".to_string(), - "--whitespace=nowarn".to_string(), - ], - "worktree" => vec!["apply".to_string(), "--whitespace=nowarn".to_string()], - "restoreIndexCheck" => vec![ - "apply".to_string(), - "--cached".to_string(), - "--reverse".to_string(), - "--check".to_string(), - "--whitespace=nowarn".to_string(), - ], - "worktreeCheck" => vec![ - "apply".to_string(), - "--reverse".to_string(), - "--check".to_string(), - "--whitespace=nowarn".to_string(), - ], - _ => { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Unsupported Git patch mode", - )) - } - }; - command(GitCommandRequest { - root: request.root, - arguments, - input: Some(request.patch), - }) -} - -fn read_commit_log( - root: &str, - selectors: Vec, - limit: usize, - failure_message: &str, -) -> Result<(Vec, bool), CoreError> { - let mut arguments = vec!["log".to_string()]; - arguments.extend(selectors); - arguments.extend([ - "--topo-order".to_string(), - "--decorate=short".to_string(), - "-n".to_string(), - (limit.saturating_add(1)).to_string(), - GIT_COMMIT_DATE_ARGUMENT.to_string(), - "--pretty=format:%H%x1f%h%x1f%P%x1f%an%x1f%ae%x1f%ad%x1f%s%x1f%D".to_string(), - ]); - let commit_output = readonly_command(GitCommandRequest { - root: root.to_string(), - arguments, - input: None, - })?; - if commit_output.exit_code != 0 { - return Err(CoreError::new(ErrorCode::ProcessFailed, failure_message) - .with_details(commit_output.output)); - } - - let all_commits = commit_output - .output - .lines() - .filter_map(parse_commit) - .collect::>(); - let has_more = all_commits.len() > limit; - Ok((all_commits.into_iter().take(limit).collect(), has_more)) -} - -/// Resolves one commit, its parent metadata, and the message body after the subject. -pub fn commit(request: GitCommitRequest) -> Result { - let root = validate_root(&request.root)?; - validate_revision(&request.commit)?; - let response = readonly_command(GitCommandRequest { - root, - arguments: vec![ - "show".to_string(), - "-s".to_string(), - // `log.showSignature` would otherwise print verification output ahead of - // the machine-parsed format. - "--no-show-signature".to_string(), - GIT_COMMIT_DATE_ARGUMENT.to_string(), - // The body is free-form multi-line text that may contain the unit - // separator, so it follows a NUL that Git never emits in the header fields. - "--pretty=format:%H%x1f%h%x1f%P%x1f%an%x1f%ae%x1f%ad%x1f%s%x1f%D%x00%b".to_string(), - request.commit, - ], - input: None, - })?; - if response.exit_code != 0 { - return Err( - CoreError::new(ErrorCode::ProcessFailed, "Git commit lookup failed") - .with_details(response.output), - ); - } - parse_commit_lookup(&response.stdout) - .ok_or_else(|| CoreError::new(ErrorCode::ProcessFailed, "Git commit was not found")) -} - -/// Splits `git show` output produced by [`commit`] into header metadata and body. -fn parse_commit_lookup(output: &str) -> Option { - let (header, body) = output.split_once('\0')?; - let commit = parse_commit(header.trim_end_matches(['\r', '\n']))?; - Some(GitCommitLookupResponse { - commit, - // Git terminates `%b` with a line break; trailing blank lines carry no content. - body: body.trim_end().to_string(), - }) -} - -/// Lists workspace-relative files changed by one commit. -pub fn commit_files(request: GitCommitFilesRequest) -> Result { - let root = validate_root(&request.root)?; - validate_revision(&request.commit)?; - let response = readonly_command(GitCommandRequest { - root, - arguments: vec![ - "-c".to_string(), - "core.quotepath=false".to_string(), - "show".to_string(), - "--pretty=format:".to_string(), - "--name-status".to_string(), - "--find-renames".to_string(), - request.commit, - ], - input: None, - })?; - if response.exit_code != 0 { - return Err( - CoreError::new(ErrorCode::ProcessFailed, "Git commit files failed") - .with_details(response.output), - ); - } - Ok(GitFilesResponse { - files: parse_name_status(&response.output), - }) -} - -/// Computes ahead/behind counts and changed files for a reference comparison. -pub fn comparison(request: GitComparisonRequest) -> Result { - let root = validate_root(&request.root)?; - if request.include_untracked - && (request.target_git_reference.is_some() - || request - .reference - .as_deref() - .is_some_and(|value| value.contains(".."))) - { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Untracked files require a worktree comparison", - )); - } - let reference = typed_reference_range( - &root, - request.git_reference.as_ref(), - request.target_git_reference.as_ref(), - request.reference.as_deref(), - )? - .ok_or_else(|| CoreError::new(ErrorCode::InvalidRequest, "Missing Git reference"))?; - validate_revision(&reference)?; - let response = readonly_command(GitCommandRequest { - root: root.clone(), - arguments: vec![ - "-c".to_string(), - "core.quotepath=false".to_string(), - "diff".to_string(), - "--name-status".to_string(), - "--find-renames".to_string(), - reference, - "--".to_string(), - ], - input: None, - })?; - if response.exit_code != 0 { - return Err( - CoreError::new(ErrorCode::ProcessFailed, "Git comparison failed") - .with_details(response.output), - ); - } - let files = parse_name_status(&response.output); - if !request.include_untracked { - return Ok(GitComparisonResponse { files }); - } - let snapshot = status(GitStatusRequest { - root, - include_index_only_changes: false, - repository_roots: Vec::new(), - summarize_untracked_directories: false, - })?; - let mut by_path: std::collections::BTreeMap<_, _> = files - .into_iter() - .map(|file| (file.path.clone(), file)) - .collect(); - for change in snapshot - .changes - .into_iter() - .filter(|change| change.untracked) - { - by_path - .entry(change.path.clone()) - .or_insert(GitFileResponse { - status: "A".into(), - path: change.path, - is_untracked: true, - }); - } - Ok(GitComparisonResponse { - files: by_path.into_values().collect(), - }) -} - -/// Reports which files would block a checkout of `reference`. -/// -/// A file blocks the switch when it has uncommitted changes *and* its content -/// differs between HEAD and the target ref. Files dirty in only one of those two -/// senses are carried across by Git without complaint. -pub fn checkout_preflight( - request: GitCheckoutPreflightRequest, -) -> Result { - let root = validate_root(&request.root)?; - let reference = request_reference( - &root, - request.git_reference.as_ref(), - request.reference.as_deref(), - )?; - - let dirty = readonly_command(GitCommandRequest { - root: root.clone(), - arguments: vec![ - "diff".to_string(), - "HEAD".to_string(), - "--name-only".to_string(), - ], - input: None, - })?; - if dirty.exit_code != 0 { - return Err( - CoreError::new(ErrorCode::ProcessFailed, "Git diff failed").with_details(dirty.output) - ); - } - let dirty_paths = dirty - .output - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .collect::>(); - - // Untracked files block a checkout too, whenever the target branch tracks the same - // path: git refuses rather than overwrite them. These never appear in `diff HEAD`. - let untracked = readonly_command(GitCommandRequest { - root: root.clone(), - arguments: vec![ - "ls-files".to_string(), - "--others".to_string(), - "--exclude-standard".to_string(), - ], - input: None, - })?; - if untracked.exit_code != 0 { - return Err( - CoreError::new(ErrorCode::ProcessFailed, "Git ls-files failed") - .with_details(untracked.output), - ); - } - let untracked_paths = untracked - .output - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .collect::>(); - - if dirty_paths.is_empty() && untracked_paths.is_empty() { - return Ok(GitCheckoutPreflightResponse { - blocking_paths: Vec::new(), - }); - } - - let mut blocking_paths = Vec::new(); - if !untracked_paths.is_empty() { - let tracked_on_target = readonly_command(GitCommandRequest { - root: root.clone(), - arguments: vec![ - "ls-tree".to_string(), - "-r".to_string(), - "--name-only".to_string(), - reference.clone(), - ], - input: None, - })?; - if tracked_on_target.exit_code != 0 { - return Err( - CoreError::new(ErrorCode::ProcessFailed, "Git ls-tree failed") - .with_details(tracked_on_target.output), - ); - } - blocking_paths.extend( - tracked_on_target - .output - .lines() - .map(str::trim) - .filter(|line| !line.is_empty() && untracked_paths.contains(line)) - .map(str::to_string), - ); - } - - if dirty_paths.is_empty() { - blocking_paths.sort(); - blocking_paths.dedup(); - return Ok(GitCheckoutPreflightResponse { blocking_paths }); - } - - let divergent = readonly_command(GitCommandRequest { - root, - arguments: vec![ - "diff".to_string(), - "HEAD".to_string(), - reference, - "--name-only".to_string(), - ], - input: None, - })?; - if divergent.exit_code != 0 { - return Err(CoreError::new(ErrorCode::ProcessFailed, "Git diff failed") - .with_details(divergent.output)); - } - - blocking_paths.extend( - divergent - .output - .lines() - .map(str::trim) - .filter(|line| !line.is_empty() && dirty_paths.contains(line)) - .map(str::to_string), - ); - blocking_paths.sort(); - blocking_paths.dedup(); - Ok(GitCheckoutPreflightResponse { blocking_paths }) -} - -/// Lists staged files that still contain conflict markers. -/// -/// Only `<<<<<<< ` and friends with their trailing space are matched: a bare -/// `=======` is also a Markdown heading underline, and matching it flags ordinary -/// documentation. `|||||||` covers the diff3 conflict style. Git skips binary -/// files itself, so a blob containing those bytes cannot trip this. -pub fn conflict_marker_paths( - request: GitConflictMarkerRequest, -) -> Result { - let root = validate_root(&request.root)?; - let paths = staged_conflict_marker_paths(&root)?; - Ok(GitConflictMarkerResponse { paths }) -} - -fn staged_conflict_marker_paths(root: &str) -> Result, CoreError> { - staged_conflict_marker_paths_with_environment(root, &[], false) -} - -fn staged_conflict_marker_paths_with_environment( - root: &str, - environment: &[(String, String)], - changed_only: bool, -) -> Result, CoreError> { - // The alternate index starts at HEAD, so its staged diff is the authoritative - // expansion of directory and glob pathspecs without repeating a long path list. - let changed_paths = if changed_only { - let changed = execute_git_with_environment( - root, - &[ - "diff".to_string(), - "--cached".to_string(), - "--no-ext-diff".to_string(), - "--name-only".to_string(), - "-z".to_string(), - ], - None, - true, - environment, - )?; - if changed.exit_code != 0 { - return Err( - CoreError::new(ErrorCode::ProcessFailed, "Git staged diff failed") - .with_details(changed.output), - ); - } - Some( - changed - .stdout - .split('\0') - .filter(|path| !path.is_empty()) - .map(str::to_string) - .collect::>(), - ) - } else { - None - }; - let arguments = vec![ - "grep".to_string(), - "--cached".to_string(), - "-l".to_string(), - "-z".to_string(), - "-E".to_string(), - r"^(<<<<<<<|>>>>>>>|\|\|\|\|\|\|\|) ".to_string(), - ]; - let found = execute_git_with_environment(root, &arguments, None, true, environment)?; - // `git grep` exits 1 when nothing matches, which is not a failure here. - if found.exit_code > 1 { - return Err( - CoreError::new(ErrorCode::ProcessFailed, "Git grep failed").with_details(found.output) - ); - } - - let mut paths: Vec = found - .stdout - .split('\0') - .filter(|path| { - !path.is_empty() - && changed_paths - .as_ref() - .map_or(true, |changed| changed.contains(*path)) - }) - .map(str::to_string) - .collect(); - paths.sort(); - paths.dedup(); - Ok(paths) -} - -fn commit_selected_paths( - root: &str, - paths: Vec, - message: String, - amend: bool, - identity: Vec, -) -> Result { - let operation = operation_state(GitOperationStateRequest { - root: root.to_string(), - })?; - if !operation.kind.is_empty() { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Finish or abort the current Git operation before committing selected files", - )); - } - - let branch = execute_git_readonly( - root, - &["symbolic-ref".into(), "--quiet".into(), "HEAD".into()], - None, - )?; - let branch_reference = branch.stdout.trim(); - if branch.exit_code != 0 || !branch_reference.starts_with("refs/heads/") { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Selected files can only be committed on a local branch", - )); - } - - let head = execute_git_readonly( - root, - &[ - "rev-parse".to_string(), - "--verify".to_string(), - "-q".to_string(), - "HEAD".to_string(), - ], - None, - )?; - if head.exit_code > 1 { - return Ok(head); - } - let original_head = (head.exit_code == 0).then(|| head.stdout.trim().to_string()); - if amend && original_head.is_none() { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "An initial commit cannot be amended", - )); - } - - // Git's own commit command still runs hooks and signing, but its HEAD and - // index live in an isolated administration directory. The reviewed branch - // is updated only after the resulting snapshot passes validation. - let common_directory = git_resolved_path( - root, - &["rev-parse", "--path-format=absolute", "--git-common-dir"], - "Git common directory", - )?; - let temporary_context = TemporaryGitCommitContext::prepare(root, original_head.as_deref())?; - let environment = temporary_context.environment(root, &common_directory); - let pathspec_input = nul_pathspec_input(&paths); - let initialize_arguments = if let Some(head) = original_head.as_ref() { - vec!["read-tree".to_string(), head.clone()] - } else { - vec!["read-tree".to_string(), "--empty".to_string()] - }; - let initialized = - execute_git_with_environment(root, &initialize_arguments, None, false, &environment)?; - if initialized.exit_code != 0 { - return Ok(initialized); - } - let stage_arguments = vec![ - "add".to_string(), - "-A".to_string(), - "--pathspec-from-file=-".to_string(), - "--pathspec-file-nul".to_string(), - ]; - let staged = execute_git_with_environment( - root, - &stage_arguments, - Some(pathspec_input.clone()), - false, - &environment, - )?; - if staged.exit_code != 0 { - return Ok(staged); - } - - let allowed_paths = staged_changed_paths(root, &environment)?; - - let marker_paths = staged_conflict_marker_paths_with_environment(root, &environment, true)?; - if !marker_paths.is_empty() { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Conflict markers remain in selected files", - ) - .with_details(marker_paths.join(", "))); - } - - let mut arguments = vec!["commit".into()]; - if amend { - arguments.push("--amend".into()); - } - arguments.extend(identity); - arguments.extend(["-m".into(), message]); - let committed = execute_git_with_environment(root, &arguments, None, false, &environment)?; - if committed.exit_code != 0 { - return Ok(committed); - } - - let committed_head = execute_git_with_environment( - root, - &["rev-parse".into(), "--verify".into(), "HEAD".into()], - None, - true, - &environment, - )?; - if committed_head.exit_code != 0 { - if let Err(error) = temporary_context.cleanup_reference(root) { - // Preserve the commit result; WARN reports a separate leaked reference. - crate::diagnostics::core_events::core_failed( - "vcs.commit.cleanup_failed", - "reference", - &error, - ); - } - return Ok(committed_head); - } - let committed_head = committed_head.stdout.trim().to_string(); - let committed_paths = commit_changed_paths(root, original_head.as_deref(), &committed_head)?; - let unexpected_paths = committed_paths - .difference(&allowed_paths) - .cloned() - .collect::>(); - if !unexpected_paths.is_empty() { - if let Err(error) = temporary_context.cleanup_reference(root) { - // Preserve the commit result; WARN reports a separate leaked reference. - crate::diagnostics::core_events::core_failed( - "vcs.commit.cleanup_failed", - "reference", - &error, - ); - } - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "A Git hook added files outside the selected commit", - ) - .with_details(unexpected_paths.join(", "))); - } - - let expected_old = match original_head.as_ref() { - Some(head) => head.clone(), - None => null_object_id(root)?, - }; - let updated = execute_git( - root, - &[ - "update-ref".into(), - "-m".into(), - if amend { - "commit (amend): selected files".into() - } else { - "commit: selected files".into() - }, - branch_reference.to_string(), - committed_head, - expected_old, - ], - None, - )?; - if updated.exit_code != 0 { - if let Err(error) = temporary_context.cleanup_reference(root) { - // Preserve the commit result; WARN reports a separate leaked reference. - crate::diagnostics::core_events::core_failed( - "vcs.commit.cleanup_failed", - "reference", - &error, - ); - } - return Ok(updated); - } - - // Reconcile only committed paths in the real index. Unrelated staging that - // another Git process created while hooks ran remains intact. - let mut reconciled = execute_git( - root, - &[ - "reset".to_string(), - "-q".to_string(), - "HEAD".to_string(), - "--pathspec-from-file=-".to_string(), - "--pathspec-file-nul".to_string(), - ], - Some(pathspec_input), - )?; - if reconciled.exit_code != 0 { - reconciled.warnings.push(GitOperationWarning::new( - "git_index_reconcile_failed", - "The commit succeeded, but the Git index could not be reconciled", - Some(reconciled.output.clone()), - )); - reconciled.exit_code = 0; - } - if let Err(error) = temporary_context.cleanup_reference(root) { - reconciled.warnings.push(GitOperationWarning::new( - "git_temporary_reference_cleanup_failed", - "The commit succeeded, but its temporary Git reference could not be removed", - error.details, - )); - } - Ok(reconciled) -} - -fn staged_changed_paths( - root: &str, - environment: &[(String, String)], -) -> Result, CoreError> { - let changed = execute_git_with_environment( - root, - &[ - "diff".into(), - "--cached".into(), - "--name-only".into(), - "-z".into(), - ], - None, - true, - environment, - )?; - if changed.exit_code != 0 { - return Err( - CoreError::new(ErrorCode::ProcessFailed, "Git staged diff failed") - .with_details(changed.output), - ); - } - Ok(changed - .stdout - .split('\0') - .filter(|path| !path.is_empty()) - .map(str::to_string) - .collect()) -} - -fn commit_changed_paths( - root: &str, - original_head: Option<&str>, - committed_head: &str, -) -> Result, CoreError> { - let arguments = if let Some(original_head) = original_head { - vec![ - "diff".into(), - "--name-only".into(), - "-z".into(), - original_head.into(), - committed_head.into(), - ] - } else { - vec![ - "diff-tree".into(), - "--root".into(), - "--no-commit-id".into(), - "--name-only".into(), - "-z".into(), - committed_head.into(), - ] - }; - let changed = execute_git_readonly(root, &arguments, None)?; - if changed.exit_code != 0 { - return Err(CoreError::new( - ErrorCode::ProcessFailed, - "Could not validate the selected commit", - ) - .with_details(changed.output)); - } - Ok(changed - .stdout - .split('\0') - .filter(|path| !path.is_empty()) - .map(str::to_string) - .collect()) -} - -fn null_object_id(root: &str) -> Result { - let format = execute_git_readonly( - root, - &["rev-parse".into(), "--show-object-format".into()], - None, - )?; - if format.exit_code != 0 { - return Err(CoreError::new( - ErrorCode::ProcessFailed, - "Could not determine Git object format", - ) - .with_details(format.output)); - } - Ok(match format.stdout.trim() { - "sha256" => "0".repeat(64), - _ => "0".repeat(40), - }) -} - -/// How an operation decides whether a dirty working tree is in its way. -enum IntegrationShape { - /// Refuses only over files differing between the merge base and the target. - MergeBase, - /// Refuses over any uncommitted change, however unrelated. - AnyDirty, - /// Refuses only over files the single replayed commit touches. - SingleCommit, -} - -/// Reports what would stop a merge, rebase, cherry-pick, or revert from starting. -/// -/// The rules differ and were each checked against Git directly: merge, cherry-pick -/// and revert refuse only when a dirty file overlaps what they would write, while a -/// rebase refuses on any uncommitted change, staged or not, however unrelated. -/// Matching Git's stderr is not an option since it is localized. -pub fn integration_preflight( - request: GitIntegrationPreflightRequest, -) -> Result { - let root = validate_root(&request.root)?; - let reference = request_reference( - &root, - request.git_reference.as_ref(), - request.reference.as_deref(), - )?; - let shape = match request.operation.as_str() { - "merge" => IntegrationShape::MergeBase, - "rebase" => IntegrationShape::AnyDirty, - // Verified against Git: both refuse only when a dirty file overlaps what the - // commit touches, exactly like a merge. The overlap set differs though, since - // they replay one commit rather than joining two branches. - "cherryPick" | "revert" => IntegrationShape::SingleCommit, - other => { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - format!("Unknown integration operation '{other}'"), - )) - } - }; - - // Tracked files differing from HEAD, staged or not: `diff HEAD` covers both. - let dirty = readonly_command(GitCommandRequest { - root: root.clone(), - arguments: vec![ - "diff".to_string(), - "HEAD".to_string(), - "--name-only".to_string(), - ], - input: None, - })?; - if dirty.exit_code != 0 { - return Err( - CoreError::new(ErrorCode::ProcessFailed, "Git diff failed").with_details(dirty.output) - ); - } - let dirty_paths: Vec = dirty - .output - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .map(str::to_string) - .collect(); - - if dirty_paths.is_empty() { - return Ok(GitIntegrationPreflightResponse { - blocking_paths: Vec::new(), - blocks_entirely: false, - }); - } - - // A rebase stops for any uncommitted change, so every dirty path is blocking - // and there is no overlap to compute. - if matches!(shape, IntegrationShape::AnyDirty) { - let mut blocking_paths = dirty_paths; - blocking_paths.sort(); - blocking_paths.dedup(); - return Ok(GitIntegrationPreflightResponse { - blocking_paths, - blocks_entirely: true, - }); - } - - // Everything else refuses only over files it would write. For a merge those are - // the files differing since the merge base; for a single replayed commit they - // are the files that commit changed against its own parent. - let written = match shape { - IntegrationShape::MergeBase => { - let base = readonly_command(GitCommandRequest { - root: root.clone(), - arguments: vec![ - "merge-base".to_string(), - "HEAD".to_string(), - reference.clone(), - ], - input: None, - })?; - if base.exit_code != 0 { - return Err( - CoreError::new(ErrorCode::ProcessFailed, "Git merge-base failed") - .with_details(base.output), - ); - } - readonly_command(GitCommandRequest { - root, - arguments: vec![ - "diff".to_string(), - base.output.trim().to_string(), - reference, - "--name-only".to_string(), - ], - input: None, - })? - } - // `diff-tree` against the commit itself; the extra flags make a merge commit - // and the root commit behave rather than print nothing. - IntegrationShape::SingleCommit => readonly_command(GitCommandRequest { - root, - arguments: vec![ - "diff-tree".to_string(), - "-r".to_string(), - "-m".to_string(), - "--root".to_string(), - "--name-only".to_string(), - "--no-commit-id".to_string(), - reference, - ], - input: None, - })?, - IntegrationShape::AnyDirty => unreachable!("handled above"), - }; - if written.exit_code != 0 { - return Err(CoreError::new(ErrorCode::ProcessFailed, "Git diff failed") - .with_details(written.output)); - } - let written_paths = written - .output - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .collect::>(); - - let mut blocking_paths: Vec = dirty_paths - .into_iter() - .filter(|path| written_paths.contains(path.as_str())) - .collect(); - blocking_paths.sort(); - blocking_paths.dedup(); - Ok(GitIntegrationPreflightResponse { - blocking_paths, - blocks_entirely: false, - }) -} - -/// Reports whether a pull can fast-forward, so the UI can ask before it fails. -/// -/// Git's own refusal for a divergent pull is a multi-line hint block that is -/// localized, so the counts are computed here instead. Fetching first would make -/// the numbers fresher, but this stays read-only: the caller decides when to hit -/// the network. -pub fn pull_preflight( - request: GitPullPreflightRequest, -) -> Result { - let root = validate_root(&request.root)?; - - let upstream = readonly_command(GitCommandRequest { - root: root.clone(), - arguments: vec![ - "rev-parse".to_string(), - "--abbrev-ref".to_string(), - "--symbolic-full-name".to_string(), - "@{upstream}".to_string(), - ], - input: None, - })?; - // A non-zero exit here means "no upstream configured", not a failure. - if upstream.exit_code != 0 { - return Ok(GitPullPreflightResponse { - upstream: None, - ahead: 0, - behind: 0, - diverged: false, - has_local_changes: false, - configured_strategy: None, - }); - } - let upstream = upstream.output.trim().to_string(); - - let counts = readonly_command(GitCommandRequest { - root: root.clone(), - arguments: vec![ - "rev-list".to_string(), - "--left-right".to_string(), - "--count".to_string(), - format!("{upstream}...HEAD"), - ], - input: None, - })?; - if counts.exit_code != 0 { - return Err( - CoreError::new(ErrorCode::ProcessFailed, "Git rev-list failed") - .with_details(counts.output), - ); - } - // `--left-right --count` prints "\t" for `upstream...HEAD`. - let mut fields = counts.output.split_whitespace(); - let behind = fields.next().and_then(|v| v.parse().ok()).unwrap_or(0); - let ahead = fields.next().and_then(|v| v.parse().ok()).unwrap_or(0); - - let dirty = readonly_command(GitCommandRequest { - root: root.clone(), - arguments: vec![ - "status".to_string(), - "--porcelain".to_string(), - "--untracked-files=no".to_string(), - ], - input: None, - })?; - if dirty.exit_code != 0 { - return Err( - CoreError::new(ErrorCode::ProcessFailed, "Git status failed") - .with_details(dirty.output), - ); - } - - Ok(GitPullPreflightResponse { - upstream: Some(upstream), - ahead, - behind, - diverged: ahead > 0 && behind > 0, - has_local_changes: !dirty.output.trim().is_empty(), - configured_strategy: configured_pull_strategy(&root)?, - }) -} - -/// The pull strategy Git is explicitly configured for, or `None` when neither -/// `pull.rebase` nor `pull.ff` says anything. `git.updatePlan` falls back to -/// fast-forward only in that case. -fn configured_pull_strategy(root: &str) -> Result, CoreError> { - let rebase = read_git_config_value(root, "pull.rebase")?; - let fast_forward = read_git_config_value(root, "pull.ff")?; - let enabled = |value: &Option, accepted: &[&str]| { - value - .as_deref() - .is_some_and(|value| accepted.contains(&value.to_ascii_lowercase().as_str())) - }; - Ok( - if enabled( - &rebase, - &["true", "yes", "on", "1", "interactive", "merges"], - ) { - Some("rebase") - } else if enabled(&fast_forward, &["only"]) { - Some("ffOnly") - } else if enabled(&fast_forward, &["false", "no", "off", "0"]) - || enabled(&rebase, &["false", "no", "off", "0"]) - { - Some("merge") - } else { - None - }, - ) -} - -/// Resolves the Git directory once, honoring worktrees and submodules where -/// `.git` is a file pointing elsewhere rather than a directory. -fn git_directory(root: &str) -> Result, CoreError> { - let response = execute_git_readonly( - root, - &["rev-parse".to_string(), "--absolute-git-dir".to_string()], - None, - )?; - if response.exit_code != 0 { - return Err( - CoreError::new(ErrorCode::ProcessFailed, "Git rev-parse failed") - .with_details(response.output), - ); - } - let raw = response.output.trim(); - if raw.is_empty() { - return Ok(None); - } - Ok(Some(PathBuf::from(raw))) -} - -/// Reads a single-line numeric counter written by an in-progress rebase. -fn rebase_counter(directory: &std::path::Path, name: &str) -> Option { - fs::read_to_string(directory.join(name)) - .ok()? - .trim() - .parse() - .ok() -} - -/// Reports whichever sequential operation Git has left half-finished. -/// -/// Detection reads the marker files Git itself writes, so an operation stopped by -/// a conflict is reported the same way whether the user started it in Lithe or on -/// the command line. -pub fn operation_state( - request: GitOperationStateRequest, -) -> Result { - let root = validate_root(&request.root)?; - - let mut kind = String::new(); - let mut reference = None; - let mut step = None; - let mut total = None; - - // Order matters: a conflicted rebase can carry a REVERT_HEAD from the commit - // it is replaying, so the more specific rebase directories are checked first. - if let Some(git_directory) = git_directory(&root)? { - let rebase_merge = git_directory.join("rebase-merge"); - let rebase_apply = git_directory.join("rebase-apply"); - let operation_directory = if fs::try_exists(&rebase_merge)? { - Some(rebase_merge) - } else if fs::try_exists(&rebase_apply)? { - Some(rebase_apply) - } else { - None - }; - if let Some(directory) = operation_directory { - kind = "rebase".to_string(); - step = rebase_counter(&directory, "msgnum"); - total = rebase_counter(&directory, "end"); - reference = fs::read_to_string(directory.join("onto")) - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()); - } else if fs::try_exists(git_directory.join("MERGE_HEAD"))? { - kind = "merge".to_string(); - } else if fs::try_exists(git_directory.join("CHERRY_PICK_HEAD"))? { - kind = "cherryPick".to_string(); - } else if fs::try_exists(git_directory.join("REVERT_HEAD"))? { - kind = "revert".to_string(); - } - } - - let status = execute_git_readonly( - &root, - &["status".to_string(), "--porcelain".to_string()], - None, - )?; - if status.exit_code != 0 { - return Err( - CoreError::new(ErrorCode::ProcessFailed, "Git status failed") - .with_details(status.output), - ); - } - - let mut conflicted_paths = status - .output - .lines() - .filter(|line| line.len() > 3 && is_conflicted_status(&line[..2])) - .map(|line| line[3..].trim().to_string()) - .filter(|path| !path.is_empty()) - .collect::>(); - conflicted_paths.sort(); - conflicted_paths.dedup(); - - Ok(GitOperationStateResponse { - kind, - reference, - step, - total, - conflicted_paths, - }) -} - -/// Continues, aborts, or skips whichever operation is currently in progress. -/// -/// The subcommand depends on what Git left behind, so the state is read first -/// rather than trusted from the caller: the UI's view of it may be a refresh -/// behind, and issuing `rebase --continue` during a merge would just fail. -fn resolve_operation(root: &str, operation: &str) -> Result { - if let Some(response) = rebase_session::resolve_owned_operation(root, operation)? { - return Ok(response); - } - let state = operation_state(GitOperationStateRequest { - root: root.to_string(), - })?; - if state.kind.is_empty() { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "No Git operation is in progress", - )); - } - - let subcommand = match state.kind.as_str() { - "rebase" => "rebase", - "merge" => "merge", - "cherryPick" => "cherry-pick", - "revert" => "revert", - _ => { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Unsupported Git operation state", - )) - } - }; - - let action = match operation { - "operationContinue" => "--continue", - "operationAbort" => "--abort", - _ => "--skip", - }; - - // Only a rebase can skip a step; merge and cherry-pick have no equivalent. - if action == "--skip" && subcommand != "rebase" { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Only a rebase can skip a step", - )); - } - - if action == "--continue" && !state.conflicted_paths.is_empty() { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Resolve the conflicted files before continuing", - ) - .with_details(state.conflicted_paths.join("\n"))); - } - - // `--continue` opens an editor for the commit message by default, which would - // hang a process launched from the GUI with no terminal attached. Pointing the - // editor at `true` makes Git accept the message it already prepared and exit. - // `merge --continue` rejects extra arguments, so this must stay a config - // override rather than a `--no-edit` flag. - let arguments = vec![ - "-c".to_string(), - "core.editor=true".to_string(), - subcommand.to_string(), - action.to_string(), - ]; - - execute_git(root, &arguments, None) -} - -/// The porcelain status pairs Git uses for an unresolved merge conflict. -fn is_conflicted_status(code: &str) -> bool { - matches!(code, "UU" | "AA" | "DD" | "DU" | "UD" | "AU" | "UA") -} - -/// Lists stashes with stable references and parsed metadata. -pub fn stashes(request: GitStashesRequest) -> Result { - let root = validate_root(&request.root)?; - let response = readonly_command(GitCommandRequest { - root, - arguments: vec![ - "stash".to_string(), - "list".to_string(), - // `--date` would also turn `%gd` into `stash@{}`, so the date is asked for in - // its own ISO form and the reference stays the positional `stash@{N}`. - "--pretty=format:%gd%x1f%gs%x1f%ai%x1f%H".to_string(), - ], - input: None, - })?; - if response.exit_code != 0 { - return Err( - CoreError::new(ErrorCode::ProcessFailed, "Git stash list failed") - .with_details(response.output), - ); - } - Ok(GitStashesResponse { - stashes: response.output.lines().filter_map(parse_stash).collect(), - }) -} - -/// Returns line attribution parsed from Git's machine-readable porcelain form. -pub fn blame(request: GitBlameRequest) -> Result { - if !is_safe_pathspec(&request.path) { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Git blame contains an invalid path", - )); - } - let response = readonly_command(GitCommandRequest { - root: request.root, - arguments: vec![ - "blame".to_string(), - "--line-porcelain".to_string(), - "--".to_string(), - request.path, - ], - input: None, - })?; - if response.exit_code != 0 { - return Err(CoreError::new(ErrorCode::ProcessFailed, "Git blame failed") - .with_details(response.output)); - } - - let mut lines = Vec::new(); - let mut commit_hash = String::new(); - let mut author_name = "Unknown".to_string(); - let mut author_time = 0; - let mut final_line = 0; - for line in response.output.lines() { - let columns = line.split_whitespace().collect::>(); - if columns.len() >= 3 && columns[0].len() == 40 { - if let Ok(parsed_line) = columns[2].parse::() { - commit_hash = columns[0].to_string(); - final_line = parsed_line; - } - } else if let Some(value) = line.strip_prefix("author ") { - author_name = value.to_string(); - } else if let Some(value) = line.strip_prefix("author-time ") { - author_time = value.parse::().unwrap_or_default(); - } else if line.starts_with('\t') && final_line > 0 { - lines.push(GitBlameLineResponse { - line: final_line, - commit_hash: commit_hash.clone(), - author_name: author_name.clone(), - author_time, - }); - final_line += 1; - } - } - Ok(GitBlameResponse { lines }) -} - -/// Normalizes CRLF and bare CR newlines to LF. Line numbers are preserved, -/// so mapping stays valid while both comparison sides share one newline -/// semantics regardless of repository autocrlf settings. -fn normalize_line_history_newlines(content: &str) -> String { - if content.contains('\r') { - content.replace("\r\n", "\n").replace('\r', "\n") - } else { - content.to_string() - } -} - -/// Counts lines the way editors do: a trailing newline starts one final, -/// empty line that Rust's `lines()` does not yield. -fn line_history_line_count(content: &str) -> usize { - content.lines().count() + usize::from(content.ends_with('\n')) -} - -/// Removes one line-history scratch directory on every exit path. -struct ScopedLineHistoryDirectory(std::path::PathBuf); - -impl Drop for ScopedLineHistoryDirectory { - fn drop(&mut self) { - fs::cleanup_directory(&self.0); - } -} - -/// Creates an isolated scratch directory under the platform temp root. The -/// snapshot may hold private source text, so POSIX permissions are applied at -/// atomic creation exactly like the Git commit context directories. -fn create_line_history_scratch() -> Result { - if let Some(directory) = - fs::remote_temp_directory("lithe-line-history").map_err(line_history_io_error)? - { - return Ok(ScopedLineHistoryDirectory(directory)); - } - let prefix = format!("lithe-line-history-{}", std::process::id()); - let mut attempt = 0u128; - loop { - let stamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|value| value.as_nanos()) - .unwrap_or_default() - + attempt; - let candidate = std::env::temp_dir().join(format!("{prefix}-{stamp}")); - let builder = std::fs::DirBuilder::new(); - #[cfg(unix)] - let builder = { - use std::os::unix::fs::DirBuilderExt; - let mut builder = builder; - builder.mode(0o700); - builder - }; - match builder.create(&candidate) { - Ok(()) => return Ok(ScopedLineHistoryDirectory(candidate)), - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { - attempt += 1; - continue; - } - Err(error) => { - return Err(CoreError::new( - ErrorCode::Unknown, - "Could not create a line-history scratch directory", - ) - .with_details(error.to_string())); - } - } - } -} - -fn line_history_io_error(error: std::io::Error) -> CoreError { - CoreError::new( - ErrorCode::Unknown, - "Could not stage the line-history snapshot", - ) - .with_details(error.to_string()) -} - -/// Parses one `@@ -a[,n] +b[,m] @@` header into raw hunk coordinates. A zero -/// count marks an empty side, whose start is the line count before the hunk. -fn parse_line_history_hunk_header(line: &str) -> Option<(usize, usize, usize, usize)> { - let rest = line.strip_prefix("@@ -")?; - let (old, rest) = rest.split_once(' ')?; - let rest = rest.strip_prefix('+')?; - let (new, _) = rest.split_once(" @@")?; - let parse = |text: &str| -> Option<(usize, usize)> { - match text.split_once(',') { - Some((start, count)) => Some((start.parse().ok()?, count.parse().ok()?)), - None => Some((text.parse().ok()?, 1)), - } - }; - let (old_start, old_count) = parse(old)?; - let (new_start, new_count) = parse(new)?; - Some((old_start, old_count, new_start, new_count)) -} - -fn parse_line_history_hunks(diff: &str) -> Vec<(usize, usize, usize, usize)> { - diff.lines() - .filter_map(parse_line_history_hunk_header) - .collect() -} - -/// Inclusive end of one hunk side; an empty side collapses to its position. -fn line_history_endpoint(start: usize, count: usize) -> usize { - if count == 0 { - start - } else { - start + count - 1 - } -} - -/// Maps snapshot lines onto pinned-revision lines through `-U0` hunks, using -/// the unified-format rule that a zero-count side starts between lines. -/// Returns matched `(snapshot_line, revision_line)` pairs plus unmapped lines -/// flagged as provably inserted (`true`) or unreliably rewritten (`false`). -fn map_snapshot_lines( - snapshot_line_count: usize, - hunks: &[(usize, usize, usize, usize)], -) -> (Vec<(usize, usize)>, Vec<(usize, bool)>) { - let mut mapped = Vec::new(); - let mut unmapped = Vec::new(); - let mut snapshot_line = 1usize; - let mut revision_line = 1usize; - for &(old_start, old_count, new_start, new_count) in hunks { - let first_new = if new_count == 0 { - new_start + 1 - } else { - new_start - }; - while snapshot_line < first_new && snapshot_line <= snapshot_line_count { - mapped.push((snapshot_line, revision_line)); - snapshot_line += 1; - revision_line += 1; - } - if new_count > 0 { - let replaced_end = new_start + new_count - 1; - while snapshot_line <= replaced_end && snapshot_line <= snapshot_line_count { - unmapped.push((snapshot_line, old_count == 0)); - snapshot_line += 1; - } - } - if old_count > 0 { - // Jump to the first revision line after the replaced block; pure - // insertions leave the incremental positions aligned already. - revision_line = old_start + old_count; - } - } - while snapshot_line <= snapshot_line_count { - mapped.push((snapshot_line, revision_line)); - snapshot_line += 1; - revision_line += 1; - } - (mapped, unmapped) -} - -/// Recovers merge relevance from the ordinary first-parent diff, keeping only -/// hunks that actually contain one of the traced line contents. Anchoring by -/// content survives commits that `-L` omitted because they shifted lines or -/// renamed the file after the merge. -/// Translates one set of commit-side line numbers onto the parent side of the -/// same commit through its `-U0` hunks. Lines inside a replacement map onto -/// the parent-side span; lines inserted by the commit disappear; untouched -/// lines shift by the hunk deltas. -fn line_history_translate_lines( - lines: &std::collections::BTreeSet, - hunks: &[(usize, usize, usize, usize)], -) -> std::collections::BTreeSet { - let mut translated = std::collections::BTreeSet::new(); - for &line in lines { - let mut handled = false; - let mut delta = 0i64; - for &(old_start, old_count, new_start, new_count) in hunks { - let new_end = new_start + new_count.saturating_sub(1); - if new_count > 0 && line >= new_start && line <= new_end { - if old_count > 0 { - // A rewrite maps the new line onto the whole parent span: - // when counts differ, the line may descend from any of the - // replaced lines (squashing three lines into one keeps the - // merge range of the middle line reachable), so relevance - // intersection stays sound while remaining a superset. - for old_line in old_start..=old_start + old_count - 1 { - translated.insert(old_line); - } - } - handled = true; - break; - } - // A zero-count new side marks the position after the boundary - // row: that row itself keeps its number, later rows shift. - let after_hunk = if new_count == 0 { - line > new_start - } else { - line >= new_start + new_count - }; - if after_hunk { - delta += old_count as i64 - new_count as i64; - } - } - if !handled { - let shifted = line as i64 + delta; - if shifted >= 1 { - translated.insert(shifted as usize); - } - } - } - translated -} - -/// Recovers merge relevance by replaying the first-parent chain between the -/// pinned base and the merge's first parent. Text anchoring cannot disambiguate -/// duplicate or blank lines, so every touched commit's diff translates the -/// traced base intervals - and renames translate the path - into the merge's -/// coordinate system before its first-parent diff is filtered. -fn line_history_merge_ranges( - root: &str, - base_commit: &str, - base_intervals: &[(usize, usize)], - base_path: &str, - compare_base: &str, - hash: &str, -) -> Result, String, Option)>, CoreError> { - let mut lines: std::collections::BTreeSet = std::collections::BTreeSet::new(); - for (start, end) in base_intervals { - for line in *start..=*end { - lines.insert(line); - } - } - let mut path = base_path.to_string(); - // Walk backwards from the pinned base towards the merge's first parent, - // restarting the listing whenever a rename changes the path. - let mut walk_end = base_commit.to_string(); - loop { - let listing = readonly_command(GitCommandRequest { - root: root.to_string(), - arguments: vec![ - "log".to_string(), - "--first-parent".to_string(), - "--format=%H".to_string(), - // Exclude the merge itself: only commits after it may shift - // coordinates, which must stay in the merge's own frame. - format!("{hash}..{walk_end}"), - "--".to_string(), - path.clone(), - ], - input: None, - })?; - if listing.exit_code != 0 { - return Ok(None); - } - let touched: Vec<&str> = listing - .output - .lines() - .filter(|line| !line.trim().is_empty()) - .collect(); - if touched.is_empty() { - break; - } - let mut renamed_at: Option = None; - for commit in &touched { - let parent = readonly_command(GitCommandRequest { - root: root.to_string(), - arguments: vec!["rev-parse".to_string(), format!("{commit}^")], - input: None, - }); - // Root commits compare against the empty tree, matching git.diff. - let base_revision = match parent { - Ok(response) if response.exit_code == 0 => response.output.trim().to_string(), - _ => empty_tree_oid(root)?.to_string(), - }; - // The diff runs without a pathspec: restricting it to the walked - // path would clip renames into synthetic "new file" patches and - // destroy both the rename pairing and the line translation. - let diff = readonly_command(GitCommandRequest { - root: root.to_string(), - arguments: vec![ - "diff".to_string(), - "--unified=0".to_string(), - "--no-ext-diff".to_string(), - "--no-textconv".to_string(), - "--find-renames".to_string(), - base_revision.clone(), - commit.to_string(), - ], - input: None, - })?; - if diff.exit_code != 0 { - continue; - } - let mut hunks: Vec<(usize, usize, usize, usize)> = Vec::new(); - let mut rename_target: Option = None; - // Collect hunks only from the segment whose new side is the - // walked path; other files in the commit are irrelevant here. - let mut segment_matches = false; - for line in diff.output.lines() { - if let Some(rest) = line.strip_prefix("diff --git a/") { - if let Some((old, new)) = rest.split_once(" b/") { - segment_matches = new == path; - if segment_matches && old != path { - rename_target = Some(old.to_string()); - } - } - continue; - } - if segment_matches { - if let Some(hunk) = parse_line_history_hunk_header(line) { - hunks.push(hunk); - } - } - } - lines = line_history_translate_lines(&lines, &hunks); - if let Some(old_name) = rename_target { - path = old_name; - // Advance the walk past the rename itself, or a back-and-forth - // rename (a -> b -> a) would rescan the same commits forever - // and run the query into its timeout. - walk_end = base_revision.clone(); - renamed_at = Some(base_revision); - break; - } - } - if renamed_at.is_none() { - break; - } - } - if lines.is_empty() { - return Ok(Some((Vec::new(), path, None))); - } - // The supplement diff runs without a pathspec, exactly like the replay: - // restricting it to the walked path would clip a rename committed by the - // merge itself into a synthetic whole-file addition and lose the - // before-content along with precise ranges. - let diff = readonly_command(GitCommandRequest { - root: root.to_string(), - arguments: vec![ - "diff".to_string(), - "--unified=0".to_string(), - "--no-ext-diff".to_string(), - "--no-textconv".to_string(), - "--find-renames".to_string(), - compare_base.to_string(), - hash.to_string(), - ], - input: None, - })?; - if diff.exit_code != 0 { - return Ok(None); - } - let mut ranges = Vec::new(); - let mut previous_path: Option = None; - let mut segment_matches = false; - for line in diff.output.lines() { - if let Some(rest) = line.strip_prefix("diff --git a/") { - if let Some((old, new)) = rest.split_once(" b/") { - segment_matches = new == path; - if segment_matches && old != path { - previous_path = Some(old.to_string()); - } - } - continue; - } - if !segment_matches { - continue; - } - if let Some((old_start, old_count, new_start, new_count)) = - parse_line_history_hunk_header(line) - { - let new_end = line_history_endpoint(new_start, new_count); - let intersects = lines - .iter() - .any(|line| *line >= new_start && *line <= new_end); - if intersects { - ranges.push(GitLineHistoryRange { - old_start, - old_end: line_history_endpoint(old_start, old_count), - new_start, - new_end, - }); - } - } - } - Ok(Some((ranges, path, previous_path))) -} - -/// Traces which commits touched one one-based line range of an editor -/// snapshot. The snapshot is mapped onto a pinned HEAD revision through an -/// isolated temp comparison, so unsaved edits stay answerable without ever -/// writing inside the workspace or repository metadata. -pub fn line_history(request: GitLineHistoryRequest) -> Result { - if !is_safe_pathspec(&request.path) { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Git line history contains an invalid path", - )); - } - if request.start_line == 0 || request.end_line < request.start_line { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Git line history requires an ascending one-based line range", - )); - } - if request.content.len() > LINE_HISTORY_MAX_SNAPSHOT_BYTES { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Git line history snapshot exceeds the size budget", - ) - .with_details(format!("{} bytes", request.content.len()))); - } - let limit = request - .limit - .unwrap_or(LINE_HISTORY_DEFAULT_LIMIT) - .clamp(1, LINE_HISTORY_MAX_LIMIT); - let snapshot = normalize_line_history_newlines(&request.content); - // Range validation uses the editor's line count, where a trailing newline - // starts one final empty line; the mapping walk uses the physical line - // count because that final line has no counterpart in any diff. - let editor_line_count = line_history_line_count(&snapshot); - let snapshot_line_count = snapshot.lines().count(); - if request.end_line > editor_line_count.max(1) { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Git line history range exceeds the snapshot", - )); - } - - // Pin one revision so the snapshot mapping and the history walk cannot - // observe different HEADs when a commit lands mid-query. - let base_commit = match readonly_command(GitCommandRequest { - root: request.root.clone(), - arguments: vec!["rev-parse".to_string(), "HEAD".to_string()], - input: None, - }) { - Ok(response) if response.exit_code == 0 => response.output.trim().to_string(), - // Only a repository without commits has no line history; every other - // failure (invalid root, timeout, cancellation) must surface. - Ok(response) if response.output.contains("unknown revision") => String::new(), - Ok(response) => { - return Err(CoreError::new( - ErrorCode::ProcessFailed, - "Git line history could not resolve HEAD", - ) - .with_details(response.output)) - } - Err(error) => return Err(error), - }; - let unmapped_new_lines = || { - (request.start_line..=request.end_line) - .map(|line| GitLineHistoryUnmappedLine { - line, - kind: "new".to_string(), - }) - .collect::>() - }; - if base_commit.is_empty() { - // A repository without commits has no line history at all. - return Ok(GitLineHistoryResponse { - base_commit, - unmapped_lines: unmapped_new_lines(), - commits: Vec::new(), - truncated: false, - }); - } - - let head_content = readonly_command(GitCommandRequest { - root: request.root.clone(), - arguments: vec![ - "show".to_string(), - format!("{base_commit}:{}", request.path), - ], - input: None, - }); - let head_content = match head_content { - Ok(response) if response.exit_code == 0 => response.output, - // Only a path absent from the pinned revision counts as new; other - // failures must propagate rather than fabricate "new" lines. - Ok(response) if response.output.contains("does not exist") => { - return Ok(GitLineHistoryResponse { - base_commit, - unmapped_lines: unmapped_new_lines(), - commits: Vec::new(), - truncated: false, - }) - } - Ok(response) => { - return Err(CoreError::new( - ErrorCode::ProcessFailed, - "Git line history could not read the pinned revision", - ) - .with_details(response.output)) - } - Err(error) => return Err(error), - }; - - let scratch = create_line_history_scratch()?; - let head_path = scratch.0.join("head.txt"); - let snapshot_path = scratch.0.join("snapshot.txt"); - let head_content = normalize_line_history_newlines(&head_content); - fs::write(&head_path, &head_content).map_err(line_history_io_error)?; - fs::write(&snapshot_path, &snapshot).map_err(line_history_io_error)?; - - // Exit code 1 only reports differences between the two scratch files; - // anything above that is a genuine failure. Driver overrides keep the - // hunk coordinates raw and deterministic on every machine. - let diff = readonly_command(GitCommandRequest { - root: request.root.clone(), - arguments: vec![ - "diff".to_string(), - "--no-index".to_string(), - "--unified=0".to_string(), - "--no-ext-diff".to_string(), - "--no-textconv".to_string(), - head_path.to_string_lossy().to_string(), - snapshot_path.to_string_lossy().to_string(), - ], - input: None, - })?; - if diff.exit_code > 1 { - return Err( - CoreError::new(ErrorCode::ProcessFailed, "Git line history mapping failed") - .with_details(diff.output), - ); - } - let hunks = parse_line_history_hunks(&diff.output); - let (mapped, unmapped) = map_snapshot_lines(snapshot_line_count, &hunks); - - let requested = request.start_line..=request.end_line; - let mut intervals: Vec<(usize, usize)> = Vec::new(); - for (snapshot_line, revision_line) in mapped { - if !requested.contains(&snapshot_line) { - continue; - } - match intervals.last_mut() { - // Split whenever revision lines are not strictly consecutive, so a - // line deleted between two queried lines never widens the range. - Some((_, end)) if *end + 1 == revision_line => *end = revision_line, - _ => intervals.push((revision_line, revision_line)), - } - } - let mut unmapped_lines = unmapped - .into_iter() - .filter(|(line, _)| requested.contains(line)) - .map(|(line, inserted)| GitLineHistoryUnmappedLine { - line, - kind: if inserted { "new" } else { "unreliable" }.to_string(), - }) - .collect::>(); - // The editor's trailing virtual line exists only for display; it never - // maps onto committed content and is reported as newly added. - for line in requested.clone().filter(|line| *line > snapshot_line_count) { - unmapped_lines.push(GitLineHistoryUnmappedLine { - line, - kind: "new".to_string(), - }); - } - unmapped_lines.sort_unstable_by_key(|entry| entry.line); - if intervals.is_empty() { - return Ok(GitLineHistoryResponse { - base_commit, - unmapped_lines, - commits: Vec::new(), - truncated: false, - }); - } - - // One process traces every interval; `-L` emits only hunks intersecting - // the traced lines, and `--first-parent` pins the merge semantics. - let mut arguments = vec!["log".to_string()]; - for (start, end) in &intervals { - arguments.push(format!("-L{start},{end}:{}", request.path)); - } - arguments.push("--first-parent".to_string()); - arguments.push("--patch".to_string()); - // Zero context keeps every emitted hunk exactly the touched lines; default - // three-line context would report whole-file ranges on small documents. - arguments.push("--unified=0".to_string()); - arguments.push("--no-color".to_string()); - arguments.push("--no-ext-diff".to_string()); - arguments.push("--no-textconv".to_string()); - arguments.push(format!("--max-count={}", limit + 1)); - arguments.push("--pretty=format:%x1e%H%x1f%an%x1f%at%x1f%P%x1f%s".to_string()); - arguments.push(base_commit.clone()); - let log = readonly_command(GitCommandRequest { - root: request.root.clone(), - arguments, - input: None, - })?; - if log.exit_code != 0 { - return Err( - CoreError::new(ErrorCode::ProcessFailed, "Git line history query failed") - .with_details(log.output), - ); - } - - let mut commits: Vec = Vec::new(); - for record in log.output.split('\x1e') { - let record = record.trim_start_matches('\n'); - if record.trim().is_empty() { - continue; - } - let (header, body) = record.split_once('\n').unwrap_or((record, "")); - let fields: Vec<&str> = header.split('\x1f').collect(); - if fields.len() < 5 { - continue; - } - let hash = fields[0].to_string(); - let parent_hashes = fields[3].split_whitespace().collect::>(); - let is_merge = parent_hashes.len() > 1; - let compare_base = parent_hashes - .first() - .map(|value| value.to_string()) - .unwrap_or_default(); - // Paths with spaces are not representable in this minimal `diff --git` - // header parse; traced files are expected to use ordinary names. - let mut path_at_commit = request.path.clone(); - let mut previous_path = None; - let mut ranges = Vec::new(); - for line in body.lines() { - if let Some(rest) = line.strip_prefix("diff --git a/") { - if let Some((old, new)) = rest.split_once(" b/") { - path_at_commit = new.to_string(); - if old != new { - previous_path = Some(old.to_string()); - } - } - } else if let Some((old_start, old_count, new_start, new_count)) = - parse_line_history_hunk_header(line) - { - ranges.push(GitLineHistoryRange { - old_start, - old_end: line_history_endpoint(old_start, old_count), - new_start, - new_end: line_history_endpoint(new_start, new_count), - }); - } - } - if is_merge && !compare_base.is_empty() { - // `-L` emits merge headers without hunks, so neither the path nor - // the ranges come from the log output. Replay the first-parent - // chain from the pinned base to the merge's parent, translating - // the base intervals and path through every touched commit's diff; - // text matching could not disambiguate duplicate or blank lines. - if let Some((supplement, resolved_path, renamed_from)) = line_history_merge_ranges( - &request.root, - &base_commit, - &intervals, - &request.path, - &compare_base, - &hash, - )? { - ranges = supplement; - if resolved_path != path_at_commit { - path_at_commit = resolved_path; - previous_path = renamed_from; - } else if previous_path.is_none() { - // The rename happened inside the merge itself; -L already - // reported the new path, but the pairing is still news. - previous_path = renamed_from; - } - } - } - commits.push(GitLineHistoryCommit { - hash, - kind: if is_merge { - "merge-first-parent" - } else { - "commit" - } - .to_string(), - author_name: fields[1].to_string(), - author_time: fields[2].parse::().unwrap_or_default(), - subject: fields[4].to_string(), - path_at_commit, - previous_path, - compare_base, - relevant_ranges: ranges, - }); - } - let truncated = commits.len() > limit; - commits.truncate(limit); - Ok(GitLineHistoryResponse { - base_commit, - unmapped_lines, - commits, - truncated, - }) -} - -/// Rejects Windows names that cannot round-trip through ordinary path identifiers. -/// POSIX names keep their native meaning; Windows validates relative paths too. -fn validate_windows_git_path(path: &Path) -> std::io::Result<()> { - let text = path.to_string_lossy().replace('\\', "/"); - let windows_path = cfg!(windows) - || text.starts_with("//") - || (text.as_bytes().get(1) == Some(&b':') && text.as_bytes()[0].is_ascii_alphabetic()); - if !windows_path { - return Ok(()); - } - let verbatim = text.strip_prefix("//?/"); - let ordinary = match verbatim { - Some(rest) - if rest - .get(..4) - .is_some_and(|prefix| prefix.eq_ignore_ascii_case("UNC/")) => - { - format!("//{}", &rest[4..]) - } - Some(rest) => rest.to_string(), - None => text.clone(), - }; - let drive = ordinary.as_bytes().get(1..3) == Some(b":/") - && ordinary.as_bytes()[0].is_ascii_alphabetic(); - let network = ordinary.starts_with("//"); - // Server/share names identify the UNC route, not local file components. - let components: Vec<_> = ordinary - .split('/') - .filter(|part| !part.is_empty()) - .collect(); - let skip = if network { - 2 - } else if drive { - 1 - } else { - 0 - }; - let invalid_component = components.iter().skip(skip).any(|part| { - if matches!(*part, "." | "..") { - return verbatim.is_some(); - } - let basename = part - .split('.') - .next() - .unwrap_or_default() - .to_ascii_uppercase(); - let reserved = matches!(basename.as_str(), "CON" | "PRN" | "AUX" | "NUL") - || basename - .strip_prefix("COM") - .or_else(|| basename.strip_prefix("LPT")) - .is_some_and(|number| { - matches!( - number, - "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³" - ) - }); - part.ends_with('.') || part.ends_with(' ') || reserved - }); - if text.starts_with("//./") - || (verbatim.is_some() && !drive && !(network && components.len() >= 2)) - || invalid_component - { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "Git does not support Windows paths requiring verbatim semantics (trailing dots/spaces, reserved names or device namespaces)", - )); - } - Ok(()) -} - -/// Removes a Windows verbatim prefix only for paths safe in ordinary native form. -/// Canonical roots cross the UI boundary and return as Git working directories; -/// unsupported names fail explicitly instead of silently accessing a sibling. -pub(crate) fn simplified_canonical_path(path: PathBuf) -> std::io::Result { - validate_windows_git_path(&path)?; - let text = path.to_string_lossy(); - let normalized = text.replace('\\', "/"); - if normalized - .get(..8) - .is_some_and(|prefix| prefix.eq_ignore_ascii_case("//?/UNC/")) - { - Ok(PathBuf::from(format!(r"\\{}", &text[8..]))) - } else if normalized.starts_with("//?/") { - Ok(PathBuf::from(&text[4..])) - } else { - Ok(path) - } -} - -/// Validates before filesystem I/O and again after canonicalization (symlinks). -fn canonicalize_simplified(path: &Path) -> std::io::Result { - validate_windows_git_path(path)?; - fs::canonicalize(path).and_then(simplified_canonical_path) -} - -/// Missing worktrees may still be listed, but invalid path identities never fall back. -fn canonicalize_or_original(path: &Path) -> std::io::Result { - match canonicalize_simplified(path) { - Err(error) if error.kind() != std::io::ErrorKind::InvalidInput => Ok(path.to_path_buf()), - result => result, - } -} - -fn git_path_error(error: std::io::Error) -> CoreError { - if error.kind() == std::io::ErrorKind::InvalidInput { - CoreError::new(ErrorCode::InvalidRequest, error.to_string()) - } else { - CoreError::new(ErrorCode::WorkspaceNotFound, "Workspace does not exist") - } -} - -fn validate_root(raw_root: &str) -> Result { - let root = canonicalize_simplified(Path::new(raw_root)).map_err(git_path_error)?; - if !fs::is_dir(&root)? { - return Err(CoreError::new( - ErrorCode::WorkspaceNotFound, - "Workspace does not exist", - )); - } - Ok(root.to_string_lossy().to_string()) -} - -fn repository_scan_error(error: std::io::Error) -> CoreError { - if error.kind() == std::io::ErrorKind::InvalidInput { - return git_path_error(error); - } - CoreError::new( - ErrorCode::Unknown, - "Could not complete workspace repository discovery", - ) - .with_details(error.to_string()) -} - -/// Resolves the gitdir a `.git` gitfile points at, mirroring Git's `read_gitfile`: -/// the target may be absolute or relative to the gitfile's own directory. -/// Returns `None` when the file is not a valid gitfile Git would open. -fn resolve_gitfile_gitdir(gitfile: &Path, contents: &str) -> Option { - let line = contents.lines().next()?; - let target = line.strip_prefix("gitdir:")?.trim().trim_matches('"'); - if target.is_empty() { - return None; - } - let target_path = Path::new(target); - if target_path.is_absolute() { - return Some(target_path.to_path_buf()); - } - Some(gitfile.parent()?.join(target_path)) -} - -/// A discovered `.git` entry only counts as a repository when Git can open it: -/// a directory needs `HEAD`, and a gitfile pointer must resolve to a gitdir that -/// holds `HEAD`. Stale submodule or worktree pointers (for example a leftover -/// submodule gitfile whose `.git/modules` directory was removed) would otherwise -/// be listed as a workspace repository and fail every status or reference load -/// for the healthy sibling repositories. -fn git_entry_opens_as_repository(git_path: &Path) -> bool { - if git_path.is_dir() { - return git_path.join("HEAD").is_file(); - } - let contents = match std::fs::read_to_string(git_path) { - Ok(contents) => contents, - Err(_) => return false, - }; - resolve_gitfile_gitdir(git_path, &contents) - .map(|gitdir| gitdir.join("HEAD").is_file()) - .unwrap_or(false) -} - -fn discover_containing_repository(root: &Path) -> Result, CoreError> { - let output = execute_git_readonly( - &root.to_string_lossy(), - &["rev-parse".into(), "--show-toplevel".into()], - None, - )?; - if output.exit_code != 0 { - return Ok(None); - } - let repository_root = output.stdout.trim_end_matches(['\r', '\n']); - if repository_root.is_empty() { - return Ok(None); - } - // Discovery must not return an unprojected host path after an IO failure. - let path = canonicalize_simplified(Path::new(repository_root)).map_err(git_path_error)?; - Ok(Some(path)) -} - -fn sort_workspace_repository_paths(paths: &mut [PathBuf], workspace_root: &Path) { - paths.sort_by(|left, right| { - let left_is_workspace = left == workspace_root; - let right_is_workspace = right == workspace_root; - if left_is_workspace != right_is_workspace { - return right_is_workspace.cmp(&left_is_workspace); - } - - let left_inside_workspace = left.starts_with(workspace_root); - let right_inside_workspace = right.starts_with(workspace_root); - if left_inside_workspace != right_inside_workspace { - return right_inside_workspace.cmp(&left_inside_workspace); - } - - let left_depth = left.components().count(); - let right_depth = right.components().count(); - left_depth - .cmp(&right_depth) - .then_with(|| path_sort_key(left).cmp(&path_sort_key(right))) - .then_with(|| left.cmp(right)) - }); -} - -fn path_sort_key(path: &Path) -> String { - path.to_string_lossy().replace('\\', "/").to_lowercase() -} - -/// The `git commit` options for a chosen author and a sign-off. -pub(crate) fn commit_identity( - author: Option<&str>, - sign_off: bool, -) -> Result, CoreError> { - let mut arguments = Vec::new(); - if let Some(author) = author { - arguments.push(format!("--author={}", validated_author(author)?)); - } - if sign_off { - arguments.push("--signoff".into()); - } - Ok(arguments) -} - -/// An exact `Name ` identity. Git reads any other `--author` value as a pattern -/// that picks a matching earlier author, which is never what a typed author means. -fn validated_author(author: &str) -> Result<&str, CoreError> { - let invalid = || { - CoreError::new( - ErrorCode::GitCommitAuthorInvalid, - "The author must be a name followed by an email address in angle brackets", - ) - }; - let (name, email) = author - .strip_suffix('>') - .and_then(|rest| rest.rsplit_once(" <")) - .ok_or_else(invalid)?; - let name_valid = !name.trim().is_empty() - && name.trim() == name - && !name.contains(['<', '>']) - && !name.chars().any(char::is_control); - let email_valid = !email.is_empty() - && !email.contains(['<', '>']) - && !email.chars().any(|c| c.is_whitespace() || c.is_control()); - if name_valid && email_valid { - Ok(author) - } else { - Err(invalid()) - } -} - -fn required_text(value: Option<&str>, label: &str) -> Result { - let value = value.map(str::trim).filter(|value| !value.is_empty()); - match value { - Some(value) if !value.contains('\0') => Ok(value.to_string()), - _ => Err(CoreError::new( - ErrorCode::InvalidRequest, - format!("Missing or invalid Git {label}"), - )), - } -} - -#[derive(Clone)] -struct ValidatedGitReference { - full_name: String, - short_name: String, - kind: String, -} - -fn invalid_git_reference() -> CoreError { - CoreError::new(ErrorCode::InvalidRequest, "Invalid Git reference") -} - -fn validated_git_reference( - root: &str, - reference: &GitReferenceRequest, -) -> Result { - if reference.full_name.contains(['\0', '\n', '\r']) - || reference.short_name.contains(['\0', '\n', '\r']) - || reference.full_name.chars().any(char::is_whitespace) - || reference.short_name.trim() != reference.short_name - { - return Err(invalid_git_reference()); - } - - let prefix = match reference.kind.as_str() { - "local" => "refs/heads/", - "remote" => "refs/remotes/", - "tag" => "refs/tags/", - _ => return Err(invalid_git_reference()), - }; - let expected_short_name = reference - .full_name - .strip_prefix(prefix) - .filter(|value| !value.is_empty()) - .ok_or_else(invalid_git_reference)?; - if expected_short_name != reference.short_name { - return Err(invalid_git_reference()); - } - - let checked = execute_git_readonly( - root, - &["check-ref-format".into(), reference.full_name.clone()], - None, - )?; - if checked.exit_code != 0 { - return Err(invalid_git_reference()); - } - if reference.kind == "remote" { - let (_, branch) = mutations::remote_branch_components(root, &reference.full_name)?; - if branch == "HEAD" { - return Err(invalid_git_reference()); - } - } - - Ok(ValidatedGitReference { - full_name: reference.full_name.clone(), - short_name: reference.short_name.clone(), - kind: reference.kind.clone(), - }) -} - -fn legacy_checkout_reference( - root: &str, - reference: &str, - kind: &str, -) -> Result { - let (full_name, short_name) = match kind { - "local" => { - let short_name = reference.strip_prefix("refs/heads/").unwrap_or(reference); - (format!("refs/heads/{short_name}"), short_name.to_string()) - } - "remote" => { - let short_name = reference - .strip_prefix("refs/remotes/") - .ok_or_else(invalid_git_reference)?; - (reference.to_string(), short_name.to_string()) - } - "tag" => { - let short_name = reference.strip_prefix("refs/tags/").unwrap_or(reference); - (format!("refs/tags/{short_name}"), short_name.to_string()) - } - _ => return Err(invalid_git_reference()), - }; - validated_git_reference( - root, - &GitReferenceRequest { - full_name, - short_name, - kind: kind.to_string(), - }, - ) -} - -fn optional_write_request_reference( - root: &str, - request: &GitWriteRequest, -) -> Result, CoreError> { - if let Some(reference) = request.git_reference.as_ref() { - return validated_git_reference(root, reference).map(|value| Some(value.full_name)); - } - request - .reference - .as_deref() - .map(|reference| validated_reference(Some(reference))) - .transpose() -} - -pub(super) fn write_request_reference( - root: &str, - request: &GitWriteRequest, -) -> Result { - // Branch restore records carry the deleted commit object ID rather than a - // live ref. Accept that exact revision only for createBranch; every other - // mutation continues to require a typed, existing Git reference. - if request.operation == "createBranch" { - if let Some(reference) = request.git_reference.as_ref() { - if reference.full_name == reference.short_name { - if let Ok(revision) = validated_revision(Some(&reference.full_name)) { - return Ok(revision); - } - } - } - } - optional_write_request_reference(root, request)? - .ok_or_else(|| CoreError::new(ErrorCode::InvalidRequest, "Missing Git reference")) -} - -fn checkout_request_reference( - root: &str, - request: &GitWriteRequest, -) -> Result { - if let Some(reference) = request.git_reference.as_ref() { - return validated_git_reference(root, reference); - } - let reference = validated_reference(request.reference.as_deref())?; - let kind = required_text(request.reference_kind.as_deref(), "reference kind")?; - legacy_checkout_reference(root, &reference, &kind) -} - -fn request_reference( - root: &str, - typed: Option<&GitReferenceRequest>, - legacy: Option<&str>, -) -> Result { - if let Some(reference) = typed { - return validated_git_reference(root, reference).map(|value| value.full_name); - } - validated_reference(legacy) -} - -fn typed_reference_range( - root: &str, - base: Option<&GitReferenceRequest>, - target: Option<&GitReferenceRequest>, - legacy: Option<&str>, -) -> Result, CoreError> { - match (base, target) { - (Some(base), Some(target)) => { - if legacy.is_some() { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Git comparison cannot combine typed and legacy references", - )); - } - let base = validated_git_reference(root, base)?; - let target = validated_git_reference(root, target)?; - Ok(Some(format!("{}..{}", base.full_name, target.full_name))) - } - (None, Some(_)) => Err(CoreError::new( - ErrorCode::InvalidRequest, - "Git comparison target requires a base reference", - )), - (Some(base), None) => { - if legacy.is_some() { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Git comparison cannot combine typed and legacy references", - )); - } - Ok(Some(validated_git_reference(root, base)?.full_name)) - } - (None, None) => legacy - .map(|value| validated_reference(Some(value))) - .transpose(), - } -} - -fn validate_paths(paths: &[String]) -> Result, CoreError> { - if paths.is_empty() || paths.iter().any(|path| !is_safe_pathspec(path)) { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Git operation contains an invalid path", - )); - } - Ok(paths.to_vec()) -} - -/// Chooses whether ignore patterns belong to the shared repository file or the -/// current checkout's local Git metadata. -enum GitIgnoreTarget { - Repository, - LocalExclude, -} - -fn append_git_ignore_patterns( - root: &str, - paths: &[String], - target: GitIgnoreTarget, -) -> Result { - let patterns = git_ignore_patterns(paths)?; - let target_path = match target { - GitIgnoreTarget::Repository => repository_root(root)?.join(".gitignore"), - GitIgnoreTarget::LocalExclude => git_path(root, "info/exclude")?, - }; - let existing = read_git_ignore_bytes(&target_path)?; - let existing_text = String::from_utf8_lossy(&existing); - let additions = patterns - .into_iter() - .filter(|pattern| !existing_text.lines().any(|line| line == pattern)) - .collect::>(); - if additions.is_empty() { - return Ok(successful_git_result()); - } - - if let Some(parent) = target_path.parent() { - fs::create_dir_all(parent).map_err(|error| git_ignore_io_error("create", error))?; - } - let mut appended = String::new(); - if !existing.is_empty() && !existing.ends_with(b"\n") { - appended.push('\n'); - } - for pattern in additions { - appended.push_str(&pattern); - appended.push('\n'); - } - fs::append(&target_path, appended.as_bytes()) - .map_err(|error| git_ignore_io_error("write", error))?; - Ok(successful_git_result()) -} - -fn read_git_ignore_bytes(path: &Path) -> Result, CoreError> { - match fs::read(path) { - Ok(content) => Ok(content), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()), - Err(error) => Err(git_ignore_io_error("read", error)), - } -} - -fn git_ignore_patterns(paths: &[String]) -> Result, CoreError> { - let mut patterns = Vec::with_capacity(paths.len()); - for path in paths { - if path.contains(['\0', '\n', '\r']) { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Git ignore operation contains an invalid path", - )); - } - let normalized = path.replace('\\', "/"); - let is_directory = normalized.ends_with('/'); - let normalized = normalized.trim_end_matches('/'); - if !is_safe_pathspec(normalized) { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Git ignore operation contains an invalid path", - )); - } - - let mut escaped = String::with_capacity(normalized.len() + 2); - escaped.push('/'); - for character in normalized.chars() { - if matches!(character, '*' | '?' | '[' | ']' | '#' | '!' | ' ') { - escaped.push('\\'); - } - escaped.push(character); - } - if is_directory { - escaped.push('/'); - } - patterns.push(escaped); - } - patterns.sort(); - patterns.dedup(); - if patterns.is_empty() { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Git ignore operation contains an invalid path", - )); - } - Ok(patterns) -} - -fn repository_root(root: &str) -> Result { - git_resolved_path(root, &["rev-parse", "--show-toplevel"], "repository root") -} - -fn git_path(root: &str, path: &str) -> Result { - git_resolved_path( - root, - &["rev-parse", "--path-format=absolute", "--git-path", path], - "Git metadata path", - ) -} - -fn git_resolved_path(root: &str, arguments: &[&str], label: &str) -> Result { - let arguments = arguments - .iter() - .map(|value| value.to_string()) - .collect::>(); - let response = execute_git_readonly(root, &arguments, None)?; - if response.exit_code != 0 { - return Err(CoreError::new( - ErrorCode::ProcessFailed, - format!("Could not resolve {label}"), - ) - .with_details(response.output)); - } - let path = response.output.trim_end_matches(['\r', '\n']); - if path.is_empty() { - return Err(CoreError::new( - ErrorCode::ProcessFailed, - format!("Could not resolve {label}"), - )); - } - validate_windows_git_path(Path::new(path)).map_err(git_path_error)?; - Ok(crate::remote::workspace::local_path(Path::new(path))) -} - -fn git_ignore_io_error(action: &str, error: std::io::Error) -> CoreError { - let code = if error.kind() == std::io::ErrorKind::PermissionDenied { - ErrorCode::PermissionDenied - } else { - ErrorCode::Unknown - }; - CoreError::new(code, format!("Could not {action} Git ignore file")) - .with_details(error.to_string()) -} - -fn successful_git_result() -> GitCommandResponse { - GitCommandResponse { - arguments: Vec::new(), - output: String::new(), - stdout: String::new(), - stderr: String::new(), - exit_code: 0, - invocations: Vec::new(), - operation_error: None, - stash_restore: None, - tag_deletion: None, - branch_deletion: None, - history_rewrite: None, - outcome_authoritative: false, - warnings: Vec::new(), - } -} - -/// Immutable commit fields needed to rebuild a linear history without invoking -/// an editor or losing author and committer attribution. -#[derive(Clone)] -struct RewriteCommit { - /// Original commit object used to derive the patch during history replay. - hash: String, - tree: String, - parents: Vec, - author_name: String, - author_email: String, - author_date: String, - committer_name: String, - committer_email: String, - committer_date: String, - message: String, -} - -/// Current local branch context and its first-parent chain, ordered from HEAD -/// toward the root commit. -struct HistoryRewriteContext { - branch_reference: String, - original_head: String, - first_parent_chain: Vec, - published_commits: HashSet, -} - -fn edited_history_head( - root: &str, - context: &HistoryRewriteContext, - revision: &str, - message: &str, -) -> Result { - let target = resolve_commit_revision(root, revision)?; - let target_index = history_commit_index(&context, &target)?; - let commits = checked_rewrite_range(root, &context, target_index)?; - let mut parent = commits[0].parents.first().cloned(); - - for (index, commit) in commits.iter().enumerate() { - let commit_message = if index == 0 { message } else { &commit.message }; - parent = Some(write_commit_tree( - root, - commit, - parent.as_deref(), - commit_message, - )?); - } - - Ok(parent.expect("rewrite range contains a commit")) -} - -fn deleted_history_head( - root: &str, - context: &HistoryRewriteContext, - revision: &str, -) -> Result { - let target = resolve_commit_revision(root, revision)?; - let target_index = history_commit_index(&context, &target)?; - let commits = checked_rewrite_range(root, &context, target_index)?; - let target_commit = &commits[0]; - let parent = target_commit.parents.first().ok_or_else(|| { - CoreError::new( - ErrorCode::InvalidRequest, - "The root commit cannot be deleted", - ) - })?; - - let mut rewritten_head = parent.clone(); - for commit in commits.iter().skip(1) { - let mut replayed_commit = commit.clone(); - replayed_commit.tree = replay_commit_tree(root, commit, &rewritten_head)?; - rewritten_head = write_commit_tree( - root, - &replayed_commit, - Some(&rewritten_head), - &commit.message, - )?; - } - - Ok(rewritten_head) -} - -fn squashed_history_head( - root: &str, - context: &HistoryRewriteContext, - revisions: &[String], - message: &str, -) -> Result { - if revisions.len() < 2 { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Select at least two commits to squash", - )); - } - - let mut selected = Vec::with_capacity(revisions.len()); - for revision in revisions { - validate_revision(revision)?; - selected.push(resolve_commit_revision(root, revision)?); - } - selected.sort(); - selected.dedup(); - if selected.len() != revisions.len() { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Select distinct commits to squash", - )); - } - - let mut selected_indices = selected - .iter() - .map(|commit| history_commit_index(&context, commit)) - .collect::, _>>()?; - selected_indices.sort_unstable(); - let newest_index = selected_indices[0]; - let oldest_index = *selected_indices - .last() - .expect("at least two commits were selected"); - if oldest_index - newest_index + 1 != selected_indices.len() { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Only a contiguous range of commits can be squashed", - )); - } - - let commits = checked_rewrite_range(root, &context, oldest_index)?; - let selected_count = oldest_index - newest_index + 1; - let oldest = &commits[0]; - let newest = &commits[selected_count - 1]; - let mut squashed = oldest.clone(); - squashed.tree.clone_from(&newest.tree); - squashed.committer_name.clone_from(&newest.committer_name); - squashed.committer_email.clone_from(&newest.committer_email); - squashed.committer_date.clone_from(&newest.committer_date); - - let mut parent = Some(write_commit_tree( - root, - &squashed, - oldest.parents.first().map(String::as_str), - message, - )?); - for commit in commits.iter().skip(selected_count) { - parent = Some(write_commit_tree( - root, - commit, - parent.as_deref(), - &commit.message, - )?); - } - - Ok(parent.expect("squash produces a commit")) -} - -fn resolve_commit_revision(root: &str, revision: &str) -> Result { - let response = execute_git_readonly( - root, - &[ - "rev-parse".into(), - "--verify".into(), - "--quiet".into(), - "--end-of-options".into(), - format!("{revision}^{{commit}}"), - ], - None, - )?; - let commit = response.output.trim(); - if response.exit_code != 0 || commit.is_empty() { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "The selected Git commit does not exist", - )); - } - Ok(commit.to_string()) -} - -fn history_commit_index(context: &HistoryRewriteContext, commit: &str) -> Result { - context - .first_parent_chain - .iter() - .position(|candidate| candidate == commit) - .ok_or_else(|| { - CoreError::new( - ErrorCode::InvalidRequest, - "Only commits on the current branch first-parent history can be rewritten", - ) - }) -} - -fn checked_rewrite_range( - root: &str, - context: &HistoryRewriteContext, - oldest_index: usize, -) -> Result, CoreError> { - let hashes = &context.first_parent_chain[..=oldest_index]; - if hashes - .iter() - .any(|commit| context.published_commits.contains(commit)) - { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Commits published to a remote cannot be rewritten", - )); - } - - let mut commits = hashes - .iter() - .rev() - .map(|hash| read_rewrite_commit(root, hash)) - .collect::, _>>()?; - if commits.iter().any(|commit| commit.parents.len() > 1) { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "A history range containing merge commits cannot be rewritten", - )); - } - commits.shrink_to_fit(); - Ok(commits) -} - -fn read_rewrite_commit(root: &str, hash: &str) -> Result { - let format = "%T%x00%P%x00%an%x00%ae%x00%aI%x00%cn%x00%ce%x00%cI%x00%B%x00"; - let response = execute_git_readonly( - root, - &[ - "show".into(), - "--no-patch".into(), - "--no-show-signature".into(), - format!("--format={format}"), - hash.to_string(), - ], - None, - )?; - if response.exit_code != 0 { - return Err(CoreError::new( - ErrorCode::ProcessFailed, - "Could not read a commit selected for history rewrite", - ) - .with_details(response.output)); - } - - let output = response - .output - .strip_suffix("\0\n") - .or_else(|| response.output.strip_suffix('\0')) - .unwrap_or(&response.output); - let fields = output.splitn(9, '\0').collect::>(); - if fields.len() != 9 { - return Err(CoreError::new( - ErrorCode::ParseFailed, - "Could not decode a commit selected for history rewrite", - )); - } - Ok(RewriteCommit { - hash: hash.to_string(), - tree: fields[0].to_string(), - parents: fields[1].split_whitespace().map(String::from).collect(), - author_name: fields[2].to_string(), - author_email: fields[3].to_string(), - author_date: fields[4].to_string(), - committer_name: fields[5].to_string(), - committer_email: fields[6].to_string(), - committer_date: fields[7].to_string(), - message: fields[8].to_string(), - }) -} - -fn replay_commit_tree( - root: &str, - commit: &RewriteCommit, - rewritten_parent: &str, -) -> Result { - let original_parent = commit.parents.first().ok_or_else(|| { - CoreError::new( - ErrorCode::InvalidRequest, - "A root commit cannot be replayed after deleting its parent", - ) - })?; - let patch_arguments = vec![ - "diff-tree".to_string(), - "--binary".to_string(), - "--full-index".to_string(), - "--no-ext-diff".to_string(), - "--no-renames".to_string(), - "--no-commit-id".to_string(), - "-p".to_string(), - original_parent.clone(), - commit.hash.clone(), - ]; - let patch = execute_git_readonly(root, &patch_arguments, None)?; - if patch.exit_code != 0 { - return Err(CoreError::new( - ErrorCode::ProcessFailed, - "Could not read changes after the deleted commit", - ) - .with_details(patch.output)); - } - - let common_directory = git_resolved_path( - root, - &["rev-parse", "--path-format=absolute", "--git-common-dir"], - "Git common directory", - )?; - let temporary_context = TemporaryGitCommitContext::prepare(root, Some(rewritten_parent))?; - let environment = temporary_context.environment(root, &common_directory); - let initialized = execute_git_with_environment( - root, - &["read-tree".to_string(), rewritten_parent.to_string()], - None, - false, - &environment, - )?; - if initialized.exit_code != 0 { - return Err(CoreError::new( - ErrorCode::ProcessFailed, - "Could not initialize history replay", - ) - .with_details(initialized.output)); - } - - if !patch.stdout.is_empty() { - let applied = execute_git_with_environment( - root, - &[ - "apply".to_string(), - "--cached".to_string(), - "--whitespace=nowarn".to_string(), - "-".to_string(), - ], - Some(patch.stdout), - false, - &environment, - )?; - if applied.exit_code != 0 { - return Err(CoreError::new( - ErrorCode::ProcessFailed, - "Could not replay changes after the deleted commit", - ) - .with_details(applied.output)); - } - } - - let tree = - execute_git_with_environment(root, &["write-tree".to_string()], None, false, &environment)?; - if tree.exit_code != 0 { - return Err(CoreError::new( - ErrorCode::ProcessFailed, - "Could not write replayed Git tree", - ) - .with_details(tree.output)); - } - let tree_hash = tree.stdout.trim(); - if tree_hash.is_empty() { - return Err(CoreError::new( - ErrorCode::ParseFailed, - "Git did not return a replayed tree identifier", - )); - } - Ok(tree_hash.to_string()) -} - -fn write_commit_tree( - root: &str, - commit: &RewriteCommit, - parent: Option<&str>, - message: &str, -) -> Result { - let mut arguments = vec![ - "-c".into(), - "commit.gpgSign=false".into(), - "commit-tree".into(), - commit.tree.clone(), - ]; - if let Some(parent) = parent { - arguments.extend(["-p".into(), parent.to_string()]); - } - arguments.extend(["-F".into(), "-".into()]); - let environment = vec![ - ("GIT_AUTHOR_NAME".into(), commit.author_name.clone()), - ("GIT_AUTHOR_EMAIL".into(), commit.author_email.clone()), - ("GIT_AUTHOR_DATE".into(), commit.author_date.clone()), - ("GIT_COMMITTER_NAME".into(), commit.committer_name.clone()), - ("GIT_COMMITTER_EMAIL".into(), commit.committer_email.clone()), - ("GIT_COMMITTER_DATE".into(), commit.committer_date.clone()), - ]; - let output = capture_git_with_environment( - root, - &arguments, - Some(message.to_string()), - false, - &environment, - )?; - if output.exit_code != 0 { - return Err(CoreError::new( - ErrorCode::ProcessFailed, - "Could not rebuild Git commit history", - ) - .with_details(output.into_command_response(&arguments).output)); - } - let hash = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if hash.is_empty() { - return Err(CoreError::new( - ErrorCode::ParseFailed, - "Git did not return a rebuilt commit identifier", - )); - } - Ok(hash) -} - -fn update_history_reference( - root: &str, - context: &HistoryRewriteContext, - new_head: &str, - reflog_message: &str, -) -> Result { - execute_git( - root, - &[ - "update-ref".into(), - "-m".into(), - reflog_message.into(), - context.branch_reference.clone(), - new_head.into(), - context.original_head.clone(), - ], - None, - ) -} - -fn validated_revision(value: Option<&str>) -> Result { - let value = required_text(value, "revision")?; - validate_revision(&value)?; - Ok(value) -} - -pub(super) fn validated_reference(value: Option<&str>) -> Result { - let value = required_text(value, "reference")?; - if value.starts_with('-') || value.chars().any(char::is_whitespace) { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Invalid Git reference", - )); - } - Ok(value) -} - -fn validated_stash_reference(value: Option<&str>) -> Result { - let value = required_text(value, "stash reference")?; - if value.starts_with('-') || value.contains(char::is_whitespace) { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Invalid Git stash reference", - )); - } - Ok(value) -} - -fn validated_branch_name(root: &str, value: Option<&str>) -> Result { - let value = required_text(value, "branch name")?; - let validation = execute_git( - root, - &["check-ref-format".into(), "--branch".into(), value.clone()], - None, - )?; - if validation.exit_code != 0 { - return Err( - CoreError::new(ErrorCode::InvalidRequest, "Invalid Git branch name") - .with_details(validation.output), - ); - } - Ok(value) -} - -/// Validates a tag name against the same refname rules `git check-ref-format` -/// enforces, so an invalid name fails before any subprocess with a stable -/// message instead of depending on localized `git tag` output. -fn validated_tag_name(value: Option<&str>) -> Result { - let value = required_text(value, "tag name")?; - if is_invalid_tag_name(&value) { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Invalid Git tag name", - )); - } - Ok(value) -} - -/// Refname rules from `git check-ref-format` plus command-line safety guards -/// (no leading dash) shared by every Git mutation argument. -fn is_invalid_tag_name(value: &str) -> bool { - if value.starts_with('-') - || value == "@" - || value.starts_with('/') - || value.ends_with('/') - || value.ends_with('.') - || value.contains("..") - || value.contains("@{") - || value.contains("//") - { - return true; - } - if value.chars().any(|character| { - character.is_control() - || matches!(character, ' ' | '~' | '^' | ':' | '?' | '*' | '[' | '\\') - }) { - return true; - } - value - .split('/') - .any(|component| component.starts_with('.') || component.ends_with(".lock")) -} - -/// Reports whether `refs/tags/` already resolves, using `--verify` so -/// the probe matches the exact ref instead of any revision expression. -fn tag_exists(root: &str, name: &str) -> Result { - let probe = execute_git( - root, - &[ - "rev-parse".into(), - "--verify".into(), - "--quiet".into(), - format!("refs/tags/{name}"), - ], - None, - )?; - Ok(probe.exit_code == 0) -} - -/// Resolves a tag target revision to a commit and returns its object id. -/// Git allows tagging trees and blobs; the tag contract only promises commit -/// targets, so `^{commit}` both validates and yields the id the -/// mutation should point at. -fn resolved_commit_target(root: &str, target: &str) -> Result, CoreError> { - let probe = execute_git( - root, - &[ - "rev-parse".into(), - "--verify".into(), - "--quiet".into(), - format!("{target}^{{commit}}"), - ], - None, - )?; - Ok((probe.exit_code == 0).then(|| probe.stdout.trim().to_string())) -} - -/// Rejects tag-form combinations Git cannot represent. -/// -/// An explicitly lightweight tag cannot also be annotated or signed; the -/// message rules match the previous Windows host behavior, including allowing a -/// whitespace-only message that the lightweight form ignores. -fn validate_create_tag_form( - message: Option<&str>, - lightweight: Option, - signed: Option, -) -> Result<(), CoreError> { - if lightweight == Some(true) - && (signed == Some(true) || message.is_some_and(|message| !message.trim().is_empty())) - { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Lightweight tags cannot include a message or signature", - )); - } - Ok(()) -} - -/// Builds the `git tag` argument vector for an already validated form and a -/// target resolved to a commit id. -/// -/// `lightweight: true` always adds `--no-sign`, so an explicit lightweight tag -/// stays independent of `tag.gpgSign`. `signed: true` adds `-s`; with a message -/// this becomes a signed annotated tag. Omitted fields keep the legacy -/// message-selects-annotated behavior. -fn create_tag_arguments( - name: &str, - target: &str, - message: Option<&str>, - lightweight: Option, - signed: Option, -) -> Vec { - if lightweight == Some(true) { - return vec![ - "tag".to_string(), - "--no-sign".to_string(), - name.to_string(), - target.to_string(), - ]; - } - let mut arguments = vec!["tag".to_string()]; - if signed == Some(true) { - arguments.push("-s".to_string()); - } - match message { - // An explicit message field (even an empty one) selects the annotated - // form. Verbatim cleanup keeps restored CRLF and trailing blank lines - // intact; UIs trim newly entered messages before send. - Some(message) => arguments.extend([ - "-a".to_string(), - "--cleanup=verbatim".to_string(), - name.to_string(), - "-m".to_string(), - message.to_string(), - target.to_string(), - ]), - None => arguments.extend([name.to_string(), target.to_string()]), - } - arguments -} - -/// Deletes one tag and returns a structured deletion record so the host can -/// offer a restore. The probes run before the deletion because `git tag -d` -/// diagnostics are localized prose that cannot be mapped to stable errors. -fn delete_tag(root: &str, value: Option<&str>) -> Result { - let name = validated_tag_name(value)?; - let reference = format!("refs/tags/{name}"); - let expected_object = resolve_ref_object(root, &reference)?.ok_or_else(|| { - CoreError::new( - ErrorCode::InvalidRequest, - format!("The tag '{name}' does not exist"), - ) - })?; - let object_type = execute_git( - root, - &["cat-file".into(), "-t".into(), expected_object.clone()], - None, - )?; - if object_type.exit_code != 0 { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - format!("The tag '{name}' does not exist"), - )); - } - let is_annotated = object_type.stdout.trim() == "tag"; - let mut message = None; - if is_annotated { - let tag_object = execute_git( - root, - &["cat-file".into(), "tag".into(), expected_object.clone()], - None, - )?; - if tag_object.exit_code == 0 { - message = annotation_message_from_tag_object(&tag_object.stdout); - } - } - // Peel the ref to a commit so pre-existing tree/blob tags cannot produce - // a recovery record that violates the restore contract. - let peeled = execute_git( - root, - &[ - "rev-parse".into(), - "--verify".into(), - format!("{expected_object}^{{commit}}"), - ], - None, - )?; - if peeled.exit_code != 0 { - return Err(CoreError::new( - ErrorCode::ProcessFailed, - format!("Could not resolve tag target '{name}'"), - ) - .with_details(peeled.output)); - } - let mut result = delete_ref_if_unchanged(root, &reference, &expected_object)?; - if result.exit_code == 0 { - result.tag_deletion = Some(GitTagDeletionResponse { - name, - deleted_target: peeled.stdout.trim().to_string(), - kind: if is_annotated { - "annotated" - } else { - "lightweight" - } - .to_string(), - message, - }); - } - Ok(result) -} - -/// Deletes one local branch and returns a structured deletion record so the -/// host can offer a restore. The commit is resolved before the deletion -/// because `git branch -d` diagnostics are localized prose. -fn delete_branch(root: &str, branch: &str) -> Result { - let reference = format!("refs/heads/{branch}"); - let target = resolve_ref_object(root, &reference)?.ok_or_else(|| { - CoreError::new( - ErrorCode::InvalidRequest, - format!("The branch '{branch}' does not exist"), - ) - })?; - ensure_branch_is_safely_deletable(root, branch, &reference, &target)?; - let mut result = delete_ref_if_unchanged(root, &reference, &target)?; - if result.exit_code == 0 { - result.branch_deletion = Some(GitBranchDeletionResponse { - name: branch.to_string(), - deleted_target: target, - }); - if let Err(error) = remove_branch_config(root, branch) { - // The ref mutation already committed. Preserve recovery data and - // report configuration cleanup as a diagnosable partial success. - result.warnings.push(GitOperationWarning::new( - "branch_config_cleanup_failed", - &error.message, - error.details, - )); - } - } - Ok(result) -} - -/// Resolves an exact refname to its current unpeeled object id. -fn resolve_ref_object(root: &str, reference: &str) -> Result, CoreError> { - let probe = execute_git( - root, - &[ - "rev-parse".into(), - "--verify".into(), - "--quiet".into(), - reference.to_string(), - ], - None, - )?; - Ok((probe.exit_code == 0).then(|| probe.stdout.trim().to_string())) -} - -/// Deletes a ref only when it still points at the object observed by the -/// caller. `update-ref` performs the comparison and mutation under the same -/// ref lock, closing the probe-then-mutate race. -fn delete_ref_if_unchanged( - root: &str, - reference: &str, - expected_object: &str, -) -> Result { - let mut result = execute_git( - root, - &[ - "update-ref".into(), - "-d".into(), - reference.to_string(), - expected_object.to_string(), - ], - None, - )?; - if result.exit_code != 0 { - result.operation_error = Some( - CoreError::new( - ErrorCode::InvalidRequest, - format!("The Git reference '{reference}' changed before it could be deleted"), - ) - .with_details(result.output.clone()), - ); - } - Ok(result) -} - -/// Preserves `git branch -d` safety before the atomic ref mutation: a branch -/// must not be checked out in any worktree and must be merged into its valid -/// upstream, or into HEAD when it has no usable upstream. -fn ensure_branch_is_safely_deletable( - root: &str, - branch: &str, - reference: &str, - target: &str, -) -> Result<(), CoreError> { - let worktrees = execute_git( - root, - &["worktree".into(), "list".into(), "--porcelain".into()], - None, - )?; - if worktrees.exit_code != 0 { - return Err( - CoreError::new(ErrorCode::ProcessFailed, "Could not inspect Git worktrees") - .with_details(worktrees.output), - ); - } - if worktrees - .stdout - .lines() - .any(|line| line == format!("branch {reference}")) - { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - format!("The branch '{branch}' is checked out in a worktree"), - )); - } - - let upstream = execute_git( - root, - &[ - "for-each-ref".into(), - "--format=%(upstream)".into(), - reference.to_string(), - ], - None, - )?; - if upstream.exit_code != 0 { - return Err(CoreError::new( - ErrorCode::ProcessFailed, - "Could not inspect the branch upstream", - ) - .with_details(upstream.output)); - } - let upstream = upstream.stdout.trim(); - let merge_target = if !upstream.is_empty() && resolved_commit_target(root, upstream)?.is_some() - { - upstream - } else { - "HEAD" - }; - let merged = execute_git( - root, - &[ - "merge-base".into(), - "--is-ancestor".into(), - target.to_string(), - merge_target.to_string(), - ], - None, - )?; - match merged.exit_code { - 0 => Ok(()), - 1 => Err(CoreError::new( - ErrorCode::InvalidRequest, - format!("The branch '{branch}' is not fully merged"), - )), - _ => Err(CoreError::new( - ErrorCode::ProcessFailed, - format!("Could not verify whether branch '{branch}' is merged"), - ) - .with_details(merged.output)), - } -} - -/// Removes branch-local configuration after the ref has been deleted, matching -/// the metadata cleanup performed by `git branch -d`. -fn remove_branch_config(root: &str, branch: &str) -> Result<(), CoreError> { - let listing = capture_git_with_options( - root, - &[ - "config".into(), - "--name-only".into(), - "--get-regexp".into(), - "^branch\\.".into(), - ], - None, - false, - )?; - if listing.exit_code == 1 { - return Ok(()); - } - if listing.exit_code != 0 { - return Err(CoreError::new( - ErrorCode::ProcessFailed, - "Could not inspect branch configuration", - ) - .with_details(String::from_utf8_lossy(&listing.stderr))); - } - let prefix = format!("branch.{branch}."); - if !String::from_utf8_lossy(&listing.stdout) - .lines() - .any(|key| key.starts_with(&prefix)) - { - return Ok(()); - } - let cleanup = capture_git_with_options( - root, - &[ - "config".into(), - "--remove-section".into(), - format!("branch.{branch}"), - ], - None, - false, - )?; - if cleanup.exit_code == 0 { - return Ok(()); - } - Err(CoreError::new( - ErrorCode::ProcessFailed, - format!("Could not remove configuration for deleted branch '{branch}'"), - ) - .with_details(String::from_utf8_lossy(&cleanup.stderr))) -} - -/// Extracts the annotation message from a raw tag object byte-for-byte, so a -/// restored tag keeps the original message including CRLF line endings and -/// trailing newlines. Only the signature block is cut, by locating its first -/// line in the raw content; the signature belongs to the previous tagger and -/// a restored tag would be signed separately. -fn annotation_message_from_tag_object(raw: &str) -> Option { - let (_, message) = raw.split_once("\n\n")?; - let mut offset = 0; - for line in message.split_inclusive('\n') { - let without_eol = line.trim_end_matches(['\r', '\n']); - if without_eol.starts_with("-----BEGIN ") && without_eol.ends_with("SIGNATURE-----") { - return Some(message[..offset].to_string()); - } - offset += line.len(); - } - Some(message.to_string()) -} - -fn local_branch_name(reference: &str) -> Result { - let branch = reference - .strip_prefix("refs/heads/") - .filter(|value| !value.is_empty()) - .ok_or_else(|| { - CoreError::new( - ErrorCode::InvalidRequest, - "Only local branches support this Git operation", - ) - })?; - if !is_safe_pathspec(branch) { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Invalid Git branch name", - )); - } - Ok(branch.to_string()) -} - -pub(super) fn current_branch(root: &str) -> Result { - let response = execute_git(root, &["branch".into(), "--show-current".into()], None)?; - if response.exit_code != 0 { - return Err(CoreError::new( - ErrorCode::ProcessFailed, - "Could not determine current branch", - ) - .with_details(response.output)); - } - let branch = response.output.trim(); - if branch.is_empty() { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Git operation requires a checked out branch", - )); - } - Ok(branch.to_string()) -} - -fn optional_current_branch(root: &str) -> Result, CoreError> { - let response = execute_git_readonly(root, &["branch".into(), "--show-current".into()], None)?; - if response.exit_code != 0 { - return Err(CoreError::new( - ErrorCode::ProcessFailed, - "Could not determine current branch", - ) - .with_details(response.output)); - } - Ok(match response.output.trim() { - "" => None, - branch => Some(branch.to_string()), - }) -} - -fn is_current_reference(root: &str, reference: &str) -> Result { - let current = current_branch(root)?; - Ok(reference == current || reference == format!("refs/heads/{current}")) -} - -fn failed_git_result(error: CoreError) -> GitCommandResponse { - GitCommandResponse { - arguments: Vec::new(), - output: String::new(), - stdout: String::new(), - stderr: String::new(), - exit_code: 1, - invocations: Vec::new(), - operation_error: Some(error), - stash_restore: None, - tag_deletion: None, - branch_deletion: None, - history_rewrite: None, - outcome_authoritative: false, - warnings: Vec::new(), - } -} - -/// Discards both index and working-tree content for an explicitly confirmed rollback. -/// The normal `discard` operation deliberately preserves staged content, while -/// this explicit operation is destructive for the whole path. -fn discard_all(root: &str, paths: &[String]) -> Result { - let mut tracked = Vec::new(); - let mut untracked = Vec::new(); - let mut tracked_paths = HashSet::new(); - let mut untracked_paths = HashSet::new(); - let literal_paths = [("GIT_LITERAL_PATHSPECS".to_string(), "1".to_string())]; - let mut final_response = successful_git_result(); - // Rollback is often called once per selected file. Never enumerate unrelated - // untracked trees for every file, and keep path arguments below Windows limits. - for batch in pathspec_batches(paths, LS_FILES_PATHSPEC_BUDGET) { - let mut arguments = vec![ - "status".to_string(), - "--porcelain=v2".to_string(), - "-z".to_string(), - "--untracked-files=all".to_string(), - "--".to_string(), - ]; - arguments.extend_from_slice(batch); - let status = execute_git_with_environment(root, &arguments, None, true, &literal_paths)?; - if status.exit_code != 0 { - return Ok(status); - } - // The shared parser consumes rename source records too; a source name - // beginning with `?? ` must not be mistaken for an untracked path. - for change in parse_status(status.stdout.as_bytes(), true) { - if change.untracked { - untracked_paths.insert(change.path); - } else { - tracked_paths.insert(change.path); - } - } - final_response = status; - } - for path in paths { - // A summarized untracked directory (`dir/`) is reported file by file. - // A staged deletion can coexist with an untracked disk copy at the - // same path. Restore HEAD rather than cleaning that retained copy. - let is_untracked = !tracked_paths.contains(path.as_str()) - && (untracked_paths.contains(path.as_str()) - || (path.ends_with('/') - && untracked_paths - .iter() - .any(|untracked| untracked.starts_with(path.as_str())))); - if is_untracked { - untracked.push(path.clone()); - } else { - tracked.push(path.clone()); - } - } - - if !tracked.is_empty() { - let arguments = vec![ - "restore".to_string(), - "--source=HEAD".to_string(), - "--staged".to_string(), - "--worktree".to_string(), - "--pathspec-from-file=-".to_string(), - "--pathspec-file-nul".to_string(), - ]; - // Passing pathspecs over stdin avoids the Windows command-line limit for - // large source-control selections and preserves unusual path characters. - let pathspec_input = tracked - .iter() - .flat_map(|path| [path.as_str(), "\0"]) - .collect::(); - let restored = execute_git_with_environment( - root, - &arguments, - Some(pathspec_input), - false, - &literal_paths, - )?; - if restored.exit_code != 0 { - return Ok(restored); - } - final_response = restored; - } - for batch in pathspec_batches(&untracked, LS_FILES_PATHSPEC_BUDGET) { - let mut arguments = vec![ - "clean".to_string(), - "-f".to_string(), - "-d".to_string(), - "--".to_string(), - ]; - arguments.extend_from_slice(batch); - let cleaned = execute_git_with_environment(root, &arguments, None, false, &literal_paths)?; - if cleaned.exit_code != 0 { - return Ok(cleaned); - } - final_response = cleaned; - } - Ok(final_response) -} - -fn pop_stash(root: &str, reference: &str) -> Result { - let mut result = execute_git( - root, - &["stash".into(), "pop".into(), reference.to_string()], - None, - )?; - let conflicted_paths = conflicted_paths(root)?; - let entry_was_kept = stash_reference_exists(root, reference)?; - if entry_was_kept && (!conflicted_paths.is_empty() || result.exit_code == 0) { - result.stash_restore = Some(GitStashRestoreResponse { - stash_reference: reference.to_string(), - conflicted_paths, - }); - } - Ok(result) -} - -fn apply_stash(root: &str, reference: &str) -> Result { - let mut result = execute_git( - root, - &["stash".into(), "apply".into(), reference.to_string()], - None, - )?; - let conflicted_paths = conflicted_paths(root)?; - if !conflicted_paths.is_empty() { - result.exit_code = 1; - result.stash_restore = Some(GitStashRestoreResponse { - stash_reference: reference.to_string(), - conflicted_paths, - }); - } - Ok(result) -} - -fn pull_with_auto_stash( - root: &str, - pull_arguments: &[String], -) -> Result { - let status = execute_git_readonly( - root, - &[ - "status".into(), - "--porcelain=v1".into(), - "-z".into(), - "--untracked-files=all".into(), - ], - None, - )?; - if status.exit_code != 0 { - return Ok(status); - } - if status.stdout.is_empty() { - return execute_git(root, pull_arguments, None); - } - - let stash_marker = format!( - "lithe: auto-stash before pull:{}:{}", - std::process::id(), - AUTO_STASH_SEQUENCE.fetch_add(1, Ordering::Relaxed) - ); - let stashed = execute_git( - root, - &[ - "stash".into(), - "push".into(), - "--include-untracked".into(), - "--message".into(), - stash_marker.clone(), - ], - None, - )?; - if stashed.exit_code != 0 { - return Ok(stashed); - } - let stash_oid = find_stash_oid_by_message(root, &stash_marker)?.ok_or_else(|| { - CoreError::new( - ErrorCode::ProcessFailed, - "Pull created a temporary stash but could not identify it", - ) - })?; - - let pulled = execute_git(root, pull_arguments, None)?; - if pulled.exit_code != 0 { - return Ok(pulled); - } - - let mut restored = execute_git( - root, - &["stash".into(), "apply".into(), stash_oid.clone()], - None, - )?; - let conflicted_paths = conflicted_paths(root)?; - let stash_reference = stash_reference_for_oid(root, &stash_oid)?; - if restored.exit_code != 0 || !conflicted_paths.is_empty() { - restored.exit_code = 1; - restored.stash_restore = Some(GitStashRestoreResponse { - stash_reference: stash_reference.unwrap_or(stash_oid), - conflicted_paths, - }); - return Ok(restored); - } - - let Some(stash_reference) = stash_reference else { - restored.warnings.push(GitOperationWarning::new( - "git_stash_drop_failed", - "The pull and stash restore succeeded, but the saved stash could not be located for removal", - None, - )); - return Ok(restored); - }; - let dropped = execute_git( - root, - &["stash".into(), "drop".into(), stash_reference], - None, - )?; - if dropped.exit_code != 0 { - restored.warnings.push(GitOperationWarning::new( - "git_stash_drop_failed", - "The pull and stash restore succeeded, but the saved stash could not be removed", - Some(dropped.output), - )); - } - Ok(restored) -} - -fn find_stash_oid_by_message(root: &str, message: &str) -> Result, CoreError> { - let list = execute_git_readonly( - root, - &["stash".into(), "list".into(), "--format=%H%x09%gs".into()], - None, - )?; - if list.exit_code != 0 { - return Err( - CoreError::new(ErrorCode::ProcessFailed, "Could not inspect Git stashes") - .with_details(list.output), - ); - } - Ok(list.stdout.lines().find_map(|line| { - let (oid, subject) = line.split_once('\t')?; - subject.contains(message).then(|| oid.to_string()) - })) -} - -fn stash_reference_for_oid(root: &str, oid: &str) -> Result, CoreError> { - let list = execute_git_readonly( - root, - &["stash".into(), "list".into(), "--format=%gd%x09%H".into()], - None, - )?; - if list.exit_code != 0 { - return Err( - CoreError::new(ErrorCode::ProcessFailed, "Could not inspect Git stashes") - .with_details(list.output), - ); - } - Ok(list.stdout.lines().find_map(|line| { - let (reference, commit) = line.split_once('\t')?; - (commit == oid).then(|| reference.to_string()) - })) -} - -fn conflicted_paths(root: &str) -> Result, CoreError> { - let response = execute_git( - root, - &[ - "diff".into(), - "--name-only".into(), - "--diff-filter=U".into(), - "--".into(), - ], - None, - )?; - if response.exit_code != 0 { - return Err( - CoreError::new(ErrorCode::ProcessFailed, "Git conflict status failed") - .with_details(response.output), - ); - } - let mut paths = response - .output - .lines() - .map(str::trim) - .filter(|path| !path.is_empty()) - .map(String::from) - .collect::>(); - paths.sort(); - paths.dedup(); - Ok(paths) -} - -fn stash_reference_exists(root: &str, reference: &str) -> Result { - let list = execute_git( - root, - &["stash".into(), "list".into(), "--format=%gd".into()], - None, - )?; - if list.exit_code != 0 { - return Ok(false); - } - Ok(list.output.lines().any(|line| line.trim() == reference)) -} - -fn find_stash_reference(root: &str, message: &str) -> Result, CoreError> { - let list = execute_git( - root, - &["stash".into(), "list".into(), "--format=%gd%x09%gs".into()], - None, - )?; - if list.exit_code != 0 { - return Ok(None); - } - Ok(list.output.lines().find_map(|line| { - let (reference, subject) = line.split_once('\t')?; - subject - .contains(message) - .then(|| reference.trim().to_string()) - })) -} - -#[derive(Clone)] -struct PushTarget { - local_branch: String, - remote: String, - /// Remote branch the push updates. For a divergent upstream this follows the - /// confirmed choice, so previews, expectation checks, leases and refspecs all - /// describe the same branch. - remote_branch: String, - /// Relationship between the local branch and its upstream; independent of the - /// confirmed choice. - target_kind: PushTargetKind, - /// Whether the destination branch does not exist on the remote yet. - creates_remote_branch: bool, - upstream: Option, - /// Branch name of a divergent upstream on `remote`; `None` for every other kind. - divergent_upstream_branch: Option, - comparison_reference: Option, -} - -/// Resolves the remote destination and commits that a subsequent push will use. -pub fn push_preview(request: GitPushPreviewRequest) -> Result { - let root = validate_root(&request.root)?; - let reference = if let Some(reference) = request.git_reference.as_ref() { - let reference = validated_git_reference(&root, reference)?; - if reference.kind != "local" { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Only local branches can be pushed", - )); - } - Some(reference.full_name) - } else { - request - .reference - .as_deref() - .map(|reference| validated_reference(Some(reference))) - .transpose()? - }; - let target = resolve_push_target(&root, reference.as_deref(), request.push_target)?; - let limit = request.limit.clamp(1, 5_000); - let local_reference = format!("refs/heads/{}", target.local_branch); - let local_head = resolve_commit_revision(&root, &local_reference)?; - let tags = resolve_push_tags( - &root, - request.push_tags.as_deref().unwrap_or("none"), - &local_head, - )?; - let remote_tracking_oid = target - .comparison_reference - .as_deref() - .map(|reference| resolve_commit_revision(&root, reference)) - .transpose()?; - let selectors = if let Some(comparison_reference) = target.comparison_reference.as_ref() { - vec![format!("{comparison_reference}..{local_reference}")] - } else { - // A branch without an upstream may still be based on another remote branch. - // Excluding every commit reachable from the selected remote keeps the preview - // focused on commits that publication would introduce. - vec![ - local_reference, - "--not".to_string(), - format!("--remotes={}", target.remote), - ] - }; - let (commits, has_more) = read_commit_log(&root, selectors, limit, "Git push preview failed")?; - - Ok(GitPushPreviewResponse { - local_branch: target.local_branch, - local_head, - remote: target.remote, - remote_branch: target.remote_branch, - target_kind: target.target_kind.as_contract(), - creates_remote_branch: target.creates_remote_branch, - remote_tracking_oid, - upstream: target.upstream, - tags, - commits, - has_more, - }) -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -/// Destination the user explicitly confirmed for a push. -/// -/// Only a divergent upstream requires this: the usual same-named destination stays -/// implicit so routine pushes keep their current interaction cost. -pub enum PushTargetChoiceRequest { - /// Publish the local branch to the same-named remote branch. - Publish, - /// Keep using the existing upstream branch, even though its name differs. - Upstream, -} - -/// Rejection for a divergent upstream that no caller confirmed. The message names -/// the command that unblocks a client without a destination picker, because the -/// native macOS push does not offer one yet. -fn push_destination_needs_confirmation( - local_branch: &str, - remote: &str, - upstream: &str, -) -> CoreError { - CoreError::new( - ErrorCode::InvalidRequest, - format!( - "Push destination needs confirmation: '{local_branch}' tracks '{upstream}', which has a different name. \ - Run `git push -u {remote} {local_branch}` to publish it under its own name, \ - or confirm the destination in a client that offers the choice." - ), - ) -} - -fn push( - root: &str, - reference: Option<&str>, - force: bool, - push_tags: Option<&str>, - expected_push: Option<&GitPushExpectationRequest>, - check_submodules: bool, - push_target: Option, -) -> Result { - let tag_scope = push_tags.unwrap_or("none"); - let tag_argument = match tag_scope { - "none" => None, - "all" => Some("--tags"), - "reachable" => Some("--follow-tags"), - _ => { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Unsupported Git push tag scope", - )) - } - }; - // Issue #1272: the effective destination is resolved once, with the caller's - // confirmed choice, by the same function the preview uses. A reviewed push is - // therefore compared against exactly the branch the user was shown. - let target = resolve_push_target(root, reference, push_target)?; - if target.target_kind == PushTargetKind::Divergent && push_target.is_none() { - return Err(push_destination_needs_confirmation( - &target.local_branch, - &target.remote, - target.upstream.as_deref().unwrap_or_default(), - )); - } - if let Some(expected_push) = expected_push { - validate_push_expectation(root, &target, tag_scope, expected_push)?; - } - // Only an upstream that was missing or deliberately replaced is repointed after - // a successful push. Confirming "upstream" keeps the foreign branch name and - // leaves the user's tracking configuration untouched. - let should_set_upstream = match target.target_kind { - PushTargetKind::Tracking => false, - PushTargetKind::Publish => target.upstream.is_none(), - PushTargetKind::Divergent => push_target == Some(PushTargetChoiceRequest::Publish), - }; - let mut arguments = vec!["push".to_string()]; - if check_submodules { - arguments.push("--recurse-submodules=check".into()); - } - if force { - // Bind reviewed pushes to the observed remote OID so an unseen remote update is rejected. - arguments.push(match expected_push { - Some(expected) => format!( - "--force-with-lease=refs/heads/{}:{}", - target.remote_branch, - expected.remote_tracking_oid.as_deref().unwrap_or_default() - ), - None => "--force-with-lease".into(), - }); - } - if expected_push.is_none() { - if let Some(tag_argument) = tag_argument { - arguments.push(tag_argument.into()); - } - } - let source = expected_push - .map(|expected| expected.local_head.clone()) - .unwrap_or_else(|| format!("refs/heads/{}", target.local_branch)); - let remote = target.remote; - let remote_branch = target.remote_branch; - let local_branch = target.local_branch; - arguments.extend([ - remote.clone(), - format!("{source}:refs/heads/{remote_branch}"), - ]); - if let Some(expected) = expected_push { - arguments.extend( - expected - .tags - .iter() - .map(|tag| format!("{}:{}", tag.object_id, tag.full_name)), - ); - } - let pushed = execute_git(root, &arguments, None)?; - if pushed.exit_code != 0 || !should_set_upstream { - return Ok(pushed); - } - - if let Some(configuration) = - configure_branch_upstream(root, &local_branch, &remote, &remote_branch)? - { - return Ok(push_with_upstream_warning(pushed, configuration)); - } - Ok(pushed) -} - -fn push_with_upstream_warning( - mut pushed: GitCommandResponse, - configuration: GitCommandResponse, -) -> GitCommandResponse { - pushed.warnings.push(GitOperationWarning::new( - "git_upstream_configuration_failed", - "The push succeeded, but the branch upstream could not be configured", - Some(configuration.output), - )); - pushed -} - -/// Returns one metadata-only snapshot for every registered worktree. -pub fn worktrees(request: GitWorktreesRequest) -> Result { - let root = validate_root(&request.root)?; - Ok(GitWorktreesResponse { - worktrees: list_worktrees(&root)?, - }) -} - -fn list_worktrees(root: &str) -> Result, CoreError> { - let arguments = vec![ - "worktree".to_string(), - "list".to_string(), - "--porcelain".to_string(), - "-z".to_string(), - ]; - let response = execute_git_readonly(root, &arguments, None)?; - if response.exit_code != 0 { - return Err( - CoreError::new(ErrorCode::ProcessFailed, "Git worktree listing failed") - .with_details(response.output), - ); - } - let current_root = canonicalize_or_original(&repository_root(root)?).map_err(git_path_error)?; - let mut records = Vec::new(); - let mut fields = Vec::new(); - for field in response.stdout.split('\0') { - if field.is_empty() { - if !fields.is_empty() { - records.push(parse_worktree_record( - &fields, - records.is_empty(), - ¤t_root, - )?); - fields.clear(); - } - } else { - fields.push(field); - } - } - if !fields.is_empty() { - records.push(parse_worktree_record( - &fields, - records.is_empty(), - ¤t_root, - )?); - } - // Git currently emits the primary worktree first, but sorting here makes - // that display contract explicit and stable across Git versions. - records.sort_by(|left, right| { - right - .is_primary - .cmp(&left.is_primary) - .then_with(|| left.path.cmp(&right.path)) - }); - Ok(records) -} - -fn worktree_paths_match(left: &str, right: &str) -> bool { - let left_path = PathBuf::from(left); - let right_path = PathBuf::from(right); - if left_path == right_path { - return true; - } - match (fs::canonicalize(&left_path), fs::canonicalize(&right_path)) { - (Ok(left), Ok(right)) => left == right, - _ => false, - } -} - -fn parse_worktree_record( - fields: &[&str], - is_primary: bool, - current_root: &Path, -) -> Result { - let path = fields - .iter() - .find_map(|field| field.strip_prefix("worktree ")) - .filter(|path| !path.is_empty()) - .ok_or_else(|| { - CoreError::new( - ErrorCode::ProcessFailed, - "Git returned an invalid worktree record", - ) - })?; - let head = fields - .iter() - .find_map(|field| field.strip_prefix("HEAD ")) - .unwrap_or_default() - .to_string(); - let branch = fields - .iter() - .find_map(|field| field.strip_prefix("branch ")) - .map(str::to_string); - let value_after_marker = |marker: &str| { - fields.iter().find_map(|field| { - if *field == marker { - Some(None) - } else { - field - .strip_prefix(&format!("{marker} ")) - .map(|value| Some(value.to_string())) - } - }) - }; - let lock = value_after_marker("locked"); - let prunable = value_after_marker("prunable"); - let reported_path = PathBuf::from(path); - let normalized_path = canonicalize_or_original(&reported_path).map_err(git_path_error)?; - Ok(GitWorktreeResponse { - path: normalized_path.to_string_lossy().to_string(), - head, - branch, - is_current: normalized_path == current_root, - is_primary, - is_bare: fields.contains(&"bare"), - is_detached: fields.contains(&"detached"), - is_locked: lock.is_some(), - lock_reason: lock.flatten(), - is_prunable: prunable.is_some(), - prune_reason: prunable.flatten(), - }) -} - -fn create_worktree(root: &str, request: &GitWriteRequest) -> Result { - let destination = required_text(request.destination.as_deref(), "worktree destination")?; - if destination.starts_with('-') || destination.contains(['\0', '\n', '\r']) { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Invalid Git worktree destination", - )); - } - let mut arguments = vec!["worktree".into(), "add".into()]; - if request.no_checkout { - arguments.push("--no-checkout".into()); - } - let mode = request.worktree_mode.as_deref().unwrap_or("newBranch"); - let source = match mode { - "newBranch" => { - let branch = validated_branch_name(root, request.name.as_deref())?; - let reference = request - .git_reference - .as_ref() - .ok_or_else(invalid_git_reference) - .and_then(|reference| validated_git_reference(root, reference))?; - if reference.kind == "remote" && request.revision.is_none() { - // Branch creation and upstream setup remain one Git mutation. - arguments.push("--track".into()); - } else { - // An explicit commit has no tracking identity. Freeze the - // result independently of branch.autoSetupMerge preferences. - arguments.push("--no-track".into()); - } - arguments.extend(["-b".into(), branch]); - if let Some(revision) = request.revision.as_deref() { - resolve_commit_revision(root, &validated_revision(Some(revision))?)? - } else { - reference.full_name - } - } - "existingBranch" => { - if request.name.is_some() || request.revision.is_some() { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Existing-branch worktrees do not accept a new branch name or revision", - )); - } - let reference = request - .git_reference - .as_ref() - .ok_or_else(invalid_git_reference) - .and_then(|reference| validated_git_reference(root, reference))?; - if reference.kind != "local" { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "An existing-branch worktree requires a local Git reference", - )); - } - let branch = validated_branch_name(root, Some(&reference.short_name))?; - resolve_commit_revision(root, &reference.full_name)?; - // Git recognizes an existing branch from its short name here; - // supplying refs/heads/... would create a detached checkout instead. - // The complete identity was validated above; Git enforces occupancy. - branch - } - "detached" => { - if request.name.is_some() { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "A detached worktree does not accept a branch name", - )); - } - arguments.push("--detach".into()); - let reference = request - .git_reference - .as_ref() - .map(|reference| validated_git_reference(root, reference)) - .transpose()?; - let revision = if let Some(revision) = request.revision.as_deref() { - validated_revision(Some(revision))? - } else { - reference.ok_or_else(invalid_git_reference)?.full_name - }; - // A fixed OID prevents a concurrently moved ref from changing the - // detached commit after this request has selected its starting point. - resolve_commit_revision(root, &revision)? - } - _ => { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Unsupported Git worktree creation mode", - )) - } - }; - let destination = crate::remote::workspace::remote_path(Path::new(&destination)) - .to_string_lossy() - .into_owned(); - arguments.extend(["--".into(), destination, source]); - execute_git(root, &arguments, None) -} - -fn mutate_worktree(root: &str, request: &GitWriteRequest) -> Result { - let destination = required_text(request.destination.as_deref(), "worktree destination")?; - if destination.starts_with('-') || destination.contains(['\0', '\n', '\r']) { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Invalid Git worktree destination", - )); - } - let entries = list_worktrees(root)?; - let target = entries - .iter() - .find(|entry| worktree_paths_match(&entry.path, &destination)) - .ok_or_else(|| { - CoreError::new( - ErrorCode::GitWorktreeNotRegistered, - "The selected path is not a registered Git worktree", - ) - })?; - let removing = request.operation == "removeWorktree"; - // Each refusal has its own code so hosts can explain it without parsing - // messages. The primary worktree is checked first: it can never be removed, - // locked or unlocked, whichever worktree the request was made from. - if target.is_primary { - return Err(CoreError::new( - ErrorCode::GitWorktreePrimary, - "The primary Git worktree cannot be removed, locked or unlocked", - )); - } - if removing && target.is_current { - return Err(CoreError::new( - ErrorCode::GitWorktreeCurrent, - "The current Git worktree cannot be removed", - )); - } - if removing && target.is_locked { - return Err(CoreError::new( - ErrorCode::GitWorktreeLocked, - "Unlock the Git worktree before removing it", - )); - } - - let mut arguments = vec!["worktree".to_string()]; - match request.operation.as_str() { - "removeWorktree" => { - arguments.push("remove".into()); - if request.force { - arguments.push("--force".into()); - } - } - "lockWorktree" => arguments.push("lock".into()), - "unlockWorktree" => arguments.push("unlock".into()), - _ => unreachable!("caller restricts worktree mutations"), - } - let destination = crate::remote::workspace::remote_path(Path::new(&destination)) - .to_string_lossy() - .into_owned(); - arguments.extend(["--".into(), destination]); - let mut response = execute_git(root, &arguments, None)?; - if removing && !request.force { - classify_unclean_worktree_removal(&mut response); - } - Ok(response) -} - -/// Git's C-locale refusal of `git worktree remove` without `--force` for a -/// checkout with modified or untracked files (`check_clean_worktree` in Git's -/// `builtin/worktree.c`). Every Git child runs with `LC_ALL=C`, so the text is -/// fixed whatever the user's system language is. -const UNCLEAN_WORKTREE_REMOVAL_MARKER: &str = "contains modified or untracked files"; - -/// Gives a failed, unforced worktree removal that Git refused for local -/// changes the `git_worktree_has_changes` code, so hosts can offer a forced -/// removal without matching stderr. The message is the first stderr line and -/// the details the combined output. Any other failure is left as it is. -fn classify_unclean_worktree_removal(response: &mut GitCommandResponse) { - if response.exit_code == 0 - || response.operation_error.is_some() - || !response.stderr.contains(UNCLEAN_WORKTREE_REMOVAL_MARKER) - { - return; - } - let message = response - .stderr - .lines() - .map(str::trim) - .find(|line| !line.is_empty()) - .unwrap_or("The Git worktree has modified or untracked files") - .to_string(); - response.operation_error = Some( - CoreError::new(ErrorCode::GitWorktreeHasChanges, message) - .with_details(response.output.clone()), - ); -} - -fn update_local_branch( - root: &str, - request: &GitWriteRequest, -) -> Result { - let reference = request - .git_reference - .as_ref() - .ok_or_else(invalid_git_reference) - .and_then(|reference| validated_git_reference(root, reference))?; - if reference.kind != "local" { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Branch update requires a local Git reference", - )); - } - if optional_current_branch(root)?.is_some_and(|current| { - reference.full_name == current || reference.full_name == format!("refs/heads/{current}") - }) { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "The current branch must be updated through pull", - )); - } - - let upstream = execute_git_readonly( - root, - &[ - "for-each-ref".into(), - "--format=%(upstream)".into(), - reference.full_name.clone(), - ], - None, - )?; - if upstream.exit_code != 0 { - return Err(CoreError::new( - ErrorCode::ProcessFailed, - "Could not inspect the branch upstream", - ) - .with_details(upstream.output)); - } - let upstream_reference = upstream.stdout.trim(); - if upstream_reference.is_empty() { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "The selected branch has no upstream", - )); - } - let (remote, remote_branch) = mutations::remote_branch_components(root, upstream_reference)?; - - // One atomic fetch refreshes the tracking ref and fast-forwards the selected - // local branch without changing HEAD. Git rejects non-fast-forward updates - // and branches checked out by any worktree. - execute_git( - root, - &[ - "fetch".into(), - "--atomic".into(), - "--no-tags".into(), - "--".into(), - remote, - format!("+refs/heads/{remote_branch}:{upstream_reference}"), - format!("refs/heads/{remote_branch}:{}", reference.full_name), - ], - None, - ) -} - -fn configure_branch_upstream( - root: &str, - local_branch: &str, - remote: &str, - remote_branch: &str, -) -> Result, CoreError> { - let remote_config = execute_git( - root, - &[ - "config".to_string(), - "--local".to_string(), - "--replace-all".to_string(), - format!("branch.{local_branch}.remote"), - remote.to_string(), - ], - None, - )?; - if remote_config.exit_code != 0 { - return Ok(Some(remote_config)); - } - let merge_config = execute_git( - root, - &[ - "config".to_string(), - "--local".to_string(), - "--replace-all".to_string(), - format!("branch.{local_branch}.merge"), - format!("refs/heads/{remote_branch}"), - ], - None, - )?; - Ok((merge_config.exit_code != 0).then_some(merge_config)) -} - -fn nul_pathspec_input(paths: &[String]) -> String { - paths - .iter() - .flat_map(|path| [path.as_str(), "\0"]) - .collect() -} - -fn empty_tree_oid(root: &str) -> Result { - let hashed = execute_git_readonly( - root, - &[ - "hash-object".to_string(), - "-t".to_string(), - "tree".to_string(), - "--stdin".to_string(), - ], - Some(String::new()), - )?; - if hashed.exit_code != 0 { - return Err(CoreError::new( - ErrorCode::ProcessFailed, - "Could not resolve Git's empty tree", - ) - .with_details(hashed.output)); - } - let oid = hashed.stdout.trim(); - validate_revision(oid)?; - Ok(oid.to_string()) -} - -fn validate_push_expectation( - root: &str, - target: &PushTarget, - tag_scope: &str, - expected: &GitPushExpectationRequest, -) -> Result<(), CoreError> { - let local_reference = format!("refs/heads/{}", target.local_branch); - let local_head = resolve_commit_revision(root, &local_reference)?; - let remote_tracking_oid = target - .comparison_reference - .as_deref() - .map(|reference| resolve_commit_revision(root, reference)) - .transpose()?; - let tags = resolve_push_tags(root, tag_scope, &local_head)?; - let expected_tags = expected - .tags - .iter() - .map(|tag| (&tag.full_name, &tag.object_id)) - .collect::>(); - let actual_tags = tags - .iter() - .map(|tag| (&tag.full_name, &tag.object_id)) - .collect::>(); - // A reviewed push records the destination relationship too, so a branch that - // started tracking `main` after the preview cannot slip through the check. - // `targetKind` is compared only when the caller supplied it: a caller that - // predates target kinds keeps its previous behaviour instead of being rejected - // as stale. - let target_kind_changed = expected - .target_kind - .as_ref() - .is_some_and(|kind| *kind != target.target_kind.as_contract()); - if expected.local_branch != target.local_branch - || expected.local_head != local_head - || expected.remote != target.remote - || expected.remote_branch != target.remote_branch - || expected.remote_tracking_oid != remote_tracking_oid - || target_kind_changed - || expected_tags != actual_tags - { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Git push preview is stale; refresh and try again.", - )); - } - Ok(()) -} - -fn resolve_push_tags( - root: &str, - scope: &str, - local_head: &str, -) -> Result, CoreError> { - if scope == "none" { - return Ok(Vec::new()); - } - if !matches!(scope, "all" | "reachable") { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Unsupported Git push tag scope", - )); - } - let listed = execute_git_readonly( - root, - &[ - "for-each-ref".into(), - "--sort=refname".into(), - "--format=%(refname)%09%(objectname)%09%(objecttype)%09%(*objectname)".into(), - "refs/tags".into(), - ], - None, - )?; - if listed.exit_code != 0 { - return Err( - CoreError::new(ErrorCode::ProcessFailed, "Could not inspect Git tags") - .with_details(listed.output), - ); - } - - let mut tags = Vec::new(); - for line in listed.stdout.lines() { - let columns = line.split('\t').collect::>(); - if columns.len() != 4 || !columns[0].starts_with("refs/tags/") || columns[1].is_empty() { - return Err(CoreError::new( - ErrorCode::ParseFailed, - "Could not decode Git tag metadata", - )); - } - if scope == "reachable" { - if columns[2] != "tag" || columns[3].is_empty() { - continue; - } - let reachable = execute_git_readonly( - root, - &[ - "merge-base".into(), - "--is-ancestor".into(), - columns[3].into(), - local_head.into(), - ], - None, - )?; - if reachable.exit_code == 1 { - continue; - } - if reachable.exit_code != 0 { - return Err(CoreError::new( - ErrorCode::ProcessFailed, - "Could not inspect Git tag reachability", - ) - .with_details(reachable.output)); - } - } - tags.push(GitPushTagResponse { - full_name: columns[0].to_string(), - object_id: columns[1].to_string(), - }); - } - Ok(tags) -} - -/// Resolves the single destination used by both `pushPreview` and `push`. -/// -/// `choice` only matters for a divergent upstream. Keeping the preview and the push -/// on this one function is what lets a reviewed push compare the exact remote -/// branch, tracking OID and lease the user was shown. -fn resolve_push_target( - root: &str, - reference: Option<&str>, - choice: Option, -) -> Result { - let local_branch = match reference { - Some(reference) => local_branch_name(&validated_reference(Some(reference))?)?, - None => current_branch(root)?, - }; - let upstream_lookup = execute_git_readonly( - root, - &[ - "rev-parse".into(), - "--abbrev-ref".into(), - format!("{local_branch}@{{upstream}}"), - ], - None, - )?; - let (upstream, upstream_components) = if upstream_lookup.exit_code == 0 { - let upstream = upstream_lookup.output.trim().to_string(); - let components = - mutations::remote_branch_components(root, &format!("refs/remotes/{upstream}"))?; - (Some(upstream), Some(components)) - } else { - (None, None) - }; - - let branch_push_remote = - read_git_config_value(root, &format!("branch.{local_branch}.pushRemote"))?; - let default_push_remote = read_git_config_value(root, "remote.pushDefault")?; - let branch_remote = read_git_config_value(root, &format!("branch.{local_branch}.remote"))? - .filter(|remote| remote != "."); - let remote = branch_push_remote - .or(default_push_remote) - .or_else(|| { - upstream_components - .as_ref() - .map(|(remote, _)| remote.clone()) - }) - .or(branch_remote) - .map(Ok) - .unwrap_or_else(|| default_push_remote_name(root))?; - validate_push_component(&remote)?; - - // The upstream only identifies a destination when it lives on the remote this - // push actually targets. A `pushRemote`/`remote.pushDefault` override therefore - // falls back to the same-named branch on the selected remote. - let upstream_branch = upstream_components - .as_ref() - .filter(|(upstream_remote, _)| upstream_remote == &remote) - .map(|(_, branch)| branch.clone()); - let (target_kind, remote_branch, divergent_upstream_branch) = match upstream_branch { - // Issue #1272: a divergent upstream means pushing would update a different - // branch, so it is never the implicit destination. The same-named branch is - // reported while the choice is pending only so a client can describe it; - // `push` refuses to use it until a choice is confirmed. - Some(branch) if branch != local_branch => { - let remote_branch = match choice { - Some(PushTargetChoiceRequest::Upstream) => branch.clone(), - Some(PushTargetChoiceRequest::Publish) | None => local_branch.clone(), - }; - (PushTargetKind::Divergent, remote_branch, Some(branch)) - } - Some(branch) => (PushTargetKind::Tracking, branch, None), - None => (PushTargetKind::Publish, local_branch.clone(), None), - }; - let target_reference = format!("refs/remotes/{remote}/{remote_branch}"); - let comparison_reference = if reference_exists(root, &target_reference)? { - Some(target_reference) - } else { - None - }; - - Ok(PushTarget { - local_branch, - remote, - remote_branch, - target_kind, - creates_remote_branch: comparison_reference.is_none(), - upstream, - divergent_upstream_branch, - comparison_reference, - }) -} - -#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -/// A branch whose upstream has a different name and therefore needs the user's -/// choice before anything is pushed. -pub struct DivergentPushDestination { - /// Short name of the local branch being pushed. - pub local_branch: String, - /// Remote that both choices target; the upstream lives on it. - pub remote: String, - /// Upstream short name such as `origin/release`. - pub upstream: String, - /// Branch name of the upstream on `remote`, which may itself contain slashes. - pub upstream_branch: String, -} - -impl DivergentPushDestination { - /// Remote branch that a confirmed `choice` updates. - pub(crate) fn remote_branch_for(&self, choice: PushTargetChoiceRequest) -> &str { - match choice { - PushTargetChoiceRequest::Publish => &self.local_branch, - PushTargetChoiceRequest::Upstream => &self.upstream_branch, - } - } -} - -/// Reports whether pushing `reference` needs a destination choice, using the same -/// resolution as `push` so a plan can ask before it commits anything. -pub(crate) fn divergent_push_destination( - root: &str, - reference: &str, -) -> Result, CoreError> { - let target = resolve_push_target(root, Some(reference), None)?; - Ok( - match ( - target.target_kind, - target.upstream, - target.divergent_upstream_branch, - ) { - (PushTargetKind::Divergent, Some(upstream), Some(upstream_branch)) => { - Some(DivergentPushDestination { - local_branch: target.local_branch, - remote: target.remote, - upstream, - upstream_branch, - }) - } - _ => None, - }, - ) -} - -/// Push destination relationship, mirroring [`GitPushTargetKind`] inside Core. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum PushTargetKind { - /// No upstream on the selected remote; publish to the same-named branch. - Publish, - /// Upstream and local branch share a name. - Tracking, - /// Upstream branch name differs from the local branch name. - Divergent, -} - -impl PushTargetKind { - /// Contract representation returned to callers. - fn as_contract(self) -> GitPushTargetKind { - match self { - Self::Publish => GitPushTargetKind::Publish, - Self::Tracking => GitPushTargetKind::Tracking, - Self::Divergent => GitPushTargetKind::Divergent, - } - } -} - -fn read_git_config_value(root: &str, key: &str) -> Result, CoreError> { - // This is an internal preflight, not a user command. A missing optional - // value returns 1 normally and must not appear as a failed console action. - let arguments = ["config".into(), "--get".into(), key.to_string()]; - let configured = - capture_git_with_options(root, &arguments, None, true)?.into_command_response(&arguments); - if configured.exit_code == 1 { - return Ok(None); - } - if configured.exit_code != 0 { - return Err( - CoreError::new(ErrorCode::ProcessFailed, "Could not read Git configuration") - .with_details(configured.output), - ); - } - let value = configured.output.trim(); - if value.is_empty() { - return Ok(None); - } - validate_push_component(value)?; - Ok(Some(value.to_string())) -} - -fn reference_exists(root: &str, reference: &str) -> Result { - let result = execute_git_readonly( - root, - &[ - "show-ref".into(), - "--verify".into(), - "--quiet".into(), - reference.to_string(), - ], - None, - )?; - if result.exit_code > 1 { - return Err(CoreError::new( - ErrorCode::ProcessFailed, - "Could not inspect Git push destination", - ) - .with_details(result.output)); - } - Ok(result.exit_code == 0) -} - -fn default_push_remote_name(root: &str) -> Result { - let remotes = execute_git_readonly(root, &["remote".into()], None)?; - if remotes.exit_code != 0 { - return Err( - CoreError::new(ErrorCode::ProcessFailed, "Could not list Git remotes") - .with_details(remotes.output), - ); - } - let remote = remotes - .output - .lines() - .map(str::trim) - .find(|remote| *remote == "origin") - .or_else(|| { - remotes - .output - .lines() - .map(str::trim) - .find(|remote| !remote.is_empty()) - }) - .ok_or_else(|| CoreError::new(ErrorCode::InvalidRequest, "No Git remote is configured"))?; - validate_push_component(remote)?; - Ok(remote.to_string()) -} - -fn validate_push_component(value: &str) -> Result<(), CoreError> { - if value.is_empty() - || value.starts_with('-') - || value.contains(['\0', '\n', '\r']) - || value.chars().any(char::is_whitespace) - { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Invalid Git push destination", - )); - } - Ok(()) -} - -fn publish_branch(root: &str, name: Option<&str>) -> Result { - let name = validated_branch_name(root, name)?; - match optional_current_branch(root)? { - Some(current) if current != name => { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Publish the currently checked out branch", - )); - } - Some(_) => {} - None => { - let created = execute_git( - root, - &["switch".into(), "-c".into(), name.clone(), "HEAD".into()], - None, - )?; - if created.exit_code != 0 { - return Ok(created); - } - } - } - execute_git( - root, - &[ - "push".into(), - "--set-upstream".into(), - "origin".into(), - name, - ], - None, - ) -} - -/// Checks out `request.reference`, honouring the conflict-resolution strategy the user -/// picked in the checkout dialog. -/// -/// Three strategies, mirroring IntelliJ IDEA: -/// - default: plain switch. Git refuses when local changes would be overwritten. -/// - `force`: `--discard-changes`, throwing the local edits away. -/// - `auto_stash`: stash, switch, then restore the stash ("smart checkout"). -fn checkout(root: &str, request: GitWriteRequest) -> Result { - if request.reference_kind.as_deref() == Some("local") { - if let Some(reference) = request - .reference - .as_deref() - .filter(|value| !value.starts_with('-') && !value.chars().any(char::is_whitespace)) - { - if is_current_reference(root, reference)? { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "The current branch is already checked out", - )); - } - } - } - if request.auto_stash { - // Reject local-only commits before creating a stash. Ordinary remote - // checkout aligns the branch; checkoutAndRebase retains its own policy. - let is_remote = request.git_reference.as_ref().map_or( - request.reference_kind.as_deref() == Some("remote"), - |reference| reference.kind == "remote", - ); - if is_remote { - let reference = checkout_request_reference(root, &request)?; - let (_, local_name) = mutations::remote_branch_components(root, &reference.full_name)?; - let local_ref = format!("refs/heads/{local_name}"); - if resolve_ref_object(root, &local_ref)?.is_some() { - remote_checkout_revisions(root, &local_ref, &reference.full_name)?; - } - } - return checkout_with_auto_stash(root, request); - } - switch_reference(root, &request) -} - -/// Checks out a local or remote branch, then rebases it onto the branch that -/// was current before the switch. A dirty tree is rejected before checkout so -/// the composite operation cannot leave the repository half-switched. -/// Stash, switch, restore. A failed switch leaves the stash untouched so the caller can -/// recover it, and a conflicting restore is reported as a failure rather than silently -/// leaving the entry behind. -fn checkout_with_auto_stash( - root: &str, - request: GitWriteRequest, -) -> Result { - let stash = execute_git( - root, - &[ - "stash".into(), - "push".into(), - "--include-untracked".into(), - "--message".into(), - AUTO_STASH_MESSAGE.into(), - ], - None, - )?; - if stash.exit_code != 0 { - return Ok(stash); - } - - let switched = switch_reference(root, &request)?; - if switched.exit_code != 0 { - // Leave the stash in place; the working tree is still on the original branch. - return Ok(switched); - } - - let Some(stash_reference) = find_stash_reference(root, AUTO_STASH_MESSAGE)? else { - return Err(CoreError::new( - ErrorCode::ProcessFailed, - "Smart Checkout created a stash but could not locate it for restore.", - )); - }; - let mut restored = execute_git( - root, - &["stash".into(), "pop".into(), stash_reference.clone()], - None, - )?; - let conflicted_paths = conflicted_paths(root)?; - let entry_was_kept = stash_reference_exists(root, &stash_reference)?; - if entry_was_kept && (restored.exit_code == 0 || !conflicted_paths.is_empty()) { - restored.exit_code = 1; - restored.stash_restore = Some(GitStashRestoreResponse { - stash_reference, - conflicted_paths, - }); - } - Ok(restored) -} - -pub(super) fn switch_reference( - root: &str, - request: &GitWriteRequest, -) -> Result { - let reference = checkout_request_reference(root, request)?; - switch_validated_reference( - root, - &reference, - request.force, - request.operation == "checkout", - ) -} - -fn switch_validated_reference( - root: &str, - reference: &ValidatedGitReference, - force: bool, - update_remote: bool, -) -> Result { - let mut base: Vec = vec!["switch".into()]; - if force { - base.push("--discard-changes".into()); - } - match reference.kind.as_str() { - "local" => { - if current_branch(root)? == reference.short_name { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "The current branch is already checked out", - )); - } - // `git switch` rejects fully qualified refs ("refs/heads/foo"), so pass the - // short branch name. Tags still need the full ref for the --detach form. - base.push(reference.short_name.clone()); - execute_git(root, &base, None) - } - "tag" => { - base.push("--detach".into()); - base.push(reference.full_name.clone()); - execute_git(root, &base, None) - } - "remote" => { - let (_, local_name) = mutations::remote_branch_components(root, &reference.full_name)?; - let local_ref = format!("refs/heads/{local_name}"); - let existing = execute_git( - root, - &[ - "show-ref".into(), - "--verify".into(), - "--quiet".into(), - local_ref.clone(), - ], - None, - )?; - if existing.exit_code == 0 { - let upstream = execute_git_readonly( - root, - &[ - "for-each-ref".into(), - "--format=%(upstream)".into(), - "--count=1".into(), - local_ref.clone(), - ], - None, - )?; - if upstream.exit_code != 0 { - return Err(CoreError::new( - ErrorCode::ProcessFailed, - "Could not inspect the local branch upstream", - ) - .with_details(upstream.output)); - } - if upstream.stdout.trim() != reference.full_name { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "A same-named local branch tracks a different Git reference", - )); - } - if update_remote { - return checkout_updated_remote_branch( - root, - &local_name, - &local_ref, - &reference.full_name, - force, - ); - } - if current_branch(root)? == local_name { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "The current branch is already checked out", - )); - } - base.push(local_name); - } else { - base.push("--track".into()); - base.push("-c".into()); - base.push(local_name); - base.push(reference.full_name.clone()); - } - execute_git(root, &base, None) - } - _ => Err(CoreError::new( - ErrorCode::InvalidRequest, - "Invalid Git reference kind", - )), - } -} - -/// Pins the local and fetched remote commits and rejects any local-only history. -fn remote_checkout_revisions( - root: &str, - local_ref: &str, - remote_ref: &str, -) -> Result<(String, String), CoreError> { - let local_revision = resolve_commit_revision(root, local_ref)?; - let remote_revision = resolve_commit_revision(root, remote_ref)?; - let ancestor = execute_git_readonly( - root, - &[ - "merge-base".into(), - "--is-ancestor".into(), - local_revision.clone(), - remote_revision.clone(), - ], - None, - )?; - match ancestor.exit_code { - 0 => Ok((local_revision, remote_revision)), - 1 => Err(CoreError::new( - ErrorCode::InvalidRequest, - "The local branch has commits not included in the selected remote branch. Reconcile those commits before checking out the remote branch.", - )), - _ => Err(CoreError::new( - ErrorCode::ProcessFailed, - "Could not compare the local and remote branch commits", - ) - .with_details(ancestor.output)), - } -} - -/// Switches to an existing tracking branch and fast-forwards to a pinned remote -/// commit, including when that local branch is already current. -fn checkout_updated_remote_branch( - root: &str, - local_name: &str, - local_ref: &str, - remote_ref: &str, - force: bool, -) -> Result { - let (local_revision, remote_revision) = remote_checkout_revisions(root, local_ref, remote_ref)?; - if !force { - // Check against the final remote tree before switching to the old local - // tree, so a blocked update cannot leave the user on another branch. - let preflight = checkout_preflight(GitCheckoutPreflightRequest { - root: root.to_string(), - reference: Some(remote_revision.clone()), - git_reference: None, - })?; - if !preflight.blocking_paths.is_empty() { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Local changes would be overwritten by checking out the remote branch", - ) - .with_details(preflight.blocking_paths.join("\n"))); - } - } - if force || current_branch(root)? != local_name { - let mut arguments = vec!["switch".into()]; - if force { - arguments.push("--discard-changes".into()); - } - arguments.push(local_name.to_string()); - let switched = execute_git(root, &arguments, None)?; - if switched.exit_code != 0 { - return Ok(switched); - } - } - // Post-checkout hooks and external clients may change HEAD. Verify the - // branch and its tip again; --ff-only also prevents losing local commits. - if current_branch(root)? != local_name - || resolve_commit_revision(root, "HEAD")? != local_revision - { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "The selected branch changed; review and retry", - )); - } - // `branch..mergeOptions=--squash` would make the merge exit 0 while - // only staging the remote diff. Override it for this call without touching - // the user's configuration, then confirm HEAD reached the pinned commit. - let merged = execute_git( - root, - &[ - "merge".into(), - "--ff-only".into(), - "--no-squash".into(), - remote_revision.clone(), - ], - None, - )?; - if merged.exit_code == 0 - && (current_branch(root)? != local_name - || resolve_commit_revision(root, "HEAD")? != remote_revision) - { - return Err(CoreError::new( - ErrorCode::ProcessFailed, - "Git did not move the branch to the selected remote commit", - ) - .with_details(merged.output)); - } - Ok(merged) -} - -fn parse_reference(line: &str) -> Option { - let columns = line.split('\t').collect::>(); - if columns.len() < 8 || columns[1].ends_with("/HEAD") { - return None; - } - let kind = if columns[0].starts_with("refs/heads/") { - "local" - } else if columns[0].starts_with("refs/remotes/") { - "remote" - } else { - "tag" - }; - let short_name = match kind { - "local" => columns[0].strip_prefix("refs/heads/"), - "remote" => columns[0].strip_prefix("refs/remotes/"), - "tag" => columns[0].strip_prefix("refs/tags/"), - _ => None, - }?; - let upstream_short_name = (!columns[3].is_empty()).then(|| columns[3].to_string()); - let (ahead, behind) = if kind == "local" && upstream_short_name.is_some() { - parse_tracking_counts(columns.get(5).copied().unwrap_or_default()) - } else { - (0, 0) - }; - Some(GitReferenceResponse { - full_name: columns[0].to_string(), - // `%(refname:short)` deliberately adds `heads/` or `tags/` when - // namespaces collide. The typed contract keeps identity in - // `fullName` and always exposes the namespace-relative short name. - short_name: short_name.to_string(), - kind: kind.to_string(), - peels_to_commit: kind != "tag" || columns[6] == "commit" || columns[7] == "commit", - is_current: columns[2].trim() == "*", - upstream_short_name, - ahead, - behind, - }) -} - -fn parse_tracking_counts(value: &str) -> (usize, usize) { - let mut ahead = 0; - let mut behind = 0; - for component in value.split(',').map(str::trim) { - if let Some(value) = component.strip_prefix("ahead ") { - ahead = value.parse().unwrap_or(0); - } else if let Some(value) = component.strip_prefix("behind ") { - behind = value.parse().unwrap_or(0); - } - } - (ahead, behind) -} - -fn parse_commit(line: &str) -> Option { - let columns = line.split('\u{1f}').collect::>(); - if columns.len() < 8 { - return None; - } - let (date, date_utc_offset_minutes) = split_commit_date_offset(columns[5]); - Some(GitCommitResponse { - hash: columns[0].to_string(), - short_hash: columns[1].to_string(), - parent_hashes: columns[2].split_whitespace().map(String::from).collect(), - author_name: columns[3].to_string(), - author_email: columns[4].to_string(), - date: date.to_string(), - date_utc_offset_minutes, - date_unix_seconds: history_query::commit_timestamp(date, date_utc_offset_minutes), - subject: columns[6].to_string(), - decorations: columns[7].to_string(), - }) -} - -/// Splits a `GIT_COMMIT_DATE_ARGUMENT` value into the displayed local date and -/// the author's UTC offset in minutes. Values without a valid `±HHMM` suffix are -/// kept whole with no offset so unexpected Git output stays visible. -fn split_commit_date_offset(value: &str) -> (&str, Option) { - let Some((date, zone)) = value.rsplit_once(' ') else { - return (value, None); - }; - let bytes = zone.as_bytes(); - if bytes.len() != 5 - || !matches!(bytes[0], b'+' | b'-') - || !zone[1..].bytes().all(|b| b.is_ascii_digit()) - { - return (value, None); - } - let hours: i32 = zone[1..3].parse().unwrap_or(0); - let minutes: i32 = zone[3..5].parse().unwrap_or(0); - if hours > 23 || minutes > 59 { - return (value, None); - } - let sign = if bytes[0] == b'-' { -1 } else { 1 }; - (date, Some(sign * (hours * 60 + minutes))) -} - -fn validate_revision(value: &str) -> Result<(), CoreError> { - if value.trim().is_empty() || value.starts_with('-') || value.contains('\0') { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Invalid Git revision", - )); - } - Ok(()) -} - -fn parse_name_status(output: &str) -> Vec { - output - .lines() - .filter_map(|line| { - let columns = line.split('\t').collect::>(); - if columns.len() < 2 || columns.last().is_some_and(|path| path.is_empty()) { - return None; - } - Some(GitFileResponse { - status: columns[0].to_string(), - path: columns.last().unwrap_or(&"").to_string(), - is_untracked: false, - }) - }) - .collect() -} - -fn parse_stash(line: &str) -> Option { - let columns = line.split('\u{1f}').collect::>(); - if columns.len() < 3 { - return None; - } - let reference = columns[0].trim(); - if reference.is_empty() { - return None; - } - let subject = columns[1].trim(); - let lower = subject.to_ascii_lowercase(); - let marker = lower.find("on ").or_else(|| lower.find(" on ")); - let branch = marker.and_then(|index| { - let raw = &subject[index + 3..]; - raw.split_once(':') - .or_else(|| raw.split_once(',')) - .map(|(branch, _)| branch.trim().to_string()) - .filter(|branch| !branch.is_empty()) - }); - let message = marker - .and_then(|index| { - subject[index + 3..] - .split_once(':') - .map(|(_, message)| message) - }) - .or_else(|| subject.split_once(':').map(|(_, message)| message)) - .unwrap_or(subject) - .trim() - .to_string(); - Some(GitStashResponse { - reference: reference.to_string(), - oid: columns - .get(3) - .copied() - .unwrap_or_default() - .trim() - .to_string(), - message, - branch, - date: columns[2].trim().to_string(), - }) -} - -pub(super) fn is_safe_pathspec(path: &str) -> bool { - let normalized = path.replace('\\', "/"); - !normalized.is_empty() - && !normalized.starts_with('/') - && !normalized.split('/').any(|component| component == "..") - && !normalized.contains(':') -} - -fn null_device() -> &'static str { - #[cfg(windows)] - { - "NUL" - } - #[cfg(not(windows))] - { - "/dev/null" - } -} - -/// One removed or added line retained with its original side's line number. -struct DiffEntry { - number: usize, - text: String, -} - -/// Parsed patch hunk before it is aligned into side-by-side rows. -struct DiffHunkRecord { - id: String, - header: String, - lines: Vec, -} - -/// Marker on stashes created by smart checkout, so the restore step can tell whether -/// `git stash pop` consumed its entry or kept it after a conflict. -const AUTO_STASH_MESSAGE: &str = "lithe: auto-stash before checkout"; - -fn without_one_trailing_cr(value: &str) -> &str { - value.strip_suffix('\r').unwrap_or(value) -} - -fn flush_diff_changes( - rows: &mut Vec, - removed: &mut Vec, - added: &mut Vec, - hunk_id: Option<&str>, -) { - let removed_text: Vec<&str> = removed.iter().map(|entry| entry.text.as_str()).collect(); - let added_text: Vec<&str> = added.iter().map(|entry| entry.text.as_str()).collect(); - for (left_index, right_index) in lithe_diff::pair_lines(&removed_text, &added_text) { - let left = left_index.map(|index| &removed[index]); - let right = right_index.map(|index| &added[index]); - let kind = match (left.is_some(), right.is_some()) { - (true, true) => "changed", - (true, false) => "removal", - (false, true) => "addition", - (false, false) => continue, - }; - let is_invisible_change = left.zip(right).and_then(|(left, right)| { - (without_one_trailing_cr(&left.text) == without_one_trailing_cr(&right.text)) - .then_some(true) - }); - let (left_changes, right_changes) = match left.zip(right) { - Some((left, right)) if is_invisible_change.is_none() => { - match lithe_diff::inline_changes( - without_one_trailing_cr(&left.text), - without_one_trailing_cr(&right.text), - ) { - Some((left, right)) => (Some(left), Some(right)), - None => (None, None), - } - } - _ => (None, None), - }; - rows.push(GitDiffRowResponse { - old_line: left.map(|entry| entry.number), - new_line: right.map(|entry| entry.number), - left: left.map(|entry| entry.text.clone()), - right: right.map(|entry| entry.text.clone()), - kind: kind.to_string(), - hunk_id: hunk_id.map(String::from), - is_invisible_change, - left_changes, - right_changes, - }); - } - removed.clear(); - added.clear(); -} - -fn parse_hunk_header(header: &str) -> Option<(usize, usize)> { - let mut columns = header.split_whitespace(); - if columns.next()? != "@@" { - return None; - } - let old_range = columns.next()?.strip_prefix('-')?; - let new_range = columns.next()?.strip_prefix('+')?; - let old_line = old_range.split(',').next()?.parse().ok()?; - let new_line = new_range.split(',').next()?.parse().ok()?; - Some((old_line, new_line)) -} - -fn parse_diff( - patch: &str, -) -> ( - Vec, - Vec, - Vec, -) { - let has_trailing_newline = patch.ends_with('\n'); - let lines = patch.strip_suffix('\n').unwrap_or(patch).split('\n'); - - let mut rows = Vec::new(); - let mut unified = Vec::new(); - let mut old_line = 0; - let mut new_line = 0; - let mut removed = Vec::new(); - let mut added = Vec::new(); - let mut current_hunk_id: Option = None; - let mut current_hunk_header = String::new(); - let mut current_hunk_lines: Vec = Vec::new(); - let mut file_header_lines: Vec = Vec::new(); - let mut hunk_records = Vec::new(); - let mut hunk_index = 0; - - for line in lines { - if line.starts_with("@@") { - if let Some(hunk_id) = current_hunk_id.take() { - flush_diff_changes(&mut rows, &mut removed, &mut added, Some(&hunk_id)); - hunk_records.push(DiffHunkRecord { - id: hunk_id, - header: std::mem::take(&mut current_hunk_header), - lines: std::mem::take(&mut current_hunk_lines), - }); - } - - let hunk_id = format!("hunk-{hunk_index}"); - hunk_index += 1; - current_hunk_id = Some(hunk_id.clone()); - current_hunk_header = line.to_string(); - current_hunk_lines = file_header_lines.clone(); - current_hunk_lines.push(line.to_string()); - if let Some((old, new)) = parse_hunk_header(line) { - old_line = old; - new_line = new; - } - unified.push(GitDiffUnifiedLineResponse { - line_type: "header".into(), - content: without_one_trailing_cr(line).into(), - old_line: None, - new_line: None, - }); - rows.push(GitDiffRowResponse { - old_line: None, - new_line: None, - left: Some(line.to_string()), - right: None, - kind: "information".to_string(), - hunk_id: Some(hunk_id), - is_invisible_change: None, - left_changes: None, - right_changes: None, - }); - } else if line.starts_with("diff --git") && current_hunk_id.is_some() { - let hunk_id = current_hunk_id.take().expect("hunk should exist"); - flush_diff_changes(&mut rows, &mut removed, &mut added, Some(&hunk_id)); - hunk_records.push(DiffHunkRecord { - id: hunk_id, - header: std::mem::take(&mut current_hunk_header), - lines: std::mem::take(&mut current_hunk_lines), - }); - file_header_lines = vec![line.to_string()]; - } else if current_hunk_id.is_none() { - file_header_lines.push(line.to_string()); - } else if line.starts_with('-') { - unified.push(GitDiffUnifiedLineResponse { - line_type: "removed".into(), - content: without_one_trailing_cr(&line[1..]).into(), - old_line: Some(old_line), - new_line: None, - }); - current_hunk_lines.push(line.to_string()); - removed.push(DiffEntry { - number: old_line, - text: line.chars().skip(1).collect(), - }); - old_line += 1; - } else if line.starts_with('+') { - unified.push(GitDiffUnifiedLineResponse { - line_type: "added".into(), - content: without_one_trailing_cr(&line[1..]).into(), - old_line: None, - new_line: Some(new_line), - }); - current_hunk_lines.push(line.to_string()); - added.push(DiffEntry { - number: new_line, - text: line.chars().skip(1).collect(), - }); - new_line += 1; - } else if line.starts_with(' ') { - unified.push(GitDiffUnifiedLineResponse { - line_type: "context".into(), - content: without_one_trailing_cr(&line[1..]).into(), - old_line: Some(old_line), - new_line: Some(new_line), - }); - let hunk_id = current_hunk_id.as_deref(); - flush_diff_changes(&mut rows, &mut removed, &mut added, hunk_id); - current_hunk_lines.push(line.to_string()); - rows.push(GitDiffRowResponse { - old_line: Some(old_line), - new_line: Some(new_line), - left: Some(line.chars().skip(1).collect::()), - right: None, - kind: "context".to_string(), - hunk_id: current_hunk_id.clone(), - is_invisible_change: None, - left_changes: None, - right_changes: None, - }); - old_line += 1; - new_line += 1; - } else if line.starts_with("\\ No newline") { - current_hunk_lines.push(line.to_string()); - } else { - current_hunk_lines.push(line.to_string()); - } - } - - if let Some(hunk_id) = current_hunk_id.take() { - flush_diff_changes(&mut rows, &mut removed, &mut added, Some(&hunk_id)); - hunk_records.push(DiffHunkRecord { - id: hunk_id, - header: current_hunk_header, - lines: current_hunk_lines, - }); - } - - let hunks = hunk_records - .into_iter() - .map(|record| { - let patch = record.lines.join("\n") + if has_trailing_newline { "\n" } else { "" }; - GitDiffHunkResponse { - id: record.id, - header: record.header, - patch, - content_patch: None, - line_start: None, - line_end: None, - } - }) - .collect(); - (rows, hunks, unified) -} - -/// Resolves the Git administrative paths and references a watcher must observe. -pub fn watch_context( - request: GitWatchContextRequest, -) -> Result, CoreError> { - let root = canonicalize_simplified(Path::new(&request.root)).map_err(git_path_error)?; - if !fs::is_dir(&root)? { - return Err(CoreError::new( - ErrorCode::WorkspaceNotFound, - "Workspace does not exist", - )); - } - - let repository_root = run_git(&root, &["rev-parse", "--show-toplevel"])?; - if !repository_root.status.success() { - return Ok(None); - } - let git_directory = run_git(&root, &["rev-parse", "--absolute-git-dir"])?; - let git_common_directory = run_git( - &root, - &["rev-parse", "--path-format=absolute", "--git-common-dir"], - )?; - - Ok(Some(GitWatchContextResponse { - repository_root: canonical_git_output(repository_root, "repository root")?, - git_directory: canonical_git_output(git_directory, "Git directory")?, - git_common_directory: canonical_git_output(git_common_directory, "Git common directory")?, - })) -} - -fn canonical_git_output(output: std::process::Output, label: &str) -> Result { - if !output.status.success() { - return Err(CoreError::new( - ErrorCode::ProcessFailed, - format!("Could not resolve {label}"), - ) - .with_details(String::from_utf8_lossy(&output.stderr))); - } - let raw_path = String::from_utf8_lossy(&output.stdout); - let path = PathBuf::from(raw_path.trim_end_matches(['\r', '\n'])); - canonicalize_simplified(&path) - .map(|path| path.to_string_lossy().into_owned()) - .map_err(|error| { - if error.kind() == std::io::ErrorKind::InvalidInput { - return git_path_error(error); - } - CoreError::new( - ErrorCode::ProcessFailed, - format!("Could not resolve {label}"), - ) - .with_details(error.to_string()) - }) -} - -/// Returns the checked-out branch and whether its HEAD must be pushed before a PR. -pub fn pull_request_context( - request: GitPullRequestContextRequest, -) -> Result { - let root = validate_root(&request.root)?; - let current_branch = optional_current_branch(&root)?; - let detached = current_branch.is_none(); - let remote_default = command_value( - &root, - &[ - "symbolic-ref", - "--quiet", - "--short", - "refs/remotes/origin/HEAD", - ], - ) - .map(|value| value.trim_start_matches("origin/").to_string()); - - let suggested_base_branch = if let Some(branch) = current_branch.as_deref() { - created_from_branch(&root, branch).or(remote_default) - } else { - detached_start_branch(&root).or(remote_default) - }; - let requires_publish = current_branch - .as_deref() - .map_or(true, |branch| branch_requires_publish(&root, branch)); - let suggested_publish_branch = if requires_publish { - current_branch.clone().or_else(|| { - command_value(&root, &["rev-parse", "--short=8", "HEAD"]) - .map(|short_hash| format!("codex/pr-{short_hash}")) - }) - } else { - None - }; - let has_uncommitted_changes = command_value( - &root, - &["status", "--porcelain", "--untracked-files=normal"], - ) - .is_some(); - - Ok(GitPullRequestContextResponse { - current_branch, - suggested_base_branch, - suggested_publish_branch, - requires_publish, - detached, - has_uncommitted_changes, - }) -} - -fn command_value(root: &str, arguments: &[&str]) -> Option { - let arguments = arguments - .iter() - .map(|value| value.to_string()) - .collect::>(); - let response = execute_git_readonly(root, &arguments, None).ok()?; - let value = response.output.trim(); - (response.exit_code == 0 && !value.is_empty()).then(|| value.to_string()) -} - -fn created_from_branch(root: &str, branch: &str) -> Option { - let reference = format!("refs/heads/{branch}"); - let response = command_value(root, &["reflog", "show", "--format=%gs", &reference])?; - let prefix = "branch: Created from "; - response.lines().find_map(|line| { - let source = line.strip_prefix(prefix)?; - if matches!(source, "HEAD" | "FETCH_HEAD" | "ORIG_HEAD") { - // Publishing a detached worktree creates its branch from HEAD. - // Preserve the worktree's original branch as the PR base instead - // of falling back to origin/HEAD after the branch is published. - detached_start_branch(root) - } else { - Some(source.trim_start_matches("origin/").to_string()) - } - }) -} - -fn detached_start_branch(root: &str) -> Option { - let reflog = command_value(root, &["reflog", "show", "--format=%H", "HEAD"])?; - // Reflog output is newest-first; the final entry is the commit at which - // this worktree's HEAD was initialized. - let starting_commit = reflog.lines().last()?.trim(); - let references = command_value( - root, - &[ - "for-each-ref", - "--sort=refname", - "--format=%(refname)", - "--points-at", - starting_commit, - "refs/heads", - "refs/remotes/origin", - ], - )?; - references - .lines() - .find_map(|reference| reference.strip_prefix("refs/heads/").map(str::to_string)) - .or_else(|| { - references.lines().find_map(|reference| { - reference - .strip_prefix("refs/remotes/origin/") - .filter(|branch| *branch != "HEAD") - .map(str::to_string) - }) - }) -} - -fn branch_requires_publish(root: &str, branch: &str) -> bool { - let origin_branch = format!("refs/remotes/origin/{branch}"); - if command_value(root, &["rev-parse", "--verify", &origin_branch]).is_none() { - return true; - } - let comparison = format!("{origin_branch}..HEAD"); - match command_value(root, &["rev-list", "--count", &comparison]) - .and_then(|count| count.parse::().ok()) - { - Some(0) => false, - Some(_) | None => true, - } -} - -/// Returns the normalized repository status and branch context. -pub fn status(request: GitStatusRequest) -> Result { - let root = canonicalize_simplified(Path::new(&request.root)).map_err(git_path_error)?; - if !fs::is_dir(&root)? { - return Err(CoreError::new( - ErrorCode::WorkspaceNotFound, - "Workspace does not exist", - )); - } - let repository_root_output = run_git(&root, &["rev-parse", "--show-toplevel"])?; - if !repository_root_output.status.success() { - return Ok(GitStatusResponse { - repository_root: None, - branch: None, - ahead: 0, - behind: 0, - changes: Vec::new(), - }); - } - let repository_root_text = String::from_utf8_lossy(&repository_root_output.stdout); - let repository_root_path = PathBuf::from(repository_root_text.trim_end_matches(['\r', '\n'])); - let repository_root = - canonicalize_or_original(&repository_root_path).map_err(git_path_error)?; - let branch = run_git(&repository_root, &["branch", "--show-current"]) - .ok() - .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string()) - .filter(|branch| !branch.is_empty()) - .or_else(|| Some("detached".to_string())); - // `normal` lets Git stop at each wholly untracked directory instead of - // enumerating every file below it; the projection below restores file - // entries for directories that are small or contain nested repositories. - let untracked_files = if request.summarize_untracked_directories { - "--untracked-files=normal" - } else { - "--untracked-files=all" - }; - let status_output = run_git( - &repository_root, - &[ - "-c", - "core.quotepath=false", - "status", - "--porcelain=v2", - "--ignore-submodules=none", - "-z", - untracked_files, - ], - )?; - if !status_output.status.success() { - return Err( - CoreError::new(ErrorCode::ProcessFailed, "Git status failed") - .with_details(String::from_utf8_lossy(&status_output.stderr)), - ); - } - let mut changes = parse_status(&status_output.stdout, request.include_index_only_changes); - if request.summarize_untracked_directories { - changes = untracked_directories::expand_collapsed_directories(&repository_root, changes)?; - } - untracked_directories::retain_outside_known_roots( - &mut changes, - &repository_root, - &request.repository_roots, - )?; - let (ahead, behind) = tracking_counts(&repository_root); - Ok(GitStatusResponse { - repository_root: Some(relative_or_absolute(&repository_root, &root)), - branch, - ahead, - behind, - changes, - }) -} - -fn tracking_counts(repository_root: &Path) -> (usize, usize) { - let Ok(output) = run_git( - repository_root, - &["rev-list", "--left-right", "--count", "@{upstream}...HEAD"], - ) else { - return (0, 0); - }; - if !output.status.success() { - return (0, 0); - } - let text = String::from_utf8_lossy(&output.stdout); - let mut values = text - .split_whitespace() - .filter_map(|value| value.parse().ok()); - let behind = values.next().unwrap_or(0); - let ahead = values.next().unwrap_or(0); - (ahead, behind) -} - -/// Resolves ignore rules through Git, including info/exclude, global rules, and negations. -/// -/// Paths are workspace-relative candidates owned by the search index. NUL-delimited -/// stdin preserves whitespace and avoids command-line limits. Missing Git/repositories -/// leave status unknown rather than guessing from directory names. -pub(crate) fn ignored_paths( - directory: &Path, - paths: &[String], -) -> Result>, CoreError> { - if paths.is_empty() { - return Ok(Some(HashSet::new())); - } - let response = execute_git_readonly( - &directory.to_string_lossy(), - &["check-ignore".into(), "--stdin".into(), "-z".into()], - Some(nul_pathspec_input(paths)), - ); - crate::protocol::cancellation::check()?; - let Ok(response) = response else { - return Ok(None); - }; - if ![0, 1].contains(&response.exit_code) { - return Ok(None); - } - Ok(Some( - response - .stdout - .split('\0') - .filter(|path| !path.is_empty()) - .map(str::to_string) - .collect(), - )) -} - -/// Upper bound on the pathspec bytes passed to one `git ls-files` call. -/// -/// Windows limits a whole command line to 32,767 UTF-16 units; batching well -/// below it keeps large reactors from failing to start the query. -const LS_FILES_PATHSPEC_BUDGET: usize = 16 * 1024; - -/// Splits `paths` into consecutive non-empty batches whose summed byte length -/// stays within `budget`; a single longer path forms its own batch. -fn pathspec_batches(paths: &[String], budget: usize) -> Vec<&[String]> { - let mut batches = Vec::new(); - let mut start = 0; - let mut used = 0; - for (index, path) in paths.iter().enumerate() { - if index > start && used + path.len() > budget { - batches.push(&paths[start..index]); - start = index; - used = 0; - } - used += path.len(); - } - if start < paths.len() { - batches.push(&paths[start..]); - } - batches -} - -fn run_git(directory: &Path, arguments: &[&str]) -> Result { - let arguments = arguments - .iter() - .map(|value| value.to_string()) - .collect::>(); - if crate::remote::workspace::is_remote() { - let output = capture_git_process( - &directory.to_string_lossy(), - &arguments, - None, - true, - &[], - false, - )?; - #[cfg(unix)] - let status = { - use std::os::unix::process::ExitStatusExt; - std::process::ExitStatus::from_raw(output.exit_code << 8) - }; - #[cfg(windows)] - let status = { - use std::os::windows::process::ExitStatusExt; - std::process::ExitStatus::from_raw(output.exit_code as u32) - }; - return Ok(std::process::Output { - status, - stdout: output.stdout, - stderr: output.stderr, - }); - } - // Status and path discovery are read-only from Lithe's point of view. Git - // may otherwise refresh its optional index data while answering a query, - // which emits `.git/index` events into the native watcher and can trigger - // another status refresh. - let root = directory.to_string_lossy(); - let mut trace = command_trace::Trace::begin(&root, &arguments, false); - let mut invocation = execution_events::Invocation::reported(false); - if let Some(invocation) = &invocation { - invocation.started(&root, &arguments); - } - let result = git_process() - .env("GIT_OPTIONAL_LOCKS", "0") - .args(&arguments) - .current_dir(directory) - .output() - .map_err(|error| { - CoreError::new(ErrorCode::ProcessStartFailed, "Could not start Git") - .with_details(error.to_string()) - }); - let exit_code = result.as_ref().ok().and_then(|output| output.status.code()); - if let Ok(output) = &result { - trace.observe(exit_code, &output.stderr); - if let Some(invocation) = &mut invocation { - invocation.output(lithe_git_host::Stream::Stdout, &output.stdout); - invocation.output(lithe_git_host::Stream::Stderr, &output.stderr); - } - } - if let Some(invocation) = invocation { - let expected_exit = - result.is_ok() && execution_events::Invocation::answered(false, &arguments, exit_code); - invocation.finished_with_expected_exit(exit_code, result.as_ref().err(), expected_exit); - } - trace.finish(result.as_ref().err()); - result -} - -fn parse_status(output: &[u8], include_index_only_changes: bool) -> Vec { - let mut changes = Vec::new(); - let mut records = output - .split(|byte| *byte == 0) - .filter(|record| !record.is_empty()); - while let Some(record) = records.next() { - let record = String::from_utf8_lossy(record); - let (status, submodule, path, original_path) = match record.as_bytes().first() { - Some(b'?') => ("??".to_string(), None, record[2..].to_string(), None), - Some(kind @ (b'1' | b'2' | b'u')) => { - // v2 headers have fixed field counts; the remaining path may - // contain whitespace. A rename's old path is the next NUL record. - let count = match kind { - b'1' => 9, - b'2' => 10, - _ => 11, - }; - let fields = record.splitn(count, ' ').collect::>(); - if fields.len() != count { - continue; - } - let status = fields[1].replace('.', " "); - let submodule = if fields[2].starts_with('S') { - Some(crate::protocol::GitSubmoduleStatus { - commit_changed: fields[2].as_bytes().get(1) == Some(&b'C'), - tracked_changes: fields[2].as_bytes().get(2) == Some(&b'M'), - untracked_changes: fields[2].as_bytes().get(3) == Some(&b'U'), - }) - } else { - None - }; - let original = if *kind == b'2' { - records - .next() - .map(|path| String::from_utf8_lossy(path).to_string()) - } else { - None - }; - (status, submodule, fields[count - 1].to_string(), original) - } - _ => continue, - }; - let bytes = status.as_bytes(); - if bytes.len() != 2 { - continue; - } - let x = bytes[0] as char; - let y = bytes[1] as char; - // Preserve the existing final-worktree projection used by Windows. - if !include_index_only_changes && x == 'A' && y == 'D' { - continue; - } - let can_toggle_staging = (x != ' ' && x != '?') - || submodule.as_ref().is_none_or(|s| s.commit_changed) - || x == 'U' - || y == 'U' - || y == 'D' - || (x == 'A' && y == 'A'); - changes.push(GitChange { - can_toggle_staging, - path, - original_path, - status, - staged: x != ' ' && x != '?', - worktree: y != ' ' && y != '?', - untracked: x == '?' && y == '?', - submodule, - untracked_file_count: None, - }); - } - changes.sort_by(|left, right| left.path.cmp(&right.path)); - changes -} - -fn relative_or_absolute(path: &Path, root: &Path) -> String { - path.strip_prefix(root) - .map(|relative| { - let value = relative.to_string_lossy().replace('\\', "/"); - if value.is_empty() { - ".".to_string() - } else { - value - } - }) - .unwrap_or_else(|_| path.to_string_lossy().replace('\\', "/")) -} - -#[cfg(test)] -mod tests { - use super::{ - annotation_message_from_tag_object, parse_commit, parse_commit_lookup, parse_diff, - pathspec_batches, simplified_canonical_path, structured_diff_from_output, - GitCommandInvocation, GitCommandResponse, GitProcessOutput, - }; - use crate::protocol::{ - CoreError, ErrorCode, GitCommitLookupResponse, GitCommitResponse, GitHistoryPageResponse, - GitHistoryResponse, GitPushPreviewResponse, GitPushTagResponse, GitPushTargetKind, - GitReferenceResponse, GitReferencesResponse, - }; - use serde_json::Value; - use std::path::PathBuf; - - /// Only an exact `Name ` names an author; the options keep Git's order. - #[test] - fn commit_identity_requires_an_exact_author() { - assert_eq!( - super::commit_identity(Some("张三 "), true).unwrap(), - ["--author=张三 ", "--signoff"] - ); - assert!(super::commit_identity(None, false).unwrap().is_empty()); - for author in ["", " A ", "A ", "A ", "A <>", "A\t"] { - let error = super::commit_identity(Some(author), false).unwrap_err(); - assert!( - matches!(error.code, ErrorCode::GitCommitAuthorInvalid), - "{author:?}" - ); - } - } - - #[test] - fn unforced_removal_refused_for_local_changes_gains_its_own_code() { - let failed = |stderr: &str| { - GitProcessOutput { - stdout: Vec::new(), - stderr: stderr.as_bytes().to_vec(), - exit_code: 128, - } - .into_command_response(&["worktree".to_string(), "remove".to_string()]) - }; - let mut dirty = failed( - "fatal: '/tmp/feature' contains modified or untracked files, use --force to delete it\n", - ); - super::classify_unclean_worktree_removal(&mut dirty); - let error = dirty.operation_error.expect("operation error"); - assert!(matches!(error.code, ErrorCode::GitWorktreeHasChanges)); - assert_eq!( - error.message, - "fatal: '/tmp/feature' contains modified or untracked files, use --force to delete it" - ); - assert_eq!( - serde_json::to_value(CoreError::new(ErrorCode::GitWorktreeHasChanges, "x")).unwrap() - ["code"], - "git_worktree_has_changes" - ); - - // Submodules also need --force, but for a different reason; that refusal - // keeps Git's plain failure rather than claiming local changes. - let mut submodules = - failed("fatal: working trees containing submodules cannot be moved or removed\n"); - super::classify_unclean_worktree_removal(&mut submodules); - assert!(submodules.operation_error.is_none()); - - // A more specific error that is already present is kept. - let mut specific = failed("fatal: '/tmp/feature' contains modified or untracked files\n"); - specific.operation_error = Some(CoreError::new(ErrorCode::Cancelled, "cancelled")); - super::classify_unclean_worktree_removal(&mut specific); - assert!(matches!( - specific.operation_error.unwrap().code, - ErrorCode::Cancelled - )); - - let mut removed = failed(""); - removed.exit_code = 0; - super::classify_unclean_worktree_removal(&mut removed); - assert!(removed.operation_error.is_none()); - } - - #[test] - fn saved_stash_sections_reject_lossy_or_oversized_patch_before_git() { - let output = |stdout| super::GitProcessOutput { - stdout, - stderr: Vec::new(), - exit_code: 0, - }; - let invalid = super::structured_numstat_output( - output(vec![b'1', b'\t', b'0', b'\t', b'a', 0, 0, 0xff]), - true, - ) - .unwrap_err(); - assert_eq!(invalid.message, "Saved patch must contain valid UTF-8"); - let oversized = super::structured_numstat_output( - output( - [ - b"1\t0\ta\0\0".as_slice(), - &vec![b'x'; super::patch_exchange::MAX_PATCH_BYTES + 1], - ] - .concat(), - ), - true, - ) - .unwrap_err(); - assert_eq!(oversized.message, "Saved patch exceeds 32 MiB"); - let empty = super::structured_numstat_output(output(Vec::new()), true).unwrap(); - assert!(empty.files.unwrap().is_empty()); - } - - #[test] - fn temporary_git_context_is_private_and_cleans_up_under_a_public_parent() { - let parent = super::TemporaryGitCommitContext::prepare_snapshot(Some(&"a".repeat(40))) - .expect("owned fixture parent should be created"); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&parent.directory, std::fs::Permissions::from_mode(0o755)) - .expect("fixture parent should allow public traversal"); - } - let context = super::TemporaryGitCommitContext::prepare_in( - &parent.directory, - "private-index", - Some(&"b".repeat(40)), - ) - .expect("private context should be created atomically"); - let directory = context.directory.clone(); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - assert_eq!( - std::fs::metadata(&directory).unwrap().permissions().mode() & 0o077, - 0 - ); - } - std::fs::write(&context.index_path, b"tracked/path\0object-id") - .expect("index fixture should stay inside the owned context"); - assert_eq!( - std::fs::read(directory.join("HEAD")).unwrap(), - format!("{}\n", "b".repeat(40)).as_bytes() - ); - drop(context); - assert!( - !directory.exists(), - "HEAD and index should be removed with their owner" - ); - } - - #[test] - fn simplified_canonical_path_strips_windows_verbatim_prefixes() { - // Windows canonicalization yields verbatim paths. Consumers normalize - // separators, which would turn them into `//?/C:/...` and break every - // later lookup of the discovered repository root. - assert_eq!( - simplified_canonical_path(PathBuf::from(r"\\?\C:\work\repo")).unwrap(), - PathBuf::from(r"C:\work\repo") - ); - assert_eq!( - simplified_canonical_path(PathBuf::from(r"\\?\UNC\server\share\repo")).unwrap(), - PathBuf::from(r"\\server\share\repo") - ); - assert_eq!( - simplified_canonical_path(PathBuf::from("/work/repo")).unwrap(), - PathBuf::from("/work/repo") - ); - } - - #[test] - fn windows_git_paths_follow_shared_identity_policy() { - let cases: Value = - serde_json::from_str(include_str!("../../../../fixtures/git/windows-paths.json")) - .unwrap(); - for case in cases["accepted"].as_array().unwrap() { - let path = - simplified_canonical_path(PathBuf::from(case["input"].as_str().unwrap())).unwrap(); - assert_eq!( - path.to_string_lossy().replace('\\', "/"), - case["normalized"].as_str().unwrap() - ); - } - for path in cases["rejected"].as_array().unwrap() { - let path = PathBuf::from(path.as_str().unwrap()); - let error = simplified_canonical_path(path.clone()).unwrap_err(); - assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput, "{path:?}"); - // A missing/stale worktree must not bypass the same identity check. - assert!(super::canonicalize_or_original(&path).is_err()); - } - } - - #[test] - fn repository_root_discovery_does_not_acquire_a_write_lease() { - struct Repository(std::path::PathBuf); - impl Drop for Repository { - fn drop(&mut self) { - std::fs::remove_dir_all(&self.0).expect("temporary repository should be removed"); - } - } - - let repository = Repository(std::env::temp_dir().join(format!( - "lithe-repository-root-{}-{}", - std::process::id(), - super::TEMPORARY_INDEX_SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed) - ))); - std::fs::create_dir_all(&repository.0).unwrap(); - let root = repository.0.to_string_lossy().into_owned(); - let initialized = super::command(super::GitCommandRequest { - root: root.clone(), - arguments: vec!["init".into(), "-q".into()], - input: None, - }) - .unwrap(); - assert_eq!(initialized.exit_code, 0); - - let lease = super::rewrite::RewriteLease::acquire(&root).unwrap(); - let response: Value = serde_json::from_str(&crate::execute_json( - &serde_json::json!({ - "id": "repository-root", - "command": "git.repositoryRoot", - "payload": { "root": root.clone() }, - }) - .to_string(), - )) - .unwrap(); - drop(lease); - - let expected_root = super::canonicalize_simplified(&repository.0) - .expect("temporary repository should canonicalize") - .to_string_lossy() - .replace('\\', "/"); - assert_eq!(response["ok"], true, "{response}"); - assert_eq!( - response["data"], - serde_json::json!(expected_root), - "{response}" - ); - } - - #[test] - fn argument_and_typed_writes_reject_an_active_repository_lease() { - struct Repository(std::path::PathBuf); - impl Drop for Repository { - fn drop(&mut self) { - std::fs::remove_dir_all(&self.0).expect("temporary repository should be removed"); - } - } - - let repository = Repository(std::env::temp_dir().join(format!( - "lithe-write-lease-{}-{}", - std::process::id(), - super::TEMPORARY_INDEX_SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed) - ))); - std::fs::create_dir_all(&repository.0).unwrap(); - let root = repository.0.to_string_lossy().into_owned(); - let _deadline = crate::protocol::cancellation::Scope::begin(None, Some(5_000)); - let initialized = super::command(super::GitCommandRequest { - root: root.clone(), - arguments: vec!["init".into(), "-q".into()], - input: None, - }) - .unwrap(); - assert_eq!(initialized.exit_code, 0); - - let lease = super::rewrite::RewriteLease::acquire(&root).unwrap(); - let (completed, completion) = std::sync::mpsc::channel(); - let worker = std::thread::spawn(move || { - let results = [ - ( - "git.command", - serde_json::json!({"arguments":["config", "test.writer", "raw"]}), - ), - ("git.write", serde_json::json!({"operation":"stageAll"})), - ] - .map(|(command, mut payload)| { - payload["root"] = serde_json::json!(root); - serde_json::from_str::(&crate::execute_json( - &serde_json::json!({ - "id": command, - "command": command, - "timeoutMilliseconds": 2_000, - "payload": payload, - }) - .to_string(), - )) - .unwrap() - }); - let _ = completed.send(results); - }); - - // The competing commands must finish while the writer still owns its - // lease. Always release it before asserting, so a blocking regression - // can terminate and be joined even when the first deadline is missed. - let results = completion.recv_timeout(std::time::Duration::from_secs(5)); - drop(lease); - if results.is_err() { - completion - .recv_timeout(std::time::Duration::from_secs(5)) - .expect("competing writer should terminate after lease cleanup"); - } - worker.join().expect("competing writer should not panic"); - for result in results.expect("competing writers must fail without waiting for the lease") { - assert_eq!(result["ok"], false, "{result}"); - assert_eq!(result["error"]["code"], "invalid_request", "{result}"); - assert_eq!( - result["error"]["message"], - "Another Git write operation is running in this repository" - ); - } - } - - #[test] - fn tag_annotation_parser_preserves_crlf_and_trailing_blank_lines() { - let raw = concat!( - "object abc123\n", - "type commit\n", - "tag v1.0\n", - "tagger Lithe Test 0 +0000\n", - "\n", - "release\r\n", - "\r\n", - "details\r\n", - "\r\n" - ); - - assert_eq!( - annotation_message_from_tag_object(raw).as_deref(), - Some("release\r\n\r\ndetails\r\n\r\n") - ); - } - - #[test] - fn tag_annotation_parser_removes_only_the_signature_block() { - let raw = concat!( - "object abc123\n", - "type commit\n", - "tag v1.0\n", - "tagger Lithe Test 0 +0000\n", - "\n", - "release\r\n", - "\r\n", - "-----BEGIN PGP SIGNATURE-----\r\n", - "signature-data\r\n", - "-----END PGP SIGNATURE-----\r\n" - ); - - assert_eq!( - annotation_message_from_tag_object(raw).as_deref(), - Some("release\r\n\r\n") - ); - } - - #[derive(serde::Deserialize)] - #[serde(rename_all = "camelCase")] - struct TagWriteFixture { - target: String, - cases: Vec, - rejected_cases: Vec, - } - - #[derive(serde::Deserialize)] - #[serde(rename_all = "camelCase")] - struct TagWriteArgumentCase { - name: String, - request: TagWriteFields, - arguments: Vec, - } - - #[derive(serde::Deserialize)] - #[serde(rename_all = "camelCase")] - struct TagWriteFields { - message: Option, - lightweight: Option, - signed: Option, - } - - #[derive(serde::Deserialize)] - #[serde(rename_all = "camelCase")] - struct TagWriteRejectedCase { - name: String, - request: TagWriteFields, - error: TagWriteRejectedError, - } - - #[derive(serde::Deserialize)] - struct TagWriteRejectedError { - code: String, - message: String, - } - - #[test] - fn create_tag_arguments_follow_the_lightweight_and_signature_matrix() { - // The fixture pins the argument matrix shared with the Windows host, - // including the combinations that must fail before any subprocess. - let fixture: TagWriteFixture = serde_json::from_str(include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../fixtures/git/tag-write-v1.json" - ))) - .expect("tag write fixture should be valid JSON"); - for case in &fixture.cases { - super::validate_create_tag_form( - case.request.message.as_deref(), - case.request.lightweight, - case.request.signed, - ) - .unwrap_or_else(|error| panic!("{}: {}", case.name, error.message)); - assert_eq!( - super::create_tag_arguments( - "v1.0", - &fixture.target, - case.request.message.as_deref(), - case.request.lightweight, - case.request.signed, - ), - case.arguments, - "{}", - case.name - ); - } - for case in &fixture.rejected_cases { - let error = super::validate_create_tag_form( - case.request.message.as_deref(), - case.request.lightweight, - case.request.signed, - ) - .expect_err(&case.name); - let encoded = serde_json::to_value(&error).expect("tag form error should encode"); - assert_eq!(encoded["code"], case.error.code, "{}", case.name); - assert_eq!(error.message, case.error.message, "{}", case.name); - } - } - - #[cfg(target_os = "windows")] - #[test] - fn background_git_processes_do_not_create_windows_console() { - assert_eq!(super::git_process_creation_flags(), 0x08000000); - } - - // A Latin-1 `é` (0xE9) decodes to U+FFFD; writing that patch back would - // replace the original byte with EF BF BD, so the response must say so. - #[test] - fn structured_diff_reports_lossy_utf8_decoding() { - let lossy = structured_diff_from_output(GitProcessOutput { - stdout: b"diff --git a/a.txt b/a.txt\n--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-caf\xe9\r\n+cafe\r\n".to_vec(), - stderr: Vec::new(), - exit_code: 1, - }); - assert!(lossy.has_decoding_loss); - assert!(lossy.patch.contains('\u{FFFD}')); - - let faithful = structured_diff_from_output(GitProcessOutput { - stdout: "diff --git a/a.txt b/a.txt\n--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-café\r\n+cafe\r\n".as_bytes().to_vec(), - stderr: Vec::new(), - exit_code: 1, - }); - assert!(!faithful.has_decoding_loss); - assert!(faithful.patch.contains("café\r\n")); - } - - #[test] - fn structured_diff_does_not_parse_git_stderr_as_file_content() { - let output = GitProcessOutput { - stdout: b"diff --git a/c.txt b/c.txt\n--- /dev/null\n+++ b/c.txt\n@@ -0,0 +1,3 @@\n+c\n+cc\n+ccc\n".to_vec(), - stderr: b"warning: CRLF will be replaced by LF\n".to_vec(), - exit_code: 1, - }; - - let diff = structured_diff_from_output(output); - - assert!(!diff.patch.contains("warning:")); - assert!(diff - .rows - .iter() - .any(|row| row.right.as_deref() == Some("ccc"))); - assert!(!diff.rows.iter().any(|row| row - .right - .as_deref() - .is_some_and(|line| line.contains("warning:")))); - } - - #[test] - fn traced_response_uses_the_final_invocation_for_compatibility_fields() { - let mut response = GitCommandResponse { - arguments: vec!["status".into()], - output: "stale".into(), - stdout: "stale".into(), - stderr: String::new(), - exit_code: 0, - invocations: vec![ - GitCommandInvocation { - arguments: vec!["status".into()], - stdout: "status\n".into(), - stderr: String::new(), - exit_code: 0, - }, - GitCommandInvocation { - arguments: vec!["stash".into(), "list".into()], - stdout: "stash@{0}\n".into(), - stderr: String::new(), - exit_code: 0, - }, - ], - operation_error: None, - stash_restore: None, - tag_deletion: None, - branch_deletion: None, - history_rewrite: None, - outcome_authoritative: false, - warnings: Vec::new(), - }; - - super::synchronize_final_invocation(&mut response); - - assert_eq!(response.arguments, vec!["stash", "list"]); - assert_eq!(response.stdout, "stash@{0}\n"); - assert_eq!(response.stderr, ""); - assert_eq!(response.output, "stash@{0}\n"); - assert_eq!(response.exit_code, 0); - } - - #[test] - fn traced_core_error_becomes_a_failed_response_with_invocations() { - let result = super::with_git_invocation_trace(|| { - let response = GitProcessOutput { - stdout: b"prepared\n".to_vec(), - stderr: Vec::new(), - exit_code: 0, - } - .into_command_response(&["stash".into(), "push".into()]); - super::record_git_invocation(&response); - Err(CoreError::new( - ErrorCode::ProcessFailed, - "Follow-up probe failed", - )) - }); - - let response = result.expect("a partial Git failure should retain command data"); - assert_eq!(response.exit_code, 0); - assert_eq!(response.arguments, vec!["stash", "push"]); - assert_eq!(response.output, "prepared\n"); - assert_eq!(response.invocations.len(), 1); - assert_eq!( - response - .operation_error - .as_ref() - .map(|error| &error.message), - Some(&"Follow-up probe failed".to_string()) - ); - } - - #[test] - fn git_process_response_preserves_executed_arguments() { - let arguments = vec!["status".to_string(), "--short".to_string()]; - let response = GitProcessOutput { - stdout: b" M README.md\n".to_vec(), - stderr: Vec::new(), - exit_code: 0, - } - .into_command_response(&arguments); - - assert_eq!(response.arguments, arguments); - assert_eq!(response.output, " M README.md\n"); - assert_eq!(response.stdout, " M README.md\n"); - assert_eq!(response.stderr, ""); - assert_eq!(response.exit_code, 0); - } - - #[test] - fn command_response_matches_shared_fixture() { - let fixture: Value = serde_json::from_str(include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../fixtures/git/command-response-v1.json" - ))) - .expect("Git command response fixture should be valid JSON"); - let response = GitCommandResponse { - arguments: vec![ - "checkout".into(), - "HEAD".into(), - "--".into(), - "README.md".into(), - ], - output: String::new(), - stdout: String::new(), - stderr: String::new(), - exit_code: 0, - invocations: vec![ - GitCommandInvocation { - arguments: vec![ - "status".into(), - "--porcelain".into(), - "--untracked-files=all".into(), - "--".into(), - "README.md".into(), - ], - stdout: " M README.md\n".into(), - stderr: String::new(), - exit_code: 0, - }, - GitCommandInvocation { - arguments: vec![ - "checkout".into(), - "HEAD".into(), - "--".into(), - "README.md".into(), - ], - stdout: String::new(), - stderr: String::new(), - exit_code: 0, - }, - ], - operation_error: None, - stash_restore: None, - tag_deletion: None, - branch_deletion: None, - history_rewrite: None, - outcome_authoritative: false, - warnings: Vec::new(), - }; - - assert_eq!( - serde_json::to_value(response).expect("Git response should serialize"), - fixture - ); - } - - #[test] - fn parse_commit_splits_the_author_utc_offset_from_the_displayed_date() { - let line = - |date: &str| format!("h\u{1f}s\u{1f}\u{1f}A\u{1f}a@x\u{1f}{date}\u{1f}msg\u{1f}"); - - let east = parse_commit(&line("2026/09/30 21:05 +0800")).expect("commit should parse"); - assert_eq!(east.date, "2026/09/30 21:05"); - assert_eq!(east.date_utc_offset_minutes, Some(480)); - assert_eq!(east.date_unix_seconds, Some(1790773500)); - - let west = parse_commit(&line("2026/09/30 08:05 -0330")).expect("commit should parse"); - assert_eq!(west.date, "2026/09/30 08:05"); - assert_eq!(west.date_utc_offset_minutes, Some(-210)); - - // Output without a recognizable zone stays visible unchanged instead of being trimmed. - let legacy = parse_commit(&line("2026/09/30 08:05")).expect("commit should parse"); - let invalid = parse_commit(&line("2026/09/30 08:05 +1260")).expect("commit should parse"); - assert_eq!(invalid.date, "2026/09/30 08:05 +1260"); - assert_eq!(invalid.date_utc_offset_minutes, None); - assert_eq!(invalid.date_unix_seconds, None); - - assert_eq!(legacy.date, "2026/09/30 08:05"); - assert_eq!(legacy.date_utc_offset_minutes, None); - } - - #[test] - fn history_response_matches_shared_fixture() { - let fixture: Value = serde_json::from_str(include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../fixtures/git/history-response-v1.json" - ))) - .expect("Git history response fixture should be valid JSON"); - let feature = GitReferenceResponse { - full_name: "refs/heads/feature/recent".into(), - short_name: "feature/recent".into(), - kind: "local".into(), - peels_to_commit: true, - is_current: true, - upstream_short_name: None, - ahead: 0, - behind: 0, - }; - let main = GitReferenceResponse { - full_name: "refs/heads/main".into(), - short_name: "main".into(), - kind: "local".into(), - peels_to_commit: true, - is_current: false, - upstream_short_name: Some("origin/main".into()), - ahead: 2, - behind: 1, - }; - let response = GitHistoryResponse { - references: vec![feature.clone(), main.clone()], - recent_references: vec![feature, main], - commits: vec![GitCommitResponse { - hash: "0123456789abcdef0123456789abcdef01234567".into(), - short_hash: "0123456".into(), - parent_hashes: Vec::new(), - author_name: "Lithe Test".into(), - author_email: "test@example.invalid".into(), - date: "2026/08/30 12:00".into(), - date_utc_offset_minutes: Some(480), - date_unix_seconds: None, - subject: "Initial commit".into(), - decorations: "HEAD -> feature/recent".into(), - }], - has_more: false, - user_name: Some("Lithe Test".into()), - user_email: Some("test@example.invalid".into()), - }; - - assert_eq!( - serde_json::to_value(response).expect("Git history response should serialize"), - fixture - ); - } - - #[test] - fn references_response_matches_shared_fixture() { - let fixture: Value = serde_json::from_str(include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../fixtures/git/references-response-v1.json" - ))) - .expect("Git references response fixture should be valid JSON"); - let feature = GitReferenceResponse { - full_name: "refs/heads/feature/recent".into(), - short_name: "feature/recent".into(), - kind: "local".into(), - peels_to_commit: true, - is_current: true, - upstream_short_name: None, - ahead: 0, - behind: 0, - }; - let main = GitReferenceResponse { - full_name: "refs/heads/main".into(), - short_name: "main".into(), - kind: "local".into(), - peels_to_commit: true, - is_current: false, - upstream_short_name: Some("origin/main".into()), - ahead: 2, - behind: 1, - }; - let response = GitReferencesResponse { - references: vec![feature.clone(), main.clone()], - recent_references: vec![feature, main], - user_name: Some("Lithe Test".into()), - user_email: Some("test@example.invalid".into()), - }; - - assert_eq!( - serde_json::to_value(response).expect("Git references response should serialize"), - fixture - ); - } - - #[test] - fn history_page_response_matches_shared_fixture() { - let fixture: Value = serde_json::from_str(include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../fixtures/git/history-page-response-v1.json" - ))) - .expect("Git history page response fixture should be valid JSON"); - let response = GitHistoryPageResponse { - commits: vec![GitCommitResponse { - hash: "0123456789abcdef0123456789abcdef01234567".into(), - short_hash: "0123456".into(), - parent_hashes: Vec::new(), - author_name: "Lithe Test".into(), - author_email: "test@example.invalid".into(), - date: "2026/08/30 12:00".into(), - date_utc_offset_minutes: Some(480), - date_unix_seconds: None, - subject: "Initial commit".into(), - decorations: "HEAD -> feature/recent".into(), - }], - next_cursor: Some("git-history-cursor-fixture".into()), - next_offset: None, - has_more: true, - }; - - assert_eq!( - serde_json::to_value(response).expect("Git history page response should serialize"), - fixture - ); - } - - #[test] - fn commit_lookup_response_matches_shared_fixture() { - let fixture: Value = serde_json::from_str(include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../fixtures/git/commit-lookup-response-v1.json" - ))) - .expect("Git commit lookup response fixture should be valid JSON"); - let response = GitCommitLookupResponse { - commit: GitCommitResponse { - hash: "0123456789abcdef0123456789abcdef01234567".into(), - short_hash: "0123456".into(), - parent_hashes: vec!["89abcdef0123456789abcdef0123456789abcdef".into()], - author_name: "Lithe Test".into(), - author_email: "test@example.invalid".into(), - date: "2026/08/30 12:00".into(), - date_utc_offset_minutes: Some(480), - date_unix_seconds: None, - subject: "Fix commit details".into(), - decorations: "HEAD -> feature/recent".into(), - }, - body: "Explain why the change is needed.\n\n- keep blank lines\n and indentation" - .into(), - }; - - assert_eq!( - serde_json::to_value(response).expect("Git commit lookup response should serialize"), - fixture - ); - } - - #[test] - fn commit_lookup_keeps_separator_characters_inside_the_body() { - // The body may contain the header's unit separator or CRLF line breaks; - // only the first NUL delimits the header from the free-form message. - let output = "abc\u{1f}ab\u{1f}p1 p2\u{1f}Dev\u{1f}dev@example.invalid\u{1f}2026/08/30 12:00\u{1f}Subject\u{1f}tag: v1\0Line \u{1f} one\r\n\r\nLine two\n\n"; - let lookup = parse_commit_lookup(output).expect("lookup output should parse"); - assert_eq!(lookup.commit.parent_hashes, ["p1", "p2"]); - assert_eq!(lookup.commit.subject, "Subject"); - assert_eq!(lookup.commit.decorations, "tag: v1"); - assert_eq!(lookup.body, "Line \u{1f} one\r\n\r\nLine two"); - - let subject_only = parse_commit_lookup( - "abc\u{1f}ab\u{1f}\u{1f}Dev\u{1f}dev@example.invalid\u{1f}2026/08/30 12:00\u{1f}Only\u{1f}\0", - ) - .expect("subject-only output should parse"); - assert_eq!(subject_only.body, ""); - assert!(parse_commit_lookup("missing delimiter").is_none()); - } - - #[test] - fn push_preview_response_matches_shared_fixture() { - let fixture: Value = serde_json::from_str(include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../fixtures/git/push-preview-v1.json" - ))) - .expect("Git push preview fixture should be valid JSON"); - let response = GitPushPreviewResponse { - local_branch: "feature/core".into(), - local_head: "2222222222222222222222222222222222222222".into(), - remote: "origin".into(), - remote_branch: "feature/core".into(), - target_kind: GitPushTargetKind::Tracking, - creates_remote_branch: false, - remote_tracking_oid: Some("1111111111111111111111111111111111111111".into()), - upstream: Some("origin/feature/core".into()), - tags: vec![GitPushTagResponse { - full_name: "refs/tags/v1.0.0".into(), - object_id: "3333333333333333333333333333333333333333".into(), - }], - commits: vec![GitCommitResponse { - hash: "2222222222222222222222222222222222222222".into(), - short_hash: "2222222".into(), - parent_hashes: vec!["1111111111111111111111111111111111111111".into()], - author_name: "Lithe Developer".into(), - author_email: "developer@lithe.local".into(), - date: "2026/08/31 10:30".into(), - date_utc_offset_minutes: Some(-300), - date_unix_seconds: None, - subject: "Add push preview".into(), - decorations: "HEAD -> feature/core".into(), - }], - has_more: false, - }; - - assert_eq!( - serde_json::to_value(response).expect("Git push preview should serialize"), - fixture - ); - } - - #[test] - fn command_error_response_matches_shared_fixture() { - let fixture: Value = serde_json::from_str(include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../fixtures/git/command-error-response-v1.json" - ))) - .expect("Git command error response fixture should be valid JSON"); - let response = GitCommandResponse { - arguments: vec![ - "stash".into(), - "push".into(), - "--include-untracked".into(), - "--message".into(), - "Lithe Smart Checkout".into(), - ], - output: "No local changes to save\n".into(), - stdout: "No local changes to save\n".into(), - stderr: String::new(), - exit_code: 0, - invocations: vec![GitCommandInvocation { - arguments: vec![ - "stash".into(), - "push".into(), - "--include-untracked".into(), - "--message".into(), - "Lithe Smart Checkout".into(), - ], - stdout: "No local changes to save\n".into(), - stderr: String::new(), - exit_code: 0, - }], - operation_error: Some(CoreError::new( - ErrorCode::InvalidRequest, - "Invalid Git reference", - )), - stash_restore: None, - tag_deletion: None, - branch_deletion: None, - history_rewrite: None, - outcome_authoritative: false, - warnings: Vec::new(), - }; - - assert_eq!( - serde_json::to_value(response).expect("Git error response should serialize"), - fixture - ); - } - - #[test] - fn structured_diff_matches_shared_fixture() { - let fixture: Value = serde_json::from_str(include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../fixtures/git/diff.json" - ))) - .expect("diff fixture should be valid JSON"); - let patch = fixture["patch"] - .as_str() - .expect("fixture patch should be text"); - let (rows, hunks, _) = parse_diff(patch); - let expected = &fixture["expected"]; - let kinds = rows.iter().map(|row| row.kind.as_str()).collect::>(); - let old_lines = rows - .iter() - .map(|row| row.old_line.map(|line| line as u64)) - .collect::>(); - let new_lines = rows - .iter() - .map(|row| row.new_line.map(|line| line as u64)) - .collect::>(); - let expected_kinds = expected["rowKinds"] - .as_array() - .expect("fixture kinds should be an array") - .iter() - .map(|kind| kind.as_str().expect("fixture kind should be text")) - .collect::>(); - let expected_old_lines = expected["oldLines"] - .as_array() - .expect("fixture old lines should be an array") - .iter() - .map(Value::as_u64) - .collect::>(); - let expected_new_lines = expected["newLines"] - .as_array() - .expect("fixture new lines should be an array") - .iter() - .map(Value::as_u64) - .collect::>(); - assert_eq!(kinds, expected_kinds); - assert_eq!(old_lines, expected_old_lines); - assert_eq!(new_lines, expected_new_lines); - assert_eq!( - hunks.len(), - expected["hunkCount"] - .as_u64() - .expect("fixture count should be a number") as usize - ); - } - - #[test] - fn pathspec_batches_stay_within_the_budget_and_keep_every_path() { - let paths = ["aaaa", "bbbb", "cc", "dddddddddd", "e"] - .map(String::from) - .to_vec(); - - let batches = pathspec_batches(&paths, 8); - - assert_eq!( - batches, - vec![&paths[0..2], &paths[2..3], &paths[3..4], &paths[4..5]] - ); - assert!(pathspec_batches(&[], 8).is_empty()); - } -} diff --git a/rust/crates/core/src/lib.rs b/rust/crates/core/src/lib.rs index 27808af3..f6dd6a79 100644 --- a/rust/crates/core/src/lib.rs +++ b/rust/crates/core/src/lib.rs @@ -1,25 +1,18 @@ //! Deterministic application services shared by the macOS and Windows hosts. -mod build; -mod debug; -pub mod diagnostics; -mod editor; -pub mod execution; -mod git; -mod lsp; -pub mod plugins; -mod project; -mod project_generators; +use lithe_build as build; +use lithe_debug as debug; +/// Run configurations and launch environments, kept in their own crate. +pub use lithe_execution as execution; +use lithe_project as project; +use lithe_project_generators as project_generators; mod protocol; -mod remote; mod runtime; /// Shared toolchain policy, independent of plugin transport and native discovery. -pub mod toolchain; -mod vcs; +pub use lithe_toolchain as toolchain; +use lithe_vcs as vcs; -pub use protocol::{ - CoreCommand, CoreError, CoreEvent, CoreRequest, CoreResponse, ErrorCode, ResponseData, -}; +pub use protocol::{CoreCommand, CoreError, CoreRequest, CoreResponse, ErrorCode, ResponseData}; /// The `ai.*` commands, kept in their own crate (`rust/crates/ai`); re-exported so the former /// `lithe_core::ai` paths keep resolving. @@ -43,12 +36,12 @@ pub fn start_lsp_owned( mut payload: serde_json::Value, registered: impl FnOnce(&str), ) -> Result { - lsp::resolve_request_paths("lsp.startServer", &mut payload)?; + lithe_document_uri::resolve_request_paths("lsp.startServer", &mut payload)?; let request = serde_json::from_value(payload).map_err(|error| { CoreError::new(ErrorCode::InvalidRequest, "Invalid language-server launch.") .with_details(error.to_string()) })?; - let response = lsp::interface::start_server_owned(request, registered)?; + let response = lithe_lsp_engine::start_server_owned(request, registered)?; serde_json::to_value(response).map_err(|error| { CoreError::new( ErrorCode::Unknown, @@ -60,8 +53,7 @@ pub fn start_lsp_owned( /// Requests cooperative cancellation of an active operation. /// -/// Native Rust hosts use this entry point while the Swift and C++ clients keep -/// using the stable `lithe_core_cancel` C ABI. +/// Rust callers use this entry point; C ABI hosts call `lithe_core_cancel`. pub fn cancel_operation(operation_id: &str) -> bool { protocol::cancellation::cancel(operation_id) } @@ -75,16 +67,16 @@ pub fn execute_json_with_events( request: &str, sink: std::sync::Arc, ) -> String { - git::execution_events::with_sink(sink, || { + lithe_request_context::events::with_sink(sink, || { let response = execute_json(request); - git::execution_events::request_finished(&response); + lithe_request_context::events::request_finished(&response); response }) } /// Runs the native AskPass helper before either host initializes its user interface. pub fn git_askpass_main(prompt: &str) -> i32 { - crate::diagnostics::helper_failures::install(); + crate::runtime::helper_failures::install(); lithe_git_host::authentication::helper_main(prompt) } @@ -92,7 +84,7 @@ pub fn git_askpass_main(prompt: &str) -> i32 { pub use project::is_workspace_event_relevant; /// Shared, IO-free classification for coalesced native Git watcher events. -pub use git::{ +pub use lithe_git::{ classify_git_metadata_change, GitMetadataChangeKind, GitMetadataEventClassification, GitMetadataWatchContext, }; @@ -105,6 +97,6 @@ pub fn attach_ide_request_dispatcher( owner: &str, dispatcher: std::sync::Arc Result<(), String> + Send + Sync>, ) -> Result<(), String> { - crate::diagnostics::helper_failures::install(); + crate::runtime::helper_failures::install(); lithe_ide_host::attach_dispatcher(host_id, owner, dispatcher) } diff --git a/rust/crates/core/src/lsp/host_policy/tests.rs b/rust/crates/core/src/lsp/host_policy/tests.rs deleted file mode 100644 index 0c6e9bea..00000000 --- a/rust/crates/core/src/lsp/host_policy/tests.rs +++ /dev/null @@ -1,84 +0,0 @@ -//! Shared-fixture coverage for the host policy commands. Both platforms -//! consume these fixtures, so a divergence here is a cross-platform bug. - -use serde_json::{json, Value}; - -fn run_fixture(command: &str, fixture: &str) { - let fixture: Value = serde_json::from_str(fixture).expect("fixture should be JSON"); - let cases = fixture["cases"] - .as_array() - .expect("fixture should list cases"); - assert!(!cases.is_empty(), "{command} fixture has no cases"); - for case in cases { - let response: Value = serde_json::from_str(&crate::execute_json( - &json!({"command": command, "payload": case["request"]}).to_string(), - )) - .expect("Core should return JSON"); - assert_eq!(response["ok"], true, "{}: {response}", case["name"]); - assert_eq!(response["data"], case["response"], "{}", case["name"]); - } -} - -#[test] -fn problems_query_matches_shared_fixture() { - run_fixture( - "lsp.problems.query", - include_str!("../../../../../fixtures/lsp/problems-query-v1.json"), - ); -} - -#[test] -fn activation_policy_matches_shared_fixture() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../../../fixtures/lsp/activation-policy-v1.json" - )) - .unwrap(); - // The fixture documents the budget; keep it pinned to the constants. - assert_eq!( - fixture["maxConsecutiveFailures"], - super::activation::MAX_CONSECUTIVE_FAILURES - ); - assert_eq!( - fixture["failureCooldownMs"], - super::activation::FAILURE_COOLDOWN_MS - ); - run_fixture( - "lsp.activationPolicy", - include_str!("../../../../../fixtures/lsp/activation-policy-v1.json"), - ); -} - -#[test] -fn completion_merge_matches_shared_fixture() { - run_fixture( - "lsp.mergeCompletions", - include_str!("../../../../../fixtures/lsp/completion-merge-v1.json"), - ); -} - -#[test] -fn malformed_requests_fail_with_invalid_request() { - for (command, payload) in [ - ( - "lsp.problems.query", - json!({"diagnostics": [{"fileKey": "a"}]}), - ), - ( - "lsp.problems.query", - json!({"diagnostics": [], "groupBy": "type"}), - ), - ("lsp.activationPolicy", json!({"event": "automaticStart"})), - ( - "lsp.activationPolicy", - json!({"event": "crashed", "nowMs": 1}), - ), - ("lsp.mergeCompletions", json!({"lists": [{"items": []}]})), - ] { - let response: Value = serde_json::from_str(&crate::execute_json( - &json!({"command": command, "payload": payload}).to_string(), - )) - .unwrap(); - assert_eq!(response["ok"], false, "{command}: {response}"); - assert_eq!(response["error"]["code"], "invalid_request", "{command}"); - } -} diff --git a/rust/crates/core/src/lsp/interface/client.rs b/rust/crates/core/src/lsp/interface/client.rs deleted file mode 100644 index a4e21cff..00000000 --- a/rust/crates/core/src/lsp/interface/client.rs +++ /dev/null @@ -1,2616 +0,0 @@ -//! Pure LSP client-state transitions and JSON-RPC message construction. - -use super::plugin_function; -use super::types::*; -use crate::lsp::apply_content_changes_sequential; -use crate::protocol::{CoreError, ErrorCode}; -use serde_json::{json, Value}; - -/// Creates a host-owned initialize request for stateless client tests. -#[cfg(test)] -pub fn client_initialize(request: ClientInitializeRequest) -> Result { - client_initialize_for_plugin(request, false) -} - -/// Offers plugin functions only when the runtime owns an identified plugin session. -pub(super) fn client_initialize_for_plugin( - request: ClientInitializeRequest, - plugin_owned: bool, -) -> 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( - &id, - "initialize", - json!({ - "processId": request.process_id, - "clientInfo": { - "name": "Lithe", - "version": env!("CARGO_PKG_VERSION") - }, - "rootUri": &request.root_uri, - "workspaceFolders": [{ - "uri": &request.root_uri, - "name": workspace_name - }], - "capabilities": { - "textDocument": { - "synchronization": { "didSave": true }, - "completion": { - "dynamicRegistration": true, - "contextSupport": true, - "completionItem": { - "snippetSupport": true, - "documentationFormat": ["markdown", "plaintext"], - // Some servers (Intelephense, for one) omit typed class and - // namespace labels unless the client advertises labelDetailsSupport. - "labelDetailsSupport": true, - "resolveSupport": { - "properties": [ - "detail", - "documentation", - "textEdit", - "additionalTextEdits", - "labelDetails" - ] - } - } - }, - "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 }, - "rangeFormatting": { "dynamicRegistration": true }, - "documentSymbol": { - "dynamicRegistration": true, - "hierarchicalDocumentSymbolSupport": true - }, - "inlayHint": { "dynamicRegistration": true }, - "semanticTokens": { - "dynamicRegistration": true, - "requests": { "full": true, "range": false }, - "tokenTypes": ["namespace", "type", "class", "enum", "interface", "struct", "typeParameter", "parameter", "variable", "property", "enumMember", "event", "function", "method", "macro", "keyword", "modifier", "comment", "string", "number", "regexp", "operator", "decorator"], - "tokenModifiers": ["declaration", "definition", "readonly", "static", "deprecated", "abstract", "async", "modification", "documentation", "defaultLibrary"], - "formats": ["relative"], - "overlappingTokenSupport": false, - "multilineTokenSupport": false - }, - "foldingRange": { - "dynamicRegistration": true, - "lineFoldingOnly": false, - "foldingRangeKind": { - "valueSet": ["comment", "imports", "region"] - }, - "foldingRange": { - "collapsedText": true - } - }, - "codeLens": { "dynamicRegistration": true }, - "codeAction": { - "dynamicRegistration": true, - "resolveSupport": { - "properties": ["edit", "command"] - }, - "codeActionLiteralSupport": { - "codeActionKind": { - "valueSet": ["quickfix", "refactor", "source"] - } - } - }, - "publishDiagnostics": { - "versionSupport": true, - "relatedInformation": true, - "tagSupport": { - "valueSet": [1, 2] - } - } - }, - "workspace": { "semanticTokens": { "refreshSupport": true }, - "applyEdit": true, - "configuration": true, - "workspaceFolders": true, - "workspaceEdit": { - "documentChanges": true - }, - "executeCommand": { "dynamicRegistration": true }, - "symbol": { - "dynamicRegistration": true, - "symbolKind": { - "valueSet": [ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, - 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26 - ] - } - } - }, - "window": { - "workDoneProgress": true - }, - "experimental": experimental_capabilities(plugin_owned, &state.offered_toolchain_providers, &state.offered_keymap_import_providers) - }, - "initializationOptions": request.initialization_options - }), - )?; - Ok(client_response(state, vec![message], Vec::new())) -} - -/// Opens a document and emits the corresponding LSP notification. -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.into(), - }; - let message = json_rpc_notification( - "textDocument/didOpen", - json!({ - "textDocument": { - "uri": document.uri, - "languageId": document.language_id, - "version": document.version, - "text": document.text - } - }), - )?; - state.diagnostics.remove(&request.uri); - state.diagnostic_versions.remove(&request.uri); - state.open_documents.insert(request.uri, document); - Ok(client_response(state, vec![message], Vec::new())) -} - -/// Updates a document and emits a monotonically versioned change notification. -/// -/// `content_changes` follow LSP `didChange` semantics: they apply in array order -/// and each range is relative to the result of the previous change. `text` is -/// optional alongside them; when present it is checked against the replay. -pub fn client_change_document( - request: ClientChangeDocumentRequest, -) -> Result { - validate_uri(&request.uri)?; - let mut state = request.state; - let Some(document) = state.open_documents.get(&request.uri).cloned() else { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Cannot change a document that is not open in the LSP client.", - )); - }; - let replayed = if request.content_changes.is_empty() { - None - } else { - match apply_content_changes_sequential(&document.text, &request.content_changes) { - Ok(text) => Some(text), - Err(_) if !request.text.is_empty() => None, - Err(error) => return Err(error), - } - }; - // A host snapshot that disagrees with the replay means one side missed an - // edit, so the snapshot replaces the document instead of extending the drift. - let (next_text, replayed) = match replayed { - Some(text) if request.text.is_empty() || text == request.text => (text, true), - _ => (request.text, false), - }; - let wire_incremental = (replayed - && state.text_document_sync == LspTextDocumentSyncKind::Incremental - && request - .content_changes - .iter() - .all(|change| change.range.is_some())) - .then_some(request.content_changes); - let content_changes = if let Some(changes) = wire_incremental.as_ref() { - json!(changes - .iter() - .map(|change| { - let range = change.range.expect("incremental changes require a range"); - json!({ - "range": { - "start": { - "line": range.start.line, - "character": range.start.utf16_column - }, - "end": { - "line": range.end.line, - "character": range.end.utf16_column - } - }, - "text": change.text - }) - }) - .collect::>()) - } else { - json!([{ "text": next_text }]) - }; - let mut document = document; - document.version += 1; - document.text = next_text.into(); - let message = json_rpc_notification( - "textDocument/didChange", - json!({ - "textDocument": { - "uri": document.uri, - "version": document.version - }, - "contentChanges": content_changes - }), - )?; - state.open_documents.insert(request.uri, document); - Ok(client_response(state, vec![message], Vec::new())) -} - -/// Closes a document and clears diagnostics owned by its URI. -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.", - )); - }; - state.diagnostics.remove(&request.uri); - state.diagnostic_versions.remove(&request.uri); - let message = json_rpc_notification( - "textDocument/didClose", - json!({ - "textDocument": { - "uri": document.uri - } - }), - )?; - Ok(client_response(state, vec![message], Vec::new())) -} - -/// Begins the two-step LSP shutdown and exit handshake. -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 { - client_feature_request_with_document_ownership(request, true) -} - -fn client_feature_request_with_document_ownership( - request: ClientFeatureRequest, - require_open_document: bool, -) -> Result { - // `workspace/symbol` is workspace-wide: it names no document, so an empty URI is accepted. - let workspace_wide = request.method == "workspace/symbol"; - if !workspace_wide { - 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 !workspace_wide && require_open_document && !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())) -} - -/// Reduces a server message for host-owned stateless client tests. -#[cfg(test)] -pub fn client_apply_server_message( - request: ClientApplyServerMessageRequest, -) -> Result { - 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()) - })?; - client_apply_server_message_for_plugin(request.state, &message, false) -} - -/// Applies function protocol messages only for sessions with a runtime-owned plugin identity. -/// -/// Takes the message already parsed: the engine parses each server message once, -/// outside the session state lock, and reduces it while holding that lock. -pub(super) fn client_apply_server_message_for_plugin( - mut state: LspClientState, - message: &Value, - plugin_owned: bool, -) -> Result { - 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)?; - // Servers may re-encode the URIs they echo back (VS Code's - // URI library writes `d%3A` for a Windows drive while this - // host opened the document as `d:`), so a raw map lookup - // silently drops every published diagnostic on Windows. - // Match through the normalized form and publish under the - // document's own canonical URI. - let document = open_document_for_published_uri(&state.open_documents, uri); - let Some(document) = document else { - return Ok(client_response(state, responses, events)); - }; - let document_uri = document.uri.clone(); - let version = params.get("version").and_then(Value::as_i64); - if version.is_some_and(|version| version != document.version) { - return Ok(client_response(state, responses, events)); - } - let diagnostics = parse_diagnostics(params.get("diagnostics")); - state - .diagnostics - .insert(document_uri.clone(), diagnostics.clone()); - if let Some(version) = version { - state - .diagnostic_versions - .insert(document_uri.clone(), version); - } else { - state.diagnostic_versions.remove(&document_uri); - } - events.push(LspClientEvent { - kind: "diagnostics".to_string(), - request_id: None, - method: None, - uri: Some(document_uri), - version, - 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)?); - } - } - plugin_function::REGISTER_METHOD | plugin_function::UNREGISTER_METHOD - if plugin_owned - && !state.shutdown_requested - && state - .server_capabilities - .iter() - .any(|feature| feature == plugin_function::FUNCTIONS_FEATURE) => - { - let params = message.get("params"); - let outcome = if method == plugin_function::REGISTER_METHOD { - plugin_function::apply_registration(&state.plugin_functions, params) - .map(|registry| (registry, Vec::new())) - } else { - plugin_function::apply_unregistration(&state.plugin_functions, params) - }; - match outcome { - Ok((registry, removed)) => { - state.plugin_functions = registry; - if let Some(id) = request_id { - responses.push(json_rpc_result(id, Value::Null)?); - } - events.push(LspClientEvent { - kind: "functionsChanged".to_string(), - request_id: None, - method: Some(method.to_string()), - uri: None, - version: None, - diagnostics: None, - result: Some(json!({ - "functions": state.plugin_functions, - "removed": removed - })), - error: None, - }); - } - Err(reason) => { - if let Some(id) = request_id { - responses.push(json_rpc_error_with_code( - id, - -32602, - reason, - plugin_function::codes::INVALID_REGISTRATION, - )?); - } - } - } - } - super::build_provider::PROGRESS_METHOD - if !state.shutdown_requested - && state.server_capabilities.iter().any(|feature| { - feature == super::build_provider::BUILD_PROVIDER_FEATURE - }) => - { - if let Some(result) = super::build_provider::progress(message.get("params")) { - events.push(LspClientEvent { - kind: "buildProgress".to_string(), - request_id: None, - method: Some(method.to_string()), - uri: None, - version: None, - diagnostics: None, - result: Some(result), - error: None, - }); - } - } - plugin_function::PROGRESS_METHOD - if plugin_owned - && !state.shutdown_requested - && state - .server_capabilities - .iter() - .any(|feature| feature == plugin_function::FUNCTIONS_FEATURE) => - { - // Malformed reports are dropped here; reports for an operation - // that is no longer running are dropped by the engine. - if let Some(progress) = plugin_function::parse_progress(message.get("params")) { - let mut result = json!({ "operationID": progress.operation_id }); - if let Some(text) = progress.message { - result["message"] = json!(text); - } - if let Some(percentage) = progress.percentage { - result["percentage"] = json!(percentage); - } - events.push(LspClientEvent { - kind: "functionProgress".to_string(), - request_id: None, - method: Some(method.to_string()), - uri: None, - version: None, - diagnostics: None, - result: Some(result), - error: None, - }); - } - } - "workspace/semanticTokens/refresh" => { - if let Some(id) = request_id { - responses.push(json_rpc_result(id, Value::Null)?); - } - events.push(LspClientEvent { - kind: "semanticTokensRefresh".to_string(), - request_id: None, - method: Some(method.to_string()), - uri: None, - version: None, - diagnostics: None, - result: None, - error: None, - }); - } - "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 plugin_owned && request_id.is_some() && method.starts_with("lithe/host/") => { - let id = request_id.expect("guarded above"); - match plugin_function::host_request( - method, - message.get("params"), - state.plugin_function_version, - &state.host_services, - ) { - // The engine answers once the host has; the request - // carries the backend's own ID back to it. - Ok(plugin_function::HostRoute::Answer(mut result)) => { - // getCapabilities also reports the options of the - // granted host services. - if let (plugin_function::GET_CAPABILITIES, Some(editor)) = - (method, &state.host_options.editor) - { - editor.report(&mut result, &state.host_services); - } - responses.push(json_rpc_result(id, result)?) - } - Ok(plugin_function::HostRoute::Forward(service)) => { - events.push(LspClientEvent { - kind: "hostRequest".to_string(), - request_id: None, - method: Some(method.to_string()), - uri: None, - version: None, - diagnostics: None, - result: Some(json!({ - "id": id, - "service": service, - "params": plugin_function::forwarded_host_params( - method, - message.get("params"), - ), - })), - error: None, - }) - } - // Another plugin's function: the engine's relay checks the - // call against the plugins' manifests and answers the - // backend itself, so the reducer only names the request. - Ok(plugin_function::HostRoute::Relay) => events.push(LspClientEvent { - kind: "functionRelayRequest".to_string(), - request_id: None, - method: Some(method.to_string()), - uri: None, - version: None, - diagnostics: None, - result: Some(json!({ - "id": id, - "params": message.get("params").cloned().unwrap_or(Value::Null), - })), - error: None, - }), - Err(refusal) => { - let mut data = json!({ "code": refusal.code }); - if let Some(details) = refusal.details { - data["details"] = details; - } - responses.push(json_rpc_error_with_data( - id, - refusal.rpc_code, - refusal.message, - data, - )?) - } - } - } - _ => { - if let Some(id) = request_id { - responses.push(match plugin_function::refused_backend_request(method) { - Some(code) => json_rpc_error_with_code( - id, - -32601, - "The host does not serve this request to a plugin process.", - code, - )?, - None => 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, - version: 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") - .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), - ); - if plugin_owned - && super::plugin_logging::negotiated( - result.get("capabilities").unwrap_or(&Value::Null), - ) - { - state - .server_capabilities - .push(super::plugin_logging::FEATURE.into()); - } - let functions = plugin_owned - .then(|| { - plugin_function::negotiate( - result.get("capabilities").unwrap_or(&Value::Null), - ) - }) - .flatten(); - if let Some((version, functions)) = functions { - state.plugin_functions = functions; - state.plugin_function_version = Some(version); - events.push(LspClientEvent { - kind: "functionsChanged".to_string(), - request_id: None, - method: Some("initialize".to_string()), - uri: None, - version: None, - diagnostics: None, - result: Some(json!({ "functions": state.plugin_functions, "removed": [] })), - error: None, - }); - } else { - state.plugin_functions.clear(); - state.plugin_function_version = None; - state - .server_capabilities - .retain(|feature| feature != plugin_function::FUNCTIONS_FEATURE); - } - // Only a plugin's own process may act as a VCS provider; a - // host-owned server that happens to answer is ignored. - state.vcs_provider = plugin_owned - .then(|| { - super::vcs_provider::negotiate( - result.get("capabilities").unwrap_or(&Value::Null), - ) - }) - .flatten(); - match &state.vcs_provider { - Some(negotiation) => events.push(LspClientEvent { - kind: super::vcs_provider::NEGOTIATED_EVENT.to_string(), - request_id: None, - method: Some("initialize".to_string()), - uri: None, - version: None, - diagnostics: None, - result: Some(json!(negotiation)), - error: None, - }), - None => state - .server_capabilities - .retain(|feature| feature != super::vcs_provider::VCS_PROVIDER_FEATURE), - } - state.toolchain_providers = plugin_owned - .then(|| { - super::toolchain_provider::negotiate( - result.get("capabilities").unwrap_or(&Value::Null), - &state.offered_toolchain_providers, - ) - }) - .flatten() - .unwrap_or_default(); - if !state.toolchain_providers.is_empty() { - events.push(LspClientEvent { - kind: super::toolchain_provider::NEGOTIATED_EVENT.to_string(), - request_id: None, - method: Some("initialize".to_string()), - uri: None, - version: None, - diagnostics: None, - result: Some(json!({ "providers": state.toolchain_providers })), - error: None, - }); - } - state.keymap_import_providers = plugin_owned - .then(|| { - super::keymap_provider::negotiate( - result.get("capabilities").unwrap_or(&Value::Null), - &state.offered_keymap_import_providers, - ) - }) - .flatten() - .unwrap_or_default(); - if !state.keymap_import_providers.is_empty() { - events.push(LspClientEvent { - kind: super::keymap_provider::NEGOTIATED_EVENT.to_string(), - request_id: None, - method: Some("initialize".to_string()), - uri: None, - version: None, - diagnostics: None, - result: Some(json!({ "providers": state.keymap_import_providers })), - error: None, - }); - } - state.text_document_sync = - text_document_sync_kind(result.get("capabilities").unwrap_or(&Value::Null)); - state.text_document_save_include_text = match result - .get("capabilities") - .and_then(|value| value.pointer("/textDocumentSync/save")) - { - Some(Value::Bool(true)) => Some(false), - Some(Value::Object(options)) => Some( - options - .get("includeText") - .and_then(Value::as_bool) - .unwrap_or(false), - ), - _ => None, - }; - if let Some(options) = result - .get("capabilities") - .and_then(|c| c.get("completionProvider")) - .filter(|options| options.is_object()) - { - state - .completion_providers - .insert(String::new(), options.clone()); - } - if let Some(options) = result - .get("capabilities") - .and_then(|c| c.get("semanticTokensProvider")) - { - if semantic_token_legend(options).is_some() { - state - .semantic_token_providers - .insert(String::new(), options.clone()); - } - } - 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.semantic_token_providers.clear(); - state.completion_providers.clear(); - state.text_document_sync = LspTextDocumentSyncKind::Full; - state.open_documents.clear(); - state.diagnostics.clear(); - state.diagnostic_versions.clear(); - state.plugin_functions.clear(); - state.plugin_function_version = None; - state.vcs_provider = None; - responses.push(json_rpc_message_without_params(None, "exit")?); - } - let result = if pending.as_deref() == Some("textDocument/semanticTokens/full") { - semantic_token_result( - state.pending_semantic_legends.remove(&id).as_ref(), - message.get("result"), - ) - } else { - 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, - version: 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(); - if method == "textDocument/semanticTokens/full" { - if let Some(legend) = state - .semantic_token_providers - .values() - .rev() - .find_map(semantic_token_legend) - { - state - .pending_semantic_legends - .insert(id.clone(), legend.clone()); - } - } - 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 - })) -} - -pub(super) 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) -} - -pub(super) fn json_rpc_result(id: &Value, result: Value) -> Result { - encode_json_rpc(json!({ - "jsonrpc": "2.0", - "id": id, - "result": result - })) -} - -/// `capabilities.experimental`: every Lithe protocol a plugin process may -/// speak next to LSP, each negotiated on its own key. -fn experimental_capabilities( - plugin_owned: bool, - toolchain_providers: &std::collections::BTreeSet, - keymap_import_providers: &std::collections::BTreeSet, -) -> Value { - let mut experimental = super::build_provider::client_experimental_capabilities(); - if plugin_owned { - let (logging_key, logging_offer) = - super::plugin_logging::client_experimental_capabilities(); - experimental[logging_key] = logging_offer; - let (functions_key, functions_offer) = plugin_function::client_experimental_capabilities(); - experimental[functions_key] = functions_offer; - let (vcs_key, vcs_offer) = super::vcs_provider::client_experimental_capabilities(); - experimental[vcs_key] = vcs_offer; - if let Some((toolchain_key, toolchain_offer)) = - super::toolchain_provider::client_experimental_capabilities(toolchain_providers) - { - experimental[toolchain_key] = toolchain_offer; - } - if let Some((key, offer)) = - super::keymap_provider::client_experimental_capabilities(keymap_import_providers) - { - experimental[key] = offer; - } - } - experimental -} - -/// A JSON-RPC error whose `data.code` carries a stable plugin-contract code. -pub(super) fn json_rpc_error_with_code( - id: &Value, - code: i64, - message: &str, - contract_code: &str, -) -> Result { - json_rpc_error_with_data(id, code, message, json!({ "code": contract_code })) -} - -/// A JSON-RPC error with the given `data`, which holds at least the stable -/// plugin-contract `code`. -pub(super) fn json_rpc_error_with_data( - id: &Value, - code: i64, - message: &str, - data: Value, -) -> Result { - encode_json_rpc(json!({ - "jsonrpc": "2.0", - "id": id, - "error": { "code": code, "message": message, "data": data } - })) -} - -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(()) - } -} - -/// Resolves a server-published document URI against the client's open -/// documents, tolerating URI re-encoding differences. -/// -/// Language servers frequently normalize the URIs a client sends them: VS -/// Code's URI library (used by Intelephense, typescript-language-server, and -/// others) percent-encodes the Windows drive colon (`d:` becomes `d%3A`) and -/// lowercases the drive letter. A raw map lookup therefore misses even though -/// the document is open. Comparison preserves URI component boundaries and -/// folds the Windows drive letter while keeping Unix paths case-sensitive. -fn open_document_for_published_uri<'a>( - documents: &'a std::collections::BTreeMap, - uri: &str, -) -> Option<&'a LspClientDocument> { - if let Some(document) = documents.get(uri) { - return Some(document); - } - let normalized = normalize_uri_for_comparison(uri); - documents - .iter() - .find(|(key, _)| normalize_uri_for_comparison(key) == normalized) - .map(|(_, document)| document) -} - -fn normalize_uri_for_comparison(uri: &str) -> String { - let serialized = url::Url::parse(uri) - .map(|url| url.to_string()) - .unwrap_or_else(|_| uri.to_owned()); - let bytes = serialized.as_bytes(); - let mut canonical = Vec::with_capacity(bytes.len()); - let mut index = 0; - const HEX: &[u8; 16] = b"0123456789ABCDEF"; - 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])) - { - let decoded = (high << 4) | low; - if decoded.is_ascii_alphanumeric() || b"-._~".contains(&decoded) { - canonical.push(decoded); - } else { - canonical.extend_from_slice(&[b'%', HEX[high as usize], HEX[low as usize]]); - } - index += 3; - continue; - } - } - canonical.push(bytes[index]); - index += 1; - } - // The input was UTF-8 and only ASCII escapes were replaced. - let mut normalized = String::from_utf8(canonical).expect("URI normalization preserves UTF-8"); - const FILE_PREFIX: &str = "file:///"; - if let Some(path) = normalized.strip_prefix(FILE_PREFIX) { - let bytes = path.as_bytes(); - if bytes.len() >= 4 && bytes[0].is_ascii_alphabetic() && &bytes[1..4] == b"%3A" { - let drive_index = FILE_PREFIX.len(); - normalized.replace_range(drive_index + 1..drive_index + 4, ":"); - } - let bytes = normalized.as_bytes(); - let drive_index = FILE_PREFIX.len(); - if bytes.len() > drive_index + 1 - && bytes[drive_index].is_ascii_alphabetic() - && bytes[drive_index + 1] == b':' - { - normalized[drive_index..drive_index + 1].make_ascii_lowercase(); - } - } - normalized -} - -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" - | "textDocument/hover" - | "textDocument/definition" - | "textDocument/declaration" - | "textDocument/typeDefinition" - | "textDocument/implementation" - | "textDocument/references" - | "textDocument/rename" - | "textDocument/formatting" - | "textDocument/rangeFormatting" - | "textDocument/documentSymbol" - | "textDocument/inlayHint" - | "textDocument/semanticTokens/full" - | "textDocument/foldingRange" - | "textDocument/documentLink" - | "workspace/symbol" - | "textDocument/codeLens" - | "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" => { - let context = request.completion_context.as_ref(); - let kind = context - .and_then(|v| v.get("triggerKind")) - .and_then(Value::as_u64) - .unwrap_or(1); - if !(1..=3).contains(&kind) - || context.is_some_and(|v| { - !v.is_object() || v.get("triggerKind").and_then(Value::as_u64).is_none() - }) - { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Invalid completion trigger kind", - )); - } - let mut trigger = json!({"triggerKind": kind}); - if kind == 2 { - let character = context - .and_then(|v| v.get("triggerCharacter")) - .and_then(Value::as_str) - .filter(|v| v.chars().count() == 1) - .ok_or_else(|| { - CoreError::new( - ErrorCode::InvalidRequest, - "Completion trigger character is required", - ) - })?; - trigger["triggerCharacter"] = json!(character); - } - Ok(json!({"textDocument": text_document, - "position": lsp_position_json(required_position(request)?), "context": trigger})) - } - "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": request.include_declaration.unwrap_or(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": formatting_options_json(request.formatting_options.as_ref())? - })), - "textDocument/rangeFormatting" => Ok(json!({ - "textDocument": text_document, - "range": lsp_range_json(required_range(request)?), - "options": formatting_options_json(request.formatting_options.as_ref())? - })), - "textDocument/inlayHint" => Ok(json!({ - "textDocument": text_document, - "range": lsp_range_json(required_range(request)?) - })), - "textDocument/semanticTokens/full" - | "textDocument/documentSymbol" - | "textDocument/foldingRange" - | "textDocument/documentLink" - | "textDocument/codeLens" => Ok(json!({ - "textDocument": text_document - })), - "workspace/symbol" => Ok(json!({ - "query": request.query.clone().unwrap_or_default() - })), - "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.", - ) - }) -} - -/// Builds the LSP `FormattingOptions` for a formatting request. -/// -/// Without caller options the historical defaults apply. Caller options are -/// merged over them, so a host passing only `tabSize` keeps the other -/// defaults. Values must be booleans, integers or strings as LSP requires, -/// and `tabSize` must be a positive integer. -fn formatting_options_json(supplied: Option<&Value>) -> Result { - let mut options = json!({ - "tabSize": 4, - "insertSpaces": true, - "trimTrailingWhitespace": true, - "insertFinalNewline": true, - "trimFinalNewlines": true - }); - let Some(supplied) = supplied.filter(|value| !value.is_null()) else { - return Ok(options); - }; - let invalid = || CoreError::new(ErrorCode::InvalidRequest, "Invalid formatting options"); - let supplied = supplied.as_object().ok_or_else(invalid)?; - for (key, value) in supplied { - let valid = match key.as_str() { - "tabSize" => value - .as_u64() - .is_some_and(|size| (1..=1024).contains(&size)), - "insertSpaces" - | "trimTrailingWhitespace" - | "insertFinalNewline" - | "trimFinalNewlines" => value.is_boolean(), - _ => value.is_boolean() || value.is_string() || value.is_i64() || value.is_u64(), - }; - if !valid { - return Err(invalid().with_details(key.clone())); - } - options[key] = value.clone(); - } - Ok(options) -} - -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": lsp_range_response_json(diagnostic.range), - "severity": diagnostic.severity, - "message": diagnostic.message, - "source": diagnostic.source, - "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 - } - }) -} - -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, - }), - 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() - }) - .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), - "isIncomplete": result.get("isIncomplete").and_then(Value::as_bool).unwrap_or(false) - })), - 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" | "textDocument/rangeFormatting") => Some(json!({ - "edits": result - .as_array() - .map(|edits| edits.iter().filter_map(parse_lsp_text_edit_value).collect::>()) - .unwrap_or_default() - })), - Some("textDocument/documentSymbol") => Some(json!({ - "symbols": parse_document_symbols(result) - })), - Some("textDocument/inlayHint") => Some(json!({ - "hints": parse_inlay_hints(result) - })), - Some("textDocument/foldingRange") => Some(json!({ - "ranges": parse_folding_ranges(result) - })), - Some("textDocument/documentLink") => Some(json!({ - "links": parse_document_links(result) - })), - Some("workspace/symbol") => Some(json!({ - "symbols": parse_workspace_symbols(result) - })), - Some("textDocument/codeLens") => Some(json!({ - "lenses": parse_code_lenses(result) - })), - 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!({ "value": result })), - // Wrapped like a command result so no provider-specific normalizer - // reinterprets fields of the document Core decodes. - Some(method) - if super::build_provider::is_build_method(method) - || method == plugin_function::INVOKE_METHOD => - { - Some(json!({ "value": 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| 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); - let mut mapped = json!({ - "label": label, - "insertText": insert_text, - "insertTextFormat": item.get("insertTextFormat").and_then(Value::as_u64).unwrap_or(1) - }); - if let Some(kind) = item.get("kind").and_then(Value::as_i64) { - mapped["kind"] = json!(kind); - } - if let Some(detail) = item.get("detail").and_then(Value::as_str) { - mapped["detail"] = json!(detail); - } - if let Some(documentation) = completion_documentation(item.get("documentation")) { - mapped["documentation"] = json!(documentation); - } - if let Some(sort_text) = item.get("sortText").and_then(Value::as_str) { - mapped["sortText"] = json!(sort_text); - } - if let Some(filter_text) = item.get("filterText").and_then(Value::as_str) { - mapped["filterText"] = json!(filter_text); - } - if let Some(edit) = item.get("textEdit").and_then(parse_lsp_text_edit_value) { - mapped["textEdit"] = edit; - } - if let Some(edits) = item - .get("additionalTextEdits") - .and_then(Value::as_array) - .map(|edits| { - edits - .iter() - .filter_map(parse_lsp_text_edit_value) - .collect::>() - }) - .filter(|edits| !edits.is_empty()) - { - mapped["additionalTextEdits"] = json!(edits); - } - // `data: null` makes some servers treat resolve as a no-op, so only - // forward a real resolve token. Namespace display lives in labelDetails; - // import `use`/`import` edits usually arrive only from completionItem/resolve. - if let Some(data) = item.get("data").filter(|value| !value.is_null()) { - mapped["data"] = data.clone(); - } - if let Some(details) = item.get("labelDetails").filter(|value| value.is_object()) { - mapped["labelDetails"] = details.clone(); - } - Some(mapped) -} - -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_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, - } -} - -/// Normalizes a `textDocument/documentSymbol` result into a unified tree. -/// -/// Servers answer with either `DocumentSymbol[]` (hierarchical) or -/// `SymbolInformation[]` (flat); the two are told apart per item by the -/// presence of `location`. Conversion rules: -/// - `DocumentSymbol` keeps `name`, `detail`, `kind`, `range`, -/// `selectionRange` and recursively normalized `children`. -/// - `SymbolInformation` becomes a childless symbol whose `range` and -/// `selectionRange` are both `location.range`, with `containerName` kept as -/// a hint; Core does not guess a hierarchy from container names. -/// - `deprecated` is true for the legacy `deprecated` flag or tag `1`. -/// - Positions use `{ line, utf16Column }`. Items with a missing name, kind or -/// range are dropped; `null` or a non-array result yields an empty list. -fn parse_document_symbols(result: &Value) -> Vec { - result - .as_array() - .map(|items| items.iter().filter_map(parse_document_symbol).collect()) - .unwrap_or_default() -} - -fn parse_document_symbol(item: &Value) -> Option { - let name = item.get("name").and_then(Value::as_str)?; - let kind = item.get("kind").and_then(Value::as_i64)?; - let tags: Vec = item - .get("tags") - .and_then(Value::as_array) - .map(|tags| tags.iter().filter_map(Value::as_i64).collect()) - .unwrap_or_default(); - let deprecated = item - .get("deprecated") - .and_then(Value::as_bool) - .unwrap_or(false) - || tags.contains(&1); - let (range, selection_range, children, container) = if let Some(location) = item.get("location") - { - let range = parse_lsp_range_value(location.get("range")?)?; - (range.clone(), range, Vec::new(), item.get("containerName")) - } else { - let range = parse_lsp_range_value(item.get("range")?)?; - let selection = item - .get("selectionRange") - .and_then(parse_lsp_range_value) - .unwrap_or_else(|| range.clone()); - let children = item - .get("children") - .and_then(Value::as_array) - .map(|children| children.iter().filter_map(parse_document_symbol).collect()) - .unwrap_or_default(); - (range, selection, children, None) - }; - Some(json!({ - "name": name, - "detail": item.get("detail").and_then(Value::as_str), - "kind": kind, - "tags": tags, - "deprecated": deprecated, - "range": range, - "selectionRange": selection_range, - "containerName": container.and_then(Value::as_str), - "children": children - })) -} - -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() -} - -/// Accepts both `SymbolInformation[]` and `WorkspaceSymbol[]`. A location with -/// only a URI keeps a null range, because the server expects `workspaceSymbol/resolve`. -fn parse_workspace_symbols(result: &Value) -> Vec { - result - .as_array() - .map(|values| { - values - .iter() - .filter_map(|symbol| { - let location = symbol.get("location")?; - Some(LspWorkspaceSymbolResponse { - name: symbol.get("name")?.as_str()?.to_string(), - kind: symbol.get("kind").and_then(Value::as_i64), - container_name: symbol - .get("containerName") - .and_then(Value::as_str) - .map(str::to_string), - uri: location.get("uri")?.as_str()?.to_string(), - range: location.get("range").and_then(parse_lsp_range), - }) - }) - .collect() - }) - .unwrap_or_default() -} - -/// Links without a range are dropped; a missing target stays `None` because the -/// server may supply it only through `documentLink/resolve`, which Core does not call. -fn parse_document_links(result: &Value) -> Vec { - result - .as_array() - .map(|values| { - values - .iter() - .filter_map(|link| { - Some(LspDocumentLinkResponse { - range: parse_lsp_range(link.get("range")?)?, - target: link - .get("target") - .and_then(Value::as_str) - .map(str::to_string), - tooltip: link - .get("tooltip") - .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() - } 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)?; - let file_path = file_path_for_uri(uri); - let is_read_only = file_path.is_none(); - Some(json!({ - "uri": uri, - "filePath": file_path, - "range": range, - "isReadOnly": is_read_only, - "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) - })) -} - -/// Decodes a file URI using native drive/UNC syntax; opaque inputs retain -/// their percent-decoded spelling for existing non-file consumers. -pub(crate) fn file_path_from_uri(uri: &str) -> String { - file_path_for_uri(uri).unwrap_or_else(|| percent_decode(uri)) -} - -fn file_path_for_uri(uri: &str) -> Option { - let (scheme, _) = uri.split_once(':')?; - if !scheme.eq_ignore_ascii_case("file") { - return None; - } - // Workspace edits already decode percent escapes, UNC authorities and the - // leading slash of file:///C:/... without depending on the build host OS. - crate::lsp::workspace_edit_plan::native_path(uri) -} - -/// Decodes `%XX` escapes in a URI component, keeping the input when the -/// decoded bytes are not UTF-8. -pub(crate) 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() { - 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(|_| value.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 text_document_sync_kind(capabilities: &Value) -> LspTextDocumentSyncKind { - let sync = capabilities.get("textDocumentSync"); - let change = match sync { - Some(Value::Number(value)) => value.as_i64().unwrap_or(1), - Some(Value::Object(value)) => value.get("change").and_then(Value::as_i64).unwrap_or(1), - _ => 1, - }; - match change { - 0 => LspTextDocumentSyncKind::None, - 2 => LspTextDocumentSyncKind::Incremental, - _ => LspTextDocumentSyncKind::Full, - } -} - -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, - "documentRangeFormattingProvider", - "rangeFormatting", - ); - add_capability( - &mut values, - capabilities, - "documentSymbolProvider", - "documentSymbols", - ); - add_capability(&mut values, capabilities, "inlayHintProvider", "inlayHints"); - add_capability( - &mut values, - capabilities, - "foldingRangeProvider", - "foldingRanges", - ); - add_capability( - &mut values, - capabilities, - "documentLinkProvider", - "documentLinks", - ); - add_capability(&mut values, capabilities, "codeLensProvider", "codeLens"); - if capabilities - .get("semanticTokensProvider") - .and_then(semantic_token_legend) - .is_some() - { - insert_unique(&mut values, "semanticTokens"); - } - add_capability( - &mut values, - capabilities, - "workspaceSymbolProvider", - "workspaceSymbols", - ); - 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"); - } - if super::build_provider::negotiated(capabilities) { - insert_unique(&mut values, super::build_provider::BUILD_PROVIDER_FEATURE); - } - if plugin_function::negotiate(capabilities).is_some() { - insert_unique(&mut values, plugin_function::FUNCTIONS_FEATURE); - } - if super::vcs_provider::negotiate(capabilities).is_some() { - insert_unique(&mut values, super::vcs_provider::VCS_PROVIDER_FEATURE); - } - 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 registration.get("method").and_then(Value::as_str) == Some("textDocument/semanticTokens") - { - if let (Some(id), Some(options)) = ( - registration.get("id").and_then(Value::as_str), - registration.get("registerOptions"), - ) { - if semantic_token_legend(options).is_some() { - state - .semantic_token_providers - .insert(id.to_string(), options.clone()); - insert_unique(&mut state.server_capabilities, "semanticTokens"); - } - } - continue; - } - if registration.get("method").and_then(Value::as_str) == Some("textDocument/completion") { - if let (Some(id), Some(options)) = ( - registration.get("id").and_then(Value::as_str), - registration - .get("registerOptions") - .filter(|options| options.is_object()), - ) { - state - .completion_providers - .insert(id.to_string(), options.clone()); - } - } - 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 unregistration.get("method").and_then(Value::as_str) - == Some("textDocument/semanticTokens") - { - if let Some(id) = unregistration.get("id").and_then(Value::as_str) { - state.semantic_token_providers.remove(id); - } - if state.semantic_token_providers.is_empty() { - state - .server_capabilities - .retain(|feature| feature != "semanticTokens"); - } - continue; - } - if unregistration.get("method").and_then(Value::as_str) == Some("textDocument/completion") { - if let Some(id) = unregistration.get("id").and_then(Value::as_str) { - state.completion_providers.remove(id); - } - } - 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); - } - } -} - -/// Completion trigger and all-commit characters the session's providers declared. -/// -/// Returns `{ "triggerCharacters": [...], "allCommitCharacters": [...] }`, each -/// list the de-duplicated union over the static capability and dynamic -/// registrations in registration order, or `None` while the session offers no -/// completion. Entries that are not non-empty strings are dropped. -pub(super) fn completion_options(state: &LspClientState) -> Option { - if !state - .server_capabilities - .iter() - .any(|feature| feature == "completion") - { - return None; - } - let collect = |key: &str| { - let mut values: Vec = Vec::new(); - for options in state.completion_providers.values() { - for value in options - .get(key) - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(Value::as_str) - .filter(|value| !value.is_empty()) - { - insert_unique(&mut values, value); - } - } - values - }; - Some(json!({ - "triggerCharacters": collect("triggerCharacters"), - "allCommitCharacters": collect("allCommitCharacters"), - })) -} - -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/rangeFormatting" => Some("rangeFormatting"), - "textDocument/documentSymbol" => Some("documentSymbols"), - "textDocument/inlayHint" => Some("inlayHints"), - "textDocument/foldingRange" => Some("foldingRanges"), - "textDocument/documentLink" => Some("documentLinks"), - "textDocument/codeLens" => Some("codeLens"), - "textDocument/codeAction" => Some("codeActions"), - "workspace/symbol" => Some("workspaceSymbols"), - "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()); - } -} - -/// A range-only provider cannot serve the full-document operation offered here. -fn semantic_token_legend(options: &Value) -> Option<&Value> { - let full = options.get("full")?; - if full != &Value::Bool(true) && !full.is_object() { - return None; - } - let legend = options.get("legend")?; - if !legend - .get("tokenTypes")? - .as_array()? - .iter() - .all(Value::is_string) - || !legend - .get("tokenModifiers")? - .as_array()? - .iter() - .all(Value::is_string) - { - return None; - } - Some(legend) -} - -/// Decode relative UTF-16 positions once in Core. Both editors consume the -/// same normalized tokens and the exact legend used by this server session. -fn semantic_token_result(legend: Option<&Value>, result: Option<&Value>) -> Option { - let legend = legend?; - let empty = Vec::new(); - let data = match result { - None | Some(Value::Null) => &empty, - Some(value) => value.get("data")?.as_array()?, - }; - if data.len() % 5 != 0 { - return None; - } - let mut tokens = Vec::with_capacity(data.len() / 5); - let (mut line, mut column) = (0_u64, 0_u64); - for chunk in data.chunks_exact(5) { - let numbers: Vec = chunk.iter().map(Value::as_u64).collect::>()?; - if numbers.iter().any(|value| *value > u32::MAX as u64) { - return None; - } - line = line.checked_add(numbers[0])?; - column = if numbers[0] == 0 { - column.checked_add(numbers[1])? - } else { - numbers[1] - }; - if line > u32::MAX as u64 - || column > u32::MAX as u64 - || numbers[2] == 0 - || numbers[3] >= legend["tokenTypes"].as_array()?.len() as u64 - { - return None; - } - tokens.push(json!({"line": line, "startChar": column, "length": numbers[2], "tokenType": numbers[3], "tokenModifiers": numbers[4]})); - } - Some( - json!({"tokenTypes": legend["tokenTypes"], "tokenModifiers": legend["tokenModifiers"], "tokens": tokens}), - ) -} - -#[cfg(test)] -mod semantic_tests { - use super::*; - - fn options() -> Value { - json!({"full": true, "legend": {"tokenTypes": ["class", "method"], "tokenModifiers": ["declaration"]}}) - } - - #[test] - fn semantic_tokens_decode_relative_utf16_positions_and_reject_malformed_data() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../../../fixtures/lsp/semantic-tokens-v1.json" - )) - .unwrap(); - let options = fixture["serverOptions"].clone(); - let legend = semantic_token_legend(&options); - let value = semantic_token_result( - legend, - Some(&json!({"data": [0, 6, 5, 0, 1, 2, 4, 3, 1, 0, 0, 7, 2, 1, 0]})), - ) - .unwrap(); - assert_eq!(value, fixture["expectedResult"]); - assert_eq!(value["tokens"][0]["startChar"], 6); - assert_eq!(value["tokens"][1]["line"], 2); - assert_eq!(value["tokens"][2]["startChar"], 11); - assert_eq!(value["tokenTypes"], json!(["class", "method"])); - assert_eq!( - semantic_token_result(legend, Some(&Value::Null)).unwrap()["tokens"], - json!([]) - ); - for data in [ - json!([0]), - json!([0, 0, 0, 0, 0]), - json!([0, 0, 1, 2, 0]), - json!([-1, 0, 1, 0, 0]), - ] { - assert!(semantic_token_result(legend, Some(&json!({"data": data}))).is_none()); - } - assert!( - semantic_token_legend(&json!({"range": true, "legend": options["legend"]})).is_none() - ); - } - - #[test] - fn semantic_registration_snapshot_survives_unregister_and_refresh_is_acknowledged() { - let mut state = LspClientState::default(); - apply_dynamic_registration( - &mut state, - &json!({"params": {"registrations": [{"id": "semantic", "method": "textDocument/semanticTokens", "registerOptions": options()}]}}), - ); - assert!(state - .server_capabilities - .contains(&"semanticTokens".to_string())); - let id = allocate_request(&mut state, "textDocument/semanticTokens/full"); - apply_dynamic_unregistration( - &mut state, - &json!({"params": {"unregisterations": [{"id": "semantic", "method": "textDocument/semanticTokens"}]}}), - ); - assert!(!state - .server_capabilities - .contains(&"semanticTokens".to_string())); - let response = client_apply_server_message(ClientApplyServerMessageRequest { - state, - message: json!({"jsonrpc": "2.0", "id": id, "result": {"data": [0,6,5,0,1]}}) - .to_string(), - }) - .unwrap(); - assert_eq!( - response.events[0].result.as_ref().unwrap()["tokens"][0]["length"], - 5 - ); - assert!(response.state.pending_semantic_legends.is_empty()); - let refresh = client_apply_server_message(ClientApplyServerMessageRequest { - state: response.state, message: json!({"jsonrpc": "2.0", "id": "refresh", "method": "workspace/semanticTokens/refresh"}).to_string(), - }).unwrap(); - assert_eq!(refresh.events[0].kind, "semanticTokensRefresh"); - assert_eq!(refresh.messages.len(), 1); - assert!(refresh.messages[0].contains("result")); - } -} - -#[cfg(test)] -mod formatting_and_completion_tests { - use super::*; - - fn request(method: &str, options: Option, with_range: bool) -> ClientFeatureRequest { - ClientFeatureRequest { - query: None, - state: LspClientState::default(), - uri: "file:///w/a.c".to_string(), - method: method.to_string(), - position: None, - new_name: None, - range: with_range.then(|| LspRange { - start: LspPosition { - line: 1, - utf16_column: 2, - }, - end: LspPosition { - line: 3, - utf16_column: 0, - }, - }), - diagnostics: Vec::new(), - completion_context: None, - completion_item: None, - code_action: None, - command: None, - formatting_options: options, - include_declaration: None, - } - } - - #[test] - fn formatting_keeps_defaults_without_caller_options() { - let params = - feature_request_params(&request("textDocument/formatting", None, false)).unwrap(); - assert_eq!( - params["options"], - json!({"tabSize": 4, "insertSpaces": true, "trimTrailingWhitespace": true, - "insertFinalNewline": true, "trimFinalNewlines": true}) - ); - } - - #[test] - fn formatting_and_range_formatting_merge_caller_options() { - let options = - json!({"tabSize": 2, "insertSpaces": false, "trimFinalNewlines": false, "custom": "x"}); - let full = feature_request_params(&request( - "textDocument/formatting", - Some(options.clone()), - false, - )) - .unwrap(); - assert_eq!(full["options"]["tabSize"], 2); - assert_eq!(full["options"]["insertSpaces"], false); - assert_eq!(full["options"]["trimFinalNewlines"], false); - assert_eq!(full["options"]["custom"], "x"); - assert_eq!(full["options"]["insertFinalNewline"], true); - let range = feature_request_params(&request( - "textDocument/rangeFormatting", - Some(options), - true, - )) - .unwrap(); - assert_eq!(range["range"]["start"], json!({"line": 1, "character": 2})); - assert_eq!(range["range"]["end"], json!({"line": 3, "character": 0})); - assert_eq!(range["options"]["tabSize"], 2); - assert!( - feature_request_params(&request("textDocument/rangeFormatting", None, false)).is_err() - ); - } - - #[test] - fn invalid_formatting_options_are_rejected() { - for options in [ - json!({"tabSize": 0}), - json!({"tabSize": "4"}), - json!({"insertSpaces": 1}), - json!({"nested": {}}), - json!([1]), - ] { - assert!(feature_request_params(&request( - "textDocument/formatting", - Some(options), - false - )) - .is_err()); - } - } - - #[test] - fn range_formatting_capability_and_result_are_normalized() { - let features = - feature_names_from_capabilities(&json!({"documentRangeFormattingProvider": true})); - assert!(features.contains(&"rangeFormatting".to_string())); - let result = lsp_feature_result_for_method( - Some("textDocument/rangeFormatting"), - Some(&json!([{ - "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 1}}, - "newText": "x" - }])), - ) - .unwrap(); - assert_eq!(result["edits"][0]["newText"], "x"); - } - - #[test] - fn document_symbol_golden_fixture_matches_normalization() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../../../fixtures/lsp/document-symbols-v1.json" - )) - .unwrap(); - for case in fixture["cases"].as_array().unwrap() { - let result = lsp_feature_result_for_method( - Some("textDocument/documentSymbol"), - Some(&case["serverResult"]), - ) - .unwrap(); - assert_eq!(result, case["expected"], "case {}", case["name"]); - } - } - - #[test] - fn document_symbol_capability_request_and_dynamic_name() { - let features = feature_names_from_capabilities(&json!({"documentSymbolProvider": true})); - assert!(features.contains(&"documentSymbols".to_string())); - assert_eq!( - feature_name_for_method("textDocument/documentSymbol"), - Some("documentSymbols") - ); - let mut request = request("textDocument/documentSymbol", None, false); - request.uri = "file:///a.rs".to_string(); - assert_eq!( - feature_request_params(&request).unwrap(), - json!({"textDocument": {"uri": "file:///a.rs"}}) - ); - assert!(validate_lsp_method("textDocument/documentSymbol").is_ok()); - } - - #[test] - fn completion_options_union_static_and_dynamic_providers() { - let mut state = LspClientState::default(); - assert!(completion_options(&state).is_none()); - state.server_capabilities.push("completion".into()); - state.completion_providers.insert( - String::new(), - json!({"triggerCharacters": [".", ":", ""], "allCommitCharacters": [";"]}), - ); - apply_dynamic_registration( - &mut state, - &json!({"params": {"registrations": [{"id": "c1", "method": "textDocument/completion", - "registerOptions": {"triggerCharacters": [":", ">"]}}]}}), - ); - assert_eq!( - completion_options(&state).unwrap(), - json!({"triggerCharacters": [".", ":", ">"], "allCommitCharacters": [";"]}) - ); - apply_dynamic_unregistration( - &mut state, - &json!({"params": {"unregisterations": [{"id": "c1", "method": "textDocument/completion"}]}}), - ); - // Withdrawing the completion registration also withdraws the feature. - assert!(completion_options(&state).is_none()); - assert!(!state.completion_providers.contains_key("c1")); - } - - #[test] - fn completion_result_preserves_is_incomplete() { - let result = lsp_feature_result_for_method( - Some("textDocument/completion"), - Some(&json!({"isIncomplete": true, "items": [{"label": "a"}]})), - ) - .unwrap(); - assert_eq!(result["isIncomplete"], true); - assert_eq!(result["items"][0]["label"], "a"); - } -} diff --git a/rust/crates/core/src/lsp/interface/engine.rs b/rust/crates/core/src/lsp/interface/engine.rs deleted file mode 100644 index 12675c9c..00000000 --- a/rust/crates/core/src/lsp/interface/engine.rs +++ /dev/null @@ -1,11580 +0,0 @@ -//! Stateful language-server sessions coordinating client state and child processes. - -use super::client::{ - client_apply_server_message_for_plugin, client_initialize_for_plugin, json_rpc_error_with_code, - json_rpc_error_with_data, json_rpc_notification, json_rpc_result, -}; -use super::engine_logging::{ - EngineLog, EngineLogInput, SessionLogIdentity, SessionRole, StderrFact, StderrLines, -}; -use super::plugin_server::{self, Admission, Arrival, PluginServerState}; -use super::process::{LspProcessHandle, LspProcessLauncher, LspProcessSpec, SystemProcessLauncher}; -use super::{ - client_change_document, client_close_document, client_feature_request_canonical, - client_open_document, client_shutdown, frame_message, parse_server_messages, - ClientChangeDocumentRequest, ClientCloseDocumentRequest, ClientFeatureRequest, - ClientInitializeRequest, ClientOpenDocumentRequest, ClientShutdownRequest, FrameMessageRequest, - LspClientDiagnostic, LspClientDocument, LspClientState, LspDocumentContentChange, LspPosition, - LspRange, ParseServerMessagesRequest, -}; -use crate::diagnostics::plugin_operations::{ - self as plugin_operations, Attempt, AttemptHandoff, AttemptSlot, Stage as AttemptStage, - LANGUAGE_SERVER_START, -}; -use crate::plugins::language_server_sessions::{ - RemoteSupport, DEFAULT_REQUEST_TIMEOUT_MILLISECONDS, DID_CHANGE_CONFIGURATION, - MAX_REQUEST_TIMEOUT_MILLISECONDS, -}; -use crate::protocol::{CoreError, ErrorCode}; -use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; -use std::collections::{BTreeMap, BTreeSet, VecDeque}; -use std::io::Read; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::{Arc, Condvar, Mutex, MutexGuard, OnceLock}; -use std::thread; -use std::time::{Duration, Instant}; - -// Cold language-server runtimes can spend more than ten seconds loading their -// bundles before answering initialize. This remains one bounded handshake, -// independent of request deadlines, with normal cancellation and disposal. -const DEFAULT_INITIALIZE_TIMEOUT_MS: u64 = 60_000; -const DEFAULT_REQUEST_TIMEOUT_MS: u64 = 30_000; -const DEFAULT_SHUTDOWN_TIMEOUT_MS: u64 = 2_000; -/// Upper bound of one build-provider request. A model request can load a -/// large build, so it may outlive the ordinary request timeout, but never -/// stays pending without a deadline. -const MAX_BUILD_PROVIDER_TIMEOUT_MS: u64 = 60 * 60_000; -/// Upper bound of one VCS-provider request. An update or check-in of a large -/// working copy over a slow link can take long, but never stays pending -/// without a deadline. -const MAX_VCS_PROVIDER_TIMEOUT_MS: u64 = 60 * 60_000; -/// Longest toolchain query a host may ask for; queries only read manager state. -const MAX_TOOLCHAIN_QUERY_TIMEOUT_MS: u64 = 60_000; -/// Bounds one provider request; the host can cancel sooner when a snapshot expires. -const MAX_KEYMAP_IMPORT_TIMEOUT_MS: u64 = 60_000; -const MONITOR_INTERVAL_MS: u64 = 10; -/// Shared plugin diagnostic budget per process session and monotonic minute. -const MAX_PLUGIN_DIAGNOSTICS_PER_MINUTE: u16 = 600; - -static ENGINE: OnceLock = OnceLock::new(); - -#[path = "engine_function_relay.rs"] -mod relay_hub; -use relay_hub::RelayHub; -#[path = "engine_function_provider.rs"] -mod function_provider; -pub use function_provider::function_provider_command; - -#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -/// Observable lifecycle of one managed language-server session. -pub enum LspLifecycleState { - /// Session state exists, but the child process has not started. - Created, - /// The child process and its standard streams are being created. - ProcessStarting, - /// The process is running and the initialize handshake is pending. - Initializing, - /// Initialization completed and semantic requests may be sent. - Ready, - /// A graceful shutdown is pending or the process is being terminated. - Stopping, - /// The session ended normally and will produce no further events. - Stopped, - /// Startup, protocol handling, or the child process failed terminally. - Failed, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -/// Validated process, workspace, initialization, and timeout settings for a server. -pub struct StartServerRequest { - pub provider_id: String, - /// Process role supplied by the host; old hosts retain language-server routing. - #[serde(default)] - pub role: SessionRole, - /// Stable VCS identity supplied explicitly by the provider catalog, never parsed from providerID. - #[serde(default)] - pub log_subject: Option, - /// Plugin that owns the process, for plugin-provided servers and build - /// providers. Only such a session can serve that plugin's functions; - /// host-owned servers leave it out. - #[serde(default)] - pub plugin_id: Option, - /// Declarative `languageServer/start` attempt the host began; Core records its - /// result. Absent for v1 hosts and SDK-owned backend processes. - #[serde(default)] - pub startup_operation: Option, - /// Host services the plugin's permissions grant the session, as the - /// launch plan lists them (`backend.hostServices`). Only a plugin-owned - /// session may name any; Core forwards only these `lithe/host/*` - /// requests to the host. - #[serde(default)] - pub host_services: Vec, - /// Options of the granted host services, such as the icons the host - /// draws for editor actions. Only a plugin-owned session may name any. - #[serde(default)] - pub host_options: super::HostOptions, - /// Provider IDs of the plugin's verified manifest. Only a plugin-owned - /// session with some is offered `litheToolchains`. - #[serde(default)] - pub toolchain_providers: Vec, - /// Shortcut import providers declared by this plugin's verified manifest. - #[serde(default)] - pub keymap_import_providers: Vec, - /// What the plugin backend may do with this plugin-owned language server - /// (plan API-25): the requests it may send, the gates they wait for, and - /// the notifications it receives. Only a plugin-owned session has one; - /// without it the backend cannot reach the server through the host. - #[serde(default)] - pub session_policy: Option, - 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, - /// Settings a plugin-provided server reads through - /// `workspace/configuration`, already expanded from the plugin's - /// `workspaceConfiguration` template. Must be an object when present. - #[serde(default)] - pub workspace_configuration: 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")] -/// Identity and initial lifecycle state of a newly created session. -pub struct StartServerResponse { - /// Catalog-derived channel for host diagnostic I/O; never derived from provider ID by hosts. - pub channel: String, - pub session_id: String, - pub state: LspLifecycleState, - pub process_id: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -/// Request targeting one existing server session. -pub struct SessionRequest { - pub session_id: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -/// Complete document contents or incremental edits to open or update in a session. -pub struct SyncDocumentRequest { - pub session_id: String, - pub uri: String, - pub language_id: String, - /// Full document text. May be omitted when `content_changes` extends a - /// document Core already holds. - #[serde(default)] - pub text: String, - /// Edits in the order they happened, each range relative to the previous result. - #[serde(default)] - pub content_changes: Vec, -} - -/// A host-confirmed disk save, carrying the exact saved text rather than newer edits. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct SaveDocumentRequest { - pub session_id: String, - pub uri: String, - pub text: String, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -/// Core-owned document synchronization state returned after an editor update. -pub struct SyncDocumentResponse { - /// Monotonic version used by all subsequent LSP requests and diagnostics. - pub document_version: i64, - /// `false` when the submitted text was already synchronized. - pub changed: bool, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -/// Workspace file events forwarded through standard LSP watched-file semantics. -pub struct WorkspaceFilesChangedRequest { - pub session_id: String, - pub changes: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -/// One absolute document URI and the filesystem transition observed by a host. -pub struct WorkspaceFileChange { - pub uri: String, - pub kind: WorkspaceFileChangeKind, -} - -#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq)] -#[serde(rename_all = "camelCase")] -/// LSP watched-file transition mapped to protocol event types 1, 2, and 3. -pub enum WorkspaceFileChangeKind { - Created, - Changed, - Deleted, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -/// Request to remove a document from a session's synchronized state. -pub struct CloseDocumentRequest { - pub session_id: String, - pub uri: String, -} - -#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -/// Semantic operation normalized across provider-specific LSP capabilities. -pub enum LspSemanticOperation { - /// `textDocument/completion`. - Completion, - /// `textDocument/hover`. - Hover, - /// `textDocument/definition`. - Definition, - /// `textDocument/declaration`. - Declaration, - /// `textDocument/typeDefinition`. - TypeDefinition, - /// `textDocument/references`. - References, - /// `textDocument/implementation`. - Implementation, - /// `textDocument/rename`. - Rename, - /// `textDocument/formatting`. - Formatting, - /// `textDocument/rangeFormatting` over the request's `range`. - RangeFormatting, - /// `textDocument/documentSymbol`: the document outline. - DocumentSymbols, - /// `textDocument/codeAction`. - CodeActions, - /// `completionItem/resolve` for a previously returned completion item. - ResolveCompletion, - /// `codeAction/resolve` for a previously returned action. - ResolveCodeAction, - /// `workspace/executeCommand` using a server-provided command payload. - ExecuteCommand, - /// `textDocument/inlayHint`. - InlayHints, - /// `textDocument/foldingRange`. - FoldingRanges, - /// `textDocument/documentLink`: clickable ranges such as URLs and file references. - DocumentLinks, - /// `workspace/symbol`: workspace-wide symbol search for `query`; names no document. - WorkspaceSymbols, - /// Full `textDocument/semanticTokens/full` with its negotiated legend. - SemanticTokens, - /// `textDocument/codeLens`. - CodeLens, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -/// Inputs for one asynchronous semantic language-server operation. -pub struct SemanticRequest { - pub session_id: String, - #[serde(default)] - pub operation_id: Option, - pub operation: LspSemanticOperation, - #[serde(default)] - pub uri: Option, - #[serde(default)] - pub position: Option, - #[serde(default)] - pub new_name: Option, - /// Search text for `workspaceSymbols`; absent means the empty query. - #[serde(default)] - pub query: Option, - #[serde(default)] - pub range: Option, - #[serde(default)] - pub diagnostics: Vec, - /// LSP completion trigger context; validated by the shared request mapper. - #[serde(default)] - pub completion_context: Option, - #[serde(default)] - pub completion_item: Option, - #[serde(default)] - pub code_action: Option, - #[serde(default)] - pub command: Option, - /// Optional LSP `FormattingOptions` (`tabSize`, `insertSpaces`, ...) for - /// `formatting` and `rangeFormatting`; unset fields keep Core defaults. - #[serde(default)] - pub formatting_options: Option, - /// For `references`: whether the answer lists the declaration itself - /// (LSP `ReferenceContext.includeDeclaration`). Unset means `true`; the - /// usages shown from a declaration (`lsp.declarationOrUsages`) pass - /// `false` so the language server leaves the declaration out. - #[serde(default)] - pub include_declaration: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -/// One build-provider request, forwarded as `build.modelPlan` or -/// `build.dependencyPlan` planned it. The result arrives as a -/// `requestCompleted` event whose `result.value` is the provider's reply, -/// which the host passes to Core's decoder unchanged. -pub struct BuildProviderRequest { - pub session_id: String, - #[serde(default)] - pub operation_id: Option, - /// `lithe/build/model` or `lithe/build/dependencies`. - pub method: String, - /// The plan's `provider.params`, sent as is. - pub params: Value, - /// Deadline of this request, in milliseconds, capped at - /// `MAX_BUILD_PROVIDER_TIMEOUT_MS`; the session's request timeout when absent. - #[serde(default)] - pub timeout_milliseconds: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -/// One `toolchains/query`, addressed to a session that negotiated the provider -/// named in `params`. The result arrives as a `requestCompleted` event whose -/// `result.value` is `{ id, response }`, the complete untrusted JSON-RPC response, -/// which the host passes to `toolchains.decodeReply`. -pub struct ToolchainProviderRequest { - pub session_id: String, - #[serde(default)] - pub operation_id: Option, - /// Query parameters; `toolchains.decodeReply` validates them with the reply. - pub params: Value, - /// Deadline of this request, capped at `MAX_TOOLCHAIN_QUERY_TIMEOUT_MS`. - #[serde(default)] - pub timeout_milliseconds: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -/// One shortcut import operation against an opaque, host-owned snapshot. -pub struct KeymapImportProviderRequest { - /// Plugin-owned session which negotiated the provider. - pub session_id: String, - /// Optional correlation identity for cancellation and completion events. - #[serde(default)] - pub operation_id: Option, - /// Plugin-local identity of the selected provider. - #[serde(rename = "providerID")] - pub provider_id: String, - /// Host-owned snapshot reference, scoped to this import operation. - #[serde(rename = "snapshotID")] - pub snapshot_id: String, - /// Requested deadline in milliseconds, bounded by the runtime. - #[serde(default)] - pub timeout_milliseconds: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -/// One VCS-provider request. Core plans the method and params from the -/// operation and its arguments, so the host never writes a raw method. The -/// result arrives as a `requestCompleted` event whose `result.value` is the -/// provider's reply, which the host passes to `vcs.decodeReply`; a failure -/// carries a `vcs.*` code. -pub struct VcsProviderRequest { - pub session_id: String, - #[serde(default)] - pub operation_id: Option, - /// The VCS the host launched the session for; a session that negotiated - /// another VCS is refused. - pub vcs: String, - /// Operation name, such as `status` or `checkin`. - pub operation: String, - /// Native absolute path of the working-copy root. - pub root: String, - /// Absolute path of the VCS client the host resolved, when the provider - /// declares one. - #[serde(default)] - pub tool: Option, - /// Operation arguments; see `crate::vcs` for each operation's shape. - #[serde(default)] - pub arguments: Value, - /// Deadline of this request, in milliseconds, capped at - /// `MAX_VCS_PROVIDER_TIMEOUT_MS`; the session's request timeout when absent. - #[serde(default)] - pub timeout_milliseconds: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -/// The host's answer to one `hostRequest` event: the outcome of a host -/// service a plugin backend asked for. Exactly one of `result` and `error`. -pub struct PluginHostResponse { - pub session_id: String, - /// The `hostRequestID` of the `hostRequest` event being answered, - /// spelled as the event spells it. - #[serde(rename = "hostRequestID")] - pub host_request_id: String, - /// The service's result; `null` for a write or delete. - #[serde(default)] - pub result: Option, - /// The host could not perform the service. Its message reaches the - /// backend, bounded. Its code passes only when it is one a host may - /// report (`plugin_function::host_failure`); otherwise it is - /// `plugin.host.failed`. - #[serde(default)] - pub error: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -/// Why the host could not perform a forwarded host-service request. -pub struct PluginHostFailure { - pub message: String, - /// Stable `plugin.host.*` code of the failure. - #[serde(default)] - pub code: Option, - /// Structured facts a backend may act on, such as the current version - /// of a `plugin.host.versionConflict`. It reaches the backend as - /// `error.data.details` when it is an object within - /// `plugin_function::MAX_HOST_DETAILS_BYTES`; otherwise it is dropped and - /// the code and message still arrive. - #[serde(default)] - pub details: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -/// One notification from the host to a plugin backend, such as a -/// subscription event or a changed set of workspace contexts. Core admits -/// only the methods of `plugin_function::HOST_NOTIFICATIONS`. -pub struct PluginHostNotification { - pub session_id: String, - pub method: String, - pub params: Value, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -/// Whether a host notification was written to the backend. `false` means -/// the backend negotiated no plugin-function protocol version. -pub struct PluginHostNotificationResponse { - pub delivered: bool, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -/// One plugin-function invocation. The result arrives as a `requestCompleted` -/// event whose `result.value` is the function's result; failures carry a -/// stable `plugin.function.*` code. Progress arrives as `requestProgress`. -pub struct PluginFunctionRequest { - pub session_id: String, - #[serde(default)] - pub operation_id: Option, - pub function: super::plugin_function::FunctionReference, - /// Serializable function input; must be a JSON object. - #[serde(default = "empty_object")] - pub params: Value, - /// Deadline of this call, in milliseconds, capped at one hour; the - /// session's request timeout when absent. - #[serde(default)] - pub timeout_milliseconds: Option, - /// Workspace context `{ contextID, generation }` the call belongs to. - /// It reaches the backend as `params.context`. - #[serde(default)] - pub context: Option, -} - -fn empty_object() -> Value { - json!({}) -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -/// One request a plugin backend sent to its own language server through the -/// host (plan API-25). The reply arrives as a `requestCompleted` event whose -/// `result.value` is the server's result; failures carry a `plugin.host.*` -/// code as `error.code`. -pub struct PluginServerRequest { - /// The plugin-owned language-server session the host resolved. - pub session_id: String, - #[serde(default)] - pub operation_id: Option, - /// The plugin and server the backend named. Core checks that the - /// session really is that contribution, so a stale or mistaken - /// resolution can never reach another plugin's server. - pub plugin_id: String, - #[serde(rename = "serverID")] - pub server_id: String, - /// An LSP request method the plugin declared in `requestMethods`. - pub method: String, - #[serde(default = "empty_object")] - pub params: Value, - /// Deadline in milliseconds, 60000 when absent and at most 300000. - #[serde(default)] - pub timeout_milliseconds: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -/// One notification a plugin backend sent to its own language server. -pub struct PluginServerNotify { - pub session_id: String, - pub plugin_id: String, - #[serde(rename = "serverID")] - pub server_id: String, - pub method: String, - #[serde(default = "empty_object")] - pub params: Value, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -/// Whether the notification reached the server. `false` means the server -/// was not initialized yet and will receive the stored configuration with -/// its `initialized` notification instead. -pub struct PluginServerNotifyResponse { - pub delivered: bool, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -/// Correlation identifier used to receive or cancel an asynchronous result. -pub struct OperationResponse { - pub operation_id: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -/// Request to cancel a pending operation in one session. -pub struct CancelOperationRequest { - pub session_id: String, - pub operation_id: String, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -/// Ordered events drained from a session since the previous poll. -pub struct PollEventsResponse { - pub events: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -/// Request that waits until queued events exist or the timeout elapses. -pub struct WaitEventsRequest { - pub session_id: String, - /// Upper bound for blocking on the session event channel, in milliseconds. - #[serde(default = "default_wait_events_timeout")] - pub timeout_milliseconds: u64, -} - -fn default_wait_events_timeout() -> u64 { - 30_000 -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -/// Sequenced lifecycle, diagnostic, result, or log event from a server session. -pub struct LspRuntimeEvent { - /// Event discriminator such as `stateChanged`, `requestCompleted`, or `diagnostics`. - #[serde(rename = "type")] - pub kind: String, - /// Core-derived catalog channel, present for structured log facts. - #[serde(skip_serializing_if = "Option::is_none")] - pub channel: Option, - /// Plugin that owns this process; omitted for host-owned sessions. - #[serde(skip_serializing_if = "Option::is_none")] - pub plugin_id: Option, - /// Explicit process role, independent of the provider's opaque ID. - #[serde(skip_serializing_if = "Option::is_none")] - pub role: Option, - /// Stable catalog identity; existing v1 fields remain available. - #[serde(skip_serializing_if = "Option::is_none")] - pub event: Option, - /// Catalog disk fields, validated and redacted by Core. - #[serde(skip_serializing_if = "Option::is_none")] - pub fields: Option>, - /// Canonical catalog severity for v2; independent of the preserved v1 level. - #[serde(skip_serializing_if = "Option::is_none")] - pub catalog_level: Option, - 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>, - /// On `featuresChanged` while `completion` is offered: the session's - /// `{ triggerCharacters, allCommitCharacters }`, from `completionProvider`. - #[serde(skip_serializing_if = "Option::is_none")] - pub completion_options: 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")] -/// Server identity reported by the LSP initialize response. -pub struct LspServerInfo { - pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub version: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -/// Structured runtime failure with session and protocol-stage context. -pub struct LspRuntimeError { - pub code: String, - pub provider_id: String, - pub session_id: String, - /// Lifecycle or protocol phase in which the error occurred. - pub stage: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub method: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub document_uri: 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, - /// Plugin-defined code of a failed plugin function (`error.data.code`), - /// passed through for the plugin's own UI; Lithe never branches on it. - #[serde(skip_serializing_if = "Option::is_none")] - pub plugin_error_code: Option, - /// Structured facts of a failed plugin-server request, such as the - /// server's own error `code` and `message`, passed to the backend as the - /// failure's details. Only `lsp.pluginServerRequest` sets it. - #[serde(skip_serializing_if = "Option::is_none")] - pub details: Option, -} - -#[cfg(test)] -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct EngineSnapshot { - pub session_id: String, - pub provider_id: String, - /// The workspace this session serves. Replacing a workspace means stopping - /// the session bound to the old root, so callers need to be able to tell - /// which root a session belongs to. - pub root_uri: 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)] -/// Response handling path required by one pending JSON-RPC request. -enum PendingKind { - /// Initial handshake whose result transitions the session to ready. - Initialize, - /// Ordinary semantic feature whose result becomes an operation event. - Feature, - /// Request a plugin backend sent to its own language server through - /// `lsp.pluginServerRequest`; the answer reaches the backend verbatim. - PluginServer, - /// Shutdown handshake after which the engine sends `exit`. - Shutdown, -} - -#[derive(Debug, Clone)] -struct PendingRequest { - kind: PendingKind, - operation_id: Option, - method: String, - document_uri: Option, - /// Document version observed when a presentation request was allocated. - document_version: Option, - created_at: Instant, - deadline: Instant, -} - -/// Links one host operation to the exact backend request and function it owns. -struct PendingFunctionCall { - function_id: String, - /// Also serves as the opaque wire operation ID; request IDs never repeat in a session. - request_id: String, -} - -struct SessionState { - lifecycle: LspLifecycleState, - client: LspClientState, - pending: BTreeMap, - request_by_operation: BTreeMap, - /// Exact function invocation owned by each active host operation. Every - /// terminal path removes its entry before that host operation ID can be reused. - function_by_operation: BTreeMap, - /// Symbolic-link facts about the open documents, rebuilt lazily from - /// `client.open_documents`. - linked_document_paths: LinkedDocumentPaths, - /// Latest workspace event per URI observed before protocol initialization. - /// The engine owns and drains this queue exactly once after `initialized`. - pending_workspace_file_changes: BTreeMap, - events: VecDeque, - next_sequence: u64, - initialize_deadline: Option, - shutdown_deadline: Option, - request_timeout: Duration, - shutdown_timeout: Duration, - terminal_event_emitted: bool, - /// Deadline cancellations awaiting the outbound worker, never written by the monitor. - deadline_cancellations: Vec, - /// Bounds an outbound maintenance write independently of caller deadlines. - maintenance_write_deadline: Option, - /// Policy and waiting requests of a plugin-owned language server; `None` - /// for every other session. - plugin_server: Option, - /// Bounds one write the function relay's lane makes to this session, so a - /// backend that stops reading its stdin cannot hold the lane forever. - relay_write_deadline: Option, - /// Backend requests for a host service awaiting `lsp.hostResponse`, by - /// the opaque ID the host sees, with the backend's own JSON-RPC ID. - host_requests: BTreeMap, - next_host_request_id: u64, - /// Trace is opt-in and serialized with protocol writes through outbound_order. - detailed_diagnostics: bool, - /// Desired trace setting retained until the initialize handshake permits notifications. - requested_diagnostics: bool, - /// Malformed plugin diagnostics are summarized once per process session. - rejected_plugin_diagnostics: bool, - /// Sender-side ingress budget uses the existing process clock, never a timer. - plugin_diagnostics_window: Instant, - plugin_diagnostics_count: u16, -} - -struct RuntimeSession { - /// Declarative plugin start attempt shared with `start_server`. - startup_attempt: AttemptSlot, - id: String, - provider_id: String, - /// Plugin that owns the process, if any; see `StartServerRequest::plugin_id`. - plugin_id: Option, - log_identity: SessionLogIdentity, - /// Fixed workspace host, inherited by protocol workers; never a global selection. - workspace_host: Option, - /// Plugin-declared settings answered to `workspace/configuration`. - /// Replaced as a whole by `lsp.updateWorkspaceConfiguration`; never held - /// across another lock. - plugin_configuration: Mutex>>, - /// Workspace URI the server was initialized with; scopes indexing - /// exclusions and other workspace-wide checks to this session's root. - root_uri: String, - /// Serializes policy eligibility with document/file-event publication and - /// policy withdrawal. Acquire before outbound_order; Git scans stay outside - /// the outbound and registry locks. - indexing_roots: Mutex>, - indexing_revision: std::sync::atomic::AtomicU64, - /// Serializes protocol-state commits with complete outbound message batches. - /// Callers acquire this before `state` whenever an action can write to the - /// server, preserving the same order in memory and on the wire. - outbound_order: Mutex<()>, - state: Mutex, - event_signal: Condvar, - process: Arc, - active: AtomicBool, - /// The engine's cross-plugin function relay; see `engine_function_relay`. - relay: Arc, -} - -/// The engine is a process-owning singleton in production, but it holds no -/// global state of its own beyond the session registry, so tests construct -/// private instances with a scripted launcher. -pub(super) struct LspEngine { - next_session_id: AtomicU64, - next_operation_id: AtomicU64, - /// Shared with the relay's worker, which reaches sessions by ID. - sessions: Arc>>>, - /// Cross-plugin function calls; every session of this engine uses it. - relay: Arc, - /// Failed wire starts have no returned identity. Retain their cleanup - /// responsibility here and retry disposal before another process starts. - failed_starts: Mutex>, - launcher: Arc, -} - -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 -} - -/// Rejects plugin configuration that is not a JSON object, the only shape -/// `workspace/configuration` sections can be looked up in. -fn checked_workspace_configuration( - configuration: Option, -) -> Result, CoreError> { - match configuration { - None | Some(Value::Null) => Ok(None), - Some(configuration @ Value::Object(_)) => Ok(Some(configuration)), - Some(_) => Err(CoreError::new( - ErrorCode::InvalidRequest, - "workspaceConfiguration must be a JSON object.", - )), - } -} - -/// Answers one `workspace/configuration` item from plugin configuration. -/// -/// A section names a dotted path (`python.analysis`); an exact top-level key -/// wins over the path so servers that use dotted keys still match. A missing -/// section answers `null`, which tells the server to use its own default. -fn configuration_section(configuration: &Value, section: Option<&str>) -> Value { - let Some(section) = section.filter(|section| !section.is_empty()) else { - return configuration.clone(); - }; - if let Some(value) = configuration.get(section) { - return value.clone(); - } - section - .split('.') - .try_fold(configuration, |value, key| value.get(key)) - .cloned() - .unwrap_or(Value::Null) -} - -/// `workspace/didChangeConfiguration` carrying the whole plugin configuration. -fn plugin_configuration_notification(configuration: &Value) -> String { - json!({ - "jsonrpc": "2.0", - "method": "workspace/didChangeConfiguration", - "params": { "settings": configuration } - }) - .to_string() -} - -/// Replaces the plugin configuration of a running plugin-provided session. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct UpdateWorkspaceConfigurationRequest { - pub session_id: String, - pub configuration: Value, -} - -/// Whether the change reached the server now or waits for `initialized`. -#[derive(Debug, Serialize, Eq, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct UpdateWorkspaceConfigurationResponse { - pub sent: bool, -} - -/// Applies a plugin setting with `effect: immediate` without a restart. -/// -/// The stored configuration answers later `workspace/configuration` requests; -/// an initialized server is also told through `workspace/didChangeConfiguration` -/// so servers that pull configuration only on that signal refresh too. -pub fn update_workspace_configuration( - request: UpdateWorkspaceConfigurationRequest, -) -> Result { - let configuration = - checked_workspace_configuration(Some(request.configuration))?.unwrap_or_else(|| json!({})); - engine() - .session(&request.session_id)? - .update_plugin_configuration(configuration) -} - -/// Withdraw excluded working copies and update supported provider import configuration. -pub(crate) fn indexing_changed(root: &Path) -> Result<(), CoreError> { - let sessions = engine() - .sessions - .lock() - .map_err(|_| CoreError::new(ErrorCode::Unknown, "LSP registry lock was poisoned"))? - .values() - .cloned() - .collect::>(); - apply_indexing_to_sessions(root, sessions) -} - -fn apply_indexing_to_sessions( - root: &Path, - sessions: Vec>, -) -> Result<(), CoreError> { - let root_uri = url::Url::from_directory_path(root).ok(); - let canonical_root = root_uri - .as_ref() - .and_then(|uri| canonical_workspace_root(uri.as_str())); - let sessions = sessions - .into_iter() - .filter(|session| { - canonical_workspace_root(&session.root_uri) == canonical_root - && session.active.load(Ordering::Acquire) - }) - .collect::>(); - if sessions.is_empty() { - return Ok(()); - } - let prepared = crate::project::indexing::prepare_provider_exclusions(root)?; - let roots = crate::project::indexing::excluded_roots(root); - let revision = prepared.as_ref().map_or(0, |policy| policy.revision); - if let Some(policy) = &prepared { - policy.check()?; - } - for session in sessions { - if let Some(policy) = &prepared { - policy.check()?; - } - if ensure_not_terminal(session.lock_state()?.lifecycle).is_err() { - continue; - } - if canonical_workspace_root(&session.root_uri) != canonical_root { - continue; - } - let mut previous = session.lock_indexing_roots()?; - if *previous == roots && session.indexing_revision.load(Ordering::Acquire) == revision { - continue; - } - let documents = session - .lock_state()? - .client - .open_documents - .keys() - .cloned() - .collect::>(); - for uri in documents { - if crate::project::indexing::uri_is_excluded(&session.root_uri, &uri)? { - session.close_document(&uri)?; - } - } - let diagnostic_uris = session - .lock_state()? - .client - .diagnostics - .keys() - .cloned() - .collect::>(); - for uri in diagnostic_uris { - if crate::project::indexing::uri_is_excluded(&session.root_uri, &uri)? { - let mut state = session.lock_state()?; - state.client.diagnostics.remove(&uri); - state.client.diagnostic_versions.remove(&uri); - push_diagnostics_event(&session, &mut state, &uri, None, Vec::new()); - } - } - let pending_uris = session - .lock_state()? - .pending_workspace_file_changes - .keys() - .cloned() - .collect::>(); - for uri in pending_uris { - if crate::project::indexing::uri_is_excluded(&session.root_uri, &uri)? { - session - .lock_state()? - .pending_workspace_file_changes - .remove(&uri); - } - } - // Exclusions are folded into the configuration answered through - // `workspace/configuration`, so an initialized server is told to pull - // the changed sections again instead of keeping its old import scope. - { - let order = session.lock_outbound_order()?; - if session.lock_state()?.client.initialized { - if let Some(configuration) = session.plugin_configuration() { - session.send_messages_or_fail( - &order, - vec![plugin_configuration_notification(&configuration)], - "indexingChanged", - )?; - } - } - } - *previous = roots.clone(); - session.indexing_revision.store(revision, Ordering::Release); - } - Ok(()) -} - -/// Starts a managed language-server process and begins LSP initialization. -pub fn start_server(request: StartServerRequest) -> Result { - engine().start_server(request) -} - -/// Native host ownership hook; it does not add an application command. -pub fn start_server_owned( - request: StartServerRequest, - registered: impl FnOnce(&str), -) -> Result { - engine().start_server_owned(request, registered) -} - -/// Performs a bounded graceful shutdown while retaining the session record. -pub fn stop_server(request: SessionRequest) -> Result<(), CoreError> { - engine().session(&request.session_id)?.stop() -} - -impl RuntimeSession { - /// Plugin configuration the server currently sees, if any. - fn plugin_configuration(&self) -> Option> { - let base = self - .plugin_configuration - .lock() - .map(|configuration| configuration.clone()) - .unwrap_or_else(|poisoned| poisoned.into_inner().clone()); - crate::lsp::languages::indexing::configuration( - &self.provider_id, - &self.root_uri, - base.as_deref(), - ) - .map(Arc::new) - } - - fn update_plugin_configuration( - &self, - configuration: Value, - ) -> Result { - if self.plugin_id.is_none() { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Only plugin-provided language servers accept plugin configuration.", - )); - } - let configuration = Arc::new(configuration); - let outbound_order = self.lock_outbound_order()?; - let initialized = { - let state = self.lock_state()?; - if matches!( - state.lifecycle, - LspLifecycleState::Stopping - | LspLifecycleState::Stopped - | LspLifecycleState::Failed - ) { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "The language session is no longer running.", - )); - } - // Under `outbound_order`, an uninitialized client sends the stored - // configuration with its `initialized` notification instead. - state.client.initialized - }; - *self - .plugin_configuration - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(configuration.clone()); - let messages = if initialized { - vec![plugin_configuration_notification( - self.plugin_configuration() - .as_deref() - .unwrap_or(&configuration), - )] - } else { - Vec::new() - }; - self.send_messages_or_fail(&outbound_order, messages, "workspaceConfigurationUpdate")?; - Ok(UpdateWorkspaceConfigurationResponse { sent: initialized }) - } -} - -/// Opens or replaces the synchronized contents of one document. -pub fn sync_document(request: SyncDocumentRequest) -> Result { - engine() - .session(&request.session_id)? - .sync_document(request) -} - -/// Notifies an interested server after a successful native save. Returns false -/// for unopened documents, unsupported saves, or a save superseded by new edits. -pub fn save_document(request: SaveDocumentRequest) -> Result { - engine() - .session(&request.session_id)? - .save_document(request) -} - -/// Forwards external source and build-configuration changes without restarting -/// the server or invalidating its durable workspace state. -pub fn workspace_files_changed(request: WorkspaceFilesChangedRequest) -> Result<(), CoreError> { - engine() - .session(&request.session_id)? - .workspace_files_changed(request.changes) -} - -/// Notifies the server that a synchronized document has closed. -pub fn close_document(request: CloseDocumentRequest) -> Result<(), CoreError> { - engine() - .session(&request.session_id)? - .close_document(&request.uri) -} - -/// Queues a semantic request and returns its operation identifier immediately. -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 }) -} - -/// Sends one build-provider request to a session that negotiated the protocol. -pub fn build_provider_request( - request: BuildProviderRequest, -) -> Result { - let operation_id = request - .operation_id - .clone() - .unwrap_or_else(|| engine().next_operation_id()); - engine() - .session(&request.session_id)? - .build_request(request, operation_id.clone())?; - Ok(OperationResponse { operation_id }) -} - -/// Sends one `toolchains/query` to a session that negotiated its provider. -pub fn toolchain_provider_request( - request: ToolchainProviderRequest, -) -> Result { - let operation_id = request - .operation_id - .clone() - .unwrap_or_else(|| engine().next_operation_id()); - engine() - .session(&request.session_id)? - .toolchain_request(request, operation_id.clone())?; - Ok(OperationResponse { operation_id }) -} - -/// Sends a shortcut import request only to a negotiated plugin provider. -pub fn keymap_import_provider_request( - request: KeymapImportProviderRequest, -) -> Result { - let operation_id = request - .operation_id - .clone() - .unwrap_or_else(|| engine().next_operation_id()); - engine() - .session(&request.session_id)? - .keymap_import_request(request, operation_id.clone())?; - Ok(OperationResponse { operation_id }) -} - -/// Sends one VCS-provider request to a session that negotiated the protocol. -pub fn vcs_provider_request(request: VcsProviderRequest) -> Result { - let operation_id = request - .operation_id - .clone() - .unwrap_or_else(|| engine().next_operation_id()); - engine() - .session(&request.session_id)? - .vcs_request(request, operation_id.clone())?; - Ok(OperationResponse { operation_id }) -} - -/// Answers one `hostRequest` event of a plugin backend with the outcome of -/// the host service it asked for. -pub fn plugin_host_response(request: PluginHostResponse) -> Result<(), CoreError> { - engine() - .session(&request.session_id)? - .host_response(request) -} - -/// Sends one host notification to a plugin backend. -pub fn plugin_host_notify( - request: PluginHostNotification, -) -> Result { - let delivered = engine() - .session(&request.session_id)? - .host_notify(request)?; - Ok(PluginHostNotificationResponse { delivered }) -} - -/// Invokes one plugin function on a session of the function's plugin. -pub fn plugin_function_request( - request: PluginFunctionRequest, -) -> Result { - let operation_id = request - .operation_id - .clone() - .unwrap_or_else(|| engine().next_operation_id()); - engine() - .session(&request.session_id)? - .function_request(request, operation_id.clone(), None)?; - Ok(OperationResponse { operation_id }) -} - -/// Sends one request of a plugin backend to the plugin's own language server. -pub fn plugin_server_request(request: PluginServerRequest) -> Result { - let operation_id = request - .operation_id - .clone() - .unwrap_or_else(|| engine().next_operation_id()); - engine() - .session(&request.session_id)? - .plugin_server_request(request, operation_id.clone())?; - Ok(OperationResponse { operation_id }) -} - -/// Sends one notification of a plugin backend to the plugin's own language server. -pub fn plugin_server_notify( - request: PluginServerNotify, -) -> Result { - let delivered = engine() - .session(&request.session_id)? - .plugin_server_notify(request)?; - Ok(PluginServerNotifyResponse { delivered }) -} - -/// Replaces the host's view of the enabled plugins and live workspace -/// contexts that the cross-plugin function relay checks calls against. Calls -/// in flight that the new view forbids end with an error. -pub fn configure_function_relay( - snapshot: super::function_relay::RelaySnapshot, -) -> Result<(), CoreError> { - engine().relay.configure(snapshot) -} - -/// Cancels a pending semantic request in both client state and the server. -pub fn cancel_operation(request: CancelOperationRequest) -> Result<(), CoreError> { - engine() - .session(&request.session_id)? - .cancel_operation(&request.operation_id) -} - -/// Host-owned toggle for one session's upstream trace diagnostics. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct SetDetailedDiagnosticsRequest { - /// Opaque session returned by startServer. - pub session_id: String, - /// True sends verbose tracing, false sends off; default sessions start disabled. - pub enabled: bool, -} - -/// Updates the trace gate only after the corresponding ordered write succeeds. -pub fn set_detailed_diagnostics(request: SetDetailedDiagnosticsRequest) -> Result<(), CoreError> { - engine() - .session(&request.session_id)? - .set_detailed_diagnostics(request.enabled) -} - -/// Drains all currently queued events in deterministic sequence order. -pub fn poll_events(request: SessionRequest) -> Result { - let events = engine().session(&request.session_id)?.poll_events()?; - Ok(PollEventsResponse { events }) -} - -/// Waits until queued events exist or the supplied timeout elapses. -pub fn wait_events(request: WaitEventsRequest) -> Result { - let events = engine() - .session(&request.session_id)? - .wait_events(Duration::from_millis(request.timeout_milliseconds))?; - Ok(PollEventsResponse { events }) -} - -/// Stops a session if necessary and removes all state owned by it. -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::with_launcher(Arc::new(SystemProcessLauncher)) - } - - fn with_launcher(launcher: Arc) -> Self { - let sessions = Arc::new(Mutex::new(BTreeMap::new())); - Self { - next_session_id: AtomicU64::new(1), - next_operation_id: AtomicU64::new(1), - relay: RelayHub::start(&sessions), - sessions, - failed_starts: Mutex::new(BTreeSet::new()), - launcher, - } - } - - 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 { - let mut registered = None; - let result = self.start_server_owned(request, |id| registered = Some(id.to_owned())); - if result.is_err() { - if let Some(id) = registered { - if self.lock_sessions()?.contains_key(&id) { - self.lock_failed_starts()?.insert(id); - // Preserve the original startup error. Incomplete cleanup - // stays owned; an explicit startup retry reaps it before - // any replacement process can be launched. - // log-exempt: Same failure already recorded: this cleanup follows the startup failure returned below and traced by this request's Core failure; residue stays in failed_starts and the next start reports ProcessFailed. - let _ = self.cleanup_failed_start(); - } - } - } - result - } - - fn lock_failed_starts(&self) -> Result>, CoreError> { - self.failed_starts.lock().map_err(|_| { - CoreError::new( - ErrorCode::Unknown, - "Failed startup cleanup lock was poisoned.", - ) - }) - } - - fn cleanup_failed_start(&self) -> Result<(), CoreError> { - // Serialize cleanup attempts, but never hold the session registry or - // protocol-state lock across process disposal. Each retry owns at most - // one two-second disposal; a backlog must not make one call unbounded. - let mut failed = self.lock_failed_starts()?; - if let Some(id) = failed.first().cloned() { - if self.lock_sessions()?.contains_key(&id) { - self.destroy(&id)?; - } - failed.remove(&id); - } - if !failed.is_empty() { - return Err(CoreError::new( - ErrorCode::ProcessFailed, - "Previous language-server startup cleanup is still pending. Retry startup to continue cleanup.", - )); - } - Ok(()) - } - - fn start_server_owned( - &self, - request: StartServerRequest, - registered: impl FnOnce(&str), - ) -> Result { - let attempt = startup_attempt(&request); - let result = self.start_server_attempt(request, &attempt, registered); - // A session that already reached a terminal result took the attempt; - // otherwise the process never started. Cancellation is not a result. - match &result { - Err(error) if !matches!(error.code, ErrorCode::Cancelled) => { - plugin_operations::finish(&attempt, Some(AttemptStage::Launch)); - } - Err(_) => plugin_operations::abandon(&attempt), - Ok(_) => {} - } - result - } - - fn start_server_attempt( - &self, - request: StartServerRequest, - attempt: &AttemptSlot, - registered: impl FnOnce(&str), - ) -> Result { - validate_start_request(&request)?; - self.cleanup_failed_start()?; - if crate::remote::workspace::is_remote() - && request - .session_policy - .as_ref() - .is_some_and(|policy| policy.remote == RemoteSupport::Unsupported) - { - return Err(CoreError::new( - ErrorCode::NotSupported, - "The plugin's language server does not support remote workspaces", - )); - } - // Initial provider settings must include native Git decisions even when - // no session or inventory warm-up has run. Never scan under registry locks. - let indexing_root = canonical_workspace_path(&request.root_uri); - let indexing_context = if let Some(root) = &indexing_root { - crate::project::indexing::prepare_provider_exclusions(root)? - } else { - None - }; - let check_indexing = || { - if let Some(context) = &indexing_context { - return context.check(); - } - if indexing_root.as_ref().is_some_and(|root| { - crate::project::indexing::active_provider_context(root).is_some() - }) { - return Err(CoreError::new( - ErrorCode::Cancelled, - "Indexing policy changed", - )); - } - Ok(()) - }; - let session_id = format!( - "lsp-session-{}", - self.next_session_id.fetch_add(1, Ordering::Relaxed) - ); - let workspace_root = PathBuf::from(&request.working_directory); - 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 workspace_configuration = - checked_workspace_configuration(request.workspace_configuration)?; - let initialize = client_initialize_for_plugin( - ClientInitializeRequest { - state: LspClientState { - offered_toolchain_providers: if request.plugin_id.is_some() { - request.toolchain_providers.iter().cloned().collect() - } else { - Default::default() - }, - offered_keymap_import_providers: if request.plugin_id.is_some() { - request.keymap_import_providers.iter().cloned().collect() - } else { - Default::default() - }, - ..LspClientState::default() - }, - root_uri: request.root_uri.clone(), - process_id: (!crate::remote::workspace::is_remote()) - .then_some(std::process::id() as i64), - initialization_options: request.initialization_options, - }, - request.plugin_id.is_some(), - )?; - let process = self.launcher.launch(LspProcessSpec { - executable: PathBuf::from(&request.executable_path), - arguments: request.arguments, - working_directory: workspace_root, - environment: request.environment, - })?; - - let request_id = (initialize.state.next_request_id - 1).to_string(); - let now = Instant::now(); - let process_id = process.handle.process_id(); - let plugin_server = request.session_policy.map(PluginServerState::new); - let session = Arc::new(RuntimeSession { - startup_attempt: Arc::clone(attempt), - id: session_id.clone(), - log_identity: SessionLogIdentity::new( - request.role, - &request.provider_id, - request.plugin_id.as_deref(), - request.log_subject.as_deref(), - ), - provider_id: request.provider_id, - plugin_id: request.plugin_id, - workspace_host: crate::remote::workspace::current(), - plugin_configuration: Mutex::new(workspace_configuration.map(Arc::new)), - root_uri: request.root_uri, - indexing_roots: Mutex::default(), - indexing_revision: std::sync::atomic::AtomicU64::new(0), - outbound_order: Mutex::new(()), - state: Mutex::new(SessionState { - lifecycle: LspLifecycleState::Created, - client: LspClientState { - host_services: request.host_services.into_iter().collect(), - host_options: request.host_options, - ..initialize.state - }, - pending: BTreeMap::from([( - request_id, - PendingRequest { - kind: PendingKind::Initialize, - operation_id: None, - method: "initialize".to_string(), - document_uri: None, - document_version: None, - created_at: now, - deadline: now + initialize_timeout, - }, - )]), - request_by_operation: BTreeMap::new(), - function_by_operation: BTreeMap::new(), - linked_document_paths: LinkedDocumentPaths::default(), - pending_workspace_file_changes: 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, - deadline_cancellations: Vec::new(), - maintenance_write_deadline: None, - plugin_server, - relay_write_deadline: None, - host_requests: BTreeMap::new(), - next_host_request_id: 1, - detailed_diagnostics: false, - requested_diagnostics: false, - rejected_plugin_diagnostics: false, - plugin_diagnostics_window: Instant::now(), - plugin_diagnostics_count: 0, - }), - event_signal: Condvar::new(), - process: process.handle, - active: AtomicBool::new(true), - relay: Arc::clone(&self.relay), - }); - session.transition(LspLifecycleState::Created, None)?; - session.transition(LspLifecycleState::ProcessStarting, None)?; - session.transition(LspLifecycleState::Initializing, None)?; - session.log( - "info", - EngineLog::ProcessStarted, - "Language server process started", - None, - ); - - // Register before checking the prepared generation. A policy change - // either retires that generation or sees this session for its update. - self.lock_sessions()? - .insert(session_id.clone(), session.clone()); - if let Err(error) = check_indexing() { - // A retired start is cancelled, not a handshake failure. Abandon - // the attempt before `fail` or the process exit can record it. - plugin_operations::abandon(attempt); - session.fail( - "indexingChanged", - "initialize", - "Indexing policy changed during language-server startup.", - Some(core_error_detail(&error)), - None, - ); - session.active.store(false, Ordering::Release); - session.kill_process(); - // No native identity has been published yet. Forget the session - // only after disposal proves that its process tree has exited. - if session.process.dispose(Duration::from_secs(2)).is_ok() { - self.lock_sessions()?.remove(&session_id); - } else { - registered(&session_id); - } - return Err(error); - } - // Native owners learn the identity before initialize writes can fail. - // They keep their package barrier until destroy verifies disposal. - registered(&session_id); - session.spawn_readers(process.output, process.errors); - session.spawn_monitor(); - session.spawn_outbound_maintenance(); - let outbound_order = session.lock_outbound_order()?; - if let Err(error) = session.send_messages(&outbound_order, initialize.messages) { - // startServer fails here, so the attempt failed to launch. - plugin_operations::finish(attempt, Some(AttemptStage::Launch)); - session.fail( - "transportFailed", - "initialize", - "Could not write the initialize request.", - Some(core_error_detail(&error)), - None, - ); - session.kill_process(); - return Err(error); - } - - if let Err(error) = check_indexing() { - // The readers already run; abandon before `fail` and the kill so - // neither the failure nor the observed exit records the cancel. - plugin_operations::abandon(attempt); - session.fail( - "indexingChanged", - "initialize", - "Indexing policy changed during language-server startup.", - Some(core_error_detail(&error)), - None, - ); - session.active.store(false, Ordering::Release); - session.kill_process(); - // The native owner already holds this identity. Keep it until - // destroy verifies disposal and returns a positive receipt. - return Err(error); - } - - Ok(StartServerResponse { - channel: session.log_identity.channel(), - session_id, - state: LspLifecycleState::Initializing, - process_id, - }) - } - - 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.", - )); - } - session.process.dispose(Duration::from_secs(2))?; - 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 save_document(&self, request: SaveDocumentRequest) -> Result { - let outbound_order = self.lock_outbound_order()?; - let message = { - let state = self.lock_state()?; - ensure_not_terminal(state.lifecycle)?; - if state.lifecycle != LspLifecycleState::Ready { - return Ok(false); - } - let Some(include_text) = state.client.text_document_save_include_text else { - return Ok(false); - }; - let Some(document) = state.client.open_documents.get(&request.uri) else { - return Ok(false); - }; - // A server may reload its working copy from the saved text. A - // delayed save must not discard a newer editor revision already - // synchronized. - if *document.text != request.text { - return Ok(false); - } - let mut params = json!({"textDocument": {"uri": request.uri}}); - if include_text { - params["text"] = json!(request.text); - } - json!({"jsonrpc": "2.0", "method": "textDocument/didSave", "params": params}) - .to_string() - }; - self.send_messages_or_fail(&outbound_order, vec![message], "saveDocument")?; - Ok(true) - } - - fn workspace_files_changed(&self, changes: Vec) -> Result<(), CoreError> { - if changes.is_empty() { - return Ok(()); - } - let _indexing_order = self.lock_indexing_roots()?; - let mut normalized = BTreeMap::new(); - for change in changes { - if crate::project::indexing::uri_is_excluded(&self.root_uri, &change.uri)? { - continue; - } - if !change.uri.contains("://") || change.uri.contains('\0') { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Workspace file changes require absolute document URIs.", - ) - .with_details(change.uri)); - } - // The last event for a URI represents its final filesystem state - // and prevents a watcher burst from causing redundant imports. - normalized.insert(change.uri, change.kind); - } - if normalized.is_empty() { - return Ok(()); - } - let outbound_order = self.lock_outbound_order()?; - let message = { - let mut state = self.lock_state()?; - ensure_not_terminal(state.lifecycle)?; - if !state.client.initialized { - state.pending_workspace_file_changes.extend(normalized); - return Ok(()); - } - workspace_file_changes_notification(normalized) - }; - self.send_messages_or_fail(&outbound_order, vec![message], "workspaceFilesChanged") - } - - fn sync_document( - &self, - request: SyncDocumentRequest, - ) -> Result { - self.sync_document_after_policy_check(request, || {}) - } - - fn sync_document_after_policy_check( - &self, - request: SyncDocumentRequest, - after_policy_check: impl FnOnce(), - ) -> Result { - let _indexing_order = self.lock_indexing_roots()?; - if crate::project::indexing::uri_is_excluded(&self.root_uri, &request.uri)? { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Document is excluded from indexing", - )); - } - after_policy_check(); - let uri = request.uri; - let mut messages = Vec::new(); - let mut stale_cancellations = Vec::new(); - let document_version; - let outbound_order = self.lock_outbound_order()?; - { - let mut state = self.lock_state()?; - ensure_not_terminal(state.lifecycle)?; - if let Some(document) = state.client.open_documents.get(&uri) { - // Hosts commonly publish both a ranged edit and the resulting - // full text. Repeating that full text must not create another - // server reconciliation cycle or advance the semantic version. - if request.content_changes.is_empty() && *document.text == *request.text { - return Ok(SyncDocumentResponse { - document_version: document.version.max(1), - changed: false, - }); - } - } - if state.client.initialized { - 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, - content_changes: request.content_changes, - })? - } else { - let text = unopened_document_text( - state.client.open_documents.get(&uri), - request.text, - &request.content_changes, - )?; - client_open_document(ClientOpenDocumentRequest { - state: state.client.clone(), - uri: uri.clone(), - language_id: request.language_id, - text, - })? - }; - state.client = response.state; - messages = response.messages; - document_version = state - .client - .open_documents - .get(&uri) - .map(|document| document.version) - .unwrap_or_default(); - stale_cancellations = - cancel_stale_document_requests_locked(self, &mut state, &uri, document_version); - } 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. - let text = unopened_document_text( - state.client.open_documents.get(&uri), - request.text, - &request.content_changes, - )?; - 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: text.into(), - }, - ); - // Version zero remains an internal "not opened on the wire" - // sentinel. Consumers receive version one, which is the exact - // version the latest queued text will have after initialize. - document_version = 1; - } - } - messages.extend(stale_cancellations); - self.send_messages_or_fail(&outbound_order, messages, "documentSync")?; - Ok(SyncDocumentResponse { - document_version, - changed: true, - }) - } - - fn close_document(&self, uri: &str) -> Result<(), CoreError> { - let mut messages = Vec::new(); - let cleared; - let outbound_order = self.lock_outbound_order()?; - { - 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(&outbound_order, messages, "documentClose") - } - - fn request(&self, request: SemanticRequest, operation_id: String) -> Result<(), CoreError> { - let document_version = request.uri.as_deref().and_then(|uri| { - self.lock_state().ok().and_then(|state| { - state - .client - .open_documents - .get(uri) - .map(|document| document.version.max(1)) - }) - }); - self.request_with_kind( - request, - operation_id, - PendingKind::Feature, - document_version, - ) - } - - /// Writes a build-provider request. It needs no open document, so it is - /// not a semantic feature request; only the negotiated protocol's methods - /// are accepted, so the host cannot use it as a generic JSON-RPC tunnel. - fn build_request( - &self, - request: BuildProviderRequest, - operation_id: String, - ) -> Result<(), CoreError> { - if !super::build_provider::is_build_method(&request.method) { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "This is not a build-provider request.", - ) - .with_details(request.method)); - } - let outbound_order = self.lock_outbound_order()?; - 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.", - )); - } - if !state - .client - .server_capabilities - .iter() - .any(|feature| feature == super::build_provider::BUILD_PROVIDER_FEATURE) - { - return Err(CoreError::new( - ErrorCode::NotSupported, - "The process did not agree on a build-provider protocol version.", - ) - .with_details(super::build_provider::BUILD_PROVIDER_FEATURE)); - } - if request.params.get("launchOptions").is_some() - && request.method != super::build_provider::PREPARE_RUN_METHOD - { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Launch options apply only to run preparation.", - )); - } - if (request.params.get("selectorKind").is_some() - || request.params.get("selectors").is_some()) - && request.method != super::build_provider::TEST_METHOD - { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Opaque selectors require a provider test request.", - )); - } - if request.params.get("testResults").is_some() - && (request.method != super::build_provider::TEST_METHOD - || request.params["testResults"] != json!(true)) - { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Inline test reports require a provider test request.", - )); - } - let mut params = request.params; - if super::build_provider::is_progress_method(&request.method) { - let object = params.as_object_mut().ok_or_else(|| { - CoreError::new( - ErrorCode::InvalidRequest, - "Build task parameters must be an object.", - ) - })?; - object.insert( - "operationID".to_string(), - json!(state.client.next_request_id.to_string()), - ); - } - let timeout = request - .timeout_milliseconds - .map(|milliseconds| { - Duration::from_millis(milliseconds.clamp(1, MAX_BUILD_PROVIDER_TIMEOUT_MS)) - }) - .unwrap_or(state.request_timeout); - let response = allocate_raw_request(state.client.clone(), &request.method, params)?; - let request_id = (response.state.next_request_id - 1).to_string(); - let now = Instant::now(); - state.client = response.state; - state.pending.insert( - request_id.clone(), - PendingRequest { - kind: PendingKind::Feature, - operation_id: Some(operation_id.clone()), - method: request.method, - document_uri: None, - document_version: None, - created_at: now, - deadline: now + timeout, - }, - ); - state - .request_by_operation - .insert(operation_id, request_id.clone()); - (response.messages, request_id) - }; - self.send_request_or_fail(&outbound_order, messages, &request_id) - } - - fn keymap_import_request( - &self, - request: KeymapImportProviderRequest, - operation_id: String, - ) -> Result<(), CoreError> { - // A snapshot reference is an opaque token, never a filesystem path. - // The future host service must separately enforce its session ownership. - if request.snapshot_id.is_empty() - || request.snapshot_id.len() > 128 - || !request - .snapshot_id - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') - { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Invalid import snapshot reference.", - )); - } - let outbound_order = self.lock_outbound_order()?; - let (messages, request_id) = { - let mut state = self.lock_state()?; - if state.lifecycle != LspLifecycleState::Ready || !state.client.initialized { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Plugin backend is not ready.", - )); - } - if state.request_by_operation.contains_key(&operation_id) { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "The operation ID is already pending.", - )); - } - if !state - .client - .keymap_import_providers - .contains(&request.provider_id) - { - return Err(CoreError::new( - ErrorCode::NotSupported, - "The plugin did not negotiate this import provider.", - ) - .with_details("plugin.keymapImport.providerUnavailable")); - } - let timeout = request - .timeout_milliseconds - .map(|milliseconds| { - Duration::from_millis(milliseconds.clamp(1, MAX_KEYMAP_IMPORT_TIMEOUT_MS)) - }) - .unwrap_or( - state - .request_timeout - .min(Duration::from_millis(MAX_KEYMAP_IMPORT_TIMEOUT_MS)), - ); - let response = allocate_raw_request( - state.client.clone(), - super::keymap_provider::IMPORT_METHOD, - json!({"providerID": request.provider_id, "snapshotID": request.snapshot_id}), - )?; - let request_id = (response.state.next_request_id - 1).to_string(); - let now = Instant::now(); - state.client = response.state; - state.pending.insert( - request_id.clone(), - PendingRequest { - kind: PendingKind::Feature, - operation_id: Some(operation_id.clone()), - method: super::keymap_provider::IMPORT_METHOD.to_string(), - document_uri: None, - document_version: None, - created_at: now, - deadline: now + timeout, - }, - ); - state - .request_by_operation - .insert(operation_id, request_id.clone()); - (response.messages, request_id) - }; - self.send_request_or_fail(&outbound_order, messages, &request_id) - } - - /// Writes a VCS-provider request planned by `crate::vcs::plan`, which - /// checks the operation's arguments and negotiated capability. - fn toolchain_request( - &self, - request: ToolchainProviderRequest, - operation_id: String, - ) -> Result<(), CoreError> { - let outbound_order = self.lock_outbound_order()?; - 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 provider = request.params["providerID"].as_str().unwrap_or_default(); - if !state - .client - .toolchain_providers - .iter() - .any(|negotiated| negotiated == provider) - { - return Err(CoreError::new( - ErrorCode::NotSupported, - "The process did not negotiate this toolchain provider.", - ) - .with_details("plugin.toolchain.providerUnavailable")); - } - let timeout = request - .timeout_milliseconds - .map(|milliseconds| { - Duration::from_millis(milliseconds.clamp(1, MAX_TOOLCHAIN_QUERY_TIMEOUT_MS)) - }) - .unwrap_or(state.request_timeout); - let response = allocate_raw_request( - state.client.clone(), - super::toolchain_provider::QUERY_METHOD, - request.params, - )?; - let request_id = (response.state.next_request_id - 1).to_string(); - let now = Instant::now(); - state.client = response.state; - state.pending.insert( - request_id.clone(), - PendingRequest { - kind: PendingKind::Feature, - operation_id: Some(operation_id.clone()), - method: super::toolchain_provider::QUERY_METHOD.to_string(), - document_uri: None, - document_version: None, - created_at: now, - deadline: now + timeout, - }, - ); - state - .request_by_operation - .insert(operation_id, request_id.clone()); - (response.messages, request_id) - }; - self.send_request_or_fail(&outbound_order, messages, &request_id) - } - - fn vcs_request( - &self, - request: VcsProviderRequest, - operation_id: String, - ) -> Result<(), CoreError> { - let operation = crate::vcs::Operation::parse(&request.operation).ok_or_else(|| { - CoreError::new(ErrorCode::InvalidRequest, "Unknown VCS operation") - .with_details(request.operation.clone()) - })?; - let outbound_order = self.lock_outbound_order()?; - 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 negotiation = state - .client - .vcs_provider - .as_ref() - .filter(|negotiation| negotiation.vcs == request.vcs) - .ok_or_else(|| { - CoreError::new( - ErrorCode::NotSupported, - format!("The process is not a negotiated {} provider.", request.vcs), - ) - .with_details("vcs.provider.unavailable") - })?; - let planned = crate::vcs::plan( - operation, - &request.root, - request.tool.as_deref(), - request.arguments, - &negotiation.capabilities, - )?; - let timeout = request - .timeout_milliseconds - .map(|milliseconds| { - Duration::from_millis(milliseconds.clamp(1, MAX_VCS_PROVIDER_TIMEOUT_MS)) - }) - .unwrap_or(state.request_timeout); - let response = - allocate_raw_request(state.client.clone(), planned.method, planned.params)?; - let request_id = (response.state.next_request_id - 1).to_string(); - let now = Instant::now(); - state.client = response.state; - state.pending.insert( - request_id.clone(), - PendingRequest { - kind: PendingKind::Feature, - operation_id: Some(operation_id.clone()), - method: planned.method.to_string(), - document_uri: None, - document_version: None, - created_at: now, - deadline: now + timeout, - }, - ); - state - .request_by_operation - .insert(operation_id, request_id.clone()); - (response.messages, request_id) - }; - self.send_request_or_fail(&outbound_order, messages, &request_id) - } - - /// Writes a `lithe/function/invoke` request. The plan comes from - /// `plugin_function::plan_invocation`, so plugin identity, negotiation, - /// and registration are checked before anything reaches the process. - /// - /// `caller` is the plugin whose call the relay forwards, and `None` for a - /// call the host starts. Only the relay passes `Some`, so a host cannot - /// name a plugin as the caller and a plugin cannot choose its own name. - fn function_request( - &self, - request: PluginFunctionRequest, - operation_id: String, - caller: Option<&str>, - ) -> Result<(), CoreError> { - if caller.is_none() && relay_hub::is_relay_operation_id(&operation_id) { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "This language-server operation ID is reserved for relayed plugin calls.", - )); - } - let outbound_order = self.lock_outbound_order()?; - 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 negotiated = state - .client - .server_capabilities - .iter() - .any(|feature| feature == super::plugin_function::FUNCTIONS_FEATURE); - // Progress echoes this per-request wire ID, never the reusable host ID. - let wire_operation_id = state.client.next_request_id.to_string(); - let params = super::plugin_function::plan_invocation( - &super::plugin_function::FunctionSession { - plugin_id: self.plugin_id.as_deref(), - negotiated, - functions: &state.client.plugin_functions, - }, - &request.function, - &wire_operation_id, - &request.params, - )?; - let mut params = params; - super::plugin_function::attach_context(&mut params, request.context.as_ref())?; - super::plugin_function::attach_caller(&mut params, caller); - let timeout = request - .timeout_milliseconds - .map(|milliseconds| { - Duration::from_millis( - milliseconds.clamp(1, super::plugin_function::MAX_FUNCTION_TIMEOUT_MS), - ) - }) - .unwrap_or(state.request_timeout); - let response = allocate_raw_request( - state.client.clone(), - super::plugin_function::INVOKE_METHOD, - params, - )?; - let request_id = (response.state.next_request_id - 1).to_string(); - let now = Instant::now(); - state.client = response.state; - state.pending.insert( - request_id.clone(), - PendingRequest { - kind: PendingKind::Feature, - operation_id: Some(operation_id.clone()), - method: super::plugin_function::INVOKE_METHOD.to_string(), - document_uri: None, - document_version: None, - created_at: now, - deadline: now + timeout, - }, - ); - state - .request_by_operation - .insert(operation_id.clone(), request_id.clone()); - state.function_by_operation.insert( - operation_id, - PendingFunctionCall { - function_id: request.function.function_id, - request_id: request_id.clone(), - }, - ); - (response.messages, request_id) - }; - self.send_request_or_fail(&outbound_order, messages, &request_id) - } - - /// Sends the backend the host's answer to one forwarded host-service - /// request. An unknown ID, including one already answered or of a session - /// that restarted, is refused so a late answer never reaches another - /// request. - fn host_response(&self, response: PluginHostResponse) -> Result<(), CoreError> { - let outbound_order = self.lock_outbound_order()?; - let message = { - let mut state = self.lock_state()?; - let backend_id = state - .host_requests - .remove(&response.host_request_id) - .ok_or_else(|| { - CoreError::new( - ErrorCode::InvalidRequest, - "No host-service request with this ID is waiting for an answer.", - ) - })?; - match (response.result, response.error) { - (_, Some(failure)) => { - let (code, rpc_code) = - super::plugin_function::host_failure(failure.code.as_deref()); - let mut data = json!({ "code": code }); - if let Some(details) = - super::plugin_function::bounded_host_details(failure.details.as_ref()) - { - data["details"] = details; - } - json_rpc_error_with_data( - &backend_id, - rpc_code, - &super::plugin_function::bounded_host_message(&failure.message), - data, - )? - } - (result, None) => { - let result = result.unwrap_or(Value::Null); - if result.to_string().len() > super::plugin_function::MAX_RESULT_BYTES { - json_rpc_error_with_code( - &backend_id, - -32014, - "Host result exceeds the byte limit.", - super::plugin_function::codes::HOST_LIMIT_EXCEEDED, - )? - } else { - json_rpc_result(&backend_id, result)? - } - } - } - }; - self.send_messages_or_fail(&outbound_order, vec![message], "hostResponse") - } - - /// Writes one host notification to a ready plugin-owned backend. - /// Returns `false`, writing nothing, when the backend negotiated no - /// plugin-function protocol version. - fn host_notify(&self, notification: PluginHostNotification) -> Result { - let outbound_order = self.lock_outbound_order()?; - let message = { - let state = self.lock_state()?; - if self.plugin_id.is_none() { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Only a plugin process receives host notifications.", - )); - } - if state.lifecycle != LspLifecycleState::Ready || !state.client.initialized { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Language server is not ready.", - )); - } - let delivered = super::plugin_function::host_notification( - ¬ification.method, - ¬ification.params, - state.client.plugin_function_version, - &state.client.host_services, - )?; - if !delivered { - return Ok(false); - } - json_rpc_notification(¬ification.method, notification.params)? - }; - self.send_messages_or_fail(&outbound_order, vec![message], "hostNotify")?; - Ok(true) - } - - /// Admits a backend's request to its language server: it is sent now, - /// held behind a gate, or joined to a command that already runs. - fn plugin_server_request( - &self, - request: PluginServerRequest, - operation_id: String, - ) -> Result<(), CoreError> { - let outbound_order = self.lock_outbound_order()?; - let (messages, request_id) = { - let mut state = self.lock_state()?; - if state.request_by_operation.contains_key(&operation_id) { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "The language-server operation ID is already pending.", - )); - } - let server = plugin_server_target( - &mut state, - self.plugin_id.as_deref(), - &request.plugin_id, - &request.server_id, - )?; - let now = Instant::now(); - let deadline = now + plugin_server_timeout(request.timeout_milliseconds); - let admission = server.admit_request(Arrival { - operation_id: &operation_id, - method: &request.method, - params: &request.params, - now, - deadline, - })?; - if admission != Admission::Send { - // Held and joined requests are written later, or never. - return Ok(()); - } - match register_plugin_server_request( - &mut state, - &operation_id, - &request.method, - request.params, - now, - deadline, - ) { - Ok(registered) => registered, - Err(error) => { - if let Some(server) = state.plugin_server.as_mut() { - server.finish(&operation_id); - } - return Err(error); - } - } - }; - self.send_request_or_fail(&outbound_order, messages, &request_id) - } - - /// Sends a backend's notification to its language server. Returns whether - /// it was sent now. - fn plugin_server_notify(&self, request: PluginServerNotify) -> Result { - { - let mut state = self.lock_state()?; - let server = plugin_server_target( - &mut state, - self.plugin_id.as_deref(), - &request.plugin_id, - &request.server_id, - )?; - server.admit_notification(&request.method, &request.params)?; - } - if request.method == DID_CHANGE_CONFIGURATION { - // The settings also answer the server's own `workspace/configuration` - // requests, so they go through the plugin configuration. - let settings = request - .params - .get("settings") - .filter(|settings| settings.is_object()) - .cloned() - .ok_or_else(|| { - CoreError::new(ErrorCode::InvalidRequest, "settings must be an object.") - .with_details(super::plugin_function::codes::HOST_INVALID_PARAMS) - })?; - return Ok(self.update_plugin_configuration(settings)?.sent); - } - let message = json_rpc_notification(&request.method, request.params)?; - let outbound_order = self.lock_outbound_order()?; - self.send_messages_or_fail(&outbound_order, vec![message], "pluginServerNotify")?; - Ok(true) - } - - fn request_with_kind( - &self, - request: SemanticRequest, - operation_id: String, - requested_kind: PendingKind, - requested_document_version: Option, - ) -> Result<(), CoreError> { - let outbound_order = self.lock_outbound_order()?; - 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(); - if let (Some(uri), Some(expected_version)) = - (uri.as_deref(), requested_document_version) - { - let current_version = state - .client - .open_documents - .get(uri) - .map(|document| document.version.max(1)) - .ok_or_else(|| { - CoreError::new( - ErrorCode::InvalidRequest, - "The document is not synchronized with the language server.", - ) - })?; - if current_version != expected_version { - return Err(CoreError::new( - ErrorCode::Cancelled, - "The document changed before the language-server request started.", - ) - .with_details(format!( - "expectedVersion={expected_version}, currentVersion={current_version}" - ))); - } - } - 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 pending_kind = requested_kind; - let response = match request.operation { - LspSemanticOperation::WorkspaceSymbols => { - let query = request.query.clone().unwrap_or_default(); - allocate_raw_request(state.client.clone(), method, json!({ "query": query }))? - } - LspSemanticOperation::ExecuteCommand => { - let command = request.command.ok_or_else(|| { - CoreError::new( - ErrorCode::InvalidRequest, - "This language-server request requires a command.", - ) - })?; - allocate_raw_request(state.client.clone(), method, command)? - } - _ => { - let uri = uri.clone().ok_or_else(|| { - CoreError::new( - ErrorCode::InvalidRequest, - "This language-server operation requires a document URI.", - ) - })?; - let document_is_open = state - .client - .open_documents - .get(&uri) - .is_some_and(|document| document.version > 0); - if !document_is_open { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "The document is not open in the language server.", - )); - } - let feature_request = ClientFeatureRequest { - state: state.client.clone(), - uri, - method: method.to_string(), - position: request.position, - new_name: request.new_name, - query: request.query, - range: request.range, - diagnostics: request.diagnostics, - completion_context: request.completion_context, - completion_item: request.completion_item, - code_action: request.code_action, - command: request.command, - formatting_options: request.formatting_options, - include_declaration: request.include_declaration, - }; - client_feature_request_canonical(feature_request)? - } - }; - let request_id = (response.state.next_request_id - 1).to_string(); - let now = Instant::now(); - let pending = PendingRequest { - kind: pending_kind, - operation_id: Some(operation_id.clone()), - method: method.to_string(), - document_uri: uri.clone(), - document_version: requested_document_version.or_else(|| { - uri.as_deref() - .and_then(|uri| state.client.open_documents.get(uri)) - .map(|document| document.version) - }), - 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) - }; - self.send_request_or_fail(&outbound_order, messages, &request_id) - } - - /// Writes an allocated request. A write failure completes the request - /// with an error and fails the session, because the server's view of the - /// conversation is no longer known. - fn send_request_or_fail( - &self, - outbound_order: &MutexGuard<'_, ()>, - messages: Vec, - request_id: &str, - ) -> Result<(), CoreError> { - if let Err(error) = self.send_messages(outbound_order, 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 outbound_order = self.lock_outbound_order()?; - let request_id = { - let mut state = self.lock_state()?; - if state - .plugin_server - .as_ref() - .is_some_and(|server| server.is_unsent(operation_id)) - { - cancel_unsent_plugin_server_request(self, &mut state, operation_id); - return Ok(()); - } - 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); - state.client.pending_semantic_legends.remove(&request_id); - state.function_by_operation.remove(operation_id); - let error = runtime_error( - self, - "requestCancelled", - "request", - Some(&pending.method), - pending.document_uri.as_deref(), - "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(&outbound_order, vec![cancellation], "requestCancel") - } - - fn stop(&self) -> Result<(), CoreError> { - let outbound_order = self.lock_outbound_order()?; - 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_function_registry(self, &mut state); - clear_runtime_diagnostics(self, &mut state); - transition_locked(self, &mut state, LspLifecycleState::Stopping, None); - plugin_operations::abandon(&self.startup_attempt); - 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, - document_version: 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(&outbound_order, messages, "shutdown")?; - if force_kill { - self.kill_process(); - } - Ok(()) - } - - fn poll_events(&self) -> Result, CoreError> { - let mut state = self.lock_state()?; - Ok(state.events.drain(..).collect()) - } - - fn wait_events(&self, timeout: Duration) -> Result, CoreError> { - let mut state = self.lock_state()?; - let deadline = Instant::now() + timeout; - loop { - crate::protocol::cancellation::check()?; - if !state.events.is_empty() { - return Ok(state.events.drain(..).collect()); - } - if matches!( - state.lifecycle, - LspLifecycleState::Stopped | LspLifecycleState::Failed - ) { - // Returning Ok([]) here would let frontend pumps spin on Core IPC - // forever after the terminal stateChanged event was drained. - let details = match state.lifecycle { - LspLifecycleState::Failed => "sessionFailed", - _ => "sessionStopped", - }; - return Err(CoreError::new( - ErrorCode::ProcessFailed, - "Language-server session is no longer running.", - ) - .with_details(details)); - } - let remaining = deadline.saturating_duration_since(Instant::now()); - if remaining.is_zero() { - return Ok(Vec::new()); - } - let (guard, wait_result) = - self.event_signal - .wait_timeout(state, remaining) - .map_err(|_| { - CoreError::new( - ErrorCode::Unknown, - "Language-server event wait lock was poisoned.", - ) - })?; - state = guard; - if wait_result.timed_out() && state.events.is_empty() { - return Ok(Vec::new()); - } - } - } - - #[cfg(test)] - fn snapshot(&self) -> Result { - let state = self.lock_state()?; - Ok(EngineSnapshot { - session_id: self.id.clone(), - provider_id: self.provider_id.clone(), - root_uri: self.root_uri.clone(), - state: state.lifecycle, - initialized: state.client.initialized, - open_documents: state.client.open_documents.clone(), - pending_operation_ids: { - let mut ids: Vec = state.request_by_operation.keys().cloned().collect(); - ids.sort(); - ids - }, - diagnostic_versions: state.client.diagnostic_versions.clone(), - }) - } - - fn spawn_readers( - self: &Arc, - mut stdout: Box, - mut stderr: Box, - ) { - let output_session = self.clone(); - thread::spawn(move || { - let _host = crate::remote::workspace::enter(output_session.workspace_host.clone()); - 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 = [0_u8; 4096]; - let mut lines = StderrLines::default(); - let started = Instant::now(); - let emit = |fact| match fact { - StderrFact::Line { - diagnostic, - truncated, - } => error_session.log( - "debug", - EngineLog::StderrReceived - .with_fields(json!({"diagnostic": diagnostic, "truncated": truncated})), - "Language-server stderr", - Some(diagnostic), - ), - StderrFact::Dropped(count) => error_session.log( - "warning", - EngineLog::Dropped.with_fields(json!({"count": count})), - "Engine stderr lines dropped", - Some(format!("count={count}")), - ), - }; - while error_session.active.load(Ordering::Acquire) { - match stderr.read(&mut chunk) { - Ok(0) => break, - Ok(count) => { - lines.feed(&chunk[..count], started.elapsed().as_millis() as u64, emit) - } - Err(error) => { - error_session.log_issue( - "warning", - "stderr", - "stderrReadFailed", - EngineLog::StderrReadFailed, - "Could not read language-server stderr", - Some(error.to_string()), - ); - break; - } - } - } - lines.finish(started.elapsed().as_millis() as u64, emit); - }); - } - - fn spawn_monitor(self: &Arc) { - let session = self.clone(); - thread::spawn(move || { - while session.active.load(Ordering::Acquire) { - if let Some(exit_code) = session.process.exit_status() { - session.handle_process_exit(exit_code); - break; - } - session.expire_deadlines(); - thread::sleep(Duration::from_millis(MONITOR_INTERVAL_MS)); - } - }); - } - - fn handle_server_message(&self, message: String) -> Result<(), CoreError> { - self.handle_server_message_after_policy_check(message, || {}) - } - - fn handle_server_message_after_policy_check( - &self, - message: String, - after_policy_check: impl FnOnce(), - ) -> 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()) - })?; - // Diagnostics and readiness flushes must share the same policy boundary - // as host document commits and exclusion withdrawal. - let _indexing_order = self.lock_indexing_roots()?; - let outbound_order = self.lock_outbound_order()?; - let response_id = if value.get("method").is_none() { - lsp_value_id(value.get("id")) - } else { - None - }; - let (known_pending, pending_before, old_capabilities, old_completion) = { - 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(), - super::client::completion_options(&state.client), - ) - }; - // 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", - EngineLog::ResponseSkipped.with_fields(json!(response_id)), - "Ignored a late language-server response", - response_id, - ); - return Ok(()); - } - // Only a remote workspace rewrites the message; a local one keeps the - // value parsed above instead of parsing the same text again. - let value = match &self.workspace_host { - Some(host) => { - let mapped = super::remote_process::map_message( - host, - &message, - false, - pending_before - .as_ref() - .map(|pending| pending.method.as_str()), - )?; - serde_json::from_str(&mapped).map_err(|error| { - CoreError::new( - ErrorCode::ParseFailed, - "Invalid mapped LSP server JSON message.", - ) - .with_details(error.to_string()) - })? - } - None => value, - }; - let method = value.get("method").and_then(Value::as_str); - if method == Some("textDocument/publishDiagnostics") { - if let Some(uri) = value.pointer("/params/uri").and_then(Value::as_str) { - if crate::project::indexing::uri_is_excluded(&self.root_uri, uri).unwrap_or(true) { - return Ok(()); - } - } - } - after_policy_check(); - if method == Some("workspace/configuration") { - if let Some(response) = self.provider_configuration_response(&value)? { - return self.send_messages_or_fail( - &outbound_order, - vec![response], - "serverRequest", - ); - } - } - - let reduced = { - let mut state = self.lock_state()?; - let redirected = if method == Some("textDocument/publishDiagnostics") { - let state = &mut *state; - diagnostics_addressed_to_open_document( - &state.client.open_documents, - &mut state.linked_document_paths, - &value, - ) - } else { - None - }; - client_apply_server_message_for_plugin( - state.client.clone(), - redirected.as_ref().unwrap_or(&value), - self.plugin_id.is_some(), - )? - }; - let mut outbound = reduced.messages; - let mut flush_documents = false; - let mut ready_server_info = None; - 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); - state.client.pending_semantic_legends.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); - state.function_by_operation.remove(operation_id); - } - } - } - - match pending_before.as_ref().map(|pending| pending.kind) { - Some(PendingKind::Initialize) => { - let server_error = value.get("error").map(Value::to_string); - if server_error.is_some() || !state.client.initialized { - state.initialize_deadline = None; - fail_initialize = Some(( - if server_error.is_some() { - "initializeFailed".to_string() - } else { - "invalidServerMessage".to_string() - }, - server_error, - )); - } else { - plugin_operations::finish(&self.startup_attempt, None); - state.initialize_deadline = None; - ready_server_info = parse_server_info(&value); - flush_documents = true; - } - } - Some(PendingKind::Feature) - if pending_before.as_ref().is_some_and(|pending| { - pending.method == super::plugin_function::INVOKE_METHOD - }) => - { - if let Some(operation_id) = pending_before - .as_ref() - .and_then(|pending| pending.operation_id.as_ref()) - { - let (result, error) = - match super::plugin_function::invocation_outcome(&value) { - Ok(result) => (Some(result), None), - Err(failure) => (None, Some(function_runtime_error(self, failure))), - }; - push_request_event( - self, - &mut state, - operation_id, - super::plugin_function::INVOKE_METHOD, - result, - error, - ); - } - } - Some(PendingKind::Feature) - if pending_before.as_ref().is_some_and(|pending| { - super::build_provider::is_execution_method(&pending.method) - }) => - { - if let Some(pending) = pending_before.as_ref() { - if let Some(operation_id) = &pending.operation_id { - let (result, error) = - match super::build_provider::execution_outcome(&value) { - Ok(result) => (Some(result), None), - Err((code, message)) => ( - None, - Some(runtime_error( - self, - code, - "request", - Some(&pending.method), - None, - &message, - None, - None, - )), - ), - }; - push_request_event( - self, - &mut state, - operation_id, - &pending.method, - result, - error, - ); - } - } - } - Some(PendingKind::Feature) - if pending_before - .as_ref() - .is_some_and(|pending| crate::vcs::is_vcs_method(&pending.method)) => - { - if let Some(pending) = pending_before.as_ref() { - if let Some(operation_id) = &pending.operation_id { - let (result, error) = match super::vcs_provider::reply_outcome(&value) { - Ok(result) => (Some(result), None), - Err(failure) => ( - None, - Some(runtime_error( - self, - failure.code, - "request", - Some(&pending.method), - None, - &failure.message, - None, - None, - )), - ), - }; - push_request_event( - self, - &mut state, - operation_id, - &pending.method, - result, - error, - ); - } - } - } - Some(PendingKind::Feature) - if pending_before.as_ref().is_some_and(|pending| { - pending.method == super::keymap_provider::IMPORT_METHOD - }) => - { - if let Some(pending) = pending_before.as_ref() { - if let Some(operation_id) = &pending.operation_id { - let (result, error) = if value.to_string().len() - > super::keymap_provider::MAX_REPLY_BYTES - { - ( - None, - Some(runtime_error( - self, - "plugin.keymapImport.replyTooLarge", - "request", - Some(&pending.method), - None, - "The shortcut import provider reply is too large.", - None, - None, - )), - ) - } else { - ( - Some( - json!({"value": {"id": value.get("id"), "response": value}}), - ), - None, - ) - }; - push_request_event( - self, - &mut state, - operation_id, - &pending.method, - result, - error, - ); - } - } - } - Some(PendingKind::Feature) - if pending_before.as_ref().is_some_and(|pending| { - pending.method == super::toolchain_provider::QUERY_METHOD - }) => - { - if let Some(pending) = pending_before.as_ref() { - if let Some(operation_id) = &pending.operation_id { - let result = super::toolchain_provider::reply_outcome(&value); - push_request_event( - self, - &mut state, - operation_id, - &pending.method, - Some(result), - None, - ); - } - } - } - 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(), - "Language server returned an error.", - Some(detail), - None, - ) - }); - let result = event.and_then(|event| event.result.clone()); - // A plugin-owned server marks locations that are not - // files read-only; answers from other servers pass through. - let result = if state.plugin_server.is_some() { - plugin_server::normalize_navigation_result(result) - } else { - result - }; - push_request_event( - self, - &mut state, - operation_id, - &pending.method, - result, - error, - ); - } - } - } - Some(PendingKind::PluginServer) => { - if let Some(pending) = pending_before.as_ref() { - if let Some(operation_id) = &pending.operation_id { - let (result, error) = - plugin_server_answer(self, &pending.method, &value); - push_request_event( - self, - &mut state, - operation_id, - &pending.method, - result, - 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 == "notification" { - outbound.extend(observe_plugin_server_notification( - self, - &mut state, - event.method.as_deref(), - event.result.as_ref(), - )); - } - if event.kind == "semanticTokensRefresh" { - push_semantic_tokens_refresh_event(self, &mut state); - } else if event.kind == "functionsChanged" { - let result = event.result.unwrap_or_default(); - let removed: Vec = result - .get("removed") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(Value::as_str) - .map(ToString::to_string) - .collect(); - outbound.extend(revoke_function_calls_locked(self, &mut state, &removed)); - push_functions_event(self, &mut state, result); - relay_hub::observe_functions_changed(self); - } else if event.kind == "functionRelayRequest" { - outbound.extend(self.relay.answer_request( - self, - &state, - event.method.as_deref().unwrap_or_default(), - &event.result.unwrap_or_default(), - )?); - } else if event.kind == "functionProgress" { - push_function_progress_locked(self, &mut state, event.result); - } else if event.kind == "buildProgress" { - push_build_progress_locked(self, &mut state, event.result); - } else if event.kind == "hostRequest" { - outbound.extend(forward_host_request_locked( - self, - &mut state, - event.method.unwrap_or_default(), - event.result.unwrap_or_default(), - )?); - } else if event.kind == super::vcs_provider::NEGOTIATED_EVENT { - push_vcs_provider_event(self, &mut state, event.result.unwrap_or_default()); - } else if event.kind == super::toolchain_provider::NEGOTIATED_EVENT { - push_negotiated_provider_event( - self, - &mut state, - event.result.unwrap_or_default(), - "toolchainProviders", - ); - } else if event.kind == super::keymap_provider::NEGOTIATED_EVENT { - push_negotiated_provider_event( - self, - &mut state, - event.result.unwrap_or_default(), - "keymapImportProviders", - ); - } else 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" - && event.method.as_deref() == Some(super::plugin_logging::METHOD) - { - receive_plugin_diagnostic(self, &mut state, event.result.as_ref()); - } else if event.kind == "notification" - && self.plugin_id.is_some() - && event.method.as_deref() == Some("$/cancelRequest") - && cancel_host_request_locked( - self, - &mut state, - event.result.as_ref(), - &mut outbound, - )? - { - // The backend withdrew one of its own host requests. - } else if event.kind == "notification" { - let method = event.method.as_deref().unwrap_or("unknown"); - let params = event.result.as_ref().unwrap_or(&Value::Null); - if method != "$/logTrace" || state.detailed_diagnostics { - let (identity, fields) = EngineLog::notification(method, params); - push_log_event( - self, - &mut state, - "info", - identity.with_fields(fields), - method, - event.result.map(|value| value.to_string()), - ); - } - } - } - if (state.client.server_capabilities != old_capabilities - || super::client::completion_options(&state.client) != old_completion) - && 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(configuration) = self.plugin_configuration() { - outbound.push(plugin_configuration_notification(&configuration)); - } - outbound.extend(self.flush_queued_documents()?); - let diagnostics_enabled = self.lock_state()?.requested_diagnostics; - if diagnostics_enabled { - // LSP forbids setTrace before the initialize response. This batch - // already starts with initialized, under the same outbound lock. - outbound.push(trace_notification(true)); - } - self.send_messages_or_fail(&outbound_order, outbound, "serverResponse")?; - let mut state = self.lock_state()?; - state.detailed_diagnostics = diagnostics_enabled; - if let Some(info) = ready_server_info { - push_server_info_event(self, &mut state, info); - } - push_log_event( - self, - &mut state, - "info", - EngineLog::ProtocolInitialized, - "Language service protocol initialized", - None, - ); - transition_locked(self, &mut state, LspLifecycleState::Ready, None); - let capabilities = state.client.server_capabilities.clone(); - push_features_event(self, &mut state, capabilities); - return Ok(()); - } - self.send_messages_or_fail(&outbound_order, outbound, "serverResponse")?; - Ok(()) - } - - fn provider_configuration_response( - &self, - message: &Value, - ) -> Result, CoreError> { - let Some(id) = message.get("id") else { - return Ok(None); - }; - let Some(configuration) = self.plugin_configuration() else { - return Ok(None); - }; - // Each item asks for one section; `scopeUri` is ignored because a - // session carries one configuration for its whole workspace. - let values: Vec = message - .get("params") - .and_then(|params| params.get("items")) - .and_then(Value::as_array) - .into_iter() - .flatten() - .map(|item| { - configuration_section(&configuration, item.get("section").and_then(Value::as_str)) - }) - .collect(); - 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 queued = { - let state = self.lock_state()?; - state - .pending_workspace_file_changes - .keys() - .cloned() - .chain( - state - .client - .open_documents - .values() - .filter(|document| document.version == 0) - .map(|document| document.uri.clone()), - ) - .collect::>() - }; - for uri in queued { - if crate::project::indexing::uri_is_excluded(&self.root_uri, &uri)? { - let mut state = self.lock_state()?; - state.pending_workspace_file_changes.remove(&uri); - if state - .client - .open_documents - .get(&uri) - .is_some_and(|document| document.version == 0) - { - state.client.open_documents.remove(&uri); - } - } - } - let mut state = self.lock_state()?; - flush_queued_documents_locked(&mut state) - } - - /// One session-owned writer sends deadline cancellations off the monitor thread. - /// The monitor remains free to terminate a stalled write and expire other callers. - fn spawn_outbound_maintenance(self: &Arc) { - let session = self.clone(); - thread::spawn(move || { - while session.active.load(Ordering::Acquire) { - session.pump_outbound_maintenance(); - thread::sleep(Duration::from_millis(MONITOR_INTERVAL_MS)); - } - }); - } - - fn pump_outbound_maintenance(&self) { - if !self - .lock_state() - .is_ok_and(|state| !state.deadline_cancellations.is_empty()) - { - return; - } - let Ok(outbound_order) = self.outbound_order.try_lock() else { - return; - }; - let Ok(mut state) = self.lock_state() else { - return; - }; - let messages: Vec = std::mem::take(&mut state.deadline_cancellations) - .into_iter() - .map(|id| { - json!({ "jsonrpc": "2.0", "method": "$/cancelRequest", "params": { "id": id } }) - .to_string() - }) - .collect(); - if messages.is_empty() { - return; - } - state.maintenance_write_deadline = Some(Instant::now() + state.request_timeout); - drop(state); - // Process termination by the monitor releases a blocked native stdin write. - // log-exempt: Same failure already recorded: send_messages_or_fail transitions the session and emits its transport failure. - let _ = self.send_messages_or_fail(&outbound_order, messages, "outboundMaintenance"); - if let Ok(mut state) = self.lock_state() { - state.maintenance_write_deadline = None; - } - } - - fn expire_deadlines(&self) { - self.expire_deadlines_at(Instant::now()); - } - - fn expire_deadlines_at(&self, now: Instant) { - // Relayed calls that no callee runs yet have no session deadline. - self.relay.expire(now); - let mut cancellations = Vec::new(); - let mut initialize_timeout = false; - let mut shutdown_timeout = false; - let mut maintenance_write_timeout = false; - let mut relay_write_timeout = false; - if let Ok(mut state) = self.lock_state() { - maintenance_write_timeout = state - .maintenance_write_deadline - .is_some_and(|deadline| now >= deadline); - if maintenance_write_timeout { - state.maintenance_write_deadline = None; - } - relay_write_timeout = state - .relay_write_deadline - .is_some_and(|deadline| now >= deadline); - if relay_write_timeout { - state.relay_write_deadline = None; - } - if state.lifecycle == LspLifecycleState::Initializing - && state - .initialize_deadline - .is_some_and(|deadline| now >= deadline) - { - state.initialize_deadline = None; - initialize_timeout = true; - // Decided under the state lock, like the initialize response, - // so exactly one of the two records the attempt. - plugin_operations::finish(&self.startup_attempt, Some(AttemptStage::Timeout)); - } - let expired: Vec<_> = state - .pending - .iter() - .filter(|(_, pending)| { - matches!( - pending.kind, - PendingKind::Feature | PendingKind::PluginServer - ) && 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); - state.client.pending_semantic_legends.remove(&request_id); - if let Some(operation_id) = pending.operation_id.as_deref() { - state.request_by_operation.remove(operation_id); - state.function_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(), - "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); - } - let held_expired = state - .plugin_server - .as_ref() - .map(|server| server.expired_held(now)) - .unwrap_or_default(); - for waiter in held_expired { - // Nothing was sent, so there is nothing to cancel on the wire. - let error = runtime_error( - self, - "requestTimeout", - "request", - Some(&waiter.method), - None, - "Language-server request timed out waiting for the server to become ready.", - None, - None, - ); - push_request_event( - self, - &mut state, - &waiter.operation_id, - &waiter.method, - None, - Some(error), - ); - } - if state.lifecycle == LspLifecycleState::Stopping - && state - .shutdown_deadline - .is_some_and(|deadline| now >= deadline) - { - state.shutdown_deadline = None; - shutdown_timeout = true; - push_issue_log_event( - self, - &mut state, - "warning", - "shutdown", - "shutdownTimeout", - EngineLog::ShutdownTimedOut, - "Language-server shutdown timed out; forcing termination", - None, - ); - } - } - if maintenance_write_timeout { - self.fail( - "transportFailed", - "outboundMaintenance", - "Language-server stdin write timed out.", - None, - None, - ); - self.kill_process(); - } else if relay_write_timeout { - // Killing the process releases the relay lane's blocked stdin write. - self.fail( - "transportFailed", - "relayWrite", - "Language-server stdin write timed out.", - None, - None, - ); - self.kill_process(); - } else if initialize_timeout { - // Timeout termination must not wait behind a blocked stdin write; - // killing the process is what releases that write. - self.fail( - "initializeTimeout", - "initialize", - "Language-server initialization timed out.", - None, - None, - ); - self.kill_process(); - } else if shutdown_timeout { - self.kill_process(); - } - if !cancellations.is_empty() { - if let Ok(mut state) = self.lock_state() { - if !matches!( - state.lifecycle, - LspLifecycleState::Stopped | LspLifecycleState::Failed - ) { - state.deadline_cancellations.extend(cancellations); - } - } - } - } - - fn handle_process_exit(&self, exit_code: Option) { - self.active.store(false, Ordering::Release); - self.process.close_input(); - if let Ok(mut state) = self.lock_state() { - let was_stopping = state.lifecycle == LspLifecycleState::Stopping; - if !was_stopping { - plugin_operations::finish(&self.startup_attempt, Some(AttemptStage::Exit)); - } - if !state.terminal_event_emitted { - let error = (!was_stopping).then(|| { - runtime_error( - self, - "serverExited", - "process", - 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); - state.client.pending_semantic_legends.remove(request_id); - if let Some(operation_id) = pending.operation_id.as_deref() { - state.request_by_operation.remove(operation_id); - state.function_by_operation.remove(operation_id); - let error = runtime_error( - self, - code, - stage, - Some(&pending.method), - pending.document_uri.as_deref(), - 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; - } - // Timeouts were already taken by their caller; every other terminal - // failure before `initialize` succeeded is a handshake failure. - plugin_operations::finish(&self.startup_attempt, Some(AttemptStage::Initialize)); - 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, - 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 set_detailed_diagnostics(&self, enabled: bool) -> Result<(), CoreError> { - let order = self.lock_outbound_order()?; - let mut state = self.lock_state()?; - let lifecycle = state.lifecycle; - if matches!( - lifecycle, - LspLifecycleState::Stopped | LspLifecycleState::Failed | LspLifecycleState::Stopping - ) { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "The session is no longer running.", - )); - } - if !state.client.initialized { - state.requested_diagnostics = enabled; - return Ok(()); - } - drop(state); - self.send_messages_or_fail(&order, vec![trace_notification(enabled)], "setTrace")?; - let mut state = self.lock_state()?; - state.requested_diagnostics = enabled; - state.detailed_diagnostics = enabled; - Ok(()) - } - - fn log( - &self, - level: &str, - identity: impl Into, - message: &str, - detail: Option, - ) { - if let Ok(mut state) = self.lock_state() { - push_log_event(self, &mut state, level, identity, message, detail); - } - } - - fn log_issue( - &self, - level: &str, - stage: &str, - code: &str, - identity: impl Into, - message: &str, - detail: Option, - ) { - if let Ok(mut state) = self.lock_state() { - push_issue_log_event( - self, &mut state, level, stage, code, identity, message, detail, - ); - } - } - - fn send_messages_or_fail( - &self, - outbound_order: &MutexGuard<'_, ()>, - messages: Vec, - stage: &str, - ) -> Result<(), CoreError> { - if messages.is_empty() { - return Ok(()); - } - if let Err(error) = self.send_messages(outbound_order, 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, - _outbound_order: &MutexGuard<'_, ()>, - messages: Vec, - ) -> Result<(), CoreError> { - for message in messages { - let message = match &self.workspace_host { - Some(host) => super::remote_process::map_message(host, &message, true, None)?, - None => message, - }; - let frame = frame_message(FrameMessageRequest { message })?.frame; - self.process.write_input(frame.as_bytes())?; - } - Ok(()) - } - - fn kill_process(&self) { - self.process.terminate(); - if let Some(error) = self.process.take_termination_failure() { - // The session owns process recovery; WARN reports cleanup degradation. - // Reuse the role routing and runtime envelope without adding a v1 log row. - if let Ok(mut state) = self.lock_state() { - let category = format!("{:?}", error.kind()); - let error = runtime_error( - self, - "processCleanupFailed", - "cleanup", - None, - None, - "Engine process cleanup failed", - None, - None, - ); - let input = EngineLog::ProcessCleanupFailed.with_fields(json!({})); - let (id, mut fields) = - input - .identity - .fact(&self.log_identity, &input.fields, Some(&error)); - fields.insert("error_code".into(), json!(category)); - fields.insert("error_domain".into(), json!("io")); - push_cleanup_fact(self, &mut state, id, fields, error); - } - } - } - - fn lock_outbound_order(&self) -> Result, CoreError> { - self.outbound_order.lock().map_err(|_| { - CoreError::new( - ErrorCode::Unknown, - "Language-server outbound ordering lock was poisoned.", - ) - }) - } - - fn lock_indexing_roots(&self) -> Result>, CoreError> { - self.indexing_roots - .lock() - .map_err(|_| CoreError::new(ErrorCode::Unknown, "LSP indexing lock was poisoned")) - } - - fn lock_state(&self) -> Result, CoreError> { - self.state.lock().map_err(|_| { - CoreError::new( - ErrorCode::Unknown, - "Language-server session state lock was poisoned.", - ) - }) - } -} - -fn trace_notification(enabled: bool) -> String { - json!({"jsonrpc":"2.0", "method":"$/setTrace", "params":{"value": if enabled { "verbose" } else { "off" }}}).to_string() -} - -fn validate_start_request(request: &StartServerRequest) -> Result<(), CoreError> { - if request.provider_id.trim().is_empty() { - return Err(invalid_field("providerId")); - } - // A host-owned server is no plugin's backend, and a service Core does not - // know could never be forwarded. - if !request.host_services.is_empty() - && (request.plugin_id.is_none() - || request - .host_services - .iter() - .any(|service| !super::plugin_function::is_host_grant(service))) - { - return Err(invalid_field("hostServices")); - } - if let Some(policy) = &request.session_policy { - policy.validate(request.plugin_id.as_deref())?; - } - if !request.host_options.is_empty() - && (request.plugin_id.is_none() - || request - .host_options - .editor - .as_ref() - .is_some_and(|editor| editor.validate().is_err())) - { - return Err(invalid_field("hostOptions")); - } - 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(()) - } -} - -/// The file a `file:` URI resolves to once symbolic links are followed, or -/// `None` for other schemes and for files that do not exist. -fn resolved_file_of_uri(uri: &str) -> Option { - let path = url::Url::parse(uri).ok()?.to_file_path().ok()?; - crate::remote::workspace_io::canonicalize(path).ok() -} - -/// Which open documents the host reached through a symbolic link. -/// -/// Only such a document can be the target of a diagnostics rewrite, so the -/// rest are remembered as examined and never resolved again while open. -#[derive(Default)] -struct LinkedDocumentPaths { - /// Open documents whose file has already been resolved. - examined: BTreeSet, - /// Resolved file of each examined document that differs from its own - /// path, keyed by the document's URI. - resolved: BTreeMap, -} - -impl LinkedDocumentPaths { - /// Drops closed documents and resolves each newly opened one once. - fn refresh(&mut self, open_documents: &BTreeMap) { - self.examined.retain(|uri| open_documents.contains_key(uri)); - self.resolved - .retain(|uri, _| open_documents.contains_key(uri)); - for uri in open_documents.keys() { - if self.examined.contains(uri) { - continue; - } - let Some(resolved) = resolved_file_of_uri(uri) else { - // Not on disk yet (an unsaved document); look again next time. - continue; - }; - self.examined.insert(uri.clone()); - let own = url::Url::parse(uri) - .ok() - .and_then(|url| url.to_file_path().ok()); - if own.as_ref() != Some(&resolved) { - self.resolved.insert(uri.clone(), resolved); - } - } - } -} - -/// Re-addresses published diagnostics to the open document they describe. -/// -/// A server reports a file at its resolved location while the host may have -/// opened it through a symbolic link (a server may answer `/private/tmp/...` -/// for a document opened as `/tmp/...` on macOS). The client reducer matches URIs -/// textually and would drop such diagnostics, so when no open document carries -/// the published URI this returns the message rewritten to the URI of the open -/// document that resolves to the same file. Returns `None` when the message -/// needs no change. -/// -/// Servers publish for many files that are not open, so this runs on a hot -/// path. Only documents opened through a link can be rewrite targets, and -/// `linked` remembers those, which makes the common case free of file-system -/// calls and the linked case a single `canonicalize` of the published file. -fn diagnostics_addressed_to_open_document( - open_documents: &BTreeMap, - linked: &mut LinkedDocumentPaths, - message: &Value, -) -> Option { - let published = message.get("params")?.get("uri")?.as_str()?; - if open_documents.contains_key(published) { - return None; - } - linked.refresh(open_documents); - if linked.resolved.is_empty() { - return None; - } - let target = resolved_file_of_uri(published)?; - let document_uri = linked - .resolved - .iter() - .find(|(_, resolved)| **resolved == target) - .map(|(uri, _)| uri.clone())?; - let mut message = message.clone(); - message["params"]["uri"] = Value::String(document_uri); - Some(message) -} - -/// The workspace directory with symbolic links resolved, when it exists. -/// -/// Resolving links touches the file system, so it happens here in the engine -/// rather than in the pure normalizer that receives the result. -fn canonical_workspace_path(root_uri: &str) -> Option { - let path = url::Url::parse(root_uri).ok()?.to_file_path().ok()?; - // Core policy identity uses the native canonical path, including Windows' - // verbatim prefix. A protocol/display path is not a policy registry key. - crate::remote::workspace_io::canonicalize(path).ok() -} - -fn canonical_workspace_root(root_uri: &str) -> Option { - let canonical = canonical_workspace_path(root_uri)?; - let canonical = canonical.to_string_lossy().replace('\\', "/"); - // Windows canonical paths carry a verbatim `\\?\` prefix that language servers never report. - Some( - canonical - .strip_prefix("//?/UNC/") - .map(|rest| format!("//{rest}")) - .or_else(|| canonical.strip_prefix("//?/").map(str::to_string)) - .unwrap_or(canonical), - ) -} - -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::RangeFormatting => "textDocument/rangeFormatting", - LspSemanticOperation::DocumentSymbols => "textDocument/documentSymbol", - LspSemanticOperation::CodeActions => "textDocument/codeAction", - LspSemanticOperation::ResolveCompletion => "completionItem/resolve", - LspSemanticOperation::ResolveCodeAction => "codeAction/resolve", - LspSemanticOperation::ExecuteCommand => "workspace/executeCommand", - LspSemanticOperation::InlayHints => "textDocument/inlayHint", - LspSemanticOperation::FoldingRanges => "textDocument/foldingRange", - LspSemanticOperation::DocumentLinks => "textDocument/documentLink", - LspSemanticOperation::WorkspaceSymbols => "workspace/symbol", - LspSemanticOperation::SemanticTokens => "textDocument/semanticTokens/full", - 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::RangeFormatting => Some("rangeFormatting"), - LspSemanticOperation::DocumentSymbols => Some("documentSymbols"), - LspSemanticOperation::CodeActions => Some("codeActions"), - LspSemanticOperation::ResolveCompletion => Some("completionResolve"), - LspSemanticOperation::ResolveCodeAction => Some("codeActionResolve"), - LspSemanticOperation::ExecuteCommand => Some("executeCommand"), - LspSemanticOperation::InlayHints => Some("inlayHints"), - LspSemanticOperation::FoldingRanges => Some("foldingRanges"), - LspSemanticOperation::DocumentLinks => Some("documentLinks"), - LspSemanticOperation::WorkspaceSymbols => Some("workspaceSymbols"), - LspSemanticOperation::SemanticTokens => Some("semanticTokens"), - 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 enqueue_runtime_event( - session: &RuntimeSession, - state: &mut SessionState, - mut event: LspRuntimeEvent, -) { - if event.event.is_some() { - let (id, fields) = ( - event.event.take().expect("source catalog identity"), - event.fields.take().expect("source catalog fields"), - ); - let level = if id == "log.dropped" { - Some(crate::diagnostics::record::LogLevel::Warn) - } else { - crate::diagnostics::catalog::event_by_id(&id).map(|entry| entry.level) - }; - // In-memory v1 detail is retained for old hosts. The v2 fields always pass - // the same normalization and catalog routing boundary as persisted facts. - // A fact that fails that boundary keeps only its v1 fields; the runtime - // event itself is never dropped and Core never panics on source data. - if let Some(level) = level { - let mut fact = crate::diagnostics::record::StructuredLogEvent { - ts: String::new(), - level, - seq: 0, - event: id.clone(), - op: None, - msg: String::new(), - fields, - }; - if let Ok(channel) = crate::diagnostics::record::prepare_catalog_event( - &mut fact, - &json!({"channel": session.log_identity.channel()}), - ) { - event.channel = Some(channel); - event.plugin_id = session.plugin_id.clone(); - event.role = Some(session.log_identity.role); - event.event = Some(id); - event.fields = Some(fact.fields); - event.catalog_level = Some(fact.level); - } - } - } - state.events.push_back(event); - session.event_signal.notify_all(); -} - -// Cleanup failure is a log row, never a state change: hosts take the latest -// `stateChanged` as the session state, so a stateless one would erase `failed`. -fn push_cleanup_fact( - session: &RuntimeSession, - state: &mut SessionState, - id: String, - fields: std::collections::BTreeMap, - error: LspRuntimeError, -) { - let sequence = take_sequence(state); - enqueue_runtime_event( - session, - state, - LspRuntimeEvent { - kind: "log".to_string(), - channel: None, - plugin_id: None, - role: None, - event: Some(id), - fields: Some(fields), - catalog_level: None, - 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: Some(error), - capabilities: None, - completion_options: None, - server_info: None, - level: Some("warning".to_string()), - message: Some("Engine process cleanup failed".to_string()), - detail: None, - }, - ); -} - -// Only a declarative language server carries a host attempt. SDK backends report -// their own operations through `lithe/log`, so a stray handoff on another role is -// ignored rather than rejecting the start. The handoff names its plugin, since a -// plugin API 1 server starts with no session plugin; a session another plugin -// owns never takes the attempt, so no fact lands on a plugin that did not run. -fn startup_attempt(request: &StartServerRequest) -> AttemptSlot { - match &request.startup_operation { - Some(handoff) - if request.role == SessionRole::LanguageServer - && request - .plugin_id - .as_ref() - .is_none_or(|owner| *owner == handoff.plugin_id) => - { - Attempt::slot(&LANGUAGE_SERVER_START, handoff.clone()) - } - _ => Attempt::none(), - } -} - -fn transition_locked( - session: &RuntimeSession, - state: &mut SessionState, - lifecycle: LspLifecycleState, - error: Option, -) { - state.lifecycle = lifecycle; - relay_hub::observe_lifecycle(session, lifecycle); - let (id, fields) = EngineLog::SessionUpdated.fact( - &session.log_identity, - &json!({"state": lifecycle}), - error.as_ref(), - ); - let sequence = take_sequence(state); - enqueue_runtime_event( - session, - state, - LspRuntimeEvent { - kind: "stateChanged".to_string(), - channel: None, - plugin_id: None, - role: None, - event: Some(id), - fields: Some(fields), - catalog_level: None, - 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, - completion_options: 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, -) { - if state - .plugin_server - .as_ref() - .is_some_and(|server| server.owns(operation_id)) - { - complete_plugin_server_request(session, state, operation_id, method, result, error); - return; - } - // No host operation waits for a relayed call: the relay answers the - // calling plugin, so the event never reaches the host. - if relay_hub::is_relayed_operation(operation_id, method) { - session.relay.callee_finished( - &session.id, - operation_id, - relay_hub::callee_outcome(result, error), - ); - return; - } - enqueue_request_completed(session, state, operation_id, method, result, error); -} - -/// Ends a request the backend sent to its plugin-owned language server. This -/// is the only place where the session policy releases such an operation, so -/// every way a request can end (answer, cancel, timeout, session failure) -/// leaves the policy consistent. Requests that joined a running command -/// finish with the same result or error. -fn complete_plugin_server_request( - session: &RuntimeSession, - state: &mut SessionState, - operation_id: &str, - method: &str, - result: Option, - mut error: Option, -) { - if let Some(error) = error.as_mut() { - error.code = plugin_server::host_error_code(&error.code).to_string(); - } - let joined = state - .plugin_server - .as_mut() - .map(|server| server.finish(operation_id)) - .unwrap_or_default(); - for waiter in joined { - enqueue_request_completed( - session, - state, - &waiter.operation_id, - &waiter.method, - result.clone(), - error.clone(), - ); - } - enqueue_request_completed(session, state, operation_id, method, result, error); -} - -fn enqueue_request_completed( - session: &RuntimeSession, - state: &mut SessionState, - operation_id: &str, - method: &str, - result: Option, - error: Option, -) { - let sequence = take_sequence(state); - enqueue_runtime_event( - session, - state, - LspRuntimeEvent { - kind: "requestCompleted".to_string(), - channel: None, - plugin_id: None, - role: None, - event: None, - fields: None, - catalog_level: None, - 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, - completion_options: None, - server_info: None, - level: None, - message: None, - detail: None, - }, - ); -} - -/// Resolves a function mapping only while both pending tables still own its exact request. -fn pending_function_request<'a>( - state: &'a SessionState, - operation_id: &str, - call: &PendingFunctionCall, -) -> Option<&'a PendingRequest> { - if state.request_by_operation.get(operation_id) != Some(&call.request_id) - || state - .client - .pending_requests - .get(&call.request_id) - .map(String::as_str) - != Some(super::plugin_function::INVOKE_METHOD) - { - return None; - } - state.pending.get(&call.request_id).filter(|pending| { - pending.kind == PendingKind::Feature - && pending.method == super::plugin_function::INVOKE_METHOD - && pending.operation_id.as_deref() == Some(operation_id) - }) -} - -/// Ends every running call of the withdrawn functions with -/// `plugin.function.revoked` and returns the `$/cancelRequest` messages -/// telling the backend to stop. A reply that still arrives is late and is -/// ignored like any reply to a request that is no longer pending. -fn revoke_function_calls_locked( - session: &RuntimeSession, - state: &mut SessionState, - removed: &[String], -) -> Vec { - let revoked: Vec = state - .function_by_operation - .iter() - .filter(|(operation, call)| { - removed.contains(&call.function_id) - && pending_function_request(state, operation, call).is_some() - }) - .map(|(operation, _)| operation.clone()) - .collect(); - let mut cancellations = Vec::new(); - for operation_id in revoked { - state.function_by_operation.remove(&operation_id); - let Some(request_id) = state.request_by_operation.remove(&operation_id) else { - continue; - }; - state.pending.remove(&request_id); - state.client.pending_requests.remove(&request_id); - let failure = super::plugin_function::FunctionFailure { - code: super::plugin_function::codes::REVOKED, - message: "The plugin withdrew this function while it was running.".to_string(), - plugin_error_code: None, - }; - let error = function_runtime_error(session, failure); - push_request_event( - session, - state, - &operation_id, - super::plugin_function::INVOKE_METHOD, - None, - Some(error), - ); - cancellations.push( - json!({ - "jsonrpc": "2.0", - "method": "$/cancelRequest", - "params": { "id": request_id } - }) - .to_string(), - ); - } - cancellations -} - -/// A wire request ID identifies one exact build operation in this session. -/// Cancellation/completion removes it from pending, so late progress is inert. -fn push_build_progress_locked( - session: &RuntimeSession, - state: &mut SessionState, - progress: Option, -) { - let Some(mut progress) = progress else { - return; - }; - let Some(id) = progress.get("operationID").and_then(Value::as_str) else { - return; - }; - let Some(pending) = state.pending.get(id) else { - return; - }; - if !super::build_provider::is_progress_method(&pending.method) { - return; - } - let Some(operation_id) = pending.operation_id.clone() else { - return; - }; - progress["operationID"] = json!(operation_id); - let method = pending.method.clone(); - let sequence = take_sequence(state); - enqueue_runtime_event( - session, - state, - LspRuntimeEvent { - kind: "requestProgress".to_string(), - channel: None, - plugin_id: None, - role: None, - event: None, - fields: None, - catalog_level: None, - sequence, - provider_id: session.provider_id.clone(), - session_id: session.id.clone(), - state: None, - operation_id: Some(operation_id), - method: Some(method), - uri: None, - version: None, - diagnostics: None, - result: Some(progress), - error: None, - capabilities: None, - completion_options: None, - server_info: None, - level: None, - message: None, - detail: None, - }, - ); -} - -/// Publishes a progress report of a call that is still running. Reports for -/// an unknown, finished, cancelled, or revoked operation are dropped, so a -/// late report never revives an ended call in the host. -fn push_function_progress_locked( - session: &RuntimeSession, - state: &mut SessionState, - progress: Option, -) { - let Some(mut progress) = progress else { - return; - }; - let Some(wire_operation_id) = progress.get("operationID").and_then(Value::as_str) else { - return; - }; - let Some(operation_id) = state - .function_by_operation - .iter() - .find_map(|(operation, call)| { - (call.request_id == wire_operation_id - && pending_function_request(state, operation, call).is_some()) - .then(|| operation.clone()) - }) - else { - return; - }; - progress["operationID"] = json!(operation_id); - let sequence = take_sequence(state); - enqueue_runtime_event( - session, - state, - LspRuntimeEvent { - kind: "requestProgress".to_string(), - channel: None, - plugin_id: None, - role: None, - event: None, - fields: None, - catalog_level: None, - sequence, - provider_id: session.provider_id.clone(), - session_id: session.id.clone(), - state: None, - operation_id: Some(operation_id), - method: Some(super::plugin_function::INVOKE_METHOD.to_string()), - uri: None, - version: None, - diagnostics: None, - result: Some(progress), - error: None, - capabilities: None, - completion_options: None, - server_info: None, - level: None, - message: None, - detail: None, - }, - ); -} - -/// Publishes the VCS and capabilities a provider negotiated, so the host can -/// check the VCS against the manifest entry and decide which actions to show. -fn push_vcs_provider_event(session: &RuntimeSession, state: &mut SessionState, result: Value) { - let sequence = take_sequence(state); - enqueue_runtime_event( - session, - state, - LspRuntimeEvent { - kind: "vcsProvider".to_string(), - channel: None, - plugin_id: None, - role: None, - event: None, - fields: None, - catalog_level: None, - 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: Some(result), - error: None, - capabilities: None, - completion_options: None, - server_info: None, - level: None, - message: None, - detail: None, - }, - ); -} - -/// Publishes the providers a backend selected, so the host queries only those. -fn push_negotiated_provider_event( - session: &RuntimeSession, - state: &mut SessionState, - result: Value, - kind: &str, -) { - let sequence = take_sequence(state); - enqueue_runtime_event( - session, - state, - LspRuntimeEvent { - kind: kind.to_string(), - channel: None, - plugin_id: None, - role: None, - event: None, - fields: None, - catalog_level: None, - 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: Some(result), - error: None, - capabilities: None, - completion_options: None, - server_info: None, - level: None, - message: None, - detail: None, - }, - ); -} - -/// Handles a backend's `$/cancelRequest` for one of its own pending host -/// requests: the backend gets `plugin.host.cancelled` at once, so the -/// pending slot frees without waiting for the host, and the host gets a -/// `hostRequestCancelled` event naming the `hostRequestID` so it can stop -/// the work. A later answer from the host for that ID is refused like any -/// unknown ID. Returns false when the notification names no pending host -/// request, so it is handled like any other notification. -fn cancel_host_request_locked( - session: &RuntimeSession, - state: &mut SessionState, - params: Option<&Value>, - outbound: &mut Vec, -) -> Result { - if state.client.plugin_function_version.is_none() { - return Ok(false); - } - let Some(backend_id) = params.and_then(|params| params.get("id")) else { - return Ok(false); - }; - let Some(host_request_id) = state - .host_requests - .iter() - .find(|(_, pending)| *pending == backend_id) - .map(|(host_request_id, _)| host_request_id.clone()) - else { - // Not a host request: it may name a call the backend relayed to - // another plugin. - return Ok(session.relay.cancel_from_caller(&session.id, backend_id)); - }; - let backend_id = state - .host_requests - .remove(&host_request_id) - .unwrap_or(Value::Null); - outbound.push(json_rpc_error_with_code( - &backend_id, - -32800, - "The plugin process cancelled this host-service request.", - super::plugin_function::codes::HOST_CANCELLED, - )?); - let sequence = take_sequence(state); - enqueue_runtime_event( - session, - state, - LspRuntimeEvent { - kind: "hostRequestCancelled".to_string(), - channel: None, - plugin_id: None, - role: None, - event: None, - fields: None, - catalog_level: None, - 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: Some(json!({ "hostRequestID": host_request_id })), - error: None, - capabilities: None, - completion_options: None, - server_info: None, - level: None, - message: None, - detail: None, - }, - ); - Ok(true) -} - -/// Publishes the session's registered functions after a registry change. -/// Publishes a backend's well-formed host-service request as `hostRequest`, -/// or answers it at once when the session already has the most requests the -/// host may owe it. Returns the messages to send to the backend. -fn forward_host_request_locked( - session: &RuntimeSession, - state: &mut SessionState, - method: String, - request: Value, -) -> Result, CoreError> { - let backend_id = request.get("id").cloned().unwrap_or(Value::Null); - if state.host_requests.len() >= super::plugin_function::MAX_PENDING_HOST_REQUESTS { - let code = super::plugin_function::codes::HOST_LIMIT_EXCEEDED; - return Ok(vec![json_rpc_error_with_code( - &backend_id, - super::plugin_function::host_rpc_code(code), - "Too many host-service requests are waiting for the host.", - code, - )?]); - } - let host_request_id = format!("host-{}", state.next_host_request_id); - state.next_host_request_id += 1; - state - .host_requests - .insert(host_request_id.clone(), backend_id); - let sequence = take_sequence(state); - enqueue_runtime_event( - session, - state, - LspRuntimeEvent { - kind: "hostRequest".to_string(), - channel: None, - plugin_id: None, - role: None, - event: None, - fields: None, - catalog_level: None, - sequence, - provider_id: session.provider_id.clone(), - session_id: session.id.clone(), - state: None, - operation_id: None, - method: Some(method), - uri: None, - version: None, - diagnostics: None, - result: Some(json!({ - "hostRequestID": host_request_id, - "service": request.get("service").cloned().unwrap_or(Value::Null), - "params": request.get("params").cloned().unwrap_or(Value::Null), - })), - error: None, - capabilities: None, - completion_options: None, - server_info: None, - level: None, - message: None, - detail: None, - }, - ); - Ok(Vec::new()) -} - -fn push_functions_event(session: &RuntimeSession, state: &mut SessionState, result: Value) { - let sequence = take_sequence(state); - enqueue_runtime_event( - session, - state, - LspRuntimeEvent { - kind: "functionsChanged".to_string(), - channel: None, - plugin_id: None, - role: None, - event: None, - fields: None, - catalog_level: None, - 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: Some(result), - error: None, - capabilities: None, - completion_options: None, - server_info: None, - level: None, - message: None, - detail: None, - }, - ); -} - -fn function_runtime_error( - session: &RuntimeSession, - failure: super::plugin_function::FunctionFailure, -) -> LspRuntimeError { - let mut error = runtime_error( - session, - failure.code, - "request", - Some(super::plugin_function::INVOKE_METHOD), - None, - &failure.message, - None, - None, - ); - error.plugin_error_code = failure.plugin_error_code; - error -} - -/// The plugin-owned server a call targets, after checking that the session -/// is that contribution and can take requests now. -fn plugin_server_target<'a>( - state: &'a mut SessionState, - session_plugin_id: Option<&str>, - plugin_id: &str, - server_id: &str, -) -> Result<&'a mut PluginServerState, CoreError> { - let ready = state.lifecycle == LspLifecycleState::Ready && state.client.initialized; - let server = state - .plugin_server - .as_mut() - .ok_or_else(plugin_server::foreign_contribution)?; - server.check_owner(session_plugin_id, plugin_id, server_id)?; - if !ready { - return Err(plugin_server::unavailable()); - } - Ok(server) -} - -fn plugin_server_timeout(requested: Option) -> Duration { - let milliseconds = requested - .unwrap_or(DEFAULT_REQUEST_TIMEOUT_MILLISECONDS) - .clamp(1, MAX_REQUEST_TIMEOUT_MILLISECONDS); - Duration::from_millis(milliseconds) -} - -/// Allocates the JSON-RPC request of an admitted operation and records it as -/// pending. Returns the messages to write and the request ID. -fn register_plugin_server_request( - state: &mut SessionState, - operation_id: &str, - method: &str, - params: Value, - created_at: Instant, - deadline: Instant, -) -> Result<(Vec, String), CoreError> { - let response = allocate_raw_request(state.client.clone(), method, params)?; - let request_id = (response.state.next_request_id - 1).to_string(); - state.client = response.state; - state.pending.insert( - request_id.clone(), - PendingRequest { - kind: PendingKind::PluginServer, - operation_id: Some(operation_id.to_string()), - method: method.to_string(), - document_uri: None, - document_version: None, - created_at, - deadline, - }, - ); - state - .request_by_operation - .insert(operation_id.to_string(), request_id.clone()); - Ok((response.messages, request_id)) -} - -/// Applies a notification the server sent to a plugin-owned session: it may -/// open gates, and the backend may have asked to receive it. Returns the -/// messages of the requests that waited for those gates. -fn observe_plugin_server_notification( - session: &RuntimeSession, - state: &mut SessionState, - method: Option<&str>, - params: Option<&Value>, -) -> Vec { - let (Some(method), Some(server)) = (method, state.plugin_server.as_mut()) else { - return Vec::new(); - }; - let observation = server.observe(method, params.unwrap_or(&Value::Null)); - if let Some(forwarded) = observation.forward { - push_negotiated_provider_event(session, state, forwarded, "pluginServerNotification"); - } - observation - .released - .into_iter() - .flat_map(|held| release_held_plugin_server_request(session, state, held)) - .collect() -} - -/// Sends a request whose gates opened. It keeps the deadline it was -/// admitted with, so the time spent waiting counts against its timeout. -fn release_held_plugin_server_request( - session: &RuntimeSession, - state: &mut SessionState, - held: plugin_server::HeldRequest, -) -> Vec { - let registered = register_plugin_server_request( - state, - &held.operation_id, - &held.method, - held.params, - held.created_at, - held.deadline, - ); - match registered { - Ok((messages, _)) => messages, - Err(error) => { - let failure = runtime_error( - session, - "transportFailed", - "request", - Some(&held.method), - None, - "Could not encode the language-server request.", - Some(&error.message), - None, - ); - push_request_event( - session, - state, - &held.operation_id, - &held.method, - None, - Some(failure), - ); - Vec::new() - } - } -} - -/// Cancels a plugin-server request that never reached the server, because it -/// waits behind a gate or joined a running command. Nothing is written. -fn cancel_unsent_plugin_server_request( - session: &RuntimeSession, - state: &mut SessionState, - operation_id: &str, -) { - let method = state - .plugin_server - .as_ref() - .and_then(|server| server.method_of(operation_id)) - .unwrap_or_default() - .to_string(); - let error = runtime_error( - session, - "requestCancelled", - "request", - Some(&method), - None, - "Language-server request was cancelled.", - None, - None, - ); - push_request_event(session, state, operation_id, &method, None, Some(error)); -} - -/// What the backend receives for a plugin-owned server's reply: the result -/// untouched, so no Lithe normalizer reinterprets the server's own protocol, -/// or the server's error code and message as details of -/// `plugin.host.languageServerError`. -fn plugin_server_answer( - session: &RuntimeSession, - method: &str, - reply: &Value, -) -> (Option, Option) { - use super::plugin_function::{bounded_host_message, codes, MAX_RESULT_BYTES}; - let failure = |code: &str, message: &str| { - runtime_error( - session, - code, - "request", - Some(method), - None, - message, - None, - None, - ) - }; - if let Some(upstream) = reply.get("error") { - let mut error = failure( - codes::HOST_LANGUAGE_SERVER_ERROR, - "The language server returned an error.", - ); - let message = upstream - .get("message") - .and_then(Value::as_str) - .map(bounded_host_message); - error.details = Some(json!({ "code": upstream.get("code"), "message": message })); - return (None, Some(error)); - } - let result = reply.get("result").cloned().unwrap_or(Value::Null); - let size = serde_json::to_vec(&result).map_or(usize::MAX, |bytes| bytes.len()); - if size > MAX_RESULT_BYTES { - let error = failure( - codes::HOST_LIMIT_EXCEEDED, - "The language server's result is too large.", - ); - return (None, Some(error)); - } - (Some(json!({ "value": result })), None) -} - -fn cancel_stale_document_requests_locked( - session: &RuntimeSession, - state: &mut SessionState, - uri: &str, - document_version: i64, -) -> Vec { - let stale_ids: Vec = state - .pending - .iter() - .filter(|(_, pending)| { - pending.document_uri.as_deref() == Some(uri) - && pending - .document_version - .is_some_and(|version| version < document_version) - && is_stale_sensitive_method(&pending.method) - }) - .map(|(request_id, _)| request_id.clone()) - .collect(); - let mut messages = Vec::new(); - for request_id in stale_ids { - let Some(pending) = state.pending.remove(&request_id) else { - continue; - }; - state.client.pending_requests.remove(&request_id); - state.client.pending_semantic_legends.remove(&request_id); - if let Some(operation_id) = pending.operation_id.as_deref() { - state.request_by_operation.remove(operation_id); - state.function_by_operation.remove(operation_id); - let error = runtime_error( - session, - "staleDocumentVersion", - "request", - Some(&pending.method), - Some(uri), - "The document changed before the language-server result arrived.", - Some("A newer document version superseded this request."), - None, - ); - push_request_event( - session, - state, - operation_id, - &pending.method, - None, - Some(error), - ); - } - messages.push( - json!({ - "jsonrpc": "2.0", - "method": "$/cancelRequest", - "params": { "id": request_id } - }) - .to_string(), - ); - } - messages -} - -fn is_stale_sensitive_method(method: &str) -> bool { - matches!( - method, - "textDocument/completion" - | "textDocument/hover" - | "textDocument/inlayHint" - | "textDocument/foldingRange" - | "textDocument/codeLens" - | "codeLens/resolve" - | "textDocument/implementation" - ) -} - -fn push_diagnostics_event( - session: &RuntimeSession, - state: &mut SessionState, - uri: &str, - version: Option, - diagnostics: Vec, -) { - let sequence = take_sequence(state); - enqueue_runtime_event( - session, - state, - LspRuntimeEvent { - kind: "diagnostics".to_string(), - channel: None, - plugin_id: None, - role: None, - event: None, - fields: None, - catalog_level: None, - 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, - completion_options: None, - server_info: None, - level: None, - message: None, - detail: None, - }, - ); -} - -fn push_semantic_tokens_refresh_event(session: &RuntimeSession, state: &mut SessionState) { - let sequence = take_sequence(state); - enqueue_runtime_event( - session, - state, - LspRuntimeEvent { - kind: "semanticTokensRefresh".to_string(), - channel: None, - plugin_id: None, - role: None, - event: None, - fields: None, - catalog_level: None, - 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, - completion_options: None, - server_info: None, - level: None, - message: None, - detail: None, - }, - ); -} - -fn push_features_event( - session: &RuntimeSession, - state: &mut SessionState, - capabilities: Vec, -) { - let completion_options = super::client::completion_options(&state.client); - let sequence = take_sequence(state); - enqueue_runtime_event( - session, - state, - LspRuntimeEvent { - kind: "featuresChanged".to_string(), - channel: None, - plugin_id: None, - role: None, - event: None, - fields: None, - catalog_level: None, - 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), - completion_options, - 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); - enqueue_runtime_event( - session, - state, - LspRuntimeEvent { - kind: "serverInfoChanged".to_string(), - channel: None, - plugin_id: None, - role: None, - event: None, - fields: None, - catalog_level: None, - 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, - completion_options: None, - server_info: Some(info), - level: None, - message: None, - detail: None, - }, - ); -} - -/// Accepts only negotiated scalar plugin facts. Persistence and overload accounting -/// reuse the bounded Core handoff instead of copying protocol params into v1 detail. -fn receive_plugin_diagnostic( - session: &RuntimeSession, - state: &mut SessionState, - params: Option<&Value>, -) { - let Some(plugin) = session.plugin_id.as_deref() else { - return; - }; - let plugin_identity = SessionLogIdentity::new( - SessionRole::Backend, - &session.provider_id, - Some(plugin), - None, - ); - let plugin = plugin_identity.segment(); - let valid = state - .client - .server_capabilities - .iter() - .any(|name| name == super::plugin_logging::FEATURE) - .then(|| { - super::plugin_logging::decode( - plugin, - &state.client.plugin_functions, - params.unwrap_or(&Value::Null), - ) - }) - .flatten(); - let Some(fact) = valid else { - if !state.rejected_plugin_diagnostics { - state.rejected_plugin_diagnostics = true; - if crate::diagnostics::core_events::record( - "plugins.diagnostics.rejected", - BTreeMap::from([ - ("plugin".into(), json!(plugin)), - ("code".into(), json!("invalid_diagnostic")), - ]), - None, - ) - .is_err() - { - crate::diagnostics::core_events::note_dropped(); - } - } - return; - }; - if state.plugin_diagnostics_window.elapsed() >= Duration::from_secs(60) { - state.plugin_diagnostics_window = Instant::now(); - state.plugin_diagnostics_count = 0; - } - if state.plugin_diagnostics_count >= MAX_PLUGIN_DIAGNOSTICS_PER_MINUTE { - crate::diagnostics::core_events::note_dropped(); - return; - } - state.plugin_diagnostics_count += 1; - // SDK contexts carry wire request IDs, not reusable UI operation IDs. Only - // a live request owned by this session may extend the host's correlation. - let operation = fact.wire_operation.as_deref().and_then(|wire| { - state - .pending - .get(wire) - .and_then(|pending| pending.operation_id.clone()) - }); - if crate::diagnostics::core_events::record(fact.event, fact.fields, operation).is_err() { - crate::diagnostics::core_events::note_dropped(); - } -} - -fn push_log_event( - session: &RuntimeSession, - state: &mut SessionState, - level: &str, - identity: impl Into, - message: &str, - detail: Option, -) { - push_classified_log_event(session, state, level, identity, message, detail, None); -} - -fn push_issue_log_event( - session: &RuntimeSession, - state: &mut SessionState, - level: &str, - stage: &str, - code: &str, - identity: impl Into, - message: &str, - detail: Option, -) { - let error = runtime_error(session, code, stage, None, None, message, None, None); - push_classified_log_event( - session, - state, - level, - identity, - message, - detail, - Some(error), - ); -} - -fn push_classified_log_event( - session: &RuntimeSession, - state: &mut SessionState, - level: &str, - identity: impl Into, - message: &str, - detail: Option, - error: Option, -) { - let input = identity.into(); - let (event_id, fields) = - input - .identity - .fact(&session.log_identity, &input.fields, error.as_ref()); - let sequence = take_sequence(state); - enqueue_runtime_event( - session, - state, - LspRuntimeEvent { - kind: "log".to_string(), - channel: None, - plugin_id: None, - role: None, - event: Some(event_id), - fields: Some(fields), - catalog_level: None, - 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, - capabilities: None, - completion_options: 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>, - 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), - message: message.to_string(), - underlying_message: underlying.map(ToString::to_string), - process_exit_code, - plugin_error_code: None, - details: None, - } -} - -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)| { - matches!( - pending.kind, - PendingKind::Feature | PendingKind::PluginServer - ) - }) - .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); - state.client.pending_semantic_legends.remove(&request_id); - if let Some(operation_id) = pending.operation_id.as_deref() { - state.request_by_operation.remove(operation_id); - state.function_by_operation.remove(operation_id); - let error = runtime_error( - session, - code, - stage, - Some(&pending.method), - pending.document_uri.as_deref(), - message, - None, - exit_code, - ); - push_request_event( - session, - state, - operation_id, - &pending.method, - None, - Some(error), - ); - } - } - // Requests held behind a gate have no pending entry, so they end here. - let held = state - .plugin_server - .as_ref() - .map(|server| server.all_held()) - .unwrap_or_default(); - for waiter in held { - let error = runtime_error( - session, - code, - stage, - Some(&waiter.method), - None, - message, - None, - exit_code, - ); - push_request_event( - session, - state, - &waiter.operation_id, - &waiter.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()); - } -} - -/// Withdraws the session registry when shutdown or process failure ends its lifetime. -fn clear_function_registry(session: &RuntimeSession, state: &mut SessionState) { - let removed: Vec<_> = std::mem::take(&mut state.client.plugin_functions) - .into_iter() - .collect(); - if !removed.is_empty() { - push_functions_event( - session, - state, - json!({ "functions": [], "removed": removed }), - ); - } -} - -fn clear_runtime_state(session: &RuntimeSession, state: &mut SessionState) { - clear_runtime_diagnostics(session, state); - state.client.initialized = false; - state.client.text_document_save_include_text = None; - state.client.shutdown_requested = false; - state.client.server_capabilities.clear(); - state.client.completion_providers.clear(); - state.client.open_documents.clear(); - state.client.pending_requests.clear(); - state.pending.clear(); - state.request_by_operation.clear(); - state.function_by_operation.clear(); - clear_function_registry(session, state); - state.client.vcs_provider = None; - state.client.toolchain_providers.clear(); - state.client.keymap_import_providers.clear(); - state.linked_document_paths = LinkedDocumentPaths::default(); - state.deadline_cancellations.clear(); - state.maintenance_write_deadline = None; - state.relay_write_deadline = None; - state.pending_workspace_file_changes.clear(); - state.initialize_deadline = None; - state.shutdown_deadline = None; - push_features_event(session, state, Vec::new()); -} - -/// Resolves the text of a document that has not been opened on the wire yet. -/// -/// Hosts may send only ranged edits, which then extend the text Core retained. -/// Edits for a document Core never received cannot be resolved, so the host -/// resends the full text. -fn unopened_document_text( - stored: Option<&LspClientDocument>, - text: String, - changes: &[LspDocumentContentChange], -) -> Result { - if !text.is_empty() || changes.is_empty() { - return Ok(text); - } - let Some(stored) = stored else { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Cannot apply ranged edits to a document Core has not received.", - ) - .with_details("documentNotSynchronized")); - }; - crate::lsp::apply_content_changes_sequential(&stored.text, changes) -} - -fn flush_queued_documents_locked(state: &mut SessionState) -> Result, CoreError> { - 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.to_string(), - })?; - state.client = response.state; - messages.extend(response.messages); - } - if !state.pending_workspace_file_changes.is_empty() { - let changes = std::mem::take(&mut state.pending_workspace_file_changes); - messages.push(workspace_file_changes_notification(changes)); - } - Ok(messages) -} - -fn workspace_file_changes_notification( - changes: BTreeMap, -) -> String { - let changes: Vec<_> = changes - .into_iter() - .map(|(uri, kind)| { - let event_type = match kind { - WorkspaceFileChangeKind::Created => 1, - WorkspaceFileChangeKind::Changed => 2, - WorkspaceFileChangeKind::Deleted => 3, - }; - json!({ "uri": uri, "type": event_type }) - }) - .collect(); - json!({ - "jsonrpc": "2.0", - "method": "workspace/didChangeWatchedFiles", - "params": { "changes": changes } - }) - .to_string() -} - -#[cfg(test)] -#[path = "engine_function_relay_tests.rs"] -mod function_relay_tests; - -#[cfg(test)] -mod tests { - use super::super::scripted::ScriptedServer; - use super::*; - /// The capabilities every test needs to reach `Ready` with a usable feature - /// surface. Individual tests narrow or extend this. - fn ready_capabilities() -> Value { - json!({ - "hoverProvider": true, - "definitionProvider": true, - "completionProvider": {}, - "renameProvider": true - }) - } - - pub(super) fn start_request(server: &ScriptedServer) -> StartServerRequest { - let _ = server; - StartServerRequest { - provider_id: "gopls".to_string(), - role: SessionRole::LanguageServer, - log_subject: None, - plugin_id: None, - startup_operation: None, - host_services: Vec::new(), - host_options: Default::default(), - toolchain_providers: Vec::new(), - keymap_import_providers: Vec::new(), - session_policy: None, - executable_path: "/usr/bin/scripted-server".to_string(), - arguments: vec!["--stdio".to_string()], - environment: BTreeMap::new(), - root_uri: "file:///workspace".to_string(), - working_directory: "/workspace".to_string(), - initialization_options: None, - workspace_configuration: None, - initialize_timeout_milliseconds: 10_000, - request_timeout_milliseconds: 10_000, - shutdown_timeout_milliseconds: 10_000, - } - } - - #[test] - fn native_owner_keeps_failed_initialize_identity_until_verified_disposal() { - let server = ScriptedServer::new(); - server.break_input(); - server.block_cleanup(true); - let engine = LspEngine::with_launcher(server.launcher()); - let registered = Mutex::new(None); - let result = engine.start_server_owned(start_request(&server), |id| { - // Registration precedes the very first write, including its failure. - assert!(server.messages().is_empty()); - *registered.lock().unwrap() = Some(id.to_string()); - }); - assert!(result.is_err()); - let id = registered - .into_inner() - .unwrap() - .expect("native owner received the session"); - assert!(engine.session(&id).is_ok()); - assert!(engine.destroy(&id).is_err()); - assert!( - engine.session(&id).is_ok(), - "failed disposal must retain the process owner" - ); - server.block_cleanup(false); - engine - .destroy(&id) - .expect("verified cleanup allows destruction"); - assert!(engine.session(&id).is_err()); - } - - #[test] - fn wire_owner_retries_failed_initialize_disposal_before_another_start() { - let server = ScriptedServer::new(); - server.break_input(); - server.block_cleanup(true); - let engine = LspEngine::with_launcher(server.launcher()); - assert!(engine.start_server(start_request(&server)).is_err()); - let id = engine - .lock_failed_starts() - .unwrap() - .first() - .cloned() - .unwrap(); - let session = engine.session(&id).unwrap(); - let events = session.poll_events().unwrap(); - assert!(events.iter().any(|event| { - event - .error - .as_ref() - .is_some_and(|error| error.code == "transportFailed") - })); - let next_id = engine.next_session_id.load(Ordering::Relaxed); - let error = engine.start_server(start_request(&server)).unwrap_err(); - assert!(matches!(error.code, ErrorCode::ProcessFailed)); - let error = engine - .start_server_owned(start_request(&server), |_| { - panic!("cleanup must precede spawn") - }) - .unwrap_err(); - assert!(matches!(error.code, ErrorCode::ProcessFailed)); - assert_eq!(engine.next_session_id.load(Ordering::Relaxed), next_id); - assert_eq!(engine.lock_sessions().unwrap().len(), 1); - assert!(Arc::ptr_eq(&session, &engine.session(&id).unwrap())); - server.block_cleanup(false); - // The original transport remains broken, but retry first reclaims the - // old owner and also disposes the replacement's failed initialization. - assert!(engine.start_server(start_request(&server)).is_err()); - assert_eq!(engine.next_session_id.load(Ordering::Relaxed), next_id + 1); - assert!(engine.lock_failed_starts().unwrap().is_empty()); - assert!(engine.lock_sessions().unwrap().is_empty()); - } - - #[test] - fn wire_owner_cleans_at_most_one_failed_start_before_rejecting_a_backlog() { - let server = ScriptedServer::new(); - server.break_input(); - let engine = LspEngine::with_launcher(server.launcher()); - let mut ids = BTreeSet::new(); - for _ in 0..2 { - let mut id = None; - assert!(engine - .start_server_owned(start_request(&server), |registered| { - id = Some(registered.to_owned()); - }) - .is_err()); - ids.insert(id.unwrap()); - } - // Simulate two wire starts already in flight before either failure - // established the cleanup barrier. - assert_eq!(ids.len(), 2); - *engine.lock_failed_starts().unwrap() = ids; - let next_id = engine.next_session_id.load(Ordering::Relaxed); - let error = engine.start_server(start_request(&server)).unwrap_err(); - assert!(matches!(error.code, ErrorCode::ProcessFailed)); - assert_eq!(engine.next_session_id.load(Ordering::Relaxed), next_id); - assert_eq!(engine.lock_sessions().unwrap().len(), 1); - assert_eq!(engine.lock_failed_starts().unwrap().len(), 1); - engine.cleanup_failed_start().unwrap(); - assert!(engine.lock_sessions().unwrap().is_empty()); - assert!(engine.lock_failed_starts().unwrap().is_empty()); - } - - #[test] - fn invalid_initial_configuration_is_rejected_before_launch() { - let server = ScriptedServer::new(); - let engine = LspEngine::with_launcher(server.launcher()); - let mut request = start_request(&server); - request.workspace_configuration = Some(json!(["not an object"])); - assert!(engine.start_server(request).is_err()); - assert!(!server.was_launched()); - } - - /// An engine with a scripted server behind it, plus the started session. - struct Harness { - engine: LspEngine, - server: ScriptedServer, - session_id: String, - /// Events are drained by every poll, so the harness accumulates them and - /// tests assert against the whole history. - events: Vec, - } - - struct IndexingPolicyFixture { - root: PathBuf, - revision: u64, - } - - impl IndexingPolicyFixture { - fn new(root: PathBuf) -> Self { - let mut fixture = Self { root, revision: 0 }; - fixture.exclude(&[]); - fixture - } - - fn exclude(&mut self, paths: &[&str]) { - self.revision = crate::project::indexing::configure( - serde_json::from_value(json!({ - "root": self.root, "manualExcludedDirectories": paths, - "includedDirectories": [], "gitIgnoreEnabled": false - })) - .unwrap(), - ) - .unwrap() - .revision; - } - } - - impl Drop for IndexingPolicyFixture { - fn drop(&mut self) { - let _ = crate::project::indexing::release( - crate::project::indexing::ReleaseIndexingRequest { - root: self.root.to_string_lossy().into_owned(), - owner_id: None, - revision: self.revision, - }, - ); - } - } - - /// A throw-away workspace with a nested directory tree, removed on drop. - struct TemporaryWorkspace { - root: PathBuf, - } - - impl TemporaryWorkspace { - fn recursive(label: &str) -> Self { - static NEXT_ID: AtomicU64 = AtomicU64::new(1); - let root = std::env::temp_dir().join(format!( - "lithe-lsp-{label}-{}-{}", - std::process::id(), - NEXT_ID.fetch_add(1, Ordering::Relaxed) - )); - std::fs::create_dir_all(root.join("reactor/module-a/nested")) - .expect("nested fixture should be creatable"); - std::fs::write( - root.join("reactor/module-a/nested/main.go"), - "package main\n", - ) - .expect("fixture file should be writable"); - Self { root } - } - - fn configure(&self, request: &mut StartServerRequest) { - request.root_uri = url::Url::from_directory_path(&self.root) - .expect("fixture root should convert to a URI") - .to_string(); - request.working_directory = self.root.to_string_lossy().into_owned(); - } - } - - impl Drop for TemporaryWorkspace { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.root); - } - } - - impl Harness { - fn start(configure: impl FnOnce(&mut StartServerRequest)) -> Self { - let server = ScriptedServer::new(); - let engine = LspEngine::with_launcher(server.launcher()); - let mut request = start_request(&server); - configure(&mut request); - let started = engine - .start_server(request) - .expect("the server should start"); - Self { - engine, - server, - session_id: started.session_id, - events: Vec::new(), - } - } - - fn ready() -> Self { - let mut harness = Self::start(|_| {}); - harness.server.complete_initialize(ready_capabilities()); - harness.await_state(LspLifecycleState::Ready); - harness - } - - fn session(&self) -> Arc { - self.engine - .session(&self.session_id) - .expect("the session should be registered") - } - - fn poll(&mut self) -> &[LspRuntimeEvent] { - let events = self - .session() - .poll_events() - .expect("polling should succeed"); - self.events.extend(events); - &self.events - } - - /// Waits until the session reports `lifecycle`, draining events as it - /// goes so nothing is lost to the poll that observes the transition. - fn await_state(&mut self, lifecycle: LspLifecycleState) { - let deadline = Instant::now() + Duration::from_secs(5); - while Instant::now() < deadline { - self.poll(); - if self.snapshot().state == lifecycle { - // The transition can happen between the poll and snapshot; - // drain once more so its co-published events are retained. - self.poll(); - return; - } - thread::sleep(Duration::from_millis(2)); - } - self.poll(); - panic!( - "session stayed in {:?} instead of reaching {lifecycle:?}", - self.snapshot().state - ); - } - - /// Waits until an event matching `matches` has been observed. - fn await_event(&mut self, matches: impl Fn(&LspRuntimeEvent) -> bool) -> &LspRuntimeEvent { - let deadline = Instant::now() + Duration::from_secs(5); - while Instant::now() < deadline { - self.poll(); - if self.events.iter().any(&matches) { - break; - } - thread::sleep(Duration::from_millis(2)); - } - self.events - .iter() - .find(|event| matches(event)) - .expect("the expected runtime event was never emitted") - } - - fn snapshot(&self) -> EngineSnapshot { - self.session().snapshot().expect("snapshot should succeed") - } - - fn sync(&self, uri: &str, text: &str) { - self.session() - .sync_document(SyncDocumentRequest { - session_id: self.session_id.clone(), - uri: uri.to_string(), - language_id: "go".to_string(), - text: text.to_string(), - content_changes: Vec::new(), - }) - .expect("syncing a document should succeed"); - } - - /// Issues a feature request and returns its opaque operation ID. - fn request(&self, operation: LspSemanticOperation, uri: &str) -> String { - let operation_id = self.engine.next_operation_id(); - self.session() - .request( - SemanticRequest { - query: None, - session_id: self.session_id.clone(), - operation_id: Some(operation_id.clone()), - operation, - uri: Some(uri.to_string()), - position: Some(LspPosition { - line: 0, - utf16_column: 0, - }), - new_name: None, - range: None, - diagnostics: Vec::new(), - completion_context: None, - completion_item: None, - code_action: None, - command: None, - formatting_options: None, - include_declaration: None, - }, - operation_id.clone(), - ) - .expect("a ready session should accept the request"); - operation_id - } - - /// The recorded JSON-RPC message with the given request ID. - fn written_request(&self, request_id: &str) -> Value { - self.server - .messages() - .into_iter() - .find(|message| message["id"] == request_id) - .expect("the request should have been written") - } - - /// The notification the server received for `method`, if any. - fn notification(&self, method: &str) -> Option { - self.server - .messages() - .into_iter() - .find(|message| message.get("method").and_then(Value::as_str) == Some(method)) - } - } - - /// A real cold Windows language server can take longer than ten seconds. - /// Exercise the omitted-field default with an injected instant: it must - /// allow that handshake while still disposing of a server that never replies. - #[test] - fn default_initialize_budget_survives_cold_start_and_still_expires() { - let parsed: StartServerRequest = serde_json::from_value(json!({ - "providerId": "cold-fixture", "executablePath": "/scripted-server", - "rootUri": "file:///workspace", "workingDirectory": "/workspace" - })) - .unwrap(); - let harness = Harness::start(|request| { - request.initialize_timeout_milliseconds = parsed.initialize_timeout_milliseconds; - }); - let session = harness.session(); - let now = Instant::now(); - session.expire_deadlines_at(now + Duration::from_secs(15)); - assert_eq!(harness.snapshot().state, LspLifecycleState::Initializing); - session.expire_deadlines_at(now + Duration::from_secs(61)); - assert_eq!(harness.snapshot().state, LspLifecycleState::Failed); - assert!(session.process.exit_status().is_some()); - } - - /// Criterion 1: a spawned process that never initializes cannot become ready. - #[test] - fn a_server_that_never_answers_initialize_fails_instead_of_becoming_ready() { - let mut harness = Harness::start(|request| { - request.initialize_timeout_milliseconds = 30; - }); - harness.await_state(LspLifecycleState::Failed); - - let states: Vec<_> = harness - .events - .iter() - .filter_map(|event| event.state) - .collect(); - assert!( - !states.contains(&LspLifecycleState::Ready), - "an uninitialized session must never pass through Ready: {states:?}" - ); - let failure = harness - .events - .iter() - .find_map(|event| event.error.as_ref()) - .expect("the timeout should be reported as a runtime error"); - assert_eq!(failure.code, "initializeTimeout"); - assert_eq!(failure.stage, "initialize"); - } - - /// Criterion 2: an initialize error cannot become ready. - #[test] - fn an_initialize_error_response_fails_the_session() { - let mut harness = Harness::start(|_| {}); - let id = harness - .server - .await_request("initialize") - .expect("initialize should be sent"); - harness.server.send(json!({ - "jsonrpc": "2.0", - "id": id, - "error": { "code": -32603, "message": "workspace is unsupported" } - })); - harness.await_state(LspLifecycleState::Failed); - - assert!(!harness.snapshot().initialized); - let failure = harness - .events - .iter() - .find_map(|event| event.error.as_ref()) - .expect("the rejection should be reported as a runtime error"); - assert_eq!(failure.code, "initializeFailed"); - assert!( - failure - .underlying_message - .as_deref() - .is_some_and(|detail| detail.contains("workspace is unsupported")), - "the server's own message must survive into the error: {:?}", - failure.underlying_message - ); - } - - /// Messages the scripted server received for `method`, in order. - fn notifications(harness: &Harness, method: &str) -> Vec { - harness - .server - .messages() - .into_iter() - .filter(|message| message.get("method").and_then(Value::as_str) == Some(method)) - .collect() - } - - #[test] - fn structured_logging_warnings_keep_source_stage_and_code() { - let harness = Harness::ready(); - let session = harness.session(); - session.log_issue( - "warn", - "processCleanup", - "processCleanupFailed", - EngineLog::ProcessCleanupFailed, - "Cleanup failed", - None, - ); - let event = session - .poll_events() - .unwrap() - .into_iter() - .find(|event| event.kind == "log") - .unwrap(); - assert_eq!(event.level.as_deref(), Some("warn")); - let error = event.error.unwrap(); - assert_eq!(error.code, "processCleanupFailed"); - assert_eq!(error.stage, "processCleanup"); - assert!(error.underlying_message.is_none()); - for (stage, code) in [ - ("stderr", "upstreamStderr"), - ("stderr", "stderrReadFailed"), - ("shutdown", "shutdownTimeout"), - ] { - session.log_issue( - "warning", - stage, - code, - if code == "upstreamStderr" { - EngineLog::StderrReceived - } else if code == "stderrReadFailed" { - EngineLog::StderrReadFailed - } else { - EngineLog::ShutdownTimedOut - }, - "Updated diagnostic wording", - None, - ); - let event = session - .poll_events() - .unwrap() - .into_iter() - .find(|event| event.kind == "log") - .unwrap(); - assert_eq!(event.level.as_deref(), Some("warning")); - let error = event.error.unwrap(); - assert_eq!(error.stage, stage); - assert_eq!(error.code, code); - } - // Ordinary logs must remain unclassified even when their wording matches an issue. - session.log( - "warning", - EngineLog::StderrReceived, - "Language-server stderr", - None, - ); - let event = session - .poll_events() - .unwrap() - .into_iter() - .find(|event| event.kind == "log") - .unwrap(); - assert!(event.error.is_none()); - } - - #[test] - fn lifecycle_catalog_identity_preserves_v1_event_without_extra_log() { - for role in [ - SessionRole::LanguageServer, - SessionRole::Vcs, - SessionRole::Build, - SessionRole::Toolchain, - SessionRole::Backend, - ] { - let harness = Harness::start(|request| { - request.role = role; - request.provider_id = "c++".into(); - request.log_subject = Some("git".into()); - request.plugin_id = Some("io.example.tools".into()); - }); - let session = harness.session(); - session.active.store(false, Ordering::Release); - session.kill_process(); - let mut state = session.state.lock().unwrap(); - state.events.clear(); - let sequence = state.next_sequence; - transition_locked(&session, &mut state, LspLifecycleState::Stopped, None); - assert!(!state.events.iter().any(|e| e.kind == "log")); - let event = state.events.front().unwrap(); - let (id, fields) = EngineLog::SessionUpdated.fact( - &session.log_identity, - &json!({"state": "stopped"}), - None, - ); - assert_eq!(event.event.as_deref(), Some(id.as_str())); - assert_eq!(event.fields.as_ref(), Some(&fields)); - assert_eq!( - event.channel.as_deref(), - Some(session.log_identity.channel().as_str()) - ); - assert_eq!(event.role, Some(role)); - assert_eq!(event.plugin_id.as_deref(), Some("io.example.tools")); - assert_eq!( - event.catalog_level, - Some(crate::diagnostics::record::LogLevel::Debug) - ); - let mut legacy = serde_json::to_value(event).unwrap(); - for key in [ - "channel", - "pluginId", - "role", - "event", - "fields", - "catalogLevel", - ] { - legacy.as_object_mut().unwrap().remove(key); - } - assert_eq!( - legacy, - json!({"type": "stateChanged", "sequence": sequence, - "providerId": "c++", "sessionId": session.id, "state": "stopped"}) - ); - state.events.clear(); - let error = runtime_error( - &session, - "processCleanupFailed", - "cleanup", - None, - None, - "Engine process cleanup failed", - None, - None, - ); - let (id, fields) = EngineLog::ProcessCleanupFailed.fact( - &session.log_identity, - &Value::Null, - Some(&error), - ); - push_cleanup_fact(&session, &mut state, id, fields, error); - let event = state.events.front().unwrap(); - assert_eq!( - event.channel.as_deref(), - Some(session.log_identity.channel().as_str()) - ); - assert_eq!( - event.catalog_level, - Some(crate::diagnostics::record::LogLevel::Warn) - ); - assert_eq!(event.kind, "log"); - assert_eq!(event.level.as_deref(), Some("warning")); - assert_eq!( - event.message.as_deref(), - Some("Engine process cleanup failed") - ); - assert!(event.detail.is_none() && event.state.is_none()); - assert_eq!( - event.error.as_ref().map(|error| error.code.as_str()), - Some("processCleanupFailed") - ); - } - } - - #[test] - fn v1_detail_is_independent_of_catalog_fields() { - let harness = Harness::start(|_| {}); - let session = harness.session(); - struct Cleanup(Arc); - impl Drop for Cleanup { - fn drop(&mut self) { - self.0.active.store(false, Ordering::Release); - self.0.kill_process(); - } - } - let _cleanup = Cleanup(session.clone()); - session.poll_events().unwrap(); - let line = " upstream diagnostic "; - session.log_issue( - "warning", - "stderr", - "upstreamStderr", - EngineLog::StderrReceived.with_fields(json!({"diagnostic": line, "truncated": false})), - "Language-server stderr", - Some(line.into()), - ); - let event = session - .poll_events() - .unwrap() - .into_iter() - .find(|e| e.event.as_deref() == Some("lsp.stderr.received")) - .unwrap(); - assert_eq!(event.level.as_deref(), Some("warning")); - assert_eq!(event.message.as_deref(), Some("Language-server stderr")); - assert_eq!(event.detail.as_deref(), Some(line)); - assert_eq!(event.fields.as_ref().unwrap()["diagnostic"], line); - assert_eq!(event.fields.as_ref().unwrap()["truncated"], false); - // A JSON-looking v1 detail cannot supply or overwrite a catalog fact. - session.log( - "info", - EngineLog::StderrReceived.with_fields(json!({"diagnostic": "kept", "truncated": true})), - "Language-server stderr", - Some("{\"diagnostic\":\"overwritten\",\"truncated\":false}".into()), - ); - let event = session.poll_events().unwrap().into_iter().next().unwrap(); - let fields = event.fields.unwrap(); - assert_eq!(fields["diagnostic"], "kept"); - assert_eq!(fields["truncated"], true); - } - - #[test] - fn engine_logging_wire_fixture_preserves_defaults_and_routes_new_commands() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../../../fixtures/diagnostics/engine-logging-v2.json" - )) - .unwrap(); - let request: StartServerRequest = serde_json::from_value(fixture["start"].clone()).unwrap(); - assert_eq!(request.role, SessionRole::Vcs); - assert_eq!( - SessionLogIdentity::new( - request.role, - &request.provider_id, - request.plugin_id.as_deref(), - request.log_subject.as_deref() - ) - .channel(), - "vcs/git" - ); - let mut legacy = fixture["start"].clone(); - legacy.as_object_mut().unwrap().remove("role"); - legacy.as_object_mut().unwrap().remove("logSubject"); - assert_eq!( - serde_json::from_value::(legacy) - .unwrap() - .role, - SessionRole::LanguageServer - ); - for name in ["trace", "drain", "resolveSubject"] { - assert!(crate::protocol::CoreCommand::parse( - fixture[name]["command"].as_str().unwrap() - ) - .is_some()); - } - let response: Value = serde_json::from_str(&crate::runtime::execute_json( - &fixture["resolveSubject"].to_string(), - )) - .unwrap(); - assert_eq!(response["data"], fixture["resolveSubject"]["result"]); - let request: SetDetailedDiagnosticsRequest = - serde_json::from_value(fixture["trace"]["payload"].clone()).unwrap(); - assert!(request.enabled); - let response: Value = - serde_json::from_str(&crate::runtime::execute_json(&fixture["drain"].to_string())) - .unwrap(); - assert_eq!(response["ok"], true); - assert!(response["data"]["events"].is_array()); - assert!(response["data"]["dropped"].as_u64().is_some()); - } - - #[test] - fn detailed_diagnostics_waits_for_initialized_and_keeps_the_latest_request() { - for enabled in [true, false] { - let harness = Harness::start(|_| {}); - let session = harness.session(); - struct Cleanup(Arc); - impl Drop for Cleanup { - fn drop(&mut self) { - self.0.active.store(false, Ordering::Release); - self.0.kill_process(); - } - } - let _cleanup = Cleanup(session.clone()); - session.set_detailed_diagnostics(true).unwrap(); - if !enabled { - session.set_detailed_diagnostics(false).unwrap(); - } - assert!(!harness - .server - .messages() - .iter() - .any(|m| m["method"] == "$/setTrace")); - assert!(!session.lock_state().unwrap().detailed_diagnostics); - - // Drive the real handshake handler synchronously; no reader timing - // or sleep is needed to establish the notification ordering. - let initialize = harness.server.await_request("initialize").unwrap(); - session - .handle_server_message( - json!({ - "jsonrpc":"2.0", "id":initialize, - "result":{"capabilities":ready_capabilities()} - }) - .to_string(), - ) - .unwrap(); - let methods: Vec<_> = harness - .server - .messages() - .into_iter() - .filter_map(|m| m["method"].as_str().map(str::to_owned)) - .collect(); - if enabled { - let initialized = methods.iter().position(|m| m == "initialized").unwrap(); - let trace = methods.iter().position(|m| m == "$/setTrace").unwrap(); - assert!(trace > initialized); - } else { - assert!(!methods.iter().any(|m| m == "$/setTrace")); - } - assert_eq!(session.lock_state().unwrap().detailed_diagnostics, enabled); - session.set_detailed_diagnostics(false).unwrap(); - assert_eq!( - harness.server.messages().last().unwrap()["params"], - json!({"value":"off"}) - ); - } - } - - #[test] - fn upstream_catalog_messages_trace_toggle_and_v1_fields_coexist() { - let harness = Harness::start(|request| { - request.role = SessionRole::Vcs; - request.log_subject = Some("git".into()); - request.plugin_id = Some("io.example.git".into()); - request.provider_id = "opaque:provider:git".into(); - }); - let session = harness.session(); - struct Cleanup(Arc); - impl Drop for Cleanup { - fn drop(&mut self) { - self.0.active.store(false, Ordering::Release); - self.0.kill_process(); - } - } - let _cleanup = Cleanup(session.clone()); - let initialize = harness.server.await_request("initialize").unwrap(); - session - .handle_server_message( - json!({ - "jsonrpc":"2.0", "id":initialize, "result":{"capabilities":{}} - }) - .to_string(), - ) - .unwrap(); - session.poll_events().unwrap(); - session.log( - "warning", - EngineLog::Dropped.with_fields(json!({"count": 7})), - "Engine stderr lines dropped", - Some("count=7".into()), - ); - let dropped = session - .poll_events() - .unwrap() - .into_iter() - .find(|event| event.event.as_deref() == Some("log.dropped")) - .unwrap(); - assert_eq!(dropped.channel.as_deref(), Some("vcs/git")); - assert_eq!(dropped.fields.unwrap()["count"], 7); - for (method, expected) in [ - ("window/logMessage", "vcs.message.warning.logged"), - ("window/showMessage", "vcs.message.warning.shown"), - ] { - session.handle_server_message(json!({"jsonrpc":"2.0", "method":method, "params":{"type":2,"message":"warning token=private-value"}}).to_string()).unwrap(); - let events = session.poll_events().unwrap(); - let event = events - .iter() - .find(|e| e.event.as_deref() == Some(expected)) - .expect("upstream catalog event"); - assert_eq!(event.channel.as_deref(), Some("vcs/git")); - assert_eq!(event.plugin_id.as_deref(), Some("io.example.git")); - assert_eq!(event.role, Some(SessionRole::Vcs)); - assert_eq!( - event.catalog_level, - Some(crate::diagnostics::record::LogLevel::Warn) - ); - assert_eq!(event.message.as_deref(), Some(method)); - assert!(event.level.is_some() && event.detail.is_some()); - let fields = event.fields.as_ref().unwrap(); - assert_eq!(fields["severity"], "warning"); - assert!(!fields["diagnostic"] - .as_str() - .unwrap() - .contains("private-value")); - } - let trace = json!({"jsonrpc":"2.0", "method":"$/logTrace", "params":{ - "message":"trace token=fixture-secret", "verbose":json!({ - "textDocument":{"text":"private_source_marker"} - }).to_string() - }}) - .to_string(); - session.handle_server_message(trace.clone()).unwrap(); - assert!(!session - .poll_events() - .unwrap() - .iter() - .any(|e| e.event.as_deref() == Some("vcs.trace.received"))); - session.set_detailed_diagnostics(true).unwrap(); - assert_eq!( - harness.server.messages().last().unwrap()["params"], - json!({"value":"verbose"}) - ); - session.handle_server_message(trace.clone()).unwrap(); - let event = session - .poll_events() - .unwrap() - .into_iter() - .find(|e| e.event.as_deref() == Some("vcs.trace.received")) - .unwrap(); - assert_eq!( - event.catalog_level, - Some(crate::diagnostics::record::LogLevel::Debug) - ); - assert_eq!(event.level.as_deref(), Some("info")); - let fields = event.fields.unwrap(); - assert_eq!(fields["diagnostic"], "trace token="); - let encoded = crate::diagnostics::record::encode_log_batch( - serde_json::from_value(json!({"validateCatalog":true,"events":[{ - "ts":"2026-11-03T00:00:00.000Z", "seq":1, "level":"DEBUG", - "event":"vcs.trace.received", "msg":"", "fields":fields - }]})) - .unwrap(), - ); - let line = encoded.results[0].line.as_ref().unwrap(); - assert!(!line.contains("private_source_marker")); - assert!(!line.contains("fixture-secret")); - session.set_detailed_diagnostics(false).unwrap(); - assert_eq!( - harness.server.messages().last().unwrap()["params"], - json!({"value":"off"}) - ); - session.handle_server_message(trace).unwrap(); - assert!(!session - .poll_events() - .unwrap() - .iter() - .any(|e| e.event.as_deref() == Some("vcs.trace.received"))); - assert!( - serde_json::from_value::(json!({"sessionId":"s"})) - .is_err() - ); - } - - #[test] - fn ranged_edits_extend_text_retained_before_the_server_is_ready() { - let mut harness = Harness::start(|request| { - request.provider_id = "gopls".to_string(); - }); - let uri = "file:///workspace/First.java"; - harness.sync(uri, "class First {}"); - let appended = LspDocumentContentChange { - range: Some(LspRange { - start: LspPosition { - line: 0, - utf16_column: 11, - }, - end: LspPosition { - line: 0, - utf16_column: 11, - }, - }), - text: "Renamed".to_string(), - }; - harness - .session() - .sync_document(SyncDocumentRequest { - session_id: harness.session_id.clone(), - uri: uri.to_string(), - language_id: "java".to_string(), - text: String::new(), - content_changes: vec![appended.clone()], - }) - .expect("ranged edits should extend the retained text"); - - harness.server.complete_initialize(ready_capabilities()); - harness.await_state(LspLifecycleState::Ready); - assert!(harness.server.await_notification("textDocument/didOpen")); - let written = String::from_utf8_lossy(&harness.server.written_bytes()).into_owned(); - assert!(written.contains("class FirstRenamed {}")); - - let error = harness - .session() - .sync_document(SyncDocumentRequest { - session_id: harness.session_id.clone(), - uri: "file:///workspace/Unknown.java".to_string(), - language_id: "java".to_string(), - text: String::new(), - content_changes: vec![appended], - }) - .expect_err("edits to an unknown document cannot be resolved"); - assert_eq!(error.details.as_deref(), Some("documentNotSynchronized")); - } - - /// A path descriptor reaches the server with reserved characters escaped. - #[test] - fn native_path_emits_the_canonical_uri_on_the_lsp_wire() { - let harness = Harness::ready(); - let mut payload = json!({"sessionId":harness.session_id, "languageId":"go", "text":"package main", - "uri":{"path":"/workspace/A#B.go","platform":"posix"}}); - crate::lsp::resolve_request_paths("lsp.syncDocument", &mut payload).unwrap(); - let request: SyncDocumentRequest = serde_json::from_value(payload).unwrap(); - harness - .session() - .sync_document(request) - .expect("document should synchronize"); - assert!(harness.server.await_notification("textDocument/didOpen")); - let opened = harness - .server - .messages() - .into_iter() - .find(|message| message["method"] == "textDocument/didOpen") - .unwrap(); - assert_eq!( - opened["params"]["textDocument"]["uri"], - "file:///workspace/A%23B.go" - ); - } - - /// Criterion 3: two syncs emit open version 1 then change version 2. - #[test] - fn consecutive_syncs_open_at_version_one_and_change_to_version_two() { - let harness = Harness::ready(); - let uri = "file:///workspace/main.go"; - harness.sync(uri, "package main"); - harness.sync(uri, "package main\nfunc main() {}"); - - let versions: Vec<_> = harness - .server - .messages() - .into_iter() - .filter_map(|message| { - let method = message.get("method")?.as_str()?.to_string(); - let document = message.get("params")?.get("textDocument")?; - let version = document.get("version")?.as_i64()?; - Some((method, version)) - }) - .collect(); - assert_eq!( - versions, - vec![ - ("textDocument/didOpen".to_string(), 1), - ("textDocument/didChange".to_string(), 2) - ] - ); - assert_eq!(harness.snapshot().open_documents[uri].version, 2); - } - - #[test] - fn all_documents_synced_during_initialize_are_opened_when_ready() { - let mut harness = Harness::start(|_| {}); - let first_uri = "file:///workspace/first.go"; - let second_uri = "file:///workspace/second.go"; - harness.sync(first_uri, "package main\nvar first = 1"); - harness.sync(second_uri, "package main\nvar second = 2"); - - harness.server.complete_initialize(ready_capabilities()); - harness.await_state(LspLifecycleState::Ready); - - let opened_uris: Vec<_> = harness - .server - .messages() - .into_iter() - .filter(|message| { - message.get("method").and_then(Value::as_str) == Some("textDocument/didOpen") - }) - .filter_map(|message| { - message - .get("params")? - .get("textDocument")? - .get("uri")? - .as_str() - .map(ToString::to_string) - }) - .collect(); - assert_eq!( - opened_uris, - vec![first_uri.to_string(), second_uri.to_string()] - ); - let snapshot = harness.snapshot(); - assert_eq!(snapshot.open_documents[first_uri].version, 1); - assert_eq!(snapshot.open_documents[second_uri].version, 1); - } - - #[test] - fn workspace_file_changes_queue_until_initialize_and_collapse_watcher_bursts() { - let mut harness = Harness::start(|_| {}); - harness - .session() - .workspace_files_changed(vec![ - WorkspaceFileChange { - uri: "file:///workspace/pom.xml".to_string(), - kind: WorkspaceFileChangeKind::Changed, - }, - WorkspaceFileChange { - uri: "file:///workspace/src/Main.java".to_string(), - kind: WorkspaceFileChangeKind::Created, - }, - WorkspaceFileChange { - uri: "file:///workspace/src/Main.java".to_string(), - kind: WorkspaceFileChangeKind::Changed, - }, - ]) - .expect("watcher events should queue before initialize"); - assert!(!harness - .server - .messages() - .iter() - .any(|message| { message["method"] == "workspace/didChangeWatchedFiles" })); - - harness.server.complete_initialize(ready_capabilities()); - harness.await_state(LspLifecycleState::Ready); - let notification = harness - .server - .messages() - .into_iter() - .find(|message| message["method"] == "workspace/didChangeWatchedFiles") - .expect("queued watcher events should flush after initialize"); - assert_eq!( - notification["params"]["changes"], - json!([ - { "uri": "file:///workspace/pom.xml", "type": 2 }, - { "uri": "file:///workspace/src/Main.java", "type": 2 } - ]) - ); - } - - /// Criterion 4: a crash fails pending operations with `serverExited`. - #[test] - fn a_crash_fails_every_pending_operation_once_with_server_exited() { - let mut harness = Harness::ready(); - let uri = "file:///workspace/main.go"; - harness.sync(uri, "package main"); - let hover = harness.request(LspSemanticOperation::Hover, uri); - let definition = harness.request(LspSemanticOperation::Definition, uri); - - harness.server.exit(Some(134)); - harness.await_state(LspLifecycleState::Failed); - - let failures: Vec<_> = harness - .events - .iter() - .filter(|event| event.kind == "requestCompleted") - .collect(); - assert_eq!( - failures.len(), - 2, - "each pending operation must fail exactly once" - ); - for event in failures { - let error = event.error.as_ref().expect("a crash cannot yield a result"); - assert_eq!(error.code, "serverExited"); - assert_eq!(error.process_exit_code, Some(134)); - } - let completed: Vec<_> = harness - .events - .iter() - .filter_map(|event| event.operation_id.clone()) - .collect(); - assert!(completed.contains(&hover) && completed.contains(&definition)); - assert!(harness.snapshot().pending_operation_ids.is_empty()); - } - - /// Criterion 5: a request deadline removes the pending request. - /// Criterion 6: a late response after that timeout is ignored. - #[test] - fn a_timed_out_request_is_removed_cancelled_and_deaf_to_its_late_response() { - let mut harness = Harness::start(|request| { - request.request_timeout_milliseconds = 30; - }); - harness.server.complete_initialize(ready_capabilities()); - harness.await_state(LspLifecycleState::Ready); - let uri = "file:///workspace/main.go"; - harness.sync(uri, "package main"); - let operation = harness.request(LspSemanticOperation::Hover, uri); - let request_id = harness - .server - .await_request("textDocument/hover") - .expect("the hover request should reach the server"); - - let timeout = harness - .await_event(|event| { - event - .error - .as_ref() - .is_some_and(|error| error.code == "requestTimeout") - }) - .clone(); - assert_eq!(timeout.operation_id.as_deref(), Some(operation.as_str())); - assert!(harness.snapshot().pending_operation_ids.is_empty()); - // The completion event is queued before the cancellation is written, so - // the wire assertion has to wait for the write rather than assume it. - assert!( - harness.server.await_notification("$/cancelRequest"), - "a timed-out request must be cancelled on the wire" - ); - - // The server answers anyway. Nothing may reach the application: the - // operation has already been completed with its timeout error. - harness.server.send(json!({ - "jsonrpc": "2.0", - "id": request_id, - "result": { "contents": "too late" } - })); - thread::sleep(Duration::from_millis(50)); - harness.poll(); - let completions = harness - .events - .iter() - .filter(|event| event.operation_id.as_deref() == Some(operation.as_str())) - .count(); - assert_eq!( - completions, 1, - "a late response must not complete the operation a second time" - ); - } - - /// Criterion 7: responses from an old session cannot affect a restarted one. - #[test] - fn a_restarted_session_ignores_the_previous_session_s_responses() { - // The first session is taken to Ready and then torn down. Its hover - // request id is recorded first, because every session numbers its - // requests from one: an id alone cannot tell two sessions apart, so only - // per-session pending state can reject a foreign response. - let mut first = Harness::ready(); - let uri = "file:///workspace/main.go"; - first.sync(uri, "package main"); - first.request(LspSemanticOperation::Hover, uri); - let stale_id = first - .server - .await_request("textDocument/hover") - .expect("the hover request should reach the server"); - first.server.exit(Some(0)); - first.await_state(LspLifecycleState::Failed); - first.engine.destroy(&first.session_id).unwrap(); - - let mut restarted = Harness::ready(); - restarted.sync(uri, "package main"); - let operation = restarted.request(LspSemanticOperation::Hover, uri); - assert_eq!( - restarted - .server - .await_request("textDocument/hover") - .as_deref(), - Some(stale_id.as_str()), - "the restarted session must reuse the id, or this proves nothing" - ); - - // Delivered to the new session, the id does match a pending request, so - // isolation cannot rest on ids. It rests on the process: the old - // session's reader thread is gone with its process, so its responses - // have no path into the new session at all. - let before = restarted.snapshot(); - first.server.send(json!({ - "jsonrpc": "2.0", - "id": stale_id, - "result": { "contents": "from the dead session" } - })); - thread::sleep(Duration::from_millis(50)); - restarted.poll(); - assert_eq!( - restarted.snapshot().pending_operation_ids, - before.pending_operation_ids, - "the old session's response must not complete the new session's request" - ); - assert!( - !restarted - .events - .iter() - .any(|event| event.kind == "requestCompleted"), - "no operation may complete from a foreign session's traffic" - ); - - // The new session's own response still lands, so the isolation above is - // not merely a dead session. - restarted.server.send(json!({ - "jsonrpc": "2.0", - "id": stale_id, - "result": { "contents": "from the live session" } - })); - let completion = restarted - .await_event(|event| event.kind == "requestCompleted") - .clone(); - assert_eq!(completion.operation_id.as_deref(), Some(operation.as_str())); - assert_eq!( - completion - .result - .as_ref() - .and_then(|result| result.get("hover")?.get("contents")) - .and_then(Value::as_str), - Some("from the live session") - ); - } - - /// A write that fails mid-session is a transport failure, not a silent drop. - #[test] - fn a_broken_stdin_fails_the_session_and_stops_writing() { - let mut harness = Harness::ready(); - let written = harness.server.written_bytes().len(); - harness.server.break_input(); - let error = harness - .session() - .sync_document(SyncDocumentRequest { - session_id: harness.session_id.clone(), - uri: "file:///workspace/main.go".to_string(), - language_id: "go".to_string(), - text: "package main".to_string(), - content_changes: Vec::new(), - }) - .expect_err("a broken pipe must surface to the caller"); - assert!(matches!(error.code, ErrorCode::ProcessFailed)); - - harness.await_state(LspLifecycleState::Failed); - let failure = harness - .events - .iter() - .find_map(|event| event.error.as_ref()) - .expect("the failure should be reported as a runtime error"); - assert_eq!(failure.code, "transportFailed"); - assert_eq!( - harness.server.written_bytes().len(), - written, - "nothing may be written after the pipe breaks" - ); - } - - /// The stopped session's own late response is dropped rather than acted on, - /// which is the same rule seen from the other side of a restart. - #[test] - fn a_response_to_an_already_failed_session_is_logged_and_dropped() { - let mut harness = Harness::ready(); - let uri = "file:///workspace/main.go"; - harness.sync(uri, "package main"); - harness.request(LspSemanticOperation::Hover, uri); - let request_id = harness - .server - .await_request("textDocument/hover") - .expect("the hover request should reach the server"); - - harness.server.exit(Some(1)); - harness.await_state(LspLifecycleState::Failed); - let completions = harness - .events - .iter() - .filter(|event| event.kind == "requestCompleted") - .count(); - assert_eq!(completions, 1, "the crash already completed the operation"); - - harness.server.send(json!({ - "jsonrpc": "2.0", - "id": request_id, - "result": { "contents": "unreachable" } - })); - thread::sleep(Duration::from_millis(50)); - harness.poll(); - assert_eq!( - harness - .events - .iter() - .filter(|event| event.kind == "requestCompleted") - .count(), - completions, - "a response after the terminal state must not complete anything" - ); - assert_eq!(harness.snapshot().state, LspLifecycleState::Failed); - } - - #[test] - fn indexing_policy_withdrawal_serializes_with_a_checked_document_commit() { - assert_policy_withdrawal_serializes_with_checked_commit(false); - } - - #[test] - fn indexing_policy_withdrawal_serializes_with_checked_diagnostics() { - assert_policy_withdrawal_serializes_with_checked_commit(true); - } - - fn assert_policy_withdrawal_serializes_with_checked_commit(diagnostics: bool) { - let workspace = TemporaryWorkspace::recursive("indexing-document-commit"); - let root = workspace.root.canonicalize().unwrap(); - let mut policy = IndexingPolicyFixture::new(root.clone()); - let mut harness = Harness::start(|request| workspace.configure(request)); - harness.server.complete_initialize(ready_capabilities()); - harness.await_state(LspLifecycleState::Ready); - let uri = url::Url::from_file_path(root.join("reactor/hidden.go")) - .unwrap() - .to_string(); - let session = harness.session(); - let (checked_tx, checked_rx) = std::sync::mpsc::channel(); - let (release_tx, release_rx) = std::sync::mpsc::channel(); - let (sync_done_tx, sync_done_rx) = std::sync::mpsc::channel(); - let syncing = { - let session = session.clone(); - let uri = uri.clone(); - thread::spawn(move || { - let checked = || { - checked_tx.send(()).unwrap(); - release_rx - .recv_timeout(Duration::from_secs(3)) - .expect("document commit was not released"); - }; - let result = if diagnostics { - session.handle_server_message_after_policy_check(json!({"jsonrpc":"2.0","method":"textDocument/publishDiagnostics","params":{ - "uri":uri,"diagnostics":[{"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":1}},"message":"old diagnostic"}] - }}).to_string(), checked) - } else { - session - .sync_document_after_policy_check( - SyncDocumentRequest { - session_id: session.id.clone(), - uri, - language_id: "java".into(), - text: "class Hidden {}".into(), - content_changes: Vec::new(), - }, - checked, - ) - .map(|_| ()) - }; - let _ = sync_done_tx.send(()); - result - }) - }; - checked_rx - .recv_timeout(Duration::from_secs(3)) - .expect("document eligibility was not reached"); - assert!( - matches!( - session.indexing_roots.try_lock(), - Err(std::sync::TryLockError::WouldBlock) - ), - "eligibility and document publication must retain the policy serialization guard" - ); - policy.exclude(&["reactor"]); - let (update_done_tx, update_done_rx) = std::sync::mpsc::channel(); - let updating = { - let session = session.clone(); - let root = root.clone(); - thread::spawn(move || { - let result = apply_indexing_to_sessions(&root, vec![session]); - let _ = update_done_tx.send(()); - result - }) - }; - release_tx.send(()).unwrap(); - sync_done_rx - .recv_timeout(Duration::from_secs(3)) - .expect("document commit did not finish"); - update_done_rx - .recv_timeout(Duration::from_secs(3)) - .expect("policy update did not finish"); - syncing.join().unwrap().unwrap(); - updating.join().unwrap().unwrap(); - assert!(!harness.snapshot().open_documents.contains_key(&uri)); - assert!(!session - .lock_state() - .unwrap() - .client - .diagnostics - .contains_key(&uri)); - if !diagnostics { - assert!(notifications(&harness, "textDocument/didClose") - .iter() - .any(|message| message["params"]["textDocument"]["uri"] == uri)); - } - } - - #[test] - fn indexing_policy_withdraws_queued_file_changes_before_service_readiness() { - let workspace = TemporaryWorkspace::recursive("indexing-pending-file-changes"); - let root = workspace.root.canonicalize().unwrap(); - let mut policy = IndexingPolicyFixture::new(root.clone()); - let mut harness = Harness::start(|request| workspace.configure(request)); - let hidden = url::Url::from_file_path(root.join("reactor/hidden.go")) - .unwrap() - .to_string(); - let visible = url::Url::from_file_path(root.join("visible.go")) - .unwrap() - .to_string(); - harness - .session() - .workspace_files_changed(vec![ - WorkspaceFileChange { - uri: hidden.clone(), - kind: WorkspaceFileChangeKind::Created, - }, - WorkspaceFileChange { - uri: visible.clone(), - kind: WorkspaceFileChangeKind::Created, - }, - ]) - .unwrap(); - assert_eq!( - harness - .session() - .lock_state() - .unwrap() - .pending_workspace_file_changes - .len(), - 2 - ); - policy.exclude(&["reactor"]); - // The policy is installed, but this independent session has not received - // its withdrawal callback yet. Readiness must recheck queued eligibility. - harness.server.complete_initialize(ready_capabilities()); - harness.await_state(LspLifecycleState::Ready); - let changes = notifications(&harness, "workspace/didChangeWatchedFiles"); - assert!(changes.iter().any(|message| message["params"]["changes"] - .as_array() - .unwrap() - .iter() - .any(|change| change["uri"] == visible))); - assert!(!changes.iter().any(|message| message["params"]["changes"] - .as_array() - .unwrap() - .iter() - .any(|change| change["uri"] == hidden))); - } - - #[test] - fn indexing_startup_cancels_when_policy_changes_during_process_launch() { - const CANCELLED_PLUGIN: &str = "io.example.indexing-cancel-launch"; - struct RetireOnLaunch { - inner: Arc, - root: PathBuf, - revision: Arc, - process: Arc>>>, - } - impl LspProcessLauncher for RetireOnLaunch { - fn launch( - &self, - spec: LspProcessSpec, - ) -> Result { - let process = self.inner.launch(spec)?; - *self.process.lock().unwrap() = Some(process.handle.clone()); - let status = crate::project::indexing::configure(serde_json::from_value(json!({ - "root": self.root, "manualExcludedDirectories": ["reactor"], "includedDirectories": [], "gitIgnoreEnabled": false - })).unwrap())?; - self.revision.store(status.revision, Ordering::Release); - Ok(process) - } - } - struct Cleanup { - root: PathBuf, - revision: Arc, - process: Arc>>>, - } - impl Drop for Cleanup { - fn drop(&mut self) { - if let Some(process) = self.process.lock().unwrap().as_ref() { - process.terminate(); - } - let _ = crate::project::indexing::release( - crate::project::indexing::ReleaseIndexingRequest { - root: self.root.to_string_lossy().into_owned(), - owner_id: None, - revision: self.revision.load(Ordering::Acquire), - }, - ); - } - } - for (initially_configured, cleanup_blocked, native_owner) in [ - (false, false, false), - (true, false, false), - (false, true, false), - (true, true, false), - (false, false, true), - (true, false, true), - (false, true, true), - (true, true, true), - ] { - let workspace = TemporaryWorkspace::recursive(&format!( - "indexing-startup-retired-{initially_configured}" - )); - let root = workspace.root.canonicalize().unwrap(); - let installed = if initially_configured { - crate::project::indexing::configure(serde_json::from_value(json!({ - "root":root,"manualExcludedDirectories":[],"includedDirectories":[],"gitIgnoreEnabled":false - })).unwrap()).unwrap().revision - } else { - 0 - }; - let cleanup = Cleanup { - root: root.clone(), - revision: Arc::new(AtomicU64::new(installed)), - process: Arc::new(Mutex::new(None)), - }; - let server = ScriptedServer::new(); - server.block_cleanup(cleanup_blocked); - let engine = LspEngine::with_launcher(Arc::new(RetireOnLaunch { - inner: server.launcher(), - root: root.clone(), - revision: cleanup.revision.clone(), - process: cleanup.process.clone(), - })); - let mut request = start_request(&server); - request.provider_id = "python".into(); - request.root_uri = url::Url::from_directory_path(&root).unwrap().to_string(); - request.working_directory = root.to_string_lossy().into_owned(); - request.startup_operation = Some(AttemptHandoff { - plugin_id: CANCELLED_PLUGIN.to_string(), - operation_id: "indexing-cancel-before-registration".to_string(), - elapsed_ms: 5, - }); - let mut registered = None; - let result = if native_owner { - engine.start_server_owned(request, |id| registered = Some(id.to_owned())) - } else { - engine.start_server(request) - }; - assert!(matches!(result.unwrap_err().code, ErrorCode::Cancelled)); - if cleanup_blocked { - let id = if native_owner { - registered.expect("incomplete disposal must reach the native owner") - } else { - engine - .lock_failed_starts() - .unwrap() - .first() - .cloned() - .expect("incomplete wire disposal retains its internal owner") - }; - assert!(engine.destroy(&id).is_err()); - assert!(engine.session(&id).is_ok()); - server.block_cleanup(false); - if native_owner { - engine - .destroy(&id) - .expect("native owner can retry verified disposal"); - } else { - engine - .cleanup_failed_start() - .expect("wire retry confirms disposal"); - assert!(engine.lock_failed_starts().unwrap().is_empty()); - } - } else { - assert!( - registered.is_none(), - "disposed startup needs no native barrier" - ); - } - assert!(engine.lock_sessions().unwrap().is_empty()); - assert!( - server.input_was_closed(), - "the cancelled startup owns process termination" - ); - assert!( - plugin_operations::test_capture::take(CANCELLED_PLUGIN).is_empty(), - "a cancelled start records no plugin operation result" - ); - } - } - - #[test] - fn native_owner_retains_indexing_cancellation_after_registration() { - const CANCELLED_PLUGIN: &str = "io.example.indexing-cancel-registered"; - let workspace = TemporaryWorkspace::recursive("indexing-owned-startup"); - let root = workspace.root.canonicalize().unwrap(); - let mut policy = IndexingPolicyFixture::new(root.clone()); - let server = ScriptedServer::new(); - server.block_cleanup(true); - let engine = LspEngine::with_launcher(server.launcher()); - let mut request = start_request(&server); - request.provider_id = "python".into(); - request.root_uri = url::Url::from_directory_path(&root).unwrap().to_string(); - request.working_directory = root.to_string_lossy().into_owned(); - request.startup_operation = Some(AttemptHandoff { - plugin_id: CANCELLED_PLUGIN.to_string(), - operation_id: "indexing-cancel-after-registration".to_string(), - elapsed_ms: 5, - }); - let mut registered = None; - let result = engine.start_server_owned(request, |id| { - registered = Some(id.to_owned()); - // Revoke between the initial policy check and initialize write. - policy.exclude(&["reactor"]); - }); - assert!(matches!(result.unwrap_err().code, ErrorCode::Cancelled)); - let id = registered.expect("native owner received its session"); - assert!(engine.destroy(&id).is_err()); - assert!(engine.session(&id).is_ok()); - server.block_cleanup(false); - engine - .destroy(&id) - .expect("native owner confirms process disposal"); - assert!(engine.session(&id).is_err()); - assert!( - plugin_operations::test_capture::take(CANCELLED_PLUGIN).is_empty(), - "a cancelled start records no plugin operation result" - ); - } - - #[test] - fn indexing_released_root_allows_a_standalone_provider_without_old_exclusions() { - let workspace = TemporaryWorkspace::recursive("indexing-released-standalone"); - let root = workspace.root.canonicalize().unwrap(); - let root_uri = url::Url::from_directory_path(&root).unwrap().to_string(); - let status = crate::project::indexing::configure(serde_json::from_value(json!({ - "root": root, "manualExcludedDirectories": ["reactor"], "includedDirectories": [], "gitIgnoreEnabled": false - })).unwrap()).unwrap(); - let old = crate::project::indexing::context(&root).unwrap(); - crate::project::indexing::release(crate::project::indexing::ReleaseIndexingRequest { - root: root.to_string_lossy().into_owned(), - revision: status.revision, - owner_id: None, - }) - .unwrap(); - assert!( - old.check().is_err(), - "late inventory readers must remain cancelled" - ); - let harness = Harness::start(|request| { - workspace.configure(request); - request.root_uri = root_uri.clone(); - request.initialization_options = - Some(json!({"settings":{"java":{"import":{"exclusions":["**/vendor/**"]}}}})); - }); - let initialize = notifications(&harness, "initialize").pop().unwrap(); - assert_eq!( - initialize["params"]["initializationOptions"]["settings"]["java"]["import"] - ["exclusions"], - json!(["**/vendor/**"]) - ); - let file_uri = url::Url::from_file_path(root.join("reactor/visible.go")) - .unwrap() - .to_string(); - assert!(!crate::project::indexing::uri_is_excluded(&root_uri, &file_uri).unwrap()); - } - - #[test] - fn indexing_cold_start_resolves_git_before_provider_initialization() { - let workspace = TemporaryWorkspace::recursive("indexing-cold-start"); - let root = workspace.root.canonicalize().unwrap(); - std::fs::create_dir_all(root.join("ignored")).unwrap(); - std::fs::write(root.join("ignored/hidden.txt"), "hidden").unwrap(); - std::fs::write(root.join(".gitignore"), "ignored/\n").unwrap(); - let git = crate::git::execute_git_readonly( - &root.to_string_lossy(), - &["init".into(), "--quiet".into()], - None, - ) - .unwrap(); - assert_eq!(git.exit_code, 0, "{}", git.stderr); - let status = crate::project::indexing::configure(serde_json::from_value(json!({ - "root": root, "manualExcludedDirectories": [], "includedDirectories": [], "gitIgnoreEnabled": true - })).unwrap()).unwrap(); - let harness = Harness::start(|request| { - workspace.configure(request); - request.root_uri = url::Url::from_directory_path(&root).unwrap().to_string(); - }); - assert!(notifications(&harness, "initialize").pop().is_some()); - for (provider, pointer) in [ - ("go", "/gopls/directoryFilters"), - ("rust", "/rust-analyzer/files/exclude"), - ] { - let value = crate::lsp::languages::indexing::configuration( - provider, - &harness.session().root_uri, - None, - ) - .unwrap(); - assert!(value - .pointer(pointer) - .unwrap() - .as_array() - .unwrap() - .iter() - .any(|item| item - == if provider == "go" { - "-ignored" - } else { - "ignored" - })); - } - crate::project::indexing::release(crate::project::indexing::ReleaseIndexingRequest { - root: root.to_string_lossy().into_owned(), - revision: status.revision, - owner_id: None, - }) - .unwrap(); - } - - #[test] - fn indexing_change_withdraws_open_working_copies_and_rejects_sync() { - let workspace = TemporaryWorkspace::recursive("indexing-working-copy"); - let root = workspace.root.canonicalize().unwrap(); - let root_uri = url::Url::from_directory_path(&root).unwrap().to_string(); - let mut harness = Harness::start(|request| { - request.root_uri = root_uri.clone(); - request.working_directory = root.to_string_lossy().into_owned(); - }); - harness.server.complete_initialize(ready_capabilities()); - harness.await_state(LspLifecycleState::Ready); - let uri = url::Url::from_file_path(root.join("reactor/main.go")) - .unwrap() - .to_string(); - harness.sync(&uri, "package main"); - let status = crate::project::indexing::configure( - serde_json::from_value(json!({ - "root":root,"manualExcludedDirectories":["reactor"], - "includedDirectories":[],"gitIgnoreEnabled":false - })) - .unwrap(), - ) - .unwrap(); - apply_indexing_to_sessions(&root, vec![harness.session()]).unwrap(); - assert!(!harness - .session() - .lock_state() - .unwrap() - .client - .open_documents - .contains_key(&uri)); - assert!(crate::project::indexing::uri_is_excluded(&root_uri, &uri).unwrap()); - let session = harness.session(); - assert!(session - .sync_document(SyncDocumentRequest { - session_id: harness.session_id.clone(), - uri: uri.clone(), - language_id: "go".into(), - text: "package main".into(), - content_changes: Vec::new(), - }) - .is_err()); - session.handle_server_message(json!({"jsonrpc":"2.0","method":"textDocument/publishDiagnostics", "params":{ - "uri":uri,"diagnostics":[{"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":1}},"severity":1,"message":"late"}] - }}).to_string()).unwrap(); - assert!(!session - .lock_state() - .unwrap() - .client - .diagnostics - .contains_key(&uri)); - - assert_eq!( - harness.session().indexing_revision.load(Ordering::Acquire), - status.revision - ); - crate::project::indexing::release(crate::project::indexing::ReleaseIndexingRequest { - owner_id: None, - root: root.to_string_lossy().into_owned(), - revision: status.revision, - }) - .unwrap(); - } - - /// Criterion 8: diagnostics for a stale document version are ignored. - /// Criterion 9: closing a document clears document and diagnostic state. - #[test] - fn diagnostics_follow_the_current_document_version_and_clear_on_close() { - let mut harness = Harness::ready(); - let uri = "file:///workspace/main.go"; - harness.sync(uri, "package main"); - harness.sync(uri, "package main\nfunc main() {}"); - - harness.server.send(json!({ - "jsonrpc": "2.0", - "method": "textDocument/publishDiagnostics", - "params": { - "uri": uri, - "version": 2, - "diagnostics": [{ - "range": { - "start": { "line": 1, "character": 0 }, - "end": { "line": 1, "character": 4 } - }, - "severity": 1, - "message": "current" - }] - } - })); - harness.await_event(|event| { - event.kind == "diagnostics" - && event - .diagnostics - .as_ref() - .is_some_and(|list| list.len() == 1) - }); - assert_eq!(harness.snapshot().diagnostic_versions[uri], 2); - - // Version 1 is behind the open document, so it cannot replace version 2. - harness.server.send(json!({ - "jsonrpc": "2.0", - "method": "textDocument/publishDiagnostics", - "params": { "uri": uri, "version": 1, "diagnostics": [] } - })); - thread::sleep(Duration::from_millis(50)); - harness.poll(); - assert_eq!( - harness.snapshot().diagnostic_versions[uri], - 2, - "a stale version must not clear current diagnostics" - ); - - harness.session().close_document(uri).unwrap(); - let snapshot = harness.snapshot(); - assert!(!snapshot.open_documents.contains_key(uri)); - assert!(!snapshot.diagnostic_versions.contains_key(uri)); - assert!( - harness.notification("textDocument/didClose").is_some(), - "the server must be told the document closed" - ); - // Clearing is published so the editor drops its markers, rather than - // leaving them until the next unrelated publish. - let cleared = harness - .poll() - .iter() - .rev() - .find(|event| event.kind == "diagnostics" && event.uri.as_deref() == Some(uri)); - assert_eq!( - cleared - .and_then(|event| event.diagnostics.as_ref()) - .map(Vec::len), - Some(0) - ); - } - - /// A declarative plugin start handed off by the host ends in exactly one Core - /// result: `initialize` success, an exit before it, or nothing for a user stop. - /// Later exits belong to the session, not the start. A plugin API 1 server - /// starts host-owned, so the handoff alone names the plugin; a session owned - /// by a different plugin records nothing. - #[test] - fn declarative_plugin_start_records_one_host_operation_per_attempt() { - const PLUGIN: &str = "io.example.declarative-start"; - const OTHER: &str = "io.example.declarative-start-other"; - let handoff = |operation_id: &'static str, owner: Option<&'static str>| { - move |request: &mut StartServerRequest| { - request.plugin_id = owner.map(str::to_string); - request.startup_operation = Some(AttemptHandoff { - plugin_id: PLUGIN.to_string(), - operation_id: operation_id.to_string(), - elapsed_ms: 5, - }); - } - }; - - let mut ready = Harness::start(handoff("declarative-ready", Some(PLUGIN))); - ready.server.complete_initialize(ready_capabilities()); - ready.await_state(LspLifecycleState::Ready); - ready.server.exit(Some(1)); - ready.await_state(LspLifecycleState::Failed); - - let mut exited = Harness::start(handoff("declarative-exited", None)); - exited.server.exit(Some(2)); - exited.await_state(LspLifecycleState::Failed); - - let mut stopped = Harness::start(handoff("declarative-stopped", Some(PLUGIN))); - stopped.session().stop().unwrap(); - stopped.await_state(LspLifecycleState::Stopped); - - let mut foreign = Harness::start(handoff("declarative-foreign", Some(OTHER))); - foreign.server.exit(Some(3)); - foreign.await_state(LspLifecycleState::Failed); - assert!(plugin_operations::test_capture::take(OTHER).is_empty()); - - let facts = plugin_operations::test_capture::take(PLUGIN); - assert!(facts - .iter() - .all(|fact| std::ptr::eq(fact.contribution, &LANGUAGE_SERVER_START))); - let results: Vec<_> = facts - .into_iter() - .map(|fact| (fact.operation_id, fact.failure)) - .collect(); - assert_eq!( - results, - [ - (Some("declarative-ready".to_string()), None), - ( - Some("declarative-exited".to_string()), - Some(AttemptStage::Exit) - ), - ] - ); - } - - /// startServer failing after spawn is a `launch` failure, and an initialize - /// timeout decided under the state lock is the only result even when the - /// response arrives afterwards. - #[test] - fn declarative_plugin_start_attributes_write_failure_and_timeout() { - const PLUGIN: &str = "io.example.declarative-start-failures"; - let handoff = |operation_id: &str, request: &mut StartServerRequest| { - request.plugin_id = Some(PLUGIN.to_string()); - request.startup_operation = Some(AttemptHandoff { - plugin_id: PLUGIN.to_string(), - operation_id: operation_id.to_string(), - elapsed_ms: 5, - }); - }; - - let broken = ScriptedServer::new(); - broken.break_input(); - let engine = LspEngine::with_launcher(broken.launcher()); - let mut request = start_request(&broken); - handoff("declarative-write-failed", &mut request); - assert!(engine.start_server(request).is_err()); - - let mut timed_out = Harness::start(|request| { - handoff("declarative-timed-out", request); - request.initialize_timeout_milliseconds = 60_000; - }); - timed_out - .session() - .expire_deadlines_at(Instant::now() + Duration::from_secs(61)); - timed_out.await_state(LspLifecycleState::Failed); - timed_out.server.complete_initialize(ready_capabilities()); - - let results: Vec<_> = plugin_operations::test_capture::take(PLUGIN) - .into_iter() - .map(|fact| (fact.operation_id, fact.failure)) - .collect(); - assert_eq!( - results, - [ - ( - Some("declarative-write-failed".to_string()), - Some(AttemptStage::Launch) - ), - ( - Some("declarative-timed-out".to_string()), - Some(AttemptStage::Timeout) - ), - ] - ); - } - - /// Criterion 10: shutdown sends exit after the response, and a shutdown that - /// is never answered force-terminates. - #[test] - fn shutdown_sends_exit_after_the_response_and_force_terminates_on_timeout() { - let mut harness = Harness::ready(); - harness.session().stop().unwrap(); - let shutdown_id = harness - .server - .await_request("shutdown") - .expect("stop should request shutdown"); - assert!( - harness.notification("exit").is_none(), - "exit must not precede the shutdown response" - ); - - harness.server.send(json!({ - "jsonrpc": "2.0", - "id": shutdown_id, - "result": null - })); - assert!( - harness.server.await_notification("exit"), - "exit must follow the shutdown response" - ); - harness.server.exit(Some(0)); - harness.await_state(LspLifecycleState::Stopped); - - let mut silent = Harness::start(|request| { - request.shutdown_timeout_milliseconds = 30; - }); - silent.server.complete_initialize(ready_capabilities()); - silent.await_state(LspLifecycleState::Ready); - silent.session().stop().unwrap(); - silent - .server - .await_request("shutdown") - .expect("stop should request shutdown"); - // No response ever arrives, so the deadline must kill the process - // instead of leaving the session stuck in Stopping. - silent.await_state(LspLifecycleState::Stopped); - silent.await_event(|event| { - event.kind == "log" - && event.error.as_ref().is_some_and(|error| { - error.stage == "shutdown" && error.code == "shutdownTimeout" - }) - }); - } - - /// Criterion 11: a malformed `Content-Length` is a transport failure. - #[test] - fn a_malformed_content_length_header_fails_the_session_in_transport() { - let mut harness = Harness::ready(); - harness.server.send_raw(b"Content-Length: banana\r\n\r\n{}"); - harness.await_state(LspLifecycleState::Failed); - - let failure = harness - .events - .iter() - .find_map(|event| event.error.as_ref()) - .expect("bad framing should be reported as a runtime error"); - assert_eq!(failure.code, "transportFailed"); - assert_eq!(failure.stage, "transport"); - } - - /// Criterion 12: a partial frame is retained until it completes. - /// Criterion 13: consecutive frames are handled in order. - #[test] - fn partial_frames_are_buffered_and_consecutive_frames_arrive_in_order() { - let mut harness = Harness::ready(); - let uri = "file:///workspace/main.go"; - harness.sync(uri, "package main"); - - let split = |uri: &str, message: &str| { - let body = json!({ - "jsonrpc": "2.0", - "method": "textDocument/publishDiagnostics", - "params": { - "uri": uri, - "version": 1, - "diagnostics": [{ - "range": { - "start": { "line": 0, "character": 0 }, - "end": { "line": 0, "character": 1 } - }, - "severity": 1, - "message": message - }] - } - }) - .to_string(); - format!("Content-Length: {}\r\n\r\n{body}", body.len()) - }; - - let first = split(uri, "first"); - let boundary = first.len() - 12; - harness.server.send_raw(first[..boundary].as_bytes()); - thread::sleep(Duration::from_millis(40)); - harness.poll(); - assert!( - !harness - .events - .iter() - .any(|event| event.kind == "diagnostics"), - "an incomplete frame must not be delivered" - ); - - // The tail of the first frame and a whole second frame arrive together, - // which is exactly how a stream coalesces writes. - harness - .server - .send_raw(format!("{}{}", &first[boundary..], split(uri, "second")).as_bytes()); - harness.await_event(|event| { - event - .diagnostics - .as_ref() - .and_then(|list| list.first()) - .is_some_and(|diagnostic| diagnostic.message == "second") - }); - let delivered: Vec<_> = harness - .events - .iter() - .filter(|event| event.kind == "diagnostics") - .filter_map(|event| event.diagnostics.as_ref()) - .filter_map(|list| list.first()) - .map(|diagnostic| diagnostic.message.clone()) - .collect(); - assert_eq!(delivered, vec!["first".to_string(), "second".to_string()]); - assert_eq!(harness.snapshot().state, LspLifecycleState::Ready); - } - - /// Criterion 14: dynamic registration and unregistration change availability. - #[test] - fn features_event_reports_completion_trigger_characters() { - let mut harness = Harness::start(|_| {}); - harness.server.complete_initialize(json!({ - "completionProvider": { - "triggerCharacters": [".", ":", ">"], - "allCommitCharacters": [";"] - } - })); - let event = harness - .await_event(|event| { - event.kind == "featuresChanged" && event.completion_options.is_some() - }) - .clone(); - let json = serde_json::to_value(&event).unwrap(); - assert_eq!( - json["completionOptions"], - json!({"triggerCharacters": [".", ":", ">"], "allCommitCharacters": [";"]}) - ); - } - - #[test] - fn dynamic_capability_registration_and_unregistration_change_availability() { - let mut harness = Harness::start(|_| {}); - // Formatting is absent from the static capabilities, so it can only - // become available through dynamic registration. - harness - .server - .complete_initialize(json!({ "hoverProvider": true })); - harness.await_state(LspLifecycleState::Ready); - let uri = "file:///workspace/main.go"; - harness.sync(uri, "package main"); - assert!(harness - .session() - .request( - SemanticRequest { - query: None, - session_id: harness.session_id.clone(), - operation_id: Some("op-formatting".to_string()), - operation: LspSemanticOperation::Formatting, - uri: Some(uri.to_string()), - position: None, - new_name: None, - range: None, - diagnostics: Vec::new(), - completion_context: None, - completion_item: None, - code_action: None, - command: None, - formatting_options: None, - include_declaration: None, - }, - "op-formatting".to_string(), - ) - .is_err()); - - harness.server.send(json!({ - "jsonrpc": "2.0", - "id": "registration-1", - "method": "client/registerCapability", - "params": { - "registrations": [{ - "id": "formatting-1", - "method": "textDocument/formatting", - "registerOptions": {} - }] - } - })); - let registered = harness - .await_event(|event| { - event - .capabilities - .as_ref() - .is_some_and(|names| names.iter().any(|name| name == "formatting")) - }) - .clone(); - assert_eq!(registered.kind, "featuresChanged"); - - harness.server.send(json!({ - "jsonrpc": "2.0", - "id": "registration-2", - "method": "client/unregisterCapability", - "params": { - "unregisterations": [{ - "id": "formatting-1", - "method": "textDocument/formatting" - }] - } - })); - // The initialize handshake also emitted a formatting-free feature set, so - // the withdrawal is only identifiable by coming after the registration. - let registered_at = registered.sequence; - harness.await_event(|event| { - event.kind == "featuresChanged" - && event.sequence > registered_at - && event - .capabilities - .as_ref() - .is_some_and(|names| !names.iter().any(|name| name == "formatting")) - }); - assert!( - harness - .session() - .request( - SemanticRequest { - query: None, - session_id: harness.session_id.clone(), - operation_id: Some("op-formatting-2".to_string()), - operation: LspSemanticOperation::Formatting, - uri: Some(uri.to_string()), - position: None, - new_name: None, - range: None, - diagnostics: Vec::new(), - completion_context: None, - completion_item: None, - code_action: None, - command: None, - formatting_options: None, - include_declaration: None, - }, - "op-formatting-2".to_string(), - ) - .is_err(), - "an unregistered capability must stop being offered" - ); - } - - /// Criterion 15: replacing the workspace stops the old root and clears it. - #[test] - fn replacing_the_workspace_stops_the_old_root_and_clears_its_state() { - let old_server = ScriptedServer::new(); - let engine = LspEngine::with_launcher(old_server.launcher()); - let old = engine.start_server(start_request(&old_server)).unwrap(); - old_server.complete_initialize(ready_capabilities()); - let old_session = engine.session(&old.session_id).unwrap(); - let deadline = Instant::now() + Duration::from_secs(5); - while Instant::now() < deadline - && old_session.snapshot().unwrap().state != LspLifecycleState::Ready - { - thread::sleep(Duration::from_millis(2)); - } - let uri = "file:///workspace/main.go"; - old_session - .sync_document(SyncDocumentRequest { - session_id: old.session_id.clone(), - uri: uri.to_string(), - language_id: "go".to_string(), - text: "package main".to_string(), - content_changes: Vec::new(), - }) - .unwrap(); - old_server.send(json!({ - "jsonrpc": "2.0", - "method": "textDocument/publishDiagnostics", - "params": { - "uri": uri, - "version": 1, - "diagnostics": [{ - "range": { - "start": { "line": 0, "character": 0 }, - "end": { "line": 0, "character": 1 } - }, - "severity": 1, - "message": "old root" - }] - } - })); - let deadline = Instant::now() + Duration::from_secs(5); - while Instant::now() < deadline - && !old_session - .snapshot() - .unwrap() - .diagnostic_versions - .contains_key(uri) - { - thread::sleep(Duration::from_millis(2)); - } - - old_session.stop().unwrap(); - let shutdown = old_server.await_request("shutdown").unwrap(); - old_server.send(json!({ "jsonrpc": "2.0", "id": shutdown, "result": null })); - old_server.exit(Some(0)); - let deadline = Instant::now() + Duration::from_secs(5); - while Instant::now() < deadline - && old_session.snapshot().unwrap().state != LspLifecycleState::Stopped - { - thread::sleep(Duration::from_millis(2)); - } - - let stopped = old_session.snapshot().unwrap(); - assert_eq!(stopped.root_uri, "file:///workspace"); - assert_eq!(stopped.state, LspLifecycleState::Stopped); - assert!(stopped.open_documents.is_empty()); - assert!(stopped.diagnostic_versions.is_empty()); - assert!(stopped.pending_operation_ids.is_empty()); - assert!( - old_server.input_was_closed(), - "the old root's stdin must be released" - ); - - // Only a stopped session may be destroyed, and a destroyed one is - // unreachable, so the replacement cannot inherit any of its state. - engine.destroy(&old.session_id).unwrap(); - assert!(engine.session(&old.session_id).is_err()); - assert!(old_session - .sync_document(SyncDocumentRequest { - session_id: old.session_id.clone(), - uri: uri.to_string(), - language_id: "go".to_string(), - text: "package main".to_string(), - content_changes: Vec::new(), - }) - .is_err()); - } - - #[test] - fn semantic_operations_are_protocol_methods_but_never_expose_request_ids() { - assert_eq!( - semantic_method(LspSemanticOperation::Definition), - "textDocument/definition" - ); - } - - #[test] - fn workspace_execute_command_does_not_require_an_open_document() { - let mut harness = Harness::start(|_| {}); - harness.server.complete_initialize(json!({ - "executeCommandProvider": { "commands": ["source.fix"] } - })); - harness.await_state(LspLifecycleState::Ready); - let operation_id = harness.engine.next_operation_id(); - - harness - .session() - .request( - SemanticRequest { - query: None, - session_id: harness.session_id.clone(), - operation_id: Some(operation_id.clone()), - operation: LspSemanticOperation::ExecuteCommand, - uri: None, - position: None, - new_name: None, - range: None, - diagnostics: Vec::new(), - completion_context: None, - completion_item: None, - code_action: None, - command: Some(json!({ - "title": "Apply fix", - "command": "source.fix", - "arguments": [] - })), - formatting_options: None, - include_declaration: None, - }, - operation_id, - ) - .expect("workspace commands should not be gated on an open document"); - - let request = harness - .server - .messages() - .into_iter() - .find(|message| message["method"] == "workspace/executeCommand") - .expect("the command should reach the language server"); - assert_eq!(request["params"]["command"], "source.fix"); - assert!(request["params"].get("textDocument").is_none()); - } - - #[test] - fn document_saves_honor_server_options_and_never_replace_newer_editor_text() { - let fixture: Value = serde_json::from_str(include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../fixtures/lsp/document-save-v1.json" - ))) - .unwrap(); - for case in fixture["cases"].as_array().unwrap() { - let option = &case["saveOption"]; - let mut harness = Harness::start(|_| {}); - let mut capabilities = ready_capabilities(); - capabilities["textDocumentSync"] = json!({"change": 1, "save": option}); - harness.server.complete_initialize(capabilities); - harness.await_state(LspLifecycleState::Ready); - let uri = case["uri"].as_str().unwrap(); - let saved_text = case["savedText"].as_str().unwrap(); - harness.sync(uri, saved_text); - let saved = || SaveDocumentRequest { - session_id: harness.session_id.clone(), - uri: uri.to_string(), - text: saved_text.to_string(), - }; - let supported = case["sent"].as_bool().unwrap(); - assert_eq!(harness.session().save_document(saved()).unwrap(), supported); - if supported { - let notification = harness - .server - .await_notification_at("textDocument/didSave", 0) - .unwrap(); - assert_eq!(notification["params"]["textDocument"]["uri"], uri); - assert_eq!( - notification["params"].get("text"), - case["includeText"] - .as_bool() - .unwrap() - .then_some(&json!(saved_text)) - ); - } - harness.sync(uri, "newer unsaved edit"); - assert!(!harness.session().save_document(saved()).unwrap()); - let session = harness.session(); - let state = session.lock_state().unwrap(); - assert_eq!( - state.client.open_documents[uri].text.as_ref(), - "newer unsaved edit" - ); - assert_eq!(state.client.open_documents[uri].version, 2); - } - } - - /// Removes a scratch directory when the owning test ends or unwinds. - struct ScratchDirectory(PathBuf); - - impl Drop for ScratchDirectory { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } - } - - // Regression: a server publishes diagnostics under the resolved path of a - // file the host opened through a symbolic link; matching the URIs textually - // dropped every error in such a workspace. - #[cfg(unix)] - #[test] - fn diagnostics_published_under_the_resolved_path_reach_the_linked_document() { - let stamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock should follow the Unix epoch") - .as_nanos(); - let scratch = ScratchDirectory(std::env::temp_dir().join(format!( - "lithe-linked-diagnostics-{}-{stamp}", - std::process::id() - ))); - let workspace = scratch.0.join("resolved"); - std::fs::create_dir_all(&workspace).expect("workspace should be creatable"); - std::fs::write(workspace.join("main.go"), "package main").expect("source file"); - let link = scratch.0.join("link"); - std::os::unix::fs::symlink(&workspace, &link).expect("link should be creatable"); - let opened_uri = url::Url::from_file_path(link.join("main.go")) - .expect("linked file converts to a URI") - .to_string(); - let published_uri = url::Url::from_file_path( - workspace - .canonicalize() - .expect("workspace canonicalizes") - .join("main.go"), - ) - .expect("resolved file converts to a URI") - .to_string(); - assert_ne!(opened_uri, published_uri); - - let mut harness = Harness::ready(); - harness.sync(&opened_uri, "package main"); - harness.server.send(json!({ - "jsonrpc": "2.0", - "method": "textDocument/publishDiagnostics", - "params": { - "uri": published_uri, - "diagnostics": [{ - "range": { - "start": { "line": 0, "character": 0 }, - "end": { "line": 0, "character": 7 } - }, - "severity": 1, - "message": "reported at the resolved path" - }] - } - })); - let event = harness - .await_event(|event| event.kind == "diagnostics") - .clone(); - assert_eq!(event.uri.as_deref(), Some(opened_uri.as_str())); - assert_eq!(event.diagnostics.map(|list| list.len()), Some(1)); - } - - fn open_document(uri: &str) -> (String, LspClientDocument) { - ( - uri.to_string(), - LspClientDocument { - uri: uri.to_string(), - language_id: "go".to_string(), - version: 1, - text: "".into(), - }, - ) - } - - // Servers publish for many files nobody has open. A session whose documents - // were not opened through a link must remember that and leave those - // messages untouched; closing a document must forget what was learned. - #[cfg(unix)] - #[test] - fn diagnostics_for_unlinked_documents_are_left_alone_and_forgotten_on_close() { - let stamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock should follow the Unix epoch") - .as_nanos(); - let scratch = ScratchDirectory(std::env::temp_dir().join(format!( - "lithe-unlinked-diagnostics-{}-{stamp}", - std::process::id() - ))); - std::fs::create_dir_all(&scratch.0).expect("scratch should be creatable"); - let real = scratch.0.canonicalize().expect("scratch canonicalizes"); - std::fs::write(real.join("main.go"), "package main").expect("source file"); - std::fs::write(real.join("other.go"), "package main").expect("source file"); - let uri_of = |name: &str| { - url::Url::from_file_path(real.join(name)) - .expect("file converts to a URI") - .to_string() - }; - let message = |uri: &str| { - json!({ - "jsonrpc": "2.0", - "method": "textDocument/publishDiagnostics", - "params": { "uri": uri, "diagnostics": [] } - }) - }; - - let documents: BTreeMap<_, _> = [open_document(&uri_of("main.go"))].into(); - let mut linked = LinkedDocumentPaths::default(); - assert_eq!( - diagnostics_addressed_to_open_document( - &documents, - &mut linked, - &message(&uri_of("other.go")) - ), - None - ); - assert!(linked.resolved.is_empty()); - assert!(linked.examined.contains(&uri_of("main.go"))); - - diagnostics_addressed_to_open_document( - &BTreeMap::new(), - &mut linked, - &message(&uri_of("other.go")), - ); - assert!(linked.examined.is_empty()); - } - - #[test] - fn wait_events_returns_queued_events_without_waiting_the_timeout() { - let harness = Harness::start(|_| {}); - let started = Instant::now(); - let events = harness - .session() - .wait_events(Duration::from_secs(2)) - .expect("waiting should succeed"); - assert!( - !events.is_empty(), - "starting a session should enqueue at least one lifecycle event" - ); - assert!( - started.elapsed() < Duration::from_millis(500), - "queued events must not wait out the timeout" - ); - } - - #[test] - fn wait_events_times_out_with_an_empty_queue() { - let mut harness = Harness::ready(); - harness.poll(); - let started = Instant::now(); - let events = harness - .session() - .wait_events(Duration::from_millis(40)) - .expect("waiting should succeed"); - assert!(events.is_empty()); - assert!(started.elapsed() >= Duration::from_millis(30)); - } - - #[test] - fn wait_events_wakes_when_the_session_enqueues_a_lifecycle_event() { - let mut harness = Harness::ready(); - harness.poll(); - let session = harness.session(); - let waiter = thread::spawn({ - let session = Arc::clone(&session); - move || { - session - .wait_events(Duration::from_secs(2)) - .expect("waiting should succeed") - } - }); - thread::sleep(Duration::from_millis(30)); - session.stop().expect("the session should stop"); - let events = waiter.join().expect("the waiter thread should finish"); - assert!( - events.iter().any(|event| event.kind == "stateChanged"), - "stop should wake waiters with a lifecycle event, got {events:?}" - ); - } - - #[test] - fn wait_events_errors_after_terminal_state_events_are_drained() { - let mut harness = Harness::ready(); - harness.poll(); - harness.session().stop().unwrap(); - let shutdown_id = harness - .server - .await_request("shutdown") - .expect("stop should request shutdown"); - harness.server.send(json!({ - "jsonrpc": "2.0", - "id": shutdown_id, - "result": null - })); - assert!( - harness.server.await_notification("exit"), - "exit must follow the shutdown response" - ); - harness.server.exit(Some(0)); - harness.await_state(LspLifecycleState::Stopped); - - // Drain any remaining lifecycle events from the stop transition. - loop { - let events = match harness.session().wait_events(Duration::from_millis(20)) { - Ok(events) => events, - Err(error) => { - assert!(matches!(error.code, ErrorCode::ProcessFailed)); - assert_eq!(error.details.as_deref(), Some("sessionStopped")); - return; - } - }; - if events.is_empty() { - break; - } - } - - let error = harness - .session() - .wait_events(Duration::from_millis(50)) - .expect_err("drained terminal sessions must not return empty Ok"); - assert!(matches!(error.code, ErrorCode::ProcessFailed)); - assert_eq!(error.details.as_deref(), Some("sessionStopped")); - } - - #[test] - fn provider_debug_preparation_retains_progress() { - let mut harness = plugin_backend(json!({"litheBuild":{"version":0}})); - let operation = - build_request(&harness, "lithe/build/prepareDebug", json!({}), None).unwrap(); - let request = harness - .server - .await_request("lithe/build/prepareDebug") - .unwrap(); - let wire_id = request.clone(); - assert_eq!( - harness.written_request(&request)["params"]["operationID"], - wire_id - ); - harness.server.send(json!({"jsonrpc":"2.0","method":"lithe/build/progress","params":{"operationID":wire_id,"output":"built"}})); - let progress = harness.await_event(|event| { - event.kind == "requestProgress" - && event.operation_id.as_deref() == Some(operation.as_str()) - }); - assert_eq!(progress.result.as_ref().unwrap()["output"], "built"); - harness.server.send(json!({"jsonrpc":"2.0","id":request,"result":{"program":"app/bin/main","arguments":[],"environment":{},"workingDirectory":"app"}})); - let done = harness.await_event(|event| { - event.kind == "requestCompleted" - && event.operation_id.as_deref() == Some(operation.as_str()) - }); - assert_eq!( - done.result.as_ref().unwrap()["value"]["program"], - "app/bin/main" - ); - } - - #[test] - fn inline_test_reports_reach_the_provider() { - let mut harness = plugin_backend(json!({"litheBuild":{"version":0}})); - let operation = build_request( - &harness, - "lithe/build/test", - json!({"testResults":true}), - None, - ) - .unwrap(); - let id = harness.server.await_request("lithe/build/test").unwrap(); - let value = json!({"exitCode":1,"output":"failure","testResults":{"format":"litheTestResults","version":0,"tests":[]}}); - harness - .server - .send(json!({"jsonrpc":"2.0","id":id,"result":value})); - let event = harness.await_event(|event| { - event.kind == "requestCompleted" - && event.operation_id.as_deref() == Some(operation.as_str()) - }); - assert_eq!(event.result.as_ref().unwrap()["value"], value); - } - - #[test] - fn provider_native_configuration_returns_only_the_projection() { - let mut harness = plugin_backend(json!({"litheBuild":{"version":0}})); - let operation = - build_request(&harness, "lithe/build/configuration", json!({}), None).unwrap(); - let id = harness - .server - .await_request("lithe/build/configuration") - .unwrap(); - let result = json!({"format":"litheBuildConfiguration","version":0,"sources":[]}); - harness - .server - .send(json!({"jsonrpc":"2.0","id":id,"result":result})); - let event = harness.await_event(|event| { - event.kind == "requestCompleted" - && event.operation_id.as_deref() == Some(operation.as_str()) - }); - assert_eq!(event.result.as_ref().unwrap()["value"], result); - } - - /// Launch options reach the provider unchanged, and only run preparation accepts them. - #[test] - fn provider_run_options_reach_the_wire_for_run_preparation_only() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../../../../shared/plugins/contract/v1/fixtures/build-providers/build-run-options.json" - )) - .unwrap(); - let params = json!({"launchOptions":fixture["launchOptions"]}); - let mut current = plugin_backend(json!({"litheBuild":{"version":0}})); - let error = build_request(¤t, "lithe/build/model", params.clone(), None).unwrap_err(); - assert!(matches!(error.code, ErrorCode::InvalidRequest)); - let operation = build_request(¤t, "lithe/build/prepareRun", params, None).unwrap(); - let id = current - .server - .await_request("lithe/build/prepareRun") - .unwrap(); - let message = current - .server - .messages() - .into_iter() - .find(|message| message["method"] == "lithe/build/prepareRun") - .unwrap(); - assert_eq!(message["params"]["launchOptions"], fixture["launchOptions"]); - current - .server - .send(json!({"jsonrpc":"2.0","id":id,"result":fixture["result"]})); - let event = current.await_event(|event| { - event.kind == "requestCompleted" - && event.operation_id.as_deref() == Some(operation.as_str()) - }); - assert_eq!(event.result.as_ref().unwrap()["value"], fixture["result"]); - } - - /// Port ownership reaches the provider and comes back in the raw plan. - #[test] - fn provider_connection_debug_options_reach_the_wire() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../../../../shared/plugins/contract/v1/fixtures/build-providers/build-connection-debug-options.json" - )) - .unwrap(); - let params = json!({"launchOptions":fixture["launchOptions"]}); - let mut current = plugin_backend(json!({"litheBuild":{"version":0}})); - let operation = build_request(¤t, "lithe/build/prepareRun", params, None).unwrap(); - let id = current - .server - .await_request("lithe/build/prepareRun") - .unwrap(); - current - .server - .send(json!({"jsonrpc":"2.0","id":id,"result":fixture["result"]})); - let event = current.await_event(|event| { - event.kind == "requestCompleted" - && event.operation_id.as_deref() == Some(operation.as_str()) - }); - assert_eq!(event.result.as_ref().unwrap()["value"], fixture["result"]); - } - - #[test] - fn provider_run_preparation_returns_the_raw_plan() { - let mut harness = plugin_backend(json!({"litheBuild":{"version":0}})); - let operation = build_request(&harness, "lithe/build/prepareRun", json!({}), None).unwrap(); - let id = harness - .server - .await_request("lithe/build/prepareRun") - .unwrap(); - let fixture: Value = serde_json::from_str(include_str!( - "../../../../../../shared/plugins/contract/v1/fixtures/build-providers/build-run-preparation.json" - )) - .unwrap(); - harness - .server - .send(json!({"jsonrpc":"2.0","id":id,"result":fixture["result"]})); - let event = harness.await_event(|event| { - event.kind == "requestCompleted" - && event.operation_id.as_deref() == Some(operation.as_str()) - }); - assert_eq!(event.result.as_ref().unwrap()["value"], fixture["result"]); - } - - #[test] - fn provider_test_discovery_uses_the_existing_request_lifecycle() { - let mut harness = plugin_backend(json!({"litheBuild":{"version":0}})); - let operation = build_request( - &harness, - "lithe/build/discoverTests", - json!({"root":"."}), - None, - ) - .unwrap(); - let id = harness - .server - .await_request("lithe/build/discoverTests") - .unwrap(); - let result = json!({"format":"litheTestDiscovery","version":0,"tests":[]}); - harness - .server - .send(json!({"jsonrpc":"2.0","id":id,"result":result})); - let event = harness.await_event(|event| { - event.kind == "requestCompleted" - && event.operation_id.as_deref() == Some(operation.as_str()) - }); - assert_eq!(event.result.as_ref().unwrap()["value"], result); - assert!(harness.snapshot().pending_operation_ids.is_empty()); - } - - /// Ordered messages use a completed round trip as a barrier: old progress - /// and replies are drained before checking that cancellation stayed final. - #[test] - fn provider_execution_progress_cancel_and_late_results_are_isolated() { - for method in [ - "lithe/build/execute", - "lithe/build/test", - "lithe/build/prepareRun", - ] { - let mut harness = Harness::start(|_| {}); - harness - .server - .complete_initialize(json!({"experimental":{"litheBuild":{"version":0}}})); - harness.await_state(LspLifecycleState::Ready); - let operation = - build_request(&harness, method, json!({"operationID":"spoof"}), None).unwrap(); - let id = harness.server.await_request(method).unwrap(); - assert_eq!(harness.written_request(&id)["params"]["operationID"], id); - harness - .server - .send(json!({"jsonrpc":"2.0","method":"lithe/build/progress", - "params":{"operationID":id,"output":"first","percentage":20}})); - let progress = harness - .await_event(|event| event.kind == "requestProgress") - .clone(); - assert_eq!(progress.operation_id.as_deref(), Some(operation.as_str())); - assert_eq!(progress.result.unwrap()["output"], "first"); - harness.session().cancel_operation(&operation).unwrap(); - let ended = harness - .await_event(|event| { - event.kind == "requestCompleted" - && event.operation_id.as_deref() == Some(operation.as_str()) - }) - .clone(); - assert_eq!(ended.error.unwrap().code, "requestCancelled"); - harness - .server - .send(json!({"jsonrpc":"2.0","method":"lithe/build/progress", - "params":{"operationID":id,"output":"late"}})); - harness - .server - .send(json!({"jsonrpc":"2.0","id":id,"result":{"exitCode":0,"output":"late"}})); - let barrier = build_request(&harness, "lithe/build/model", json!({}), None).unwrap(); - let barrier_id = harness.server.await_request("lithe/build/model").unwrap(); - harness - .server - .send(json!({"jsonrpc":"2.0","id":barrier_id,"result":{}})); - harness.await_event(|event| { - event.kind == "requestCompleted" - && event.operation_id.as_deref() == Some(barrier.as_str()) - }); - assert_eq!( - harness - .events - .iter() - .filter(|event| event.kind == "requestProgress") - .count(), - 1 - ); - assert_eq!( - harness - .events - .iter() - .filter(|event| event.kind == "requestCompleted" - && event.operation_id.as_deref() == Some(operation.as_str())) - .count(), - 1 - ); - } - } - - /// Host operation IDs may be reused; only the current wire request ID - /// may contribute progress to that new call. - #[test] - fn provider_execution_reused_host_ids_reject_old_wire_progress() { - for method in [ - "lithe/build/execute", - "lithe/build/test", - "lithe/build/prepareRun", - ] { - let mut harness = plugin_backend(json!({"litheBuild":{"version":0}})); - let operation = "reused-build".to_string(); - let send = |harness: &Harness| { - harness - .session() - .build_request( - BuildProviderRequest { - session_id: harness.session_id.clone(), - operation_id: Some(operation.clone()), - method: method.to_string(), - params: json!({}), - timeout_milliseconds: None, - }, - operation.clone(), - ) - .unwrap() - }; - send(&harness); - let old = harness.server.await_request(method).unwrap(); - harness.session().cancel_operation(&operation).unwrap(); - harness.await_event(|event| { - event.kind == "requestCompleted" - && event.operation_id.as_deref() == Some(operation.as_str()) - }); - send(&harness); - let current = harness.server.await_request_at(method, 1).unwrap(); - assert_ne!(old, current); - for (id, output) in [(old, "old"), (current, "current")] { - harness - .server - .send(json!({"jsonrpc":"2.0","method":"lithe/build/progress", - "params":{"operationID":id,"output":output}})); - } - harness.await_event(|event| { - event.kind == "requestProgress" - && event - .result - .as_ref() - .is_some_and(|result| result["output"] == "current") - }); - assert!(harness - .events - .iter() - .filter(|event| event.kind == "requestProgress") - .all(|event| event.result.as_ref().unwrap()["output"] != "old")); - harness.session().cancel_operation(&operation).unwrap(); - } - } - - #[test] - fn provider_execution_validates_results_and_expires_without_sleeping() { - for method in [ - "lithe/build/execute", - "lithe/build/test", - "lithe/build/prepareRun", - ] { - let mut harness = Harness::start(|_| {}); - harness - .server - .complete_initialize(json!({"experimental":{"litheBuild":{"version":0}}})); - harness.await_state(LspLifecycleState::Ready); - for (index, (reply, code)) in [ - (json!({"exitCode":7,"output":"failed build"}), None), - ( - json!({"exitCode":"bad"}), - Some("plugin.build.invalidResult"), - ), - ] - .into_iter() - .enumerate() - { - let operation = build_request(&harness, method, json!({}), None).unwrap(); - let id = harness.server.await_request_at(method, index).unwrap(); - harness - .server - .send(json!({"jsonrpc":"2.0","id":id,"result":reply})); - let ended = harness - .await_event(|event| { - event.kind == "requestCompleted" - && event.operation_id.as_deref() == Some(operation.as_str()) - }) - .clone(); - // Preparation returns raw launch data; the build decoder validates it. - let code = if method == "lithe/build/prepareRun" { - None - } else { - code - }; - assert_eq!(ended.error.as_ref().map(|error| error.code.as_str()), code); - if code.is_none() { - assert_eq!(ended.result.unwrap()["value"], reply); - } - } - let operation = build_request(&harness, method, json!({}), Some(1000)).unwrap(); - harness.server.await_request_at(method, 2).unwrap(); - harness - .session() - .expire_deadlines_at(Instant::now() + Duration::from_secs(2)); - assert_eq!( - harness - .await_event(|event| event.kind == "requestCompleted" - && event.operation_id.as_deref() == Some(operation.as_str())) - .error - .as_ref() - .unwrap() - .code, - "requestTimeout" - ); - assert!(harness.snapshot().pending_operation_ids.is_empty()); - } - } - - /// A process that answered the build-provider protocol with version 0. - fn negotiated_build_provider() -> Harness { - let mut harness = Harness::start(|_| {}); - harness.server.complete_initialize(json!({ - "experimental": { "litheBuild": { "version": 0 } } - })); - harness.await_state(LspLifecycleState::Ready); - harness - } - - fn build_request( - harness: &Harness, - method: &str, - params: Value, - timeout_milliseconds: Option, - ) -> Result { - let operation_id = harness.engine.next_operation_id(); - harness - .session() - .build_request( - BuildProviderRequest { - session_id: harness.session_id.clone(), - operation_id: Some(operation_id.clone()), - method: method.to_string(), - params, - timeout_milliseconds, - }, - operation_id.clone(), - ) - .map(|()| operation_id) - } - - /// Every session offers the protocol, but one whose process did not - /// choose a version must never receive a build request: a shared - /// language server keeps serving its language without it. - #[test] - fn build_requests_need_a_negotiated_protocol_version() { - let harness = Harness::ready(); - let initialize = harness - .server - .messages() - .into_iter() - .find(|message| message["method"] == "initialize") - .expect("initialize should have been written"); - assert_eq!( - initialize["params"]["capabilities"]["experimental"]["litheBuild"], - json!({ "versions": [0] }) - ); - - let error = build_request(&harness, "lithe/build/model", json!({}), None) - .expect_err("an unnegotiated session must refuse build requests"); - assert!(matches!(error.code, ErrorCode::NotSupported)); - assert!(harness - .server - .messages() - .iter() - .all(|message| message["method"] != "lithe/build/model")); - } - - /// A reply naming a version the host did not offer negotiates nothing, - /// for the build protocol and the plugin-function protocol alike. - #[test] - fn a_reply_naming_an_unoffered_version_negotiates_nothing() { - let harness = plugin_backend(json!({ - "litheBuild": { "version": 1 }, - "litheFunctions": { "version": 1, "functions": [{ "id": "snapshot.refresh" }] } - })); - let error = build_request(&harness, "lithe/build/model", json!({}), None) - .expect_err("version 1 was not offered"); - assert!(matches!(error.code, ErrorCode::NotSupported)); - let error = invoke(&harness, FUNCTION_PLUGIN, "snapshot.refresh", None) - .expect_err("version 1 was not offered"); - assert!(matches!(error.code, ErrorCode::NotSupported)); - assert!(harness.server.messages().iter().all(|message| { - message["method"] != "lithe/build/model" && message["method"] != "lithe/function/invoke" - })); - } - - /// A model request carries the declared shared-input snapshot to the provider verbatim. - #[test] - fn model_inputs_reach_the_provider_verbatim() { - let mut harness = negotiated_build_provider(); - let params = json!({"modelInputs":{"version":0,"revision":7,"inputs":[]}}); - let operation = build_request(&harness, "lithe/build/model", params.clone(), None).unwrap(); - let id = harness - .server - .await_request("lithe/build/model") - .expect("the provider receives its snapshot"); - assert_eq!(harness.written_request(&id)["params"], params); - harness - .server - .send(json!({"jsonrpc":"2.0","id":id,"result":{"projects":[]}})); - harness.await_event(|event| { - event.operation_id.as_deref() == Some(&operation) && event.kind == "requestCompleted" - }); - } - - #[test] - fn a_negotiated_build_request_forwards_the_plan_and_returns_the_reply() { - let mut harness = negotiated_build_provider(); - let params = json!({ - "buildSystemID": "make", - "root": "", - "options": { "offline": true }, - "optionArguments": ["--offline"], - "environment": {} - }); - let operation = - build_request(&harness, "lithe/build/model", params.clone(), None).expect("accepted"); - let request_id = harness - .server - .await_request("lithe/build/model") - .expect("the request should reach the provider"); - assert_eq!(harness.written_request(&request_id)["params"], params); - - // A reply with a `locations` field proves no navigation normalizer - // rewrites the document Core decodes. - let reply = json!({ "format": "litheBuildModel", "version": 0, "locations": [] }); - harness - .server - .send(json!({ "jsonrpc": "2.0", "id": request_id, "result": reply })); - let completed = harness - .await_event(|event| { - event.kind == "requestCompleted" - && event.operation_id.as_deref() == Some(operation.as_str()) - }) - .clone(); - assert!(completed.error.is_none()); - assert_eq!(completed.result, Some(json!({ "value": reply }))); - assert!(harness.snapshot().pending_operation_ids.is_empty()); - } - - /// Only the negotiated methods pass, so the build path cannot become a - /// generic JSON-RPC tunnel into a language server. - #[test] - fn build_requests_accept_only_the_protocol_methods() { - let harness = negotiated_build_provider(); - for method in ["workspace/executeCommand", "shutdown"] { - let error = build_request(&harness, method, json!({}), None) - .expect_err("only protocol methods are build requests"); - assert!(matches!(error.code, ErrorCode::InvalidRequest), "{method}"); - } - assert!(harness - .server - .messages() - .iter() - .all(|message| message["method"] != "workspace/executeCommand")); - } - - /// A build request's own deadline replaces the session's request timeout, - /// here shortened, and the timed-out request is cancelled on the wire. - #[test] - fn a_build_request_times_out_on_its_own_deadline() { - let mut harness = negotiated_build_provider(); - let operation = build_request(&harness, "lithe/build/dependencies", json!({}), Some(30)) - .expect("accepted"); - harness - .server - .await_request("lithe/build/dependencies") - .expect("the request should reach the provider"); - let timeout = harness - .await_event(|event| event.operation_id.as_deref() == Some(operation.as_str())) - .clone(); - assert_eq!( - timeout.error.as_ref().map(|error| error.code.as_str()), - Some("requestTimeout") - ); - assert!(harness.server.await_notification("$/cancelRequest")); - } - - #[test] - fn keymap_import_negotiation_is_available_only_to_plugin_owned_sessions() { - for plugin_owned in [false, true] { - let mut harness = Harness::start(|request| { - request.keymap_import_providers = vec!["fixture".to_string()]; - if plugin_owned { - request.plugin_id = Some("dev.lithe.fixture.import".to_string()); - } - }); - harness.server.complete_initialize(json!({"experimental": { - "litheKeymapImport": {"version": 0, "providers": ["fixture"]} - }})); - harness.await_state(LspLifecycleState::Ready); - let initialize = harness - .server - .messages() - .into_iter() - .find(|message| message["method"] == "initialize") - .unwrap(); - let offer = - initialize["params"]["capabilities"]["experimental"].get("litheKeymapImport"); - assert_eq!(offer.is_some(), plugin_owned); - if plugin_owned { - assert_eq!( - offer, - Some(&json!({"versions": [0], "providers": ["fixture"]})) - ); - } - let event = harness - .events - .iter() - .find(|event| event.kind == "keymapImportProviders"); - assert_eq!(event.is_some(), plugin_owned); - if let Some(event) = event { - assert_eq!(event.result, Some(json!({"providers": ["fixture"]}))); - } - } - } - - #[test] - fn keymap_import_provider_cannot_select_an_undeclared_identity() { - let mut harness = Harness::start(|request| { - request.plugin_id = Some("dev.lithe.fixture.import".to_string()); - request.keymap_import_providers = vec!["fixture".to_string()]; - }); - harness.server.complete_initialize(json!({"experimental": { - "litheKeymapImport": {"version": 0, "providers": ["fixture", "other"]} - }})); - harness.await_state(LspLifecycleState::Ready); - assert!(harness - .events - .iter() - .all(|event| event.kind != "keymapImportProviders")); - } - - #[test] - fn keymap_import_request_only_reaches_its_negotiated_provider() { - let mut harness = Harness::start(|request| { - request.plugin_id = Some("dev.lithe.fixture.import".to_string()); - request.keymap_import_providers = vec!["fixture".to_string()]; - }); - harness.server.complete_initialize(json!({"experimental": { - "litheKeymapImport": {"version": 0, "providers": ["fixture"]} - }})); - harness.await_state(LspLifecycleState::Ready); - let request = |provider: &str| KeymapImportProviderRequest { - session_id: harness.session_id.clone(), - operation_id: None, - provider_id: provider.to_string(), - snapshot_id: "snapshot-1".to_string(), - timeout_milliseconds: Some(1_000), - }; - assert!(harness - .session() - .keymap_import_request(request("other"), "refused".to_string()) - .is_err()); - assert!(harness - .server - .messages() - .iter() - .all(|message| message["method"] != "lithe/keymap/import")); - harness - .session() - .keymap_import_request(request("fixture"), "import-1".to_string()) - .unwrap(); - let id = harness.server.await_request("lithe/keymap/import").unwrap(); - let wire_id: u64 = id.parse().unwrap(); - let sent = harness.written_request(&id); - assert_eq!( - sent["params"], - json!({"providerID": "fixture", "snapshotID": "snapshot-1"}) - ); - let response = json!({"jsonrpc": "2.0", "id": wire_id, "result": {"bindings": []}}); - harness.server.send(response.clone()); - let event = harness.await_event(|event| { - event.kind == "requestCompleted" && event.operation_id.as_deref() == Some("import-1") - }); - assert_eq!( - event.result, - Some(json!({"value": {"id": wire_id, "response": response}})) - ); - } - - #[test] - fn keymap_import_request_rejects_paths_and_can_be_cancelled() { - let mut harness = Harness::start(|request| { - request.plugin_id = Some("dev.lithe.fixture.import".to_string()); - request.keymap_import_providers = vec!["fixture".to_string()]; - }); - harness.server.complete_initialize(json!({"experimental": { - "litheKeymapImport": {"version": 0, "providers": ["fixture"]} - }})); - harness.await_state(LspLifecycleState::Ready); - let mut request = KeymapImportProviderRequest { - session_id: harness.session_id.clone(), - operation_id: None, - provider_id: "fixture".to_string(), - snapshot_id: "../private".to_string(), - timeout_milliseconds: None, - }; - assert!(harness - .session() - .keymap_import_request(request.clone(), "invalid".to_string()) - .is_err()); - assert!(harness - .server - .messages() - .iter() - .all(|message| message["method"] != "lithe/keymap/import")); - request.snapshot_id = "snapshot-1".to_string(); - harness - .session() - .keymap_import_request(request, "import-2".to_string()) - .unwrap(); - harness.server.await_request("lithe/keymap/import").unwrap(); - harness.session().cancel_operation("import-2").unwrap(); - assert!(harness.server.await_notification("$/cancelRequest")); - let event = harness.await_event(|event| { - event.kind == "requestCompleted" && event.operation_id.as_deref() == Some("import-2") - }); - assert_eq!( - event.error.as_ref().map(|error| error.code.as_str()), - Some("requestCancelled") - ); - assert!(event.result.is_none()); - } - - #[test] - fn keymap_import_request_rejects_oversized_provider_replies() { - let mut harness = Harness::start(|request| { - request.plugin_id = Some("dev.lithe.fixture.import".to_string()); - request.keymap_import_providers = vec!["fixture".to_string()]; - }); - harness.server.complete_initialize(json!({"experimental": { - "litheKeymapImport": {"version": 0, "providers": ["fixture"]} - }})); - harness.await_state(LspLifecycleState::Ready); - let request = KeymapImportProviderRequest { - session_id: harness.session_id.clone(), - operation_id: None, - provider_id: "fixture".to_string(), - snapshot_id: "snapshot-1".to_string(), - timeout_milliseconds: Some(5_000), - }; - harness - .session() - .keymap_import_request(request, "import-3".to_string()) - .unwrap(); - let id = harness.server.await_request("lithe/keymap/import").unwrap(); - harness - .server - .send(json!({"jsonrpc": "2.0", "id": id.parse::().unwrap(), - "result": {"data": "x".repeat(super::super::keymap_provider::MAX_REPLY_BYTES)}})); - let event = harness.await_event(|event| { - event.kind == "requestCompleted" && event.operation_id.as_deref() == Some("import-3") - }); - assert_eq!( - event.error.as_ref().map(|error| error.code.as_str()), - Some("plugin.keymapImport.replyTooLarge") - ); - assert!(event.result.is_none()); - } - - // Toolchain providers against a scripted backend. Only a plugin-owned - // session launched with declared providers is offered `litheToolchains`, - // and only a provider its process selected receives `toolchains/query`. - - fn toolchain_backend(answer: Value) -> Harness { - let mut harness = Harness::start(|request| { - request.plugin_id = Some("dev.lithe.fixture.toolchain-provider".to_string()); - request.toolchain_providers = vec!["mise".to_string()]; - }); - harness - .server - .complete_initialize(json!({ "experimental": { "litheToolchains": answer } })); - harness.await_state(LspLifecycleState::Ready); - harness - } - - fn toolchain_query(harness: &Harness, provider: &str) -> Result { - let operation_id = harness.engine.next_operation_id(); - harness - .session() - .toolchain_request( - ToolchainProviderRequest { - session_id: harness.session_id.clone(), - operation_id: Some(operation_id.clone()), - params: json!({ "operationID": "query-1", "generation": 1, "providerID": provider }), - timeout_milliseconds: None, - }, - operation_id.clone(), - ) - .map(|()| operation_id) - } - - fn no_toolchain_query_written(harness: &Harness) -> bool { - harness - .server - .messages() - .iter() - .all(|message| message["method"] != "toolchains/query") - } - - #[test] - fn host_sessions_are_not_offered_the_toolchain_protocol() { - let harness = Harness::start(|request| { - request.toolchain_providers = vec!["mise".to_string()]; - }); - harness.server.complete_initialize(json!({})); - let initialize = harness - .server - .messages() - .into_iter() - .find(|message| message["method"] == "initialize") - .expect("initialize should have been written"); - assert!(initialize["params"]["capabilities"]["experimental"] - .get("litheToolchains") - .is_none()); - } - - #[test] - fn a_query_reaches_only_a_selected_provider_and_returns_the_raw_response() { - let mut harness = toolchain_backend(json!({ "version": 0, "providers": ["mise"] })); - let initialize = harness - .server - .messages() - .into_iter() - .find(|message| message["method"] == "initialize") - .expect("initialize should have been written"); - assert_eq!( - initialize["params"]["capabilities"]["experimental"]["litheToolchains"], - json!({ "versions": [0], "providers": ["mise"] }) - ); - let event = harness - .await_event(|event| event.kind == "toolchainProviders") - .clone(); - assert_eq!(event.result, Some(json!({ "providers": ["mise"] }))); - - let refused = toolchain_query(&harness, "asdf").expect_err("not selected"); - assert_eq!( - refused.details.as_deref(), - Some("plugin.toolchain.providerUnavailable") - ); - assert!(no_toolchain_query_written(&harness)); - - let operation = toolchain_query(&harness, "mise").expect("written"); - let id = harness - .server - .await_request("toolchains/query") - .expect("the query is written"); - let wire_id: u64 = id.parse().expect("a numeric wire ID"); - // Even a provider error stays a raw reply: decodeReply owns its mapping. - let response = json!({ - "jsonrpc": "2.0", "id": wire_id, - "error": { "code": -32073, "message": "untrusted", "data": { "code": "x" } } - }); - harness.server.send(response.clone()); - let event = harness - .await_event(|event| { - event.kind == "requestCompleted" - && event.operation_id.as_deref() == Some(operation.as_str()) - }) - .clone(); - assert!(event.error.is_none()); - assert_eq!( - event.result, - Some(json!({ "value": { "id": wire_id, "response": response } })) - ); - } - - #[test] - fn an_undeclared_toolchain_provider_enables_nothing() { - let mut harness = toolchain_backend(json!({ "version": 0, "providers": ["mise", "asdf"] })); - harness.poll(); - assert!(harness - .events - .iter() - .all(|event| event.kind != "toolchainProviders")); - let error = toolchain_query(&harness, "mise").expect_err("refused"); - assert!(matches!(error.code, ErrorCode::NotSupported)); - assert!(no_toolchain_query_written(&harness)); - } - - // VCS providers against a scripted backend. Only a plugin-owned session - // is offered `litheVcs`, and only one whose process answered with a VCS - // ID and every required capability receives requests, each planned by - // Core from an operation name. - - const VCS_PLUGIN: &str = "dev.lithe.fixture.vcs-provider"; - - fn vcs_capabilities() -> Value { - json!({ "status": 0, "baseContent": 0, "checkin": 0, "rollback": 0, "update": 0 }) - } - - fn vcs_backend(answer: Value) -> Harness { - let mut harness = Harness::start(|request| { - request.plugin_id = Some(VCS_PLUGIN.to_string()); - }); - harness - .server - .complete_initialize(json!({ "experimental": { "litheVcs": answer } })); - harness.await_state(LspLifecycleState::Ready); - harness - } - - fn negotiated_vcs_provider() -> Harness { - vcs_backend(json!({ "version": 0, "vcs": "svn", "capabilities": vcs_capabilities() })) - } - - /// A native absolute path on every platform the tests run on. - fn working_copy() -> String { - std::env::temp_dir() - .join("lithe-vcs-working-copy") - .to_string_lossy() - .into_owned() - } - - fn vcs_request( - harness: &Harness, - vcs: &str, - operation: &str, - arguments: Value, - ) -> Result { - let operation_id = harness.engine.next_operation_id(); - harness - .session() - .vcs_request( - VcsProviderRequest { - session_id: harness.session_id.clone(), - operation_id: Some(operation_id.clone()), - vcs: vcs.to_string(), - operation: operation.to_string(), - root: working_copy(), - tool: None, - arguments, - timeout_milliseconds: None, - }, - operation_id.clone(), - ) - .map(|()| operation_id) - } - - /// Feature names the session recorded from the provider's answer. - fn vcs_features(harness: &Harness) -> Vec { - let session = harness.session(); - let state = session.lock_state().expect("state"); - state.client.server_capabilities.clone() - } - - fn no_vcs_request_written(harness: &Harness) -> bool { - harness.server.messages().iter().all(|message| { - !message["method"] - .as_str() - .is_some_and(|method| method.starts_with("lithe/vcs/")) - }) - } - - #[test] - fn host_sessions_are_not_offered_the_vcs_protocol() { - let harness = Harness::ready(); - let initialize = harness - .server - .messages() - .into_iter() - .find(|message| message["method"] == "initialize") - .expect("initialize should have been written"); - assert!(initialize["params"]["capabilities"]["experimental"] - .get("litheVcs") - .is_none()); - let error = vcs_request(&harness, "svn", "status", json!({})) - .expect_err("a host session is never a provider"); - assert!(matches!(error.code, ErrorCode::NotSupported)); - assert_eq!(error.details.as_deref(), Some("vcs.provider.unavailable")); - assert!(no_vcs_request_written(&harness)); - } - - /// The negotiated VCS and capabilities reach the host as one event, so - /// it can check the VCS against the manifest and pick the actions to show. - #[test] - fn a_negotiated_provider_is_announced_with_its_capabilities() { - let mut harness = negotiated_vcs_provider(); - let event = harness - .await_event(|event| event.kind == "vcsProvider") - .clone(); - assert_eq!( - event.result, - Some(json!({ "vcs": "svn", "capabilities": vcs_capabilities() })) - ); - assert!(vcs_features(&harness) - .iter() - .any(|feature| feature == "litheVcs")); - } - - /// The host has no side for credentials or console routing: an - /// authentication request is refused so the provider fails with - /// `vcs.authentication.failed`, and an execution report is passed on as a - /// session log entry. - #[test] - fn provider_authentication_is_refused_and_execution_reports_pass_through() { - let mut harness = negotiated_vcs_provider(); - harness.server.send(json!({ - "jsonrpc": "2.0", - "id": "auth-1", - "method": "lithe/vcs/authenticate", - "params": { "realm": " Example" } - })); - assert_eq!(await_reply(&harness, "auth-1")["error"]["code"], -32601); - harness.server.send(json!({ - "jsonrpc": "2.0", - "method": "lithe/vcs/execution", - "params": { "kind": "started", "arguments": ["status", "--xml"] } - })); - let event = harness - .await_event(|event| { - event.kind == "log" && event.message.as_deref() == Some("lithe/vcs/execution") - }) - .clone(); - assert!(event - .detail - .as_deref() - .is_some_and(|detail| detail.contains("--xml"))); - } - - /// An answer without a required capability makes the process a plain - /// plugin session: no announcement, no feature, and no request. - #[test] - fn an_incomplete_vcs_answer_is_not_a_provider() { - let mut harness = vcs_backend(json!({ - "version": 0, "vcs": "svn", "capabilities": { "status": 0, "baseContent": 0 } - })); - harness.poll(); - assert!(harness - .events - .iter() - .all(|event| event.kind != "vcsProvider")); - assert!(vcs_features(&harness) - .iter() - .all(|feature| feature != "litheVcs")); - let error = vcs_request(&harness, "svn", "status", json!({})).expect_err("refused"); - assert_eq!(error.details.as_deref(), Some("vcs.provider.unavailable")); - assert!(no_vcs_request_written(&harness)); - } - - #[test] - fn a_vcs_request_is_planned_from_its_operation_and_returns_the_reply() { - let mut harness = negotiated_vcs_provider(); - let operation = vcs_request( - &harness, - "svn", - "checkin", - json!({ "paths": ["src/b.txt", "src/a.txt"], "message": "Fix" }), - ) - .expect("accepted"); - let request_id = harness - .server - .await_request("lithe/vcs/checkin") - .expect("the request should reach the provider"); - assert_eq!( - harness.written_request(&request_id)["params"], - json!({ - "paths": ["src/a.txt", "src/b.txt"], - "message": "Fix", - "root": { "path": working_copy() }, - "tool": null - }) - ); - let reply = json!({ "revision": { "id": "12", "display": "r12", "kind": "number" } }); - harness - .server - .send(json!({ "jsonrpc": "2.0", "id": request_id, "result": reply })); - let completed = harness - .await_event(|event| { - event.kind == "requestCompleted" - && event.operation_id.as_deref() == Some(operation.as_str()) - }) - .clone(); - assert!(completed.error.is_none(), "{:?}", completed.error); - assert_eq!(completed.result, Some(json!({ "value": reply }))); - assert!(harness.snapshot().pending_operation_ids.is_empty()); - } - - /// The host names an operation, never a method, so the session cannot - /// be used as a tunnel; another VCS, an unknown operation, a capability - /// the provider lacks, or a path leaving the root never reach the wire. - #[test] - fn vcs_requests_are_refused_before_they_are_written() { - let harness = negotiated_vcs_provider(); - let error = vcs_request(&harness, "hg", "status", json!({})).expect_err("wrong VCS"); - assert_eq!(error.details.as_deref(), Some("vcs.provider.unavailable")); - let error = - vcs_request(&harness, "svn", "shutdown", json!({})).expect_err("no such operation"); - assert!(matches!(error.code, ErrorCode::InvalidRequest)); - let error = vcs_request(&harness, "svn", "annotate", json!({ "path": "a.txt" })) - .expect_err("not negotiated"); - assert!(matches!(error.code, ErrorCode::NotSupported)); - assert_eq!(error.details.as_deref(), Some("vcs.capability.unsupported")); - let error = vcs_request(&harness, "svn", "rollback", json!({ "paths": ["../x"] })) - .expect_err("outside the root"); - assert!(matches!(error.code, ErrorCode::InvalidRequest)); - assert!(no_vcs_request_written(&harness)); - } - - /// A provider may name one of the contract's failure codes; any other - /// code, including one of Lithe's own, becomes the generic failure. - #[test] - fn vcs_failures_keep_only_contract_codes() { - let mut harness = negotiated_vcs_provider(); - for (index, (data, expected)) in [ - (json!({ "code": "vcs.outOfDate" }), "vcs.outOfDate"), - ( - json!({ "code": "plugin.vcs.invalidOutput" }), - "vcs.provider.failed", - ), - ] - .into_iter() - .enumerate() - { - let operation = vcs_request(&harness, "svn", "update", json!({})).expect("sent"); - let request_id = harness - .server - .await_request_at("lithe/vcs/update", index) - .expect("the request should reach the provider"); - harness.server.send(json!({ - "jsonrpc": "2.0", - "id": request_id, - "error": { "code": -32000, "message": "Working copy is out of date.", "data": data } - })); - let error = harness - .await_event(|event| event.operation_id.as_deref() == Some(operation.as_str())) - .error - .clone() - .expect("an error reply fails the request"); - assert_eq!(error.code, expected); - assert_eq!(error.message, "Working copy is out of date."); - } - } - - // Plugin functions against a scripted backend. The scripted server plays - // the plugin process, so every outcome (result, failure, cancellation, - // deadline, late reply, revocation, and compatibility with a backend that - // only speaks the build protocol) is driven at a chosen moment. - - const FUNCTION_PLUGIN: &str = "dev.lithe.fixture.build"; - - /// A session owned by `FUNCTION_PLUGIN` whose backend answered - /// `initialize` with `experimental`. - fn plugin_backend(experimental: Value) -> Harness { - let mut harness = Harness::start(|request| { - request.plugin_id = Some(FUNCTION_PLUGIN.to_string()); - }); - harness - .server - .complete_initialize(json!({ "experimental": experimental })); - harness.await_state(LspLifecycleState::Ready); - harness - } - - fn function_backend() -> Harness { - plugin_backend(json!({ - "litheBuild": { "version": 0 }, - "litheFunctions": { "version": 0, "functions": [{ "id": "snapshot.refresh" }] } - })) - } - - fn invoke( - harness: &Harness, - plugin_id: &str, - function_id: &str, - timeout_milliseconds: Option, - ) -> Result { - let operation_id = harness.engine.next_operation_id(); - invoke_with_operation( - harness, - plugin_id, - function_id, - timeout_milliseconds, - operation_id, - ) - } - - /// Starts a call using a host-selected ID so regressions can exercise ID reuse. - fn invoke_with_operation( - harness: &Harness, - plugin_id: &str, - function_id: &str, - timeout_milliseconds: Option, - operation_id: String, - ) -> Result { - harness - .session() - .function_request( - PluginFunctionRequest { - session_id: harness.session_id.clone(), - operation_id: Some(operation_id.clone()), - function: super::super::plugin_function::FunctionReference { - plugin_id: plugin_id.to_string(), - function_id: function_id.to_string(), - }, - params: json!({ "project": "app" }), - timeout_milliseconds, - context: None, - }, - operation_id.clone(), - None, - ) - .map(|()| operation_id) - } - - /// The engine's reply to a request the scripted backend sent, waited for - /// with a bounded deadline because the reply is written by the reader thread. - fn await_reply(harness: &Harness, id: &str) -> Value { - harness - .server - .await_response(id) - .unwrap_or_else(|| panic!("the engine never answered backend request {id}")) - } - - fn plugin_configured() -> Harness { - let mut harness = Harness::start(|request| { - request.plugin_id = Some("dev.lithe.go".to_string()); - request.workspace_configuration = Some(json!({ - "gopls": { "ui": { "staticcheck": true } }, - "go.dotted": 7 - })); - }); - harness.server.complete_initialize(ready_capabilities()); - harness.await_state(LspLifecycleState::Ready); - harness - } - - #[test] - fn plugin_configuration_answers_sections_and_announces_itself() { - let harness = plugin_configured(); - assert!(harness - .server - .await_notification("workspace/didChangeConfiguration")); - harness.server.send(json!({ - "jsonrpc": "2.0", - "id": "cfg-1", - "method": "workspace/configuration", - "params": { "items": [ - { "section": "gopls" }, - { "section": "gopls.ui.staticcheck" }, - { "section": "go.dotted" }, - { "section": "missing.path" } - ] } - })); - let reply = await_reply(&harness, "cfg-1"); - assert_eq!( - reply["result"], - json!([{ "ui": { "staticcheck": true } }, true, 7, null]) - ); - } - - #[test] - fn plugin_configuration_updates_reach_a_running_server() { - let harness = plugin_configured(); - let response = harness - .session() - .update_plugin_configuration(json!({ "gopls": { "ui": { "staticcheck": false } } })) - .expect("the update should be accepted"); - assert!(response.sent); - let change = harness - .server - .await_notification_at("workspace/didChangeConfiguration", 1) - .expect("the configuration change was never sent"); - assert_eq!( - change["params"]["settings"]["gopls"]["ui"]["staticcheck"], - json!(false) - ); - } - - #[test] - fn host_servers_reject_plugin_configuration() { - let harness = Harness::ready(); - assert!(harness - .session() - .update_plugin_configuration(json!({})) - .is_err()); - assert!(checked_workspace_configuration(Some(json!([1]))).is_err()); - } - - fn completions(harness: &mut Harness, operation: &str) -> Vec { - harness.poll(); - harness - .events - .iter() - .filter(|event| { - event.kind == "requestCompleted" && event.operation_id.as_deref() == Some(operation) - }) - .cloned() - .collect() - } - - #[test] - fn a_plugin_function_is_routed_to_its_plugin_and_returns_its_result() { - let mut harness = function_backend(); - let initialize = harness - .server - .messages() - .into_iter() - .find(|message| message["method"] == "initialize") - .expect("initialize should have been written"); - assert_eq!( - initialize["params"]["capabilities"]["experimental"], - json!({ - "litheBuild": { "versions": [0] }, - "litheFunctions": { "versions": [0] }, - "litheLogging": { "versions": [0] }, - "litheVcs": { "versions": [0] } - }) - ); - - // Another plugin's reference never reaches this process. - let error = invoke( - &harness, - "dev.lithe.fixture.other-build", - "snapshot.refresh", - None, - ) - .expect_err("a foreign plugin is refused"); - assert!(matches!(error.code, ErrorCode::PermissionDenied)); - assert_eq!( - error.details.as_deref(), - Some("plugin.function.wrongPlugin") - ); - let error = invoke(&harness, FUNCTION_PLUGIN, "snapshot.clear", None) - .expect_err("an unregistered function is refused"); - assert_eq!(error.details.as_deref(), Some("plugin.function.unknown")); - assert!(harness - .server - .messages() - .iter() - .all(|message| message["method"] != "lithe/function/invoke")); - - let operation = invoke(&harness, FUNCTION_PLUGIN, "snapshot.refresh", None).expect("sent"); - let request_id = harness - .server - .await_request("lithe/function/invoke") - .expect("the call should reach the backend"); - let wire_operation = harness.written_request(&request_id)["params"]["operationID"].clone(); - assert!(wire_operation.as_str().is_some_and(|id| !id.is_empty())); - assert_eq!( - harness.written_request(&request_id)["params"], - json!({ - "functionID": "snapshot.refresh", - "operationID": wire_operation, - "params": { "project": "app" } - }) - ); - harness.server.send(json!({ - "jsonrpc": "2.0", - "id": request_id, - "result": { "snapshot": 3, "locations": [] } - })); - let completed = harness - .await_event(|event| { - event.kind == "requestCompleted" - && event.operation_id.as_deref() == Some(operation.as_str()) - }) - .clone(); - assert!(completed.error.is_none()); - assert_eq!( - completed.result, - Some(json!({ "value": { "snapshot": 3, "locations": [] } })) - ); - assert!(harness.snapshot().pending_operation_ids.is_empty()); - } - - #[test] - fn a_failing_plugin_function_reports_the_stable_and_the_plugin_code() { - let mut harness = function_backend(); - let operation = invoke(&harness, FUNCTION_PLUGIN, "snapshot.refresh", None).expect("sent"); - let request_id = harness - .server - .await_request("lithe/function/invoke") - .expect("the call should reach the backend"); - harness.server.send(json!({ - "jsonrpc": "2.0", - "id": request_id, - "error": { "code": -32000, "message": "Snapshot is locked.", "data": { "code": "build.snapshotLocked" } } - })); - let error = harness - .await_event(|event| event.operation_id.as_deref() == Some(operation.as_str())) - .error - .clone() - .expect("an error reply fails the call"); - assert_eq!(error.code, "plugin.function.failed"); - assert_eq!(error.message, "Snapshot is locked."); - assert_eq!( - error.plugin_error_code.as_deref(), - Some("build.snapshotLocked") - ); - } - - /// Cancellation reaches the backend, ends the call once, and neither a - /// late reply nor a late progress report revives it. - #[test] - fn a_cancelled_plugin_function_ignores_its_late_reply_and_progress() { - let mut harness = function_backend(); - let operation = invoke(&harness, FUNCTION_PLUGIN, "snapshot.refresh", None).expect("sent"); - let request_id = harness - .server - .await_request("lithe/function/invoke") - .expect("the call should reach the backend"); - let wire_operation = harness.written_request(&request_id)["params"]["operationID"].clone(); - harness.server.send(json!({ - "jsonrpc": "2.0", - "method": "lithe/function/progress", - "params": { "operationID": wire_operation, "message": "Reading snapshot", "percentage": 40 } - })); - let progress = harness - .await_event(|event| event.kind == "requestProgress") - .clone(); - assert_eq!(progress.operation_id.as_deref(), Some(operation.as_str())); - assert_eq!( - progress.result, - Some( - json!({ "operationID": operation, "message": "Reading snapshot", "percentage": 40 }) - ) - ); - - harness - .session() - .cancel_operation(&operation) - .expect("a running call can be cancelled"); - assert!(harness.server.await_notification("$/cancelRequest")); - harness.server.send(json!({ - "jsonrpc": "2.0", - "method": "lithe/function/progress", - "params": { "operationID": wire_operation, "percentage": 90 } - })); - harness - .server - .send(json!({ "jsonrpc": "2.0", "id": request_id, "result": "too late" })); - // A request the backend sends after the late messages is answered only - // once the reader has processed them, so its reply orders the check. - harness.server.send(json!({ - "jsonrpc": "2.0", - "id": "fence", - "method": "workspace/configuration", - "params": { "items": [] } - })); - await_reply(&harness, "fence"); - - let ended = completions(&mut harness, &operation); - assert_eq!( - ended.len(), - 1, - "a late reply must not complete the call again" - ); - assert_eq!( - ended[0].error.as_ref().map(|error| error.code.as_str()), - Some("requestCancelled") - ); - assert_eq!( - harness - .events - .iter() - .filter(|event| event.kind == "requestProgress") - .count(), - 1, - "progress after cancellation must be dropped" - ); - } - - #[test] - fn a_plugin_function_times_out_on_its_own_deadline() { - let mut harness = function_backend(); - let operation = - invoke(&harness, FUNCTION_PLUGIN, "snapshot.refresh", Some(60_000)).expect("sent"); - harness - .server - .await_request("lithe/function/invoke") - .expect("the call should reach the backend"); - harness - .session() - .expire_deadlines_at(Instant::now() + Duration::from_secs(30)); - assert!(completions(&mut harness, &operation).is_empty()); - assert_eq!( - harness.snapshot().pending_operation_ids, - vec![operation.clone()] - ); - harness - .session() - .expire_deadlines_at(Instant::now() + Duration::from_secs(61)); - let timeout = harness - .await_event(|event| event.operation_id.as_deref() == Some(operation.as_str())) - .clone(); - assert_eq!( - timeout.error.as_ref().map(|error| error.code.as_str()), - Some("requestTimeout") - ); - assert!(harness.server.await_notification("$/cancelRequest")); - } - - /// Registration after `initialize` is all or nothing, and withdrawing a - /// function ends its running calls instead of letting them finish later. - #[test] - fn unregistering_a_running_function_revokes_the_call() { - let mut harness = function_backend(); - harness.server.send(json!({ - "jsonrpc": "2.0", - "id": "register-bad", - "method": "lithe/function/register", - "params": { "functions": [{ "id": "snapshot.clear" }, { "id": "snapshot.refresh" }] } - })); - let refused = await_reply(&harness, "register-bad"); - assert_eq!( - refused["error"]["data"]["code"], - "plugin.function.invalidRegistration" - ); - - harness.server.send(json!({ - "jsonrpc": "2.0", - "id": "register", - "method": "lithe/function/register", - "params": { "functions": [{ "id": "snapshot.clear" }] } - })); - assert_eq!(await_reply(&harness, "register")["result"], Value::Null); - let changed = harness - .await_event(|event| { - event.kind == "functionsChanged" - && event.result.as_ref().is_some_and(|result| { - result["functions"] == json!(["snapshot.clear", "snapshot.refresh"]) - }) - }) - .clone(); - assert_eq!( - changed.result, - Some(json!({ "functions": ["snapshot.clear", "snapshot.refresh"], "removed": [] })) - ); - - let operation = invoke(&harness, FUNCTION_PLUGIN, "snapshot.clear", None).expect("sent"); - let request_id = harness - .server - .await_request("lithe/function/invoke") - .expect("the call should reach the backend"); - harness.server.send(json!({ - "jsonrpc": "2.0", - "id": "unregister", - "method": "lithe/function/unregister", - "params": { "ids": ["snapshot.clear"] } - })); - assert_eq!(await_reply(&harness, "unregister")["result"], Value::Null); - let revoked = harness - .await_event(|event| event.operation_id.as_deref() == Some(operation.as_str())) - .clone(); - assert_eq!( - revoked.error.as_ref().map(|error| error.code.as_str()), - Some("plugin.function.revoked") - ); - // The revocation is published before the cancellation is written, so - // the write is awaited rather than expected to be there already. - let cancel = harness - .server - .await_notification_at("$/cancelRequest", 0) - .expect("the revoked call should be cancelled on the wire"); - assert_eq!(cancel["params"]["id"], request_id); - let error = invoke(&harness, FUNCTION_PLUGIN, "snapshot.clear", None) - .expect_err("a withdrawn function is unknown"); - assert_eq!(error.details.as_deref(), Some("plugin.function.unknown")); - assert!(harness.snapshot().pending_operation_ids.is_empty()); - } - - /// A backend that speaks only the build protocol keeps its build requests; - /// it receives no function calls and its function messages are not - /// mistaken for registrations. - #[test] - fn a_build_only_backend_keeps_working_without_plugin_functions() { - let mut harness = plugin_backend(json!({ "litheBuild": { "version": 0 } })); - let error = invoke(&harness, FUNCTION_PLUGIN, "snapshot.refresh", None) - .expect_err("functions need the negotiated protocol"); - assert!(matches!(error.code, ErrorCode::NotSupported)); - assert_eq!( - error.details.as_deref(), - Some("plugin.function.unavailable") - ); - - harness.server.send(json!({ - "jsonrpc": "2.0", - "id": "register", - "method": "lithe/function/register", - "params": { "functions": [{ "id": "snapshot.refresh" }] } - })); - assert_eq!(await_reply(&harness, "register")["error"]["code"], -32601); - - let operation = build_request(&harness, "lithe/build/model", json!({}), None) - .expect("the build protocol is unaffected"); - let request_id = harness - .server - .await_request("lithe/build/model") - .expect("the build request should reach the backend"); - let reply = json!({ "format": "litheBuildModel", "version": 0 }); - harness - .server - .send(json!({ "jsonrpc": "2.0", "id": request_id, "result": reply })); - let completed = harness - .await_event(|event| event.operation_id.as_deref() == Some(operation.as_str())) - .clone(); - assert_eq!(completed.result, Some(json!({ "value": reply }))); - } - - /// Host-owned sessions neither offer nor accept the plugin-function protocol. - #[test] - fn a_host_owned_session_serves_no_plugin_functions() { - let mut harness = Harness::start(|_| {}); - harness.server.complete_initialize(json!({ - "experimental": { "litheFunctions": { "version": 0, "functions": [{ "id": "snapshot.refresh" }] } } - })); - harness.await_state(LspLifecycleState::Ready); - let initialize = harness - .server - .messages() - .into_iter() - .find(|message| message["method"] == "initialize") - .expect("initialize written"); - assert!(initialize["params"]["capabilities"]["experimental"] - .get("litheFunctions") - .is_none()); - assert!(!harness - .session() - .lock_state() - .expect("session state") - .client - .server_capabilities - .iter() - .any(|feature| feature == "litheFunctions")); - for (id, method, params) in [ - ( - "host-register", - "lithe/function/register", - json!({ "functions": [{ "id": "snapshot.clear" }] }), - ), - ( - "host-unregister", - "lithe/function/unregister", - json!({ "ids": ["snapshot.refresh"] }), - ), - ] { - harness - .server - .send(json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })); - assert_eq!(await_reply(&harness, id)["error"]["code"], -32601); - } - harness.poll(); - assert!(harness - .events - .iter() - .all(|event| event.kind != "functionsChanged")); - let error = invoke(&harness, FUNCTION_PLUGIN, "snapshot.refresh", None) - .expect_err("a session without a plugin is refused"); - assert_eq!( - error.details.as_deref(), - Some("plugin.function.unavailable") - ); - } - - /// Ends the first invocation at a controlled boundary before reusing its host ID. - fn finish_function_for_reuse( - harness: &mut Harness, - operation: &str, - request: &str, - ending: &str, - ) { - match ending { - "cancel" => harness - .session() - .cancel_operation(operation) - .expect("cancelled"), - "timeout" => harness - .session() - .expire_deadlines_at(Instant::now() + Duration::from_secs(61)), - "complete" => harness - .server - .send(json!({ "jsonrpc": "2.0", "id": request, "result": "first" })), - "error" => harness.server.send(json!({ "jsonrpc": "2.0", "id": request, "error": { "code": -32000, "message": "first failed" } })), - "revoke" => { - harness.server.send(json!({ - "jsonrpc": "2.0", "id": "withdraw-first", "method": "lithe/function/unregister", - "params": { "ids": ["snapshot.refresh"] } - })); - assert_eq!( - await_reply(harness, "withdraw-first")["result"], - Value::Null - ); - } - _ => panic!("unknown termination scenario"), - } - harness.await_event(|event| { - event.kind == "requestCompleted" && event.operation_id.as_deref() == Some(operation) - }); - if matches!(ending, "cancel" | "timeout" | "revoke") { - assert!(harness.server.await_notification("$/cancelRequest")); - } - if ending == "revoke" { - harness.server.send(json!({ - "jsonrpc": "2.0", "id": "restore-first", "method": "lithe/function/register", - "params": { "functions": [{ "id": "snapshot.refresh" }] } - })); - assert_eq!(await_reply(harness, "restore-first")["result"], Value::Null); - } - } - - #[test] - fn plugin_function_initialization_publishes_the_full_registry() { - for functions in [ - json!([{ "id": "snapshot.refresh" }, { "id": "snapshot.clear" }]), - json!([]), - ] { - let mut harness = plugin_backend( - json!({ "litheFunctions": { "version": 0, "functions": functions } }), - ); - let initialized = harness - .await_event(|event| event.kind == "functionsChanged") - .clone(); - let mut expected: Vec<_> = functions - .as_array() - .expect("descriptors") - .iter() - .map(|item| item["id"].as_str().expect("function identifier")) - .collect(); - expected.sort_unstable(); - assert_eq!( - initialized.result, - Some(json!({ "functions": expected, "removed": [] })) - ); - assert_eq!( - harness - .events - .iter() - .filter(|event| event.kind == "functionsChanged") - .count(), - 1 - ); - } - } - - /// An ended call's response and progress cannot become the next call's result, - /// even when the host chooses the same operation ID after every terminal outcome. - #[test] - fn reused_plugin_function_operation_ids_ignore_old_wire_progress() { - for ending in ["cancel", "timeout", "complete", "error", "revoke"] { - let mut harness = function_backend(); - harness.server.send(json!({ - "jsonrpc": "2.0", "id": "register-second", "method": "lithe/function/register", - "params": { "functions": [{ "id": "snapshot.clear" }] } - })); - assert_eq!( - await_reply(&harness, "register-second")["result"], - Value::Null - ); - let operation = "reusable-function-operation"; - invoke_with_operation( - &harness, - FUNCTION_PLUGIN, - "snapshot.refresh", - Some(60_000), - operation.to_string(), - ) - .expect("first call"); - let first = harness - .server - .await_request("lithe/function/invoke") - .expect("first wire request"); - let old_wire = harness.written_request(&first)["params"]["operationID"].clone(); - finish_function_for_reuse(&mut harness, operation, &first, ending); - invoke_with_operation( - &harness, - FUNCTION_PLUGIN, - "snapshot.clear", - None, - operation.to_string(), - ) - .expect("reused host ID"); - let second = harness - .server - .await_request_at("lithe/function/invoke", 1) - .expect("second wire request"); - let current_wire = harness.written_request(&second)["params"]["operationID"].clone(); - assert_ne!( - old_wire, current_wire, - "wire identity must change after {ending}" - ); - harness.server.send(json!({ - "jsonrpc": "2.0", "id": "withdraw-old-function", "method": "lithe/function/unregister", - "params": { "ids": ["snapshot.refresh"] } - })); - assert_eq!( - await_reply(&harness, "withdraw-old-function")["result"], - Value::Null - ); - assert_eq!( - harness.snapshot().pending_operation_ids, - vec![operation.to_string()] - ); - assert!(harness - .server - .messages() - .iter() - .all(|message| message["method"] != "$/cancelRequest" - || message["params"]["id"] != second)); - harness - .server - .send(json!({ "jsonrpc": "2.0", "id": first, "result": "late" })); - harness.server.send(json!({ "jsonrpc": "2.0", "method": "lithe/function/progress", "params": { "operationID": old_wire, "message": "stale" } })); - harness.server.send(json!({ "jsonrpc": "2.0", "method": "lithe/function/progress", "params": { "operationID": current_wire, "message": "current", "percentage": 50 } })); - let progress = harness - .await_event(|event| event.kind == "requestProgress") - .clone(); - assert_eq!(progress.operation_id.as_deref(), Some(operation)); - assert_eq!( - progress.result, - Some(json!({ "operationID": operation, "message": "current", "percentage": 50 })), - "{ending}" - ); - harness - .server - .send(json!({ "jsonrpc": "2.0", "id": second, "result": "second" })); - harness.await_event(|event| { - event.kind == "requestCompleted" - && event.result == Some(json!({ "value": "second" })) - }); - assert_eq!(completions(&mut harness, operation).len(), 2, "{ending}"); - assert_eq!( - harness - .events - .iter() - .filter(|event| event.kind == "requestProgress") - .count(), - 1, - "{ending}" - ); - assert!(harness.snapshot().pending_operation_ids.is_empty()); - } - } - - /// Revocation after a function has ended must never cancel an unrelated - /// build that reused the same host operation ID. - #[test] - fn reused_plugin_function_operation_ids_preserve_build_requests() { - for ending in ["cancel", "timeout", "complete", "error", "revoke"] { - let mut harness = function_backend(); - let operation = "reusable-build-operation"; - invoke_with_operation( - &harness, - FUNCTION_PLUGIN, - "snapshot.refresh", - Some(60_000), - operation.to_string(), - ) - .expect("first call"); - let first = harness - .server - .await_request("lithe/function/invoke") - .expect("function request"); - let old_wire = harness.written_request(&first)["params"]["operationID"].clone(); - finish_function_for_reuse(&mut harness, operation, &first, ending); - harness - .session() - .build_request( - BuildProviderRequest { - session_id: harness.session_id.clone(), - operation_id: Some(operation.to_string()), - method: "lithe/build/model".to_string(), - params: json!({}), - timeout_milliseconds: None, - }, - operation.to_string(), - ) - .expect("build may reuse host operation ID"); - let build = harness - .server - .await_request("lithe/build/model") - .expect("build request"); - harness - .server - .send(json!({ "jsonrpc": "2.0", "id": first, "result": "late" })); - harness.server.send(json!({ "jsonrpc": "2.0", "method": "lithe/function/progress", "params": { "operationID": old_wire, "message": "stale" } })); - harness.server.send(json!({ - "jsonrpc": "2.0", "id": "withdraw-during-build", "method": "lithe/function/unregister", - "params": { "ids": ["snapshot.refresh"] } - })); - assert_eq!( - await_reply(&harness, "withdraw-during-build")["result"], - Value::Null - ); - harness.poll(); - assert!(harness - .events - .iter() - .any(|event| event.kind == "functionsChanged" - && event.result - == Some(json!({ "functions": [], "removed": ["snapshot.refresh"] })))); - assert_eq!( - harness.snapshot().pending_operation_ids, - vec![operation.to_string()], - "{ending}" - ); - assert!(harness - .server - .messages() - .iter() - .all(|message| message["method"] != "$/cancelRequest" - || message["params"]["id"] != build)); - harness - .server - .send(json!({ "jsonrpc": "2.0", "id": build, "result": { "version": 1 } })); - harness.await_event(|event| { - event.kind == "requestCompleted" - && event.method.as_deref() == Some("lithe/build/model") - }); - let ended = completions(&mut harness, operation); - assert_eq!(ended.len(), 2, "{ending}"); - assert!(ended[1].error.is_none(), "{ending}: {:?}", ended[1]); - assert!( - harness - .events - .iter() - .all(|event| event.kind != "requestProgress"), - "{ending}" - ); - } - } - - #[test] - fn stopping_a_plugin_function_withdraws_registry_and_ignores_late_messages() { - let mut harness = function_backend(); - let operation = invoke(&harness, FUNCTION_PLUGIN, "snapshot.refresh", None).expect("call"); - let request = harness - .server - .await_request("lithe/function/invoke") - .expect("wire request"); - let wire = harness.written_request(&request)["params"]["operationID"].clone(); - harness.session().stop().expect("shutdown starts"); - let shutdown = harness - .server - .await_request("shutdown") - .expect("shutdown request"); - harness.poll(); - let registry_changes = harness - .events - .iter() - .filter(|event| event.kind == "functionsChanged") - .count(); - assert_eq!( - registry_changes, 2, - "initialize publishes, then stop withdraws" - ); - harness.server.send(json!({ "jsonrpc": "2.0", "method": "lithe/function/progress", "params": { "operationID": wire, "message": "late" } })); - harness - .server - .send(json!({ "jsonrpc": "2.0", "id": request, "result": "late" })); - for (id, method, params) in [ - ( - "late-register", - "lithe/function/register", - json!({ "functions": [{ "id": "snapshot.late" }] }), - ), - ( - "late-unregister", - "lithe/function/unregister", - json!({ "ids": ["snapshot.refresh"] }), - ), - ] { - harness - .server - .send(json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })); - // The reply fences every earlier notification before asserting absence. - assert_eq!(await_reply(&harness, id)["error"]["code"], -32601); - } - harness.poll(); - assert!(harness - .session() - .lock_state() - .expect("session state") - .client - .plugin_functions - .is_empty()); - assert_eq!( - harness - .events - .iter() - .filter(|event| event.kind == "functionsChanged") - .count(), - registry_changes - ); - harness - .server - .send(json!({ "jsonrpc": "2.0", "id": shutdown, "result": null })); - assert!(harness.server.await_notification("exit")); - harness.server.exit(Some(0)); - harness.await_state(LspLifecycleState::Stopped); - assert_eq!(completions(&mut harness, &operation).len(), 1); - assert!(harness - .events - .iter() - .all(|event| event.kind != "requestProgress")); - assert!(harness - .events - .iter() - .any(|event| event.kind == "functionsChanged" - && event.result - == Some(json!({ "functions": [], "removed": ["snapshot.refresh"] })))); - } - - #[test] - fn a_plugin_function_process_failure_withdraws_registry_and_completes_calls() { - let mut harness = function_backend(); - let operation = invoke(&harness, FUNCTION_PLUGIN, "snapshot.refresh", None).expect("call"); - harness - .server - .await_request("lithe/function/invoke") - .expect("wire request"); - harness.server.exit(Some(42)); - harness.await_state(LspLifecycleState::Failed); - let ended = completions(&mut harness, &operation); - assert_eq!(ended.len(), 1); - assert!(ended[0].error.is_some()); - assert!(harness.snapshot().pending_operation_ids.is_empty()); - assert!(harness - .events - .iter() - .any(|event| event.kind == "functionsChanged" - && event.result - == Some(json!({ "functions": [], "removed": ["snapshot.refresh"] })))); - } - - /// Domain dispatch could route back into the backend that asked, whose - /// handler would wait on itself; a host method the host does not offer is - /// unavailable. - #[test] - fn a_backend_cannot_ask_the_host_to_dispatch_back_into_itself() { - let harness = function_backend(); - for (id, method, code) in [ - ( - "invoke", - "lithe/function/invoke", - "plugin.function.recursiveCall", - ), - ( - "model", - "lithe/build/model", - "plugin.function.recursiveCall", - ), - ( - "process", - "lithe/host/process.run", - "plugin.host.unavailable", - ), - ] { - harness.server.send(json!({ - "jsonrpc": "2.0", - "id": id, - "method": method, - "params": {} - })); - let reply = await_reply(&harness, id); - assert_eq!(reply["error"]["data"]["code"], code, "{method}"); - } - } - - fn host_service_backend(host_services: &[&str]) -> Harness { - let host_services: Vec = host_services.iter().map(ToString::to_string).collect(); - let mut harness = Harness::start(move |request| { - request.plugin_id = Some(FUNCTION_PLUGIN.to_string()); - request.host_services = host_services.clone(); - }); - harness.server.complete_initialize(json!({ - "experimental": { "litheFunctions": { "version": 0 } } - })); - harness.await_state(LspLifecycleState::Ready); - harness - } - - fn host_response( - harness: &Harness, - host_request_id: &str, - result: Option, - error: Option<&str>, - ) -> Result<(), CoreError> { - // Decoded from the wire shape `lsp.hostResponse` documents, so the - // field spelling is checked with the round trip. - let mut wire = json!({ - "sessionId": harness.session_id, - "hostRequestID": host_request_id, - }); - if let Some(result) = result { - wire["result"] = result; - } - if let Some(message) = error { - wire["error"] = match message.split_once('|') { - Some((code, message)) => json!({ "message": message, "code": code }), - None => json!({ "message": message }), - }; - } - let response: PluginHostResponse = - serde_json::from_value(wire).expect("documented wire shape"); - harness.session().host_response(response) - } - - /// A granted credential request reaches the host as `hostRequest`. The - /// backend receives exactly the host's answer under its own ID, and a - /// second answer to the same request is refused instead of reaching a - /// later request. - #[test] - fn a_granted_host_service_request_round_trips_through_the_host() { - let mut harness = host_service_backend(&["credentials"]); - harness.server.send(json!({ - "jsonrpc": "2.0", "id": "read", - "method": "lithe/host/credentials/get", - "params": { "key": "connection.prod" } - })); - let event = harness - .await_event(|event| event.kind == "hostRequest") - .clone(); - assert_eq!(event.method.as_deref(), Some("lithe/host/credentials/get")); - let request = event.result.expect("the request is described"); - assert_eq!(request["service"], "credentials"); - assert_eq!(request["params"], json!({ "key": "connection.prod" })); - let read_id = request["hostRequestID"].as_str().unwrap().to_string(); - host_response( - &harness, - &read_id, - Some(json!({ "secret": "s3cret" })), - None, - ) - .expect("the host answers once"); - assert_eq!( - await_reply(&harness, "read")["result"], - json!({ "secret": "s3cret" }) - ); - assert!(host_response(&harness, &read_id, None, None).is_err()); - - harness.server.send(json!({ - "jsonrpc": "2.0", "id": "write", - "method": "lithe/host/credentials/set", - "params": { "key": "connection.prod", "secret": "s3cret" } - })); - let event = harness - .await_event(|event| { - event.kind == "hostRequest" - && event.method.as_deref() == Some("lithe/host/credentials/set") - }) - .clone(); - let write_id = event.result.unwrap()["hostRequestID"] - .as_str() - .unwrap() - .to_string(); - assert_ne!(write_id, read_id); - host_response(&harness, &write_id, None, Some("The keychain is locked.")) - .expect("the host reports its failure"); - let reply = await_reply(&harness, "write"); - assert_eq!(reply["error"]["data"]["code"], "plugin.host.failed"); - assert_eq!(reply["error"]["message"], "The keychain is locked."); - } - - /// Host services are offered only for the services the plugin's - /// permissions grant; a refusal never reaches the host. - #[test] - fn host_services_need_a_grant() { - let mut harness = host_service_backend(&[]); - harness.server.send(json!({ - "jsonrpc": "2.0", "id": "read", - "method": "lithe/host/credentials/get", - "params": { "key": "connection.prod" } - })); - let reply = await_reply(&harness, "read"); - assert_eq!( - reply["error"]["data"]["code"], - "plugin.host.permissionDenied" - ); - harness.poll(); - assert!(harness - .events - .iter() - .all(|event| event.kind != "hostRequest")); - } - - /// A backend that negotiated no plugin-function protocol is offered no - /// host service and receives no host notification, whatever the plugin's - /// permissions grant. - #[test] - fn a_backend_without_the_function_protocol_gets_no_host_services() { - let mut harness = Harness::start(|request| { - request.plugin_id = Some(FUNCTION_PLUGIN.to_string()); - request.host_services = vec!["credentials".to_string(), "workspace".to_string()]; - }); - harness.server.complete_initialize(json!({ - "experimental": { "litheBuild": { "version": 0 } } - })); - harness.await_state(LspLifecycleState::Ready); - harness.server.send(json!({ - "jsonrpc": "2.0", "id": "read", - "method": "lithe/host/credentials/get", - "params": { "key": "connection.prod" } - })); - let reply = await_reply(&harness, "read"); - assert_eq!(reply["error"]["data"]["code"], "plugin.host.unavailable"); - harness.poll(); - assert!(harness - .events - .iter() - .all(|event| event.kind != "hostRequest")); - let request: PluginHostNotification = serde_json::from_value(json!({ - "sessionId": harness.session_id, - "method": "lithe/host/workspace/didChangeContexts", - "params": { "revision": 2 }, - })) - .expect("documented wire shape"); - assert!(!harness - .session() - .host_notify(request) - .expect("a known notification")); - } - - /// A host that stops answering cannot make a backend grow the session's - /// state: requests beyond the bound fail at once. - #[test] - fn unanswered_host_requests_are_bounded() { - let harness = host_service_backend(&["credentials"]); - let limit = super::super::plugin_function::MAX_PENDING_HOST_REQUESTS; - for index in 0..=limit { - harness.server.send(json!({ - "jsonrpc": "2.0", "id": format!("read-{index}"), - "method": "lithe/host/credentials/get", - "params": { "key": "connection.prod" } - })); - } - let reply = await_reply(&harness, &format!("read-{limit}")); - assert_eq!(reply["error"]["code"], -32014); - assert_eq!(reply["error"]["data"]["code"], "plugin.host.limitExceeded"); - assert_eq!( - harness.session().lock_state().unwrap().host_requests.len(), - limit - ); - } - - /// Core answers capability discovery itself, listing only the session's - /// grants; the host never sees the request. - #[test] - fn capability_discovery_is_answered_by_core() { - let mut harness = host_service_backend(&["workspace"]); - harness.server.send(json!({ - "jsonrpc": "2.0", "id": "caps", - "method": "lithe/host/getCapabilities" - })); - let reply = await_reply(&harness, "caps"); - assert_eq!(reply["result"]["protocolVersion"], 0); - assert_eq!(reply["result"]["backendAuthority"], "local"); - assert!(reply["result"]["services"]["workspace"].is_object()); - assert!(reply["result"]["services"]["credentials"].is_null()); - harness.poll(); - assert!(harness - .events - .iter() - .all(|event| event.kind != "hostRequest")); - } - - /// Editor options the host declared at start are reported with the - /// methods they belong to, and only when those methods are granted. - #[test] - fn capability_discovery_reports_granted_editor_options() { - use crate::plugins::editor_actions::{EditorHostOptions, REGISTER_PROVIDERS}; - let mut harness = Harness::start(|request| { - request.plugin_id = Some(FUNCTION_PLUGIN.to_string()); - request.host_services = vec![REGISTER_PROVIDERS.to_string()]; - request.host_options.editor = Some(EditorHostOptions { - action_icons: vec!["run".into(), "test-failed".into()], - action_source_bytes: 1024 * 1024, - provider_types: vec!["hover".into(), "codeLens".into()], - }); - }); - harness.server.complete_initialize(json!({ - "experimental": { "litheFunctions": { "version": 0 } } - })); - harness.await_state(LspLifecycleState::Ready); - harness.server.send(json!({ - "jsonrpc": "2.0", "id": "caps", - "method": "lithe/host/getCapabilities" - })); - let reply = await_reply(&harness, "caps"); - let editor = &reply["result"]["services"]["editor"]; - assert_eq!(editor["methods"], json!([REGISTER_PROVIDERS])); - assert_eq!( - editor["options"], - json!({ "providerTypes": ["hover", "codeLens"] }) - ); - assert!(reply["result"]["limits"]["editorActionSourceBytes"].is_null()); - harness.poll(); - assert!(harness - .events - .iter() - .all(|event| event.kind != "hostRequest")); - } - - /// Host options belong to a plugin backend and must be ones Core can - /// enforce. - #[test] - fn start_contract_rejects_unenforceable_host_options() { - use crate::plugins::editor_actions::EditorHostOptions; - let server = ScriptedServer::new(); - let mut request = start_request(&server); - let options = EditorHostOptions { - action_icons: vec!["run".into()], - action_source_bytes: 1024 * 1024, - provider_types: Vec::new(), - }; - request.host_options.editor = Some(options.clone()); - request.plugin_id = None; - assert!(validate_start_request(&request).is_err()); - request.plugin_id = Some(FUNCTION_PLUGIN.to_string()); - assert!(validate_start_request(&request).is_ok()); - for invalid in [ - EditorHostOptions { - action_source_bytes: 2 * 1024 * 1024, - ..options.clone() - }, - EditorHostOptions { - action_icons: vec!["Run Fast".into()], - ..options.clone() - }, - EditorHostOptions { - provider_types: vec!["diagnostics".into()], - ..options.clone() - }, - ] { - request.host_options.editor = Some(invalid); - assert!(validate_start_request(&request).is_err()); - } - } - - /// A field a method does not define is answered as an unsupported option - /// naming it, so a backend can retry without it; the host never sees the - /// request. - #[test] - fn an_unknown_option_is_named_to_the_backend() { - let mut harness = host_service_backend(&["workspace"]); - harness.server.send(json!({ - "jsonrpc": "2.0", "id": "folders", - "method": "lithe/host/workspace/getFolders", - "params": { - "context": { "contextID": "w-1", "generation": 1 }, - "includeHidden": true - } - })); - let reply = await_reply(&harness, "folders"); - assert_eq!(reply["error"]["code"], -32018); - assert_eq!( - reply["error"]["data"]["code"], - "plugin.host.unsupportedOption" - ); - assert_eq!( - reply["error"]["data"]["details"], - json!({ "option": "includeHidden" }) - ); - harness.poll(); - assert!(harness - .events - .iter() - .all(|event| event.kind != "hostRequest")); - } - - /// A host failure's code reaches the backend when it is one a host may - /// report; an unknown code is `plugin.host.failed`. - #[test] - fn typed_host_failures_reach_the_backend() { - let mut harness = host_service_backend(&["workspace"]); - for (id, code, expected, rpc_code) in [ - ( - "a", - "plugin.host.contextExpired", - "plugin.host.contextExpired", - -32011, - ), - ("b", "plugin.function.unknown", "plugin.host.failed", -32603), - ] { - harness.server.send(json!({ - "jsonrpc": "2.0", "id": id, - "method": "lithe/host/workspace/getFolders", - "params": { "context": { "contextID": "w1", "generation": 1 } } - })); - let event = harness - .await_event(|event| event.kind == "hostRequest") - .clone(); - let host_request_id = event.result.unwrap()["hostRequestID"] - .as_str() - .unwrap() - .to_string(); - harness.events.clear(); - host_response( - &harness, - &host_request_id, - None, - Some(&format!("{code}|The workspace closed.")), - ) - .expect("the host reports its failure"); - let reply = await_reply(&harness, id); - assert_eq!(reply["error"]["data"]["code"], expected, "{code}"); - assert_eq!(reply["error"]["code"], rpc_code, "{code}"); - } - } - - /// A host failure's details reach the backend under - /// `error.data.details`, next to the stable code. - #[test] - fn host_failure_details_reach_the_backend() { - let mut harness = host_service_backend(&["workspace"]); - harness.server.send(json!({ - "jsonrpc": "2.0", "id": "folders", - "method": "lithe/host/workspace/getFolders", - "params": { "context": { "contextID": "w1", "generation": 1 } } - })); - let host_request_id = harness - .await_event(|event| event.kind == "hostRequest") - .clone() - .result - .unwrap()["hostRequestID"] - .as_str() - .unwrap() - .to_string(); - let response: PluginHostResponse = serde_json::from_value(json!({ - "sessionId": harness.session_id, - "hostRequestID": host_request_id, - "error": { - "message": "The context changed.", - "code": "plugin.host.contextExpired", - "details": { "generation": 2 } - } - })) - .expect("documented wire shape"); - harness - .session() - .host_response(response) - .expect("the host reports its failure"); - let reply = await_reply(&harness, "folders"); - assert_eq!(reply["error"]["data"]["code"], "plugin.host.contextExpired"); - assert_eq!( - reply["error"]["data"]["details"], - json!({ "generation": 2 }) - ); - } - - /// `lsp.hostNotify` writes an admitted notification to the backend and - /// refuses one the session was not granted. - #[test] - fn host_notifications_reach_the_backend() { - let harness = host_service_backend(&["workspace"]); - let notify = |method: &str, params: Value| { - let request: PluginHostNotification = serde_json::from_value(json!({ - "sessionId": harness.session_id, - "method": method, - "params": params, - })) - .expect("documented wire shape"); - harness.session().host_notify(request) - }; - assert!(notify( - "lithe/host/workspace/didChangeContexts", - json!({ "revision": 2 }) - ) - .expect("an admitted notification")); - assert!(harness - .server - .await_notification("lithe/host/workspace/didChangeContexts")); - assert!(notify("lithe/build/run", json!({})).is_err()); - } - - /// A backend that cancels its own host request is answered at once, the - /// host learns which request to stop, and its late answer is refused. - #[test] - fn a_backend_can_cancel_its_host_request() { - let mut harness = host_service_backend(&["workspace"]); - harness.server.send(json!({ - "jsonrpc": "2.0", "id": "list", - "method": "lithe/host/workspace/listContexts" - })); - let host_request_id = harness - .await_event(|event| event.kind == "hostRequest") - .clone() - .result - .unwrap()["hostRequestID"] - .as_str() - .unwrap() - .to_string(); - harness.server.send(json!({ - "jsonrpc": "2.0", "method": "$/cancelRequest", "params": { "id": "list" } - })); - let cancelled = harness - .await_event(|event| event.kind == "hostRequestCancelled") - .clone(); - assert_eq!( - cancelled.result.unwrap()["hostRequestID"], - json!(host_request_id) - ); - let reply = await_reply(&harness, "list"); - assert_eq!(reply["error"]["data"]["code"], "plugin.host.cancelled"); - assert!(host_response(&harness, &host_request_id, Some(json!({})), None).is_err()); - } - - /// A call's workspace context reaches the backend as `params.context`, - /// and a malformed reference never leaves Core. - #[test] - fn invocations_carry_the_workspace_context() { - let harness = plugin_backend(json!({ - "litheFunctions": { "version": 0, "functions": [{ "id": "snapshot.refresh" }] } - })); - let call = |context: Value| { - let operation_id = harness.engine.next_operation_id(); - harness.session().function_request( - PluginFunctionRequest { - session_id: harness.session_id.clone(), - operation_id: Some(operation_id.clone()), - function: super::super::plugin_function::FunctionReference { - plugin_id: FUNCTION_PLUGIN.to_string(), - function_id: "snapshot.refresh".to_string(), - }, - params: json!({}), - timeout_milliseconds: None, - context: Some(context), - }, - operation_id, - None, - ) - }; - let error = call(json!({ "contextID": "w/1", "generation": 1 })) - .expect_err("a malformed context is refused"); - assert_eq!( - error.details.as_deref(), - Some("plugin.function.invalidParams") - ); - call(json!({ "contextID": "w1", "generation": 2 })).expect("sent"); - let request_id = harness - .server - .await_request("lithe/function/invoke") - .expect("the call should reach the backend"); - assert_eq!( - harness.written_request(&request_id)["params"]["context"], - json!({ "contextID": "w1", "generation": 2 }) - ); - } - - /// Requests and notifications of a plugin backend to its own language - /// server (plan API-25). The stub servers are Metals-like (a gate and a - /// command that runs one at a time) and rust-analyzer-like (neither), so - /// no case depends on one language. - mod plugin_owned_server { - use super::*; - - const PLUGIN: &str = "dev.lithe.metals"; - const SERVER: &str = "metals"; - const STATUS: &str = "metals/status"; - - /// The policy of the Metals-like server. `gated` adds the gate that - /// holds requests until the server reports it is ready. - fn metals_policy(gated: bool) -> Value { - let mut policy = json!({ - "serverID": SERVER, - "requestMethods": [ - "textDocument/definition", - "workspace/executeCommand", - "metals/slow", - "metals/didFocus" - ], - "commands": ["build-import", "build-connect"], - "serializedCommands": ["build-import"], - "forwardNotifications": [STATUS], - }); - if gated { - policy["gates"] = json!([{ - "id": "imported", - "until": "notification", - "method": STATUS, - "match": { "text": "ready" } - }]); - } - policy - } - - /// A rust-analyzer-like server: no gate, no serialized command. - fn rust_analyzer_policy() -> Value { - json!({ - "serverID": SERVER, - "requestMethods": ["experimental/runnables"], - }) - } - - fn start(policy: Value) -> Harness { - let mut harness = Harness::start(move |request| { - request.plugin_id = Some(PLUGIN.to_string()); - request.session_policy = - Some(serde_json::from_value(policy).expect("the policy should decode")); - }); - harness.server.complete_initialize(ready_capabilities()); - harness.await_state(LspLifecycleState::Ready); - harness - } - - /// The `lsp.pluginServerRequest` payload the host sends. - fn wire(harness: &Harness, operation_id: &str, method: &str, params: Value) -> Value { - json!({ - "sessionId": harness.session_id, - "operationId": operation_id, - "pluginId": PLUGIN, - "serverID": SERVER, - "method": method, - "params": params, - }) - } - - fn send(harness: &Harness, wire: Value) -> Result<(), CoreError> { - let operation_id = wire["operationId"].as_str().unwrap().to_string(); - let request: PluginServerRequest = - serde_json::from_value(wire).expect("documented wire shape"); - harness - .session() - .plugin_server_request(request, operation_id) - } - - fn call( - harness: &Harness, - operation_id: &str, - method: &str, - params: Value, - ) -> Result<(), CoreError> { - send(harness, wire(harness, operation_id, method, params)) - } - - fn run_command( - harness: &Harness, - operation_id: &str, - command: &str, - ) -> Result<(), CoreError> { - call( - harness, - operation_id, - "workspace/executeCommand", - json!({ "command": command, "arguments": [] }), - ) - } - - fn notify(harness: &Harness, method: &str, params: Value) -> Result { - let request: PluginServerNotify = serde_json::from_value(json!({ - "sessionId": harness.session_id, - "pluginId": PLUGIN, - "serverID": SERVER, - "method": method, - "params": params, - })) - .expect("documented wire shape"); - harness.session().plugin_server_notify(request) - } - - /// The server reports `text` through its status notification. - fn report_status(harness: &Harness, text: &str) { - harness.server.send(json!({ - "jsonrpc": "2.0", - "method": STATUS, - "params": { "text": text } - })); - } - - /// Waits until Core has applied the status notification of `text`. The - /// notification is forwarded to the backend, so the forwarded event - /// proves the engine already processed it. - fn await_status_seen(harness: &mut Harness, text: &str) { - harness.await_event(|event| { - event.kind == "pluginServerNotification" - && event - .result - .as_ref() - .is_some_and(|result| result["params"]["text"] == text) - }); - } - - fn reply(harness: &Harness, request_id: &str, result: Value) { - harness.server.send(json!({ - "jsonrpc": "2.0", "id": request_id, "result": result - })); - } - - fn completed(harness: &mut Harness, operation_id: &str) -> LspRuntimeEvent { - harness - .await_event(|event| { - event.kind == "requestCompleted" - && event.operation_id.as_deref() == Some(operation_id) - }) - .clone() - } - - fn error_code(event: &LspRuntimeEvent) -> &str { - &event.error.as_ref().expect("the request failed").code - } - - fn written(harness: &Harness, method: &str) -> usize { - harness - .server - .messages() - .iter() - .filter(|message| message["method"] == method) - .count() - } - - fn refusal_code(error: CoreError) -> String { - error.details.expect("a refusal names its plugin.host code") - } - - /// The server's result reaches the backend untouched, even for a - /// method whose result Lithe normalizes for its own editor. - #[test] - fn a_declared_request_returns_the_servers_own_result() { - let mut harness = start(metals_policy(false)); - call( - &harness, - "def-1", - "textDocument/definition", - json!({ "textDocument": { "uri": "file:///workspace/A.scala" } }), - ) - .expect("a declared method is sent"); - let request_id = harness - .server - .await_request("textDocument/definition") - .expect("the request should reach the server"); - let location = json!({ - "uri": "jar:file:///cache/lib.jar!/Lib.scala", - "range": { - "start": { "line": 1, "character": 2 }, - "end": { "line": 1, "character": 5 } - } - }); - reply(&harness, &request_id, json!([location])); - - let event = completed(&mut harness, "def-1"); - assert_eq!(event.result, Some(json!({ "value": [location] }))); - assert!(event.error.is_none()); - } - - #[test] - fn a_server_without_gates_or_commands_sends_at_once() { - let mut harness = start(rust_analyzer_policy()); - call(&harness, "run-1", "experimental/runnables", json!({})) - .expect("a declared method is sent"); - let request_id = harness - .server - .await_request("experimental/runnables") - .expect("nothing holds the request"); - reply(&harness, &request_id, json!([])); - assert_eq!( - completed(&mut harness, "run-1").result, - Some(json!({ "value": [] })) - ); - } - - #[test] - fn a_server_error_keeps_its_code_and_message_as_details() { - let mut harness = start(metals_policy(false)); - call(&harness, "slow-1", "metals/slow", json!({})).unwrap(); - let request_id = harness.server.await_request("metals/slow").unwrap(); - harness.server.send(json!({ - "jsonrpc": "2.0", - "id": request_id, - "error": { "code": -32001, "message": "Build server is not connected." } - })); - - let event = completed(&mut harness, "slow-1"); - assert_eq!(error_code(&event), "plugin.host.languageServerError"); - assert_eq!( - event.error.unwrap().details, - Some(json!({ "code": -32001, "message": "Build server is not connected." })) - ); - } - - #[test] - fn a_method_the_plugin_did_not_declare_never_reaches_the_server() { - let harness = start(metals_policy(false)); - let error = call(&harness, "bad-1", "textDocument/rename", json!({})) - .expect_err("an undeclared method is refused"); - assert_eq!(refusal_code(error), "plugin.host.methodNotDeclared"); - - let error = run_command(&harness, "bad-2", "wipe-workspace") - .expect_err("an undeclared command is refused"); - assert_eq!(refusal_code(error), "plugin.host.methodNotDeclared"); - assert_eq!(written(&harness, "textDocument/rename"), 0); - assert_eq!(written(&harness, "workspace/executeCommand"), 0); - } - - /// The host resolves the session, so a call that names another plugin - /// or server, or a session without a policy, is the host's mistake and - /// must not reach any server. - #[test] - fn a_session_of_another_contribution_is_refused() { - let harness = start(metals_policy(false)); - for (plugin_id, server_id) in [("dev.lithe.other", SERVER), (PLUGIN, "other-server")] { - let mut foreign = wire(&harness, "foreign-1", "metals/slow", json!({})); - foreign["pluginId"] = json!(plugin_id); - foreign["serverID"] = json!(server_id); - let error = send(&harness, foreign).expect_err("a foreign contribution is refused"); - assert_eq!(refusal_code(error), "plugin.host.foreignContribution"); - } - assert_eq!(written(&harness, "metals/slow"), 0); - - let plain = Harness::ready(); - let error = call(&plain, "plain-1", "metals/slow", json!({})) - .expect_err("a session without a policy is no plugin server"); - assert_eq!(refusal_code(error), "plugin.host.foreignContribution"); - } - - #[test] - fn requests_wait_for_the_gate_and_are_sent_in_order() { - let mut harness = start(metals_policy(true)); - call(&harness, "first", "metals/slow", json!({ "n": 1 })).unwrap(); - call( - &harness, - "second", - "textDocument/definition", - json!({ "n": 2 }), - ) - .unwrap(); - assert_eq!(written(&harness, "metals/slow"), 0); - - // A status that does not match the gate leaves it closed. - report_status(&harness, "importing"); - await_status_seen(&mut harness, "importing"); - assert_eq!(written(&harness, "metals/slow"), 0); - assert_eq!(written(&harness, "textDocument/definition"), 0); - - report_status(&harness, "ready"); - let first = harness.server.await_request("metals/slow").unwrap(); - let second = harness - .server - .await_request("textDocument/definition") - .unwrap(); - let messages = harness.server.messages(); - let position = |id: &str| messages.iter().position(|message| message["id"] == id); - assert!( - position(&first) < position(&second), - "arrival order is kept" - ); - - reply(&harness, &first, json!("one")); - assert_eq!( - completed(&mut harness, "first").result, - Some(json!({ "value": "one" })) - ); - } - - #[test] - fn a_held_request_can_be_cancelled_without_touching_the_server() { - let mut harness = start(metals_policy(true)); - call(&harness, "held-1", "metals/slow", json!({})).unwrap(); - harness - .session() - .cancel_operation("held-1") - .expect("a held request can be cancelled"); - - let event = completed(&mut harness, "held-1"); - assert_eq!(error_code(&event), "plugin.host.cancelled"); - - // Opening the gate afterwards sends nothing: the request is gone. - report_status(&harness, "ready"); - await_status_seen(&mut harness, "ready"); - assert_eq!(written(&harness, "metals/slow"), 0); - assert_eq!(written(&harness, "$/cancelRequest"), 0); - } - - #[test] - fn a_held_request_times_out_while_it_waits() { - let mut harness = start(metals_policy(true)); - let mut held = wire(&harness, "held-1", "metals/slow", json!({})); - held["timeoutMilliseconds"] = json!(1); - send(&harness, held).unwrap(); - - let event = completed(&mut harness, "held-1"); - assert_eq!(error_code(&event), "plugin.host.timeout"); - assert_eq!(written(&harness, "metals/slow"), 0); - } - - #[test] - fn a_sent_request_times_out_and_is_cancelled_on_the_server() { - let mut harness = start(metals_policy(false)); - let mut slow = wire(&harness, "slow-1", "metals/slow", json!({})); - slow["timeoutMilliseconds"] = json!(1); - send(&harness, slow).unwrap(); - let request_id = harness.server.await_request("metals/slow").unwrap(); - - let event = completed(&mut harness, "slow-1"); - assert_eq!(error_code(&event), "plugin.host.timeout"); - let cancel = harness - .server - .await_notification_at("$/cancelRequest", 0) - .expect("the server is told to stop"); - assert_eq!(cancel["params"]["id"], json!(request_id)); - } - - /// A second request for a serialized command does not start a second - /// run; both finish with the answer of the one that runs. - #[test] - fn a_serialized_command_joins_the_one_that_runs() { - let mut harness = start(metals_policy(false)); - run_command(&harness, "import-1", "build-import").unwrap(); - let request_id = harness - .server - .await_request("workspace/executeCommand") - .unwrap(); - run_command(&harness, "import-2", "build-import").unwrap(); - // A command that is not serialized is independent of the first. - run_command(&harness, "connect-1", "build-connect").unwrap(); - harness - .server - .await_request_at("workspace/executeCommand", 1) - .expect("an independent command is sent"); - assert_eq!(written(&harness, "workspace/executeCommand"), 2); - - reply(&harness, &request_id, json!({ "imported": 3 })); - for operation_id in ["import-1", "import-2"] { - let event = completed(&mut harness, operation_id); - assert_eq!(event.result, Some(json!({ "value": { "imported": 3 } }))); - } - assert!(!harness - .events - .iter() - .any(|event| event.operation_id.as_deref() == Some("connect-1"))); - - // The command can run again once the first run is over. - run_command(&harness, "import-3", "build-import").unwrap(); - harness - .server - .await_request_at("workspace/executeCommand", 2) - .expect("a finished command can run again"); - } - - #[test] - fn cancelling_a_joiner_leaves_the_running_command_alone() { - let mut harness = start(metals_policy(false)); - run_command(&harness, "import-1", "build-import").unwrap(); - let request_id = harness - .server - .await_request("workspace/executeCommand") - .unwrap(); - run_command(&harness, "import-2", "build-import").unwrap(); - harness.session().cancel_operation("import-2").unwrap(); - assert_eq!( - error_code(&completed(&mut harness, "import-2")), - "plugin.host.cancelled" - ); - assert_eq!(written(&harness, "$/cancelRequest"), 0); - - reply(&harness, &request_id, json!(null)); - assert_eq!( - completed(&mut harness, "import-1").result, - Some(json!({ "value": null })) - ); - } - - #[test] - fn only_declared_server_notifications_are_forwarded() { - let mut harness = start(metals_policy(false)); - harness.server.send(json!({ - "jsonrpc": "2.0", - "method": "metals/unlisted", - "params": { "text": "private" } - })); - report_status(&harness, "indexing"); - await_status_seen(&mut harness, "indexing"); - - let forwarded: Vec<_> = harness - .events - .iter() - .filter(|event| event.kind == "pluginServerNotification") - .collect(); - assert_eq!(forwarded.len(), 1); - assert_eq!( - forwarded[0].result, - Some(json!({ - "serverID": SERVER, - "method": STATUS, - "params": { "text": "indexing" } - })) - ); - } - - #[test] - fn a_declared_notification_is_sent_and_a_command_is_not_a_notification() { - let harness = start(metals_policy(false)); - let sent = notify( - &harness, - "metals/didFocus", - json!({ "uri": "file:///A.scala" }), - ); - assert!(sent.expect("a declared notification is sent")); - let message = harness - .server - .await_notification_at("metals/didFocus", 0) - .expect("the server receives it"); - assert_eq!(message["params"], json!({ "uri": "file:///A.scala" })); - - let error = notify(&harness, "workspace/executeCommand", json!({})) - .expect_err("commands run as requests only"); - assert_eq!(refusal_code(error), "plugin.host.methodNotDeclared"); - let error = notify(&harness, "metals/unlisted", json!({})) - .expect_err("an undeclared notification is refused"); - assert_eq!(refusal_code(error), "plugin.host.methodNotDeclared"); - } - - /// New settings replace the stored configuration, so they also answer - /// the server's own `workspace/configuration` requests. - #[test] - fn changed_configuration_goes_through_the_plugin_configuration() { - let harness = start(metals_policy(false)); - let settings = json!({ "metals": { "scalafmt": true } }); - let sent = notify( - &harness, - "workspace/didChangeConfiguration", - json!({ "settings": settings }), - ); - assert!(sent.expect("the change is accepted")); - let change = harness - .server - .await_notification_at("workspace/didChangeConfiguration", 0) - .expect("the server receives the change"); - assert_eq!(change["params"]["settings"], settings); - - let error = notify( - &harness, - "workspace/didChangeConfiguration", - json!({ "settings": 1 }), - ) - .expect_err("settings must be an object"); - assert_eq!(refusal_code(error), "plugin.host.invalidParams"); - } - - #[test] - fn a_stopped_server_fails_what_is_in_flight_and_refuses_new_calls() { - let mut harness = start(metals_policy(false)); - call(&harness, "slow-1", "metals/slow", json!({})).unwrap(); - call(&harness, "focus-1", "metals/didFocus", json!({})).unwrap(); - harness.server.await_request("metals/slow").unwrap(); - harness.server.await_request("metals/didFocus").unwrap(); - - harness.server.exit(Some(1)); - harness.await_state(LspLifecycleState::Failed); - for operation_id in ["slow-1", "focus-1"] { - let event = completed(&mut harness, operation_id); - assert_eq!(error_code(&event), "plugin.host.languageServerUnavailable"); - } - - let error = call(&harness, "late-1", "metals/slow", json!({})) - .expect_err("a stopped server takes no requests"); - assert_eq!(refusal_code(error), "plugin.host.languageServerUnavailable"); - } - - #[test] - fn a_gated_request_still_waiting_fails_when_the_server_stops() { - let mut harness = start(metals_policy(true)); - call(&harness, "held-1", "metals/slow", json!({})).unwrap(); - - harness.server.exit(Some(1)); - harness.await_state(LspLifecycleState::Failed); - let event = completed(&mut harness, "held-1"); - assert_eq!(error_code(&event), "plugin.host.languageServerUnavailable"); - } - - #[test] - fn an_invalid_policy_is_refused_at_start() { - let refused = |plugin_id: Option<&str>, policy: Value| { - let server = ScriptedServer::new(); - let engine = LspEngine::with_launcher(server.launcher()); - let mut request = start_request(&server); - request.plugin_id = plugin_id.map(ToString::to_string); - request.session_policy = Some(serde_json::from_value(policy).unwrap()); - match engine.start_server(request) { - Err(error) => matches!(error.code, ErrorCode::InvalidRequest), - Ok(_) => false, - } - }; - // The server ID is no protocol name. - assert!(refused(Some(PLUGIN), json!({ "serverID": "" }))); - // A serialized command must also be a declared command. - assert!(refused( - Some(PLUGIN), - json!({ "serverID": SERVER, "serializedCommands": ["build-import"] }) - )); - // A host-owned server has no backend, so it cannot have a policy. - assert!(refused(None, metals_policy(false))); - } - } -} diff --git a/rust/crates/core/src/lsp/interface/mod.rs b/rust/crates/core/src/lsp/interface/mod.rs deleted file mode 100644 index f26f4ce1..00000000 --- a/rust/crates/core/src/lsp/interface/mod.rs +++ /dev/null @@ -1,45 +0,0 @@ -//! Standard LSP contracts and the stateful client/session implementation. - -mod build_provider; -mod client; -/// Decodes the path of a `file:` URI an extension reply names. -pub(crate) use client::percent_decode; -mod document_uri; -mod engine; -mod engine_logging; -mod function_relay; -mod keymap_provider; -pub(crate) mod plugin_function; -mod plugin_logging; -mod plugin_server; -pub(crate) mod process; -pub(crate) mod remote_process; -#[cfg(test)] -mod scripted; -mod toolchain_provider; -mod transport; -mod types; -mod vcs_provider; - -pub(crate) use client::*; -pub(crate) use document_uri::{ - document_uri_command, resolve_request_paths, DocumentPathPlatform, MAX_PATH_BYTES, -}; -pub(crate) use engine::*; -pub(crate) use engine_logging::{resolve_engine_log_subject, ResolveEngineLogSubjectRequest}; -pub(crate) use function_relay::RelaySnapshot; -pub(crate) use transport::*; -pub(crate) use types::*; - -/// The versions a Lithe runtime protocol offers in `initialize`. A runtime -/// protocol has no version of its own: it speaks the version of the contract -/// that owns it, the capability version of the manifest section that declares -/// the provider (or the plugin API version for plugin logging). The host -/// therefore offers exactly the versions of that contract it implements, and a -/// reply counts as negotiated only when it names one of them. -fn offered_versions(contract_versions: &[u32]) -> Vec { - contract_versions - .iter() - .map(|version| u64::from(*version)) - .collect() -} diff --git a/rust/crates/core/src/lsp/interface/plugin_function.rs b/rust/crates/core/src/lsp/interface/plugin_function.rs deleted file mode 100644 index 1e160c6f..00000000 --- a/rust/crates/core/src/lsp/interface/plugin_function.rs +++ /dev/null @@ -1,2455 +0,0 @@ -//! Plugin functions: plugin-private actions a backend registers and the host -//! invokes on behalf of the same plugin, next to the typed domain protocols. -//! -//! The protocol version is the `backend` capability version, negotiated in -//! `initialize` under `capabilities.experimental.litheFunctions`, -//! independently of `litheBuild`, so a backend can speak either one. The -//! protocol covers function registration and invocation, the host services of -//! `host_request` (credentials, `lithe/host/getCapabilities`, subscriptions, -//! workspace contexts with a workspace context on invocations, typed host -//! failure codes and details, host notifications), and the cross-plugin -//! function relay (`lithe/host/functions/*`) with the Core-written `caller` -//! of an invocation. Functions never replace a domain -//! operation: a build model, dependency tree, or task still travels over its -//! typed `lithe/build/*` method and Core's plan and decode commands. This -//! module holds the deterministic rules (identifiers, registration, invocation -//! planning, outcome and progress normalization, and the refusal of requests a -//! backend may not make); the session engine applies them to live traffic. -//! The rules live in the plugin contract -//! (`shared/plugins/contract/v1/README.md`, "Plugin functions"). - -use crate::plugins::capabilities::{versions as capability_versions, BACKEND}; -use crate::plugins::language_server_sessions::{self, is_protocol_name}; -use crate::protocol::{CoreError, ErrorCode}; -use serde::Deserialize; -use serde_json::{json, Value}; -use std::collections::BTreeSet; - -/// Key under `capabilities.experimental` on both sides of the exchange. -const EXPERIMENTAL_KEY: &str = "litheFunctions"; - -/// Plugin-function protocol versions this client speaks: the versions of the -/// `backend` capability. -fn supported_versions() -> Vec { - super::offered_versions(capability_versions(BACKEND)) -} - -/// Feature name recorded in the session's server capabilities once a backend -/// answered a supported version with a valid function list. -pub(crate) const FUNCTIONS_FEATURE: &str = "litheFunctions"; - -/// Host-to-backend request that runs one registered function. -pub(crate) const INVOKE_METHOD: &str = "lithe/function/invoke"; -/// Backend-to-host request adding functions after `initialize`. -pub(crate) const REGISTER_METHOD: &str = "lithe/function/register"; -/// Backend-to-host request withdrawing functions; running calls are revoked. -pub(crate) const UNREGISTER_METHOD: &str = "lithe/function/unregister"; -/// Backend-to-host notification reporting progress of one running call. -pub(crate) const PROGRESS_METHOD: &str = "lithe/function/progress"; - -/// Upper bound of functions one session may have registered at a time. -const MAX_FUNCTIONS: usize = 256; -const MAX_FUNCTION_ID_BYTES: usize = 128; -const MAX_PLUGIN_ID_BYTES: usize = 128; -const MAX_PLUGIN_ERROR_CODE_BYTES: usize = 64; -/// Bound of a backend-supplied failure or progress message, in UTF-8 bytes. -const MAX_MESSAGE_BYTES: usize = 1024; -/// Bound of one serialized function result. Larger documents belong in a -/// domain protocol with its own decoder, not in a plugin-private reply. -pub(crate) const MAX_RESULT_BYTES: usize = 1 << 20; -/// Upper bound of one invocation's deadline, matching build-provider requests. -pub(crate) const MAX_FUNCTION_TIMEOUT_MS: u64 = 60 * 60_000; - -/// Host service that keeps secrets for the plugin, private to its ID. -pub(crate) const HOST_SERVICE_CREDENTIALS: &str = "credentials"; -/// Host service listing open workspace contexts and their folders, granted -/// by `workspace.metadata` to a `backend` process. -pub(crate) const HOST_SERVICE_WORKSPACE: &str = "workspace"; -/// Capability discovery. Every negotiated plugin-owned session has it; -/// Core answers it without involving the host. -const HOST_SERVICE_HOST: &str = "host"; -/// Ending subscriptions other services created. Every negotiated -/// plugin-owned session has it, because only the session that holds a -/// subscription can name it. -const HOST_SERVICE_SUBSCRIPTIONS: &str = "subscriptions"; -/// Calls into the functions other plugins export. The grant comes from the -/// manifest's `requires.pluginFunctions.calls`, not from a permission: the -/// caller's declaration plus the provider's `exports` is the consent, and -/// Core's relay checks both on every call. -pub(crate) const HOST_SERVICE_FUNCTIONS: &str = "functions"; -/// Services granted as a whole by their aggregate grant. Every other host -/// method is granted individually, so adding a method to `workspace` or -/// `editor` never widens an existing permission. -pub(crate) const GRANTABLE_HOST_SERVICES: &[&str] = &[ - HOST_SERVICE_CREDENTIALS, - HOST_SERVICE_WORKSPACE, - HOST_SERVICE_FUNCTIONS, -]; - -/// Whether a launch grant names a service granted as a whole or an implemented -/// host method. -pub(crate) fn is_host_grant(grant: &str) -> bool { - GRANTABLE_HOST_SERVICES.contains(&grant) - || HOST_METHODS - .iter() - .any(|entry| entry.method == grant && entry.permission.is_some()) -} - -/// Methods admitted by one manifest permission. The platform intersects these -/// with its registered method handlers before starting the backend. -pub(crate) fn methods_for_permission(permission: &str) -> Vec<&'static str> { - HOST_METHODS - .iter() - .filter(|entry| entry.permission == Some(permission)) - .map(|entry| entry.method) - .collect() -} - -/// Permissions for the additive host services. -pub(crate) const HOST_PERMISSIONS: &[&str] = &[ - "workspace.files.read", - "workspace.files.watch", - "workspace.search", - "documents.read", - "editor.edit", - "editor.actions", - "editor.providers", - "editor.open", - "preview.open", - "browser.open", - "window.messages", - "terminal.create", - "keymaps.readSelectedSources", - "run.start", - "debug.start", - PERMISSION_LANGUAGE_SERVER_REQUEST, - PERMISSION_DOCUMENTS_CONTENT_PROVIDER, -]; - -/// Permission of the `languageServer` host service (plan API-25): a backend -/// may send requests and notifications to, and report the status of, the -/// language servers its own plugin declares. -pub(crate) const PERMISSION_LANGUAGE_SERVER_REQUEST: &str = "languageServer.request"; -/// Permission of `documents/registerContentProvider` (plan API-12): the -/// backend supplies the read-only text of URIs of a scheme it registers. -pub(crate) const PERMISSION_DOCUMENTS_CONTENT_PROVIDER: &str = "documents.contentProvider"; - -const CREDENTIALS_GET: &str = "lithe/host/credentials/get"; -const CREDENTIALS_SET: &str = "lithe/host/credentials/set"; -const CREDENTIALS_DELETE: &str = "lithe/host/credentials/delete"; -pub(crate) const GET_CAPABILITIES: &str = "lithe/host/getCapabilities"; -const SUBSCRIPTIONS_DISPOSE: &str = "lithe/host/subscriptions/dispose"; -const WORKSPACE_LIST_CONTEXTS: &str = "lithe/host/workspace/listContexts"; -const WORKSPACE_GET_CONTEXT: &str = "lithe/host/workspace/getContext"; -const WORKSPACE_GET_FOLDERS: &str = "lithe/host/workspace/getFolders"; -/// Backend request listing the plugins that export a function it may call. -pub(crate) const FUNCTIONS_PROVIDERS: &str = "lithe/host/functions/providers"; -/// Backend request calling a function another plugin exports. -pub(crate) const FUNCTIONS_INVOKE: &str = "lithe/host/functions/invoke"; -const MAX_CREDENTIAL_KEY_BYTES: usize = 128; -/// Bound of one stored secret, in UTF-8 bytes; enough for a password, a -/// token, or a private key, not for documents. -pub(crate) const MAX_CREDENTIAL_SECRET_BYTES: usize = 16 * 1024; -/// Host requests one session may have awaiting the host's answer. -pub(crate) const MAX_PENDING_HOST_REQUESTS: usize = 16; -/// Bound of the serialized `details` object of one host failure; enough -/// for a few numbers and identifiers, not for documents. -pub(crate) const MAX_HOST_DETAILS_BYTES: usize = 4 * 1024; -/// Bound of the serialized params of one host-to-backend notification, -/// the same as one function result. -pub(crate) const MAX_HOST_NOTIFICATION_BYTES: usize = MAX_RESULT_BYTES; - -/// Stable codes of the plugin-function contract. Synchronous refusals carry -/// them in `CoreError::details`; asynchronous outcomes in the runtime error's -/// `code`; refused backend requests in the JSON-RPC error's `data.code`. -pub(crate) mod codes { - /// The session cannot serve plugin functions: it belongs to no plugin, or - /// its backend did not negotiate the protocol. - pub const UNAVAILABLE: &str = "plugin.function.unavailable"; - /// The function is not registered on the session. - pub const UNKNOWN: &str = "plugin.function.unknown"; - /// The reference names another plugin than the one owning the session. - pub const WRONG_PLUGIN: &str = "plugin.function.wrongPlugin"; - /// The plugin or function ID of the reference is malformed. - pub const INVALID_REFERENCE: &str = "plugin.function.invalidReference"; - /// Invocation params are not a JSON object. - pub const INVALID_PARAMS: &str = "plugin.function.invalidParams"; - /// A register or unregister request was refused as a whole. - pub const INVALID_REGISTRATION: &str = "plugin.function.invalidRegistration"; - /// The backend answered the invocation with an error. - pub const FAILED: &str = "plugin.function.failed"; - /// The backend's answer has no result or exceeds the result bound. - pub const INVALID_RESULT: &str = "plugin.function.invalidResult"; - /// The function was unregistered while the call was running. - pub const REVOKED: &str = "plugin.function.revoked"; - /// The backend asked the host to dispatch a domain operation or function, - /// which could route back to the backend itself. - pub const RECURSIVE_CALL: &str = "plugin.function.recursiveCall"; - /// The backend asked for a host capability this host does not offer. - pub const HOST_UNAVAILABLE: &str = "plugin.host.unavailable"; - /// The host offers the service, but the plugin's permissions do not - /// grant it to this session. - pub const HOST_PERMISSION_DENIED: &str = "plugin.host.permissionDenied"; - /// A host-service request has malformed or unknown params. - pub const HOST_INVALID_PARAMS: &str = "plugin.host.invalidParams"; - /// The host could not perform the service. - pub const HOST_FAILED: &str = "plugin.host.failed"; - /// The request names a workspace context that was closed or replaced - /// by a newer generation. - pub const HOST_CONTEXT_EXPIRED: &str = "plugin.host.contextExpired"; - /// The context's authority (for example a remote workspace) does not - /// implement the requested file or process operation. The host never - /// falls back to the local placeholder directory. - pub const HOST_AUTHORITY_UNSUPPORTED: &str = "plugin.host.authorityUnsupported"; - /// A versioned write found a newer version than the one it names. - pub const HOST_VERSION_CONFLICT: &str = "plugin.host.versionConflict"; - /// The request exceeds a host limit on size or count. Retrying the same - /// request fails again; rate limits are `HOST_RATE_LIMITED`. - pub const HOST_LIMIT_EXCEEDED: &str = "plugin.host.limitExceeded"; - /// The backend cancelled the request, or the host stopped it. - pub const HOST_CANCELLED: &str = "plugin.host.cancelled"; - /// The host's own deadline for the operation passed. - pub const HOST_TIMEOUT: &str = "plugin.host.timeout"; - /// The resource the request names (a file, a document, a window) does - /// not exist or is no longer open. - pub const HOST_NOT_FOUND: &str = "plugin.host.notFound"; - /// The resource exists but may not be changed. - pub const HOST_READ_ONLY: &str = "plugin.host.readOnly"; - /// The params are well formed, but this host does not implement an - /// option they ask for. - pub const HOST_UNSUPPORTED_OPTION: &str = "plugin.host.unsupportedOption"; - /// The resource is not in the state the operation needs, for example a - /// document with unsaved changes or of another kind; the backend changes - /// the state or the request, not merely retries. - pub const HOST_PRECONDITION_FAILED: &str = "plugin.host.preconditionFailed"; - /// The host cannot serve the request at the moment, for example while - /// the user composes text or an index loads; the same request may - /// succeed later. - pub const HOST_BUSY: &str = "plugin.host.busy"; - /// The plugin made too many requests of this kind recently; the same - /// request may succeed after a while. - pub const HOST_RATE_LIMITED: &str = "plugin.host.rateLimited"; - /// A tool definition the request references resolves to no executable - /// for this workspace; `details` carries `{tool, reason}`. - pub const HOST_TOOL_UNRESOLVED: &str = "plugin.host.toolUnresolved"; - /// The language server a `languageServer` request names is not running - /// in the context: it is starting, stopped, crashed, or not started. - pub const HOST_LANGUAGE_SERVER_UNAVAILABLE: &str = "plugin.host.languageServerUnavailable"; - /// The language server answered a forwarded request with an error; the - /// failure's `details` carry the server's `code` and `message`. - pub const HOST_LANGUAGE_SERVER_ERROR: &str = "plugin.host.languageServerError"; - /// The method or command is not in the language server's declared - /// `requestMethods`, `commands`, or allowed notifications. - pub const HOST_METHOD_NOT_DECLARED: &str = "plugin.host.methodNotDeclared"; - /// The request names a contribution, such as a language server, that - /// another plugin owns. - pub const HOST_FOREIGN_CONTRIBUTION: &str = "plugin.host.foreignContribution"; - /// A page's call or a host operation ended because the backend process - /// exited. Reported by hosts to pages, never sent to a backend. - pub const BACKEND_EXITED: &str = "plugin.backend.exited"; - - // Cross-plugin relay (`lithe/host/functions/*`). Each - // refusal has its own code and number, so a caller can branch on either. - - /// The caller's manifest does not declare a call to this function. - pub const NOT_DECLARED: &str = "plugin.function.notDeclared"; - /// The plugin that should provide the function does not export it, or - /// has not registered it with its running backend. - pub const NOT_EXPORTED: &str = "plugin.function.notExported"; - /// The providing plugin is not installed or not enabled, or was disabled - /// or removed while the call was running. - pub const PROVIDER_DISABLED: &str = "plugin.function.providerDisabled"; - /// The caller declared another contract version than the provider exports. - pub const VERSION_MISMATCH: &str = "plugin.function.versionMismatch"; - /// The provider is already on the call chain, so the call would come - /// back to a plugin that is waiting for it (including a plugin calling - /// itself). - pub const CALL_CYCLE: &str = "plugin.function.callCycle"; - /// The call chain is longer than the depth limit. - pub const DEPTH_EXCEEDED: &str = "plugin.function.depthExceeded"; - /// The calling plugin already has the most relayed calls it may have - /// running at once. - pub const TOO_MANY_CALLS: &str = "plugin.function.tooManyCalls"; - /// The relayed params or result exceed the relay's byte limit. - pub const PAYLOAD_TOO_LARGE: &str = "plugin.function.payloadTooLarge"; - /// The provider's backend could not serve the call: it did not start in - /// time, exited, or withdrew the function while the call ran. - pub const PROVIDER_UNAVAILABLE: &str = "plugin.function.providerUnavailable"; -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] -#[serde(deny_unknown_fields)] -/// Address of one plugin function: the owning plugin plus its function ID. -/// The host routes by plugin identity first, so a reference can never reach -/// a session of another plugin. -pub struct FunctionReference { - #[serde(rename = "pluginID")] - pub plugin_id: String, - #[serde(rename = "functionID")] - pub function_id: String, -} - -/// What a session knows when an invocation is planned. -pub(crate) struct FunctionSession<'a> { - /// Plugin that started the session; `None` for host-owned servers. - pub plugin_id: Option<&'a str>, - /// Whether the backend negotiated a supported protocol version. - pub negotiated: bool, - /// Currently registered function IDs. - pub functions: &'a BTreeSet, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -/// Terminal failure of one invocation, before it becomes a runtime error. -pub(crate) struct FunctionFailure { - pub code: &'static str, - pub message: String, - /// Plugin-defined code from `error.data.code`, for the plugin's own UI. - /// Lithe never interprets it. - pub plugin_error_code: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -/// One accepted progress report. -pub(crate) struct FunctionProgress { - pub operation_id: String, - pub message: Option, - /// Whole percent in `0..=100`. - pub percentage: Option, -} - -/// The client half of the negotiation, placed under `capabilities.experimental`. -pub(crate) fn client_experimental_capabilities() -> (&'static str, Value) { - ( - EXPERIMENTAL_KEY, - json!({ "versions": supported_versions() }), - ) -} - -/// The version and functions of a backend that answered a version this -/// client offered, or `None` when the protocol was not negotiated. A -/// malformed function list rejects the whole answer, so a session never -/// starts with half a registry. -pub(crate) fn negotiate(capabilities: &Value) -> Option<(u64, BTreeSet)> { - let answer = capabilities - .get("experimental") - .and_then(|experimental| experimental.get(EXPERIMENTAL_KEY))?; - let version = answer.get("version").and_then(Value::as_u64)?; - if !supported_versions().contains(&version) { - return None; - } - let functions = match answer.get("functions") { - None => BTreeSet::new(), - Some(descriptors) => descriptor_ids(descriptors, &BTreeSet::new()).ok()?, - }; - Some((version, functions)) -} - -/// The registry after a `lithe/function/register` request. The request is -/// all or nothing: one malformed, duplicate, or already registered function, -/// or exceeding the session limit, refuses every function in it. -pub(crate) fn apply_registration( - current: &BTreeSet, - params: Option<&Value>, -) -> Result, &'static str> { - let descriptors = params - .and_then(|params| params.get("functions")) - .filter(|functions| functions.as_array().is_some_and(|items| !items.is_empty())) - .ok_or("`functions` must be a nonempty array.")?; - let added = descriptor_ids(descriptors, current)?; - let mut registry = current.clone(); - registry.extend(added); - Ok(registry) -} - -/// The registry and the withdrawn IDs after a `lithe/function/unregister` -/// request, all or nothing like registration. -pub(crate) fn apply_unregistration( - current: &BTreeSet, - params: Option<&Value>, -) -> Result<(BTreeSet, Vec), &'static str> { - let ids = params - .and_then(|params| params.get("ids")) - .and_then(Value::as_array) - .filter(|ids| !ids.is_empty()) - .ok_or("`ids` must be a nonempty array.")?; - let mut removed = BTreeSet::new(); - for id in ids { - let id = id.as_str().ok_or("Every ID must be a string.")?; - if !current.contains(id) { - return Err("Only registered functions can be unregistered."); - } - if !removed.insert(id.to_string()) { - return Err("An ID is listed twice."); - } - } - let registry = current.difference(&removed).cloned().collect(); - Ok((registry, removed.into_iter().collect())) -} - -/// Params of the `lithe/function/invoke` request for `reference`, or the -/// refusal with its stable code in `details`. Checks run in a fixed order so -/// every host reports the same code for the same request. -pub(crate) fn plan_invocation( - session: &FunctionSession<'_>, - reference: &FunctionReference, - operation_id: &str, - params: &Value, -) -> Result { - if !is_plugin_id(&reference.plugin_id) || !is_function_id(&reference.function_id) { - return Err(refusal( - ErrorCode::InvalidRequest, - "The function reference is malformed.", - codes::INVALID_REFERENCE, - )); - } - if !params.is_object() { - return Err(refusal( - ErrorCode::InvalidRequest, - "Function params must be a JSON object.", - codes::INVALID_PARAMS, - )); - } - let Some(owner) = session.plugin_id else { - return Err(refusal( - ErrorCode::NotSupported, - "This session does not belong to a plugin.", - codes::UNAVAILABLE, - )); - }; - if owner != reference.plugin_id { - return Err(refusal( - ErrorCode::PermissionDenied, - "The function belongs to another plugin.", - codes::WRONG_PLUGIN, - )); - } - if !session.negotiated { - return Err(refusal( - ErrorCode::NotSupported, - "The plugin process did not agree on a plugin-function protocol version.", - codes::UNAVAILABLE, - )); - } - if !session.functions.contains(&reference.function_id) { - return Err(refusal( - ErrorCode::NotSupported, - "The plugin has not registered this function.", - codes::UNKNOWN, - )); - } - Ok(json!({ - "functionID": reference.function_id, - "operationID": operation_id, - "params": params - })) -} - -/// Normalizes the backend's JSON-RPC response to an invocation. A success is -/// wrapped as `{ "value": result }` like build-provider replies, so `null` is -/// a valid result distinct from a missing one. -pub(crate) fn invocation_outcome(response: &Value) -> Result { - if let Some(error) = response.get("error") { - let message = error - .get("message") - .and_then(Value::as_str) - .map(bounded_message) - .filter(|message| !message.is_empty()) - .unwrap_or_else(|| "The plugin function failed.".to_string()); - let plugin_error_code = error - .get("data") - .and_then(|data| data.get("code")) - .and_then(Value::as_str) - .filter(|code| is_plugin_error_code(code)) - .map(ToString::to_string); - return Err(FunctionFailure { - code: codes::FAILED, - message, - plugin_error_code, - }); - } - let Some(result) = response.get("result") else { - return Err(invalid_result("The plugin function returned no result.")); - }; - // Serialized length is what crosses the C ABI and the host bridge. - if result.to_string().len() > MAX_RESULT_BYTES { - return Err(invalid_result( - "The plugin function returned more data than a function result may hold.", - )); - } - Ok(json!({ "value": result })) -} - -/// A progress report that is well formed, or `None` to ignore it. Whether the -/// operation is still running is the engine's check, not this function's. -pub(crate) fn parse_progress(params: Option<&Value>) -> Option { - let params = params?.as_object()?; - let operation_id = params.get("operationID")?.as_str()?.to_string(); - let message = match params.get("message") { - None => None, - Some(message) => Some(bounded_message(message.as_str()?)), - }; - let percentage = match params.get("percentage") { - None => None, - Some(percentage) => Some(percentage.as_u64().filter(|value| *value <= 100)?), - }; - Some(FunctionProgress { - operation_id, - message, - percentage, - }) -} - -/// The stable code for a backend request the host refuses on principle, or -/// `None` for any other method. Domain dispatch can route back into the -/// requesting backend, whose handler would then wait on itself; host -/// capabilities are offered only to a plugin-owned session that negotiated -/// the protocol. -pub(crate) fn refused_backend_request(method: &str) -> Option<&'static str> { - if method == INVOKE_METHOD - || method.starts_with("lithe/build/") - || method.starts_with("lithe/keymap/") - { - Some(codes::RECURSIVE_CALL) - } else if method.starts_with("lithe/host/") { - Some(codes::HOST_UNAVAILABLE) - } else { - None - } -} - -/// Why a backend's `lithe/host/*` request is not forwarded to the host. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct HostRequestRefusal { - /// JSON-RPC error code of the answer. - pub rpc_code: i64, - /// Stable contract code, sent as `error.data.code`. - pub code: &'static str, - pub message: &'static str, - /// Sent as `error.data.details`, such as the `option` a host does not - /// support. - pub details: Option, -} - -/// Where an accepted `lithe/host/*` request goes. -#[derive(Debug, Clone, PartialEq)] -pub(crate) enum HostRoute { - /// To the host, as a `hostRequest` event of this service. - Forward(&'static str), - /// Answered by Core with this result; the host never sees the request. - Answer(Value), - /// Another plugin's function: Core's relay checks, dispatches, and - /// answers it. The host sees only the start request for a provider whose - /// backend is not running. - Relay, -} - -/// How Core checks the params of one host method before the host sees them. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum HostParams { - /// Absent, `null`, or `{}`. - Empty, - /// `{ key }`, or `{ key, secret }` when `with_secret`. - Credential { with_secret: bool }, - /// `{ subscriptionID }`. - Subscription, - /// Exactly `{ context }`, a well-formed context reference. - Context, - /// Snapshot source listing or one bounded chunk from a selected source. - KeymapSnapshot { chunk: bool }, - /// `{ context, serverID, method, params?, timeoutMilliseconds? }`: one - /// request to the plugin's own language server (plan API-25). - LanguageServerRequest, - /// `{ context, serverID, method, params? }`: one notification to it. - LanguageServerNotify, - /// `{ context, serverID, state, message?, percentage? }`: the plugin's - /// preparation status of the server. - LanguageServerStatus, - /// `{ context, scheme, function }`: the function that supplies the text - /// of a URI scheme (plan API-12). - ContentProvider, - /// Exactly `{ functionID }`, a well-formed function ID. - FunctionProviders, - /// `{ pluginID, functionID, context, params?, timeoutMilliseconds? }`. - FunctionInvoke, - /// Any object whose `context`, when present, is a well-formed - /// reference. The service checks the rest and answers - /// `plugin.host.invalidParams` or, for a field it does not know, - /// `plugin.host.unsupportedOption` itself; for methods whose params - /// Core cannot type without owning their domain. - // No row uses it yet; the file, editor, and window services that follow - // add their rows with it. - #[allow(dead_code)] - Object, -} - -/// One row of the host-method table. -pub(crate) struct HostMethod { - pub method: &'static str, - /// Service whose grant admits the method. - pub service: &'static str, - pub params: HostParams, - /// The method's own grant, or `None` when the service's aggregate grant admits it. - pub permission: Option<&'static str>, -} - -/// Every backend-to-host method Core admits. A method missing here is -/// `plugin.host.unavailable` whatever the host could do, so a new host -/// service adds its rows here, its grant to `GRANTABLE_HOST_SERVICES` and -/// the manifest permission, and its handler to the host's dispatcher. -pub(crate) const HOST_METHODS: &[HostMethod] = &[ - HostMethod { - method: RUN_START, - service: HOST_SERVICE_RUN, - permission: Some("run.start"), - params: HostParams::Object, - }, - HostMethod { - method: "lithe/host/run/stop", - service: HOST_SERVICE_RUN, - permission: Some("run.start"), - params: HostParams::Object, - }, - HostMethod { - method: "lithe/host/debug/start", - service: "debug", - permission: Some("debug.start"), - params: HostParams::Object, - }, - HostMethod { - method: FUNCTIONS_PROVIDERS, - service: HOST_SERVICE_FUNCTIONS, - permission: None, - params: HostParams::FunctionProviders, - }, - HostMethod { - method: FUNCTIONS_INVOKE, - service: HOST_SERVICE_FUNCTIONS, - permission: None, - params: HostParams::FunctionInvoke, - }, - HostMethod { - method: "lithe/host/keymapImport/listInputs", - service: "keymapImport", - permission: Some("keymaps.readSelectedSources"), - params: HostParams::KeymapSnapshot { chunk: false }, - }, - HostMethod { - method: "lithe/host/keymapImport/readChunk", - service: "keymapImport", - permission: Some("keymaps.readSelectedSources"), - params: HostParams::KeymapSnapshot { chunk: true }, - }, - HostMethod { - method: "lithe/host/terminal/reopen", - service: "terminal", - permission: Some("terminal.create"), - params: HostParams::Object, - }, - HostMethod { - method: "lithe/host/terminal/dispose", - service: "terminal", - permission: Some("terminal.create"), - params: HostParams::Object, - }, - HostMethod { - method: "lithe/host/terminal/create", - service: "terminal", - permission: Some("terminal.create"), - params: HostParams::Object, - }, - HostMethod { - method: "lithe/host/terminal/show", - service: "terminal", - permission: Some("terminal.create"), - params: HostParams::Object, - }, - HostMethod { - method: "lithe/host/terminal/close", - service: "terminal", - permission: Some("terminal.create"), - params: HostParams::Object, - }, - HostMethod { - method: "lithe/host/terminal/onExit", - service: "terminal", - permission: Some("terminal.create"), - params: HostParams::Object, - }, - HostMethod { - method: CREDENTIALS_GET, - service: HOST_SERVICE_CREDENTIALS, - permission: None, - params: HostParams::Credential { with_secret: false }, - }, - HostMethod { - method: CREDENTIALS_SET, - service: HOST_SERVICE_CREDENTIALS, - permission: None, - params: HostParams::Credential { with_secret: true }, - }, - HostMethod { - method: CREDENTIALS_DELETE, - service: HOST_SERVICE_CREDENTIALS, - permission: None, - params: HostParams::Credential { with_secret: false }, - }, - HostMethod { - method: GET_CAPABILITIES, - service: HOST_SERVICE_HOST, - permission: None, - params: HostParams::Empty, - }, - HostMethod { - method: SUBSCRIPTIONS_DISPOSE, - service: HOST_SERVICE_SUBSCRIPTIONS, - permission: None, - params: HostParams::Subscription, - }, - HostMethod { - method: WORKSPACE_LIST_CONTEXTS, - service: HOST_SERVICE_WORKSPACE, - permission: None, - params: HostParams::Empty, - }, - HostMethod { - method: WORKSPACE_GET_CONTEXT, - service: HOST_SERVICE_WORKSPACE, - permission: None, - params: HostParams::Context, - }, - HostMethod { - method: WORKSPACE_GET_FOLDERS, - service: HOST_SERVICE_WORKSPACE, - permission: None, - params: HostParams::Context, - }, - HostMethod { - method: "lithe/host/workspace/readDirectory", - service: "workspace", - permission: Some("workspace.files.read"), - params: HostParams::Object, - }, - HostMethod { - method: "lithe/host/workspace/stat", - service: "workspace", - permission: Some("workspace.files.read"), - params: HostParams::Object, - }, - HostMethod { - method: "lithe/host/workspace/readFile", - service: "workspace", - permission: Some("workspace.files.read"), - params: HostParams::Object, - }, - HostMethod { - method: "lithe/host/workspace/watch", - service: "workspace", - permission: Some("workspace.files.watch"), - params: HostParams::Object, - }, - HostMethod { - method: "lithe/host/workspace/findFiles", - service: "workspace", - permission: Some("workspace.search"), - params: HostParams::Object, - }, - HostMethod { - method: "lithe/host/workspace/findText", - service: "workspace", - permission: Some("workspace.search"), - params: HostParams::Object, - }, - HostMethod { - method: "lithe/host/documents/list", - service: "documents", - permission: Some("documents.read"), - params: HostParams::Object, - }, - HostMethod { - method: "lithe/host/documents/getSnapshot", - service: "documents", - permission: Some("documents.read"), - params: HostParams::Object, - }, - HostMethod { - method: "lithe/host/editor/registerActions", - service: "editor", - permission: Some("editor.actions"), - params: HostParams::Object, - }, - HostMethod { - method: "lithe/host/editor/registerProviders", - service: "editor", - permission: Some("editor.providers"), - params: HostParams::Object, - }, - HostMethod { - method: "lithe/host/editor/getActive", - service: "editor", - permission: Some("documents.read"), - params: HostParams::Object, - }, - HostMethod { - method: "lithe/host/editor/getSelections", - service: "editor", - permission: Some("documents.read"), - params: HostParams::Object, - }, - HostMethod { - method: "lithe/host/editor/applyEdits", - service: "editor", - permission: Some("editor.edit"), - params: HostParams::Object, - }, - HostMethod { - method: "lithe/host/editor/replaceSelections", - service: "editor", - permission: Some("editor.edit"), - params: HostParams::Object, - }, - HostMethod { - method: "lithe/host/editor/insertText", - service: "editor", - permission: Some("editor.edit"), - params: HostParams::Object, - }, - HostMethod { - method: "lithe/host/editor/open", - service: "editor", - permission: Some("editor.open"), - params: HostParams::Object, - }, - HostMethod { - method: "lithe/host/editor/reveal", - service: "editor", - permission: Some("editor.open"), - params: HostParams::Object, - }, - HostMethod { - method: "lithe/host/preview/openHTML", - service: "preview", - permission: Some("preview.open"), - params: HostParams::Object, - }, - HostMethod { - method: "lithe/host/browser/openExternal", - service: "browser", - permission: Some("browser.open"), - params: HostParams::Object, - }, - HostMethod { - method: "lithe/host/window/showInformation", - service: "window", - permission: Some("window.messages"), - params: HostParams::Object, - }, - HostMethod { - method: "lithe/host/window/showWarning", - service: "window", - permission: Some("window.messages"), - params: HostParams::Object, - }, - HostMethod { - method: "lithe/host/window/showError", - service: "window", - permission: Some("window.messages"), - params: HostParams::Object, - }, - // Plan API-25: the plugin's own language servers. The host resolves - // `{ context, serverID }` to the session and Core's - // `lsp.pluginServerRequest` enforces the declared session policy. - HostMethod { - method: LANGUAGE_SERVER_REQUEST, - service: HOST_SERVICE_LANGUAGE_SERVER, - permission: Some(PERMISSION_LANGUAGE_SERVER_REQUEST), - params: HostParams::LanguageServerRequest, - }, - HostMethod { - method: LANGUAGE_SERVER_NOTIFY, - service: HOST_SERVICE_LANGUAGE_SERVER, - permission: Some(PERMISSION_LANGUAGE_SERVER_REQUEST), - params: HostParams::LanguageServerNotify, - }, - HostMethod { - method: LANGUAGE_SERVER_SET_STATUS, - service: HOST_SERVICE_LANGUAGE_SERVER, - permission: Some(PERMISSION_LANGUAGE_SERVER_REQUEST), - params: HostParams::LanguageServerStatus, - }, - // Plan API-12: read-only text of a URI scheme the backend serves. - HostMethod { - method: DOCUMENTS_REGISTER_CONTENT_PROVIDER, - service: "documents", - permission: Some(PERMISSION_DOCUMENTS_CONTENT_PROVIDER), - params: HostParams::ContentProvider, - }, -]; - -/// Host service of the plugin's own language servers (plan API-25). -pub(crate) const HOST_SERVICE_LANGUAGE_SERVER: &str = "languageServer"; -/// Forwards one declared request to the plugin's language server. -pub(crate) const LANGUAGE_SERVER_REQUEST: &str = "lithe/host/languageServer/request"; -/// Forwards one notification to the plugin's language server. -pub(crate) const LANGUAGE_SERVER_NOTIFY: &str = "lithe/host/languageServer/notify"; -/// Reports the plugin's preparation state of a language server to the user. -pub(crate) const LANGUAGE_SERVER_SET_STATUS: &str = "lithe/host/languageServer/setStatus"; -/// Registers a backend function as the content provider of a URI scheme. -pub(crate) const DOCUMENTS_REGISTER_CONTENT_PROVIDER: &str = - "lithe/host/documents/registerContentProvider"; -/// Lifecycle of a language server the plugin declares. -const LANGUAGE_SERVER_DID_CHANGE_STATE: &str = "lithe/host/languageServer/didChangeState"; -/// A server notification the language server's `forwardNotifications` lists. -const LANGUAGE_SERVER_DID_RECEIVE_NOTIFICATION: &str = - "lithe/host/languageServer/didReceiveNotification"; - -/// Services every negotiated plugin-owned session has without a permission: -/// they reveal nothing beyond the session's own grants. -fn is_implicit_service(service: &str) -> bool { - service == HOST_SERVICE_HOST - || service == HOST_SERVICE_SUBSCRIPTIONS - || service == HOST_SERVICE_COMMANDS -} - -/// Where a backend request for a host service goes, or why it is refused. -/// `version` is the negotiated protocol version, `None` when the backend did -/// not negotiate one, and `granted` the services the plugin's permissions -/// grant the session. The params are checked here, so the host only ever sees -/// a well-formed request. A field the method does not define is -/// `plugin.host.unsupportedOption` naming it, so a later host can add it as -/// an option without a new version and a backend can tell an option this -/// host lacks from a malformed request. -pub(crate) fn host_request( - method: &str, - params: Option<&Value>, - version: Option, - granted: &BTreeSet, -) -> Result { - let entry = version.and_then(|_| HOST_METHODS.iter().find(|entry| entry.method == method)); - let (Some(version), Some(entry)) = (version, entry) else { - return Err(HostRequestRefusal { - rpc_code: -32601, - code: codes::HOST_UNAVAILABLE, - message: "The host offers no such service to this plugin process.", - details: None, - }); - }; - if !is_implicit_service(entry.service) - && !granted.contains(if entry.permission.is_some() { - entry.method - } else { - entry.service - }) - { - return Err(HostRequestRefusal { - rpc_code: host_rpc_code(codes::HOST_PERMISSION_DENIED), - code: codes::HOST_PERMISSION_DENIED, - message: "The plugin does not declare the permission for this host service.", - details: None, - }); - } - if params.is_some_and(|value| value.to_string().len() > MAX_RESULT_BYTES) { - return Err(HostRequestRefusal { - rpc_code: host_rpc_code(codes::HOST_LIMIT_EXCEEDED), - code: codes::HOST_LIMIT_EXCEEDED, - message: "Host request exceeds the byte limit.", - details: None, - }); - } - check_host_params(entry.params, params)?; - if entry.method == GET_CAPABILITIES { - return Ok(HostRoute::Answer(capabilities_answer(version, granted))); - } - if entry.service == HOST_SERVICE_FUNCTIONS { - return Ok(HostRoute::Relay); - } - Ok(HostRoute::Forward(entry.service)) -} - -/// The params the host receives for an accepted request: the backend's own, -/// except that a status message is cut to the bound a progress message has, -/// so a hostile backend cannot inflate every event that carries it. -pub(crate) fn forwarded_host_params(method: &str, params: Option<&Value>) -> Value { - let mut forwarded = params.cloned().unwrap_or(Value::Null); - if method == LANGUAGE_SERVER_SET_STATUS { - if let Some(message) = forwarded.get_mut("message") { - if let Some(text) = message.as_str() { - *message = Value::String(bounded_message(text)); - } - } - } - forwarded -} - -/// The answer to `lithe/host/getCapabilities`: the negotiated version, the -/// services and methods this session may call, where the backend runs, and -/// the bounds Core enforces. It lists only what the session is granted, so -/// it reveals nothing about other plugins. `granted` is what the host -/// passed to `lsp.startServer.hostServices`; a host passes only services it -/// serves, so a listed method never answers `plugin.host.unavailable` for -/// want of a handler. -pub(crate) fn capabilities_answer(version: u64, granted: &BTreeSet) -> Value { - let admitted = |service: &str| is_implicit_service(service) || granted.contains(service); - let mut services = serde_json::Map::new(); - let mut add = |service: &str, kind: &str, method: &str| { - let entry = services - .entry(service.to_string()) - .or_insert_with(|| json!({ "methods": [], "notifications": [] })); - if let Some(list) = entry[kind].as_array_mut() { - list.push(json!(method)); - } - }; - for entry in HOST_METHODS.iter().filter(|entry| { - if entry.permission.is_some() { - granted.contains(entry.method) - } else { - admitted(entry.service) - } - }) { - add(entry.service, "methods", entry.method); - } - for entry in HOST_NOTIFICATIONS - .iter() - .filter(|entry| entry.admitted(granted)) - { - add(entry.service, "notifications", entry.method); - } - json!({ - "protocolVersion": version, - // The host starts every plugin backend on the machine that runs - // Lithe, also for remote workspaces. - "backendAuthority": "local", - "services": services, - "limits": { - "pendingHostRequests": MAX_PENDING_HOST_REQUESTS, - "functionResultBytes": MAX_RESULT_BYTES, - "hostRequestBytes": MAX_RESULT_BYTES, - "hostResultBytes": MAX_RESULT_BYTES, - "errorMessageBytes": MAX_MESSAGE_BYTES, - "errorDetailsBytes": MAX_HOST_DETAILS_BYTES, - "notificationBytes": MAX_HOST_NOTIFICATION_BYTES, - }, - }) -} - -/// How Core checks the params of one host-to-backend notification. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum NotificationParams { - /// Exactly `{ revision }`, a positive integer. - Revision, - /// Exactly `{ subscriptionID, sequence, event }`: the subscription the - /// backend holds, a positive sequence that grows per subscription, and - /// the service's event object. - SubscriptionEvent, - /// `{ commandID, context? }`: one of the plugin's own commands and the - /// workspace context of the window it ran in. - Command, - /// `{ runID, exitCode?, signal? }`: a Run session the backend started - /// ended with an exit status, a signal, or neither when the host lost it. - RunExit, - /// `{ context, serverID, state, generation }`: a lifecycle change of one - /// of the plugin's language servers. - LanguageServerState, - /// `{ context, serverID, generation, method, params? | truncated }`: a - /// forwarded server notification, without params when too large. - LanguageServerNotification, -} - -/// One row of the host-notification table. -pub(crate) struct HostNotification { - pub method: &'static str, - /// Service whose grant admits sending it; implicit services need none. - pub service: &'static str, - /// Host method whose individual grant admits it instead of `service`, - /// for a notification that reports on a method granted per permission. - pub grant: Option<&'static str>, - pub params: NotificationParams, -} - -impl HostNotification { - /// Whether the session's grants admit sending this notification. - fn admitted(&self, granted: &BTreeSet) -> bool { - match self.grant { - Some(method) => granted.contains(method), - None => is_implicit_service(self.service) || holds_service(self.service, granted), - } - } -} - -const WORKSPACE_DID_CHANGE_CONTEXTS: &str = "lithe/host/workspace/didChangeContexts"; -const SUBSCRIPTIONS_NOTIFY: &str = "lithe/host/subscriptions/notify"; -const COMMANDS_DID_RUN: &str = "lithe/host/commands/didRun"; -/// Starts a host-owned Run session; its grant also admits `RUN_DID_EXIT`. -const RUN_START: &str = "lithe/host/run/start"; -const RUN_DID_EXIT: &str = "lithe/host/run/didExit"; -/// Host-owned Run sessions a backend starts and stops. -const HOST_SERVICE_RUN: &str = "run"; -/// Longest opaque run ID a host may hand out. -const MAX_RUN_ID_BYTES: usize = 128; -/// The plugin's own UI commands reaching its backend. Every negotiated -/// plugin-owned session has it; a command names only the plugin's own IDs. -const HOST_SERVICE_COMMANDS: &str = "commands"; - -/// Every host-to-backend notification the host may send through -/// `lsp.hostNotify`. A notification missing here never reaches a backend, -/// so the host cannot use the channel to call into the backend's own -/// protocol (functions, build or VCS methods). -pub(crate) const HOST_NOTIFICATIONS: &[HostNotification] = &[ - HostNotification { - method: WORKSPACE_DID_CHANGE_CONTEXTS, - service: HOST_SERVICE_WORKSPACE, - grant: None, - params: NotificationParams::Revision, - }, - HostNotification { - method: SUBSCRIPTIONS_NOTIFY, - service: HOST_SERVICE_SUBSCRIPTIONS, - grant: None, - params: NotificationParams::SubscriptionEvent, - }, - HostNotification { - method: COMMANDS_DID_RUN, - service: HOST_SERVICE_COMMANDS, - grant: None, - params: NotificationParams::Command, - }, - HostNotification { - method: RUN_DID_EXIT, - service: HOST_SERVICE_RUN, - grant: Some(RUN_START), - params: NotificationParams::RunExit, - }, - HostNotification { - method: LANGUAGE_SERVER_DID_CHANGE_STATE, - service: HOST_SERVICE_LANGUAGE_SERVER, - grant: None, - params: NotificationParams::LanguageServerState, - }, - HostNotification { - method: LANGUAGE_SERVER_DID_RECEIVE_NOTIFICATION, - service: HOST_SERVICE_LANGUAGE_SERVER, - grant: None, - params: NotificationParams::LanguageServerNotification, - }, -]; - -/// Whether the session holds a service: its aggregate grant, or, for a -/// service granted per method, any of its methods. -fn holds_service(service: &str, granted: &BTreeSet) -> bool { - granted.contains(service) - || HOST_METHODS.iter().any(|entry| { - entry.service == service && entry.permission.is_some() && granted.contains(entry.method) - }) -} - -/// A language server state a `didChangeState` notification may carry. -fn is_language_server_state(value: &str) -> bool { - matches!(value, "starting" | "running" | "stopped" | "crashed") -} - -/// Whether a host notification reaches this session. `Ok(false)` means the -/// backend negotiated no protocol version (`version` is `None`), so the host -/// may skip it; a method outside the table, one whose service the session -/// was not granted, or malformed params are the host's mistake and refused. -pub(crate) fn host_notification( - method: &str, - params: &Value, - version: Option, - granted: &BTreeSet, -) -> Result { - let invalid = |message: &str| { - refusal( - ErrorCode::InvalidRequest, - message, - codes::HOST_INVALID_PARAMS, - ) - }; - let Some(entry) = HOST_NOTIFICATIONS - .iter() - .find(|entry| entry.method == method) - else { - return Err(refusal( - ErrorCode::InvalidRequest, - "The host may not send this notification to a plugin process.", - codes::HOST_UNAVAILABLE, - )); - }; - if !entry.admitted(granted) { - return Err(refusal( - ErrorCode::InvalidRequest, - "The session was not granted the service of this notification.", - codes::HOST_PERMISSION_DENIED, - )); - } - let Some(object) = params.as_object() else { - return Err(invalid("Notification params must be an object.")); - }; - if serde_json::to_vec(params).map_or(true, |bytes| bytes.len() > MAX_HOST_NOTIFICATION_BYTES) { - return Err(invalid( - "Notification params exceed the notification bound.", - )); - } - let positive = |key: &str| { - object - .get(key) - .and_then(Value::as_u64) - .is_some_and(|value| value >= 1) - }; - let only = |keys: &[&str]| object.keys().all(|key| keys.contains(&key.as_str())); - let well_formed = match entry.params { - NotificationParams::Revision => only(&["revision"]) && positive("revision"), - NotificationParams::SubscriptionEvent => { - only(&["subscriptionID", "sequence", "event"]) - && object - .get("subscriptionID") - .and_then(Value::as_str) - .is_some_and(is_credential_key) - && positive("sequence") - && object.get("event").is_some_and(Value::is_object) - } - NotificationParams::Command => { - only(&["commandID", "context"]) - && object - .get("commandID") - .and_then(Value::as_str) - .is_some_and(is_command_id) - && object.get("context").map_or(true, is_context_reference) - } - NotificationParams::RunExit => { - only(&["runID", "exitCode", "signal"]) - && object - .get("runID") - .and_then(Value::as_str) - .is_some_and(|id| id.len() <= MAX_RUN_ID_BYTES && is_credential_key(id)) - && object.get("exitCode").map_or(true, |code| { - code.as_i64() - .is_some_and(|code| i32::try_from(code).is_ok()) - }) - && object.get("signal").map_or(true, |signal| { - signal - .as_u64() - .is_some_and(|signal| (1..=127).contains(&signal)) - }) - } - NotificationParams::LanguageServerState => { - only(&["context", "serverID", "state", "generation"]) - && object.get("context").is_some_and(is_context_reference) - && object - .get("serverID") - .and_then(Value::as_str) - .is_some_and(is_protocol_name) - && object - .get("state") - .and_then(Value::as_str) - .is_some_and(is_language_server_state) - && positive("generation") - } - NotificationParams::LanguageServerNotification => { - let truncated = object.get("truncated"); - only(&[ - "context", - "serverID", - "generation", - "method", - "params", - "truncated", - ]) && object.get("context").is_some_and(is_context_reference) - && object - .get("serverID") - .and_then(Value::as_str) - .is_some_and(is_protocol_name) - && object - .get("method") - .and_then(Value::as_str) - .is_some_and(is_protocol_name) - && positive("generation") - // A truncated notification drops its params entirely. - && match truncated { - None => true, - Some(Value::Bool(true)) => !object.contains_key("params"), - Some(_) => false, - } - } - }; - if !well_formed { - return Err(invalid( - "Notification params do not match the notification.", - )); - } - Ok(version.is_some()) -} - -/// A UI command ID as the manifest schema spells it: `[a-z0-9.-]+`. -fn is_command_id(value: &str) -> bool { - !value.is_empty() - && value.len() <= MAX_FUNCTION_ID_BYTES - && value.chars().all(|character| { - character.is_ascii_lowercase() - || character.is_ascii_digit() - || matches!(character, '.' | '-') - }) -} - -fn check_host_params(rule: HostParams, params: Option<&Value>) -> Result<(), HostRequestRefusal> { - let invalid = |message| HostRequestRefusal { - rpc_code: -32602, - code: codes::HOST_INVALID_PARAMS, - message, - details: None, - }; - let empty = serde_json::Map::new(); - let params = match params { - None | Some(Value::Null) if rule == HostParams::Empty => &empty, - Some(Value::Object(params)) => params, - _ => return Err(invalid("Params must be an object.")), - }; - let allowed: &[&str] = match rule { - HostParams::Empty => &[], - HostParams::Credential { with_secret: true } => &["key", "secret"], - HostParams::Credential { with_secret: false } => &["key"], - HostParams::Subscription => &["subscriptionID"], - HostParams::Context => &["context"], - HostParams::FunctionProviders => &["functionID"], - HostParams::FunctionInvoke => &[ - "pluginID", - "functionID", - "context", - "params", - "timeoutMilliseconds", - "parentOperationID", - ], - HostParams::KeymapSnapshot { chunk: false } => &["snapshotID", "providerID"], - HostParams::KeymapSnapshot { chunk: true } => &[ - "snapshotID", - "providerID", - "fileID", - "offset", - "maximumBytes", - ], - HostParams::LanguageServerRequest => &[ - "context", - "serverID", - "method", - "params", - "timeoutMilliseconds", - ], - HostParams::LanguageServerNotify => &["context", "serverID", "method", "params"], - HostParams::LanguageServerStatus => { - &["context", "serverID", "state", "message", "percentage"] - } - HostParams::ContentProvider => &["context", "scheme", "function"], - HostParams::Object => { - return match params.get("context") { - Some(context) if !is_context_reference(context) => { - Err(invalid(CONTEXT_REFERENCE_MESSAGE)) - } - _ => Ok(()), - }; - } - }; - if let Some(option) = params.keys().find(|key| !allowed.contains(&key.as_str())) { - return Err(HostRequestRefusal { - rpc_code: host_rpc_code(codes::HOST_UNSUPPORTED_OPTION), - code: codes::HOST_UNSUPPORTED_OPTION, - message: "Params name an option this host does not support.", - details: Some(json!({ "option": option })), - }); - } - match rule { - HostParams::Credential { with_secret } => { - if !params - .get("key") - .and_then(Value::as_str) - .is_some_and(is_credential_key) - { - return Err(invalid( - "A credential key is 1-128 ASCII letters, digits, '.', '_', ':' or '-'.", - )); - } - if with_secret - && !params - .get("secret") - .and_then(Value::as_str) - .is_some_and(|secret| secret.len() <= MAX_CREDENTIAL_SECRET_BYTES) - { - return Err(invalid("A secret is a string of at most 16 KiB.")); - } - } - HostParams::Subscription => { - if !params - .get("subscriptionID") - .and_then(Value::as_str) - .is_some_and(is_credential_key) - { - return Err(invalid( - "A subscription ID is 1-128 ASCII letters, digits, '.', '_', ':' or '-'.", - )); - } - } - HostParams::Context => { - if !params.get("context").is_some_and(is_context_reference) { - return Err(invalid(CONTEXT_REFERENCE_MESSAGE)); - } - } - HostParams::FunctionProviders => { - if !params - .get("functionID") - .and_then(Value::as_str) - .is_some_and(is_function_id) - { - return Err(invalid(FUNCTION_ID_MESSAGE)); - } - } - HostParams::FunctionInvoke => check_function_invoke_params(params)?, - HostParams::KeymapSnapshot { chunk } => { - let opaque_id = |key: &str| { - params.get(key).and_then(Value::as_str).is_some_and(|id| { - !id.is_empty() - && id.len() <= 128 - && id - .bytes() - .all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_') - }) - }; - let provider = params - .get("providerID") - .and_then(Value::as_str) - .is_some_and(crate::plugins::keymap_import::valid_provider_id); - let valid_chunk = !chunk - || (opaque_id("fileID") - && params - .get("offset") - .and_then(Value::as_u64) - .is_some_and(|offset| { - offset <= crate::plugins::keymap_import::MAX_INPUT_FILE_BYTES as u64 - }) - && params - .get("maximumBytes") - .and_then(Value::as_u64) - .is_some_and(|size| { - size > 0 - && size - <= crate::plugins::keymap_import::MAX_INPUT_CHUNK_BYTES as u64 - })); - if !opaque_id("snapshotID") || !provider || !valid_chunk { - return Err(invalid( - "Snapshot source parameters are malformed or exceed their bounds.", - )); - } - } - HostParams::LanguageServerRequest - | HostParams::LanguageServerNotify - | HostParams::LanguageServerStatus => { - check_language_server_params(rule, params, &invalid)?; - } - HostParams::ContentProvider => { - if !params.get("context").is_some_and(is_context_reference) { - return Err(invalid(CONTEXT_REFERENCE_MESSAGE)); - } - let scheme = params.get("scheme").and_then(Value::as_str); - if !scheme.is_some_and(is_content_scheme) { - return Err(invalid( - "A content scheme is 1-64 lowercase ASCII letters, digits, '+', '-' or '.', starts with a letter, and is not a scheme the host owns.", - )); - } - if !params - .get("function") - .and_then(Value::as_str) - .is_some_and(is_function_id) - { - return Err(invalid( - "The content function must be a plugin function ID.", - )); - } - } - HostParams::Empty | HostParams::Object => {} - } - Ok(()) -} - -/// Checks the params the `languageServer` host methods share: the context, -/// the server, the method, and each method's own fields. Whether the server -/// belongs to the plugin and the method is declared is the engine's answer, -/// since only it holds the session policy. -fn check_language_server_params( - rule: HostParams, - params: &serde_json::Map, - invalid: &dyn Fn(&'static str) -> HostRequestRefusal, -) -> Result<(), HostRequestRefusal> { - if !params.get("context").is_some_and(is_context_reference) { - return Err(invalid(CONTEXT_REFERENCE_MESSAGE)); - } - if !params - .get("serverID") - .and_then(Value::as_str) - .is_some_and(is_protocol_name) - { - return Err(invalid( - "serverID must name one of the plugin's language servers.", - )); - } - if rule == HostParams::LanguageServerStatus { - if !params - .get("state") - .and_then(Value::as_str) - .is_some_and(is_status_state) - { - return Err(invalid( - "state is one of preparing, ready, degraded, or failed.", - )); - } - if params - .get("message") - .is_some_and(|message| !message.is_string()) - { - return Err(invalid("message must be a string.")); - } - if params - .get("percentage") - .is_some_and(|value| !value.as_u64().is_some_and(|percentage| percentage <= 100)) - { - return Err(invalid("percentage is a whole number from 0 to 100.")); - } - return Ok(()); - } - if !params - .get("method") - .and_then(Value::as_str) - .is_some_and(is_protocol_name) - { - return Err(invalid("method must be a non-empty LSP method name.")); - } - if rule == HostParams::LanguageServerRequest - && params.get("timeoutMilliseconds").is_some_and(|value| { - !value.as_u64().is_some_and(|milliseconds| { - (1..=language_server_sessions::MAX_REQUEST_TIMEOUT_MILLISECONDS) - .contains(&milliseconds) - }) - }) - { - return Err(invalid( - "timeoutMilliseconds is a whole number from 1 to 300000.", - )); - } - Ok(()) -} - -const FUNCTION_ID_MESSAGE: &str = - "A function ID is dot-separated lowerCamelCase segments of at most 128 bytes."; - -/// The shape of a `lithe/host/functions/invoke` request. Whether the caller -/// declared the call and the provider exports it is the relay's check, which -/// needs the plugin snapshot. -fn check_function_invoke_params( - params: &serde_json::Map, -) -> Result<(), HostRequestRefusal> { - let invalid = |message| HostRequestRefusal { - rpc_code: -32602, - code: codes::HOST_INVALID_PARAMS, - message, - details: None, - }; - if !params - .get("pluginID") - .and_then(Value::as_str) - .is_some_and(is_plugin_id) - { - return Err(invalid( - "A plugin ID is lowercase letters, digits, '.' and '-'.", - )); - } - if !params - .get("functionID") - .and_then(Value::as_str) - .is_some_and(is_function_id) - { - return Err(invalid(FUNCTION_ID_MESSAGE)); - } - if !params.get("context").is_some_and(is_context_reference) { - return Err(invalid(CONTEXT_REFERENCE_MESSAGE)); - } - if params.get("params").is_some_and(|value| !value.is_object()) { - return Err(invalid("Function params must be a JSON object.")); - } - if params - .get("timeoutMilliseconds") - .is_some_and(|value| !value.as_u64().is_some_and(|milliseconds| milliseconds >= 1)) - { - return Err(invalid("A timeout is a positive number of milliseconds.")); - } - // Only the shape is checked here. Whether the ID names a call this backend - // is serving needs the session's pending requests, so the relay decides. - if params.get("parentOperationID").is_some_and(|value| { - !value - .as_str() - .is_some_and(|id| (1..=128).contains(&id.len())) - }) { - return Err(invalid( - "A parent operation ID is the operationID of a call this backend is serving, 1-128 bytes.", - )); - } - Ok(()) -} - -/// A preparation state a plugin may report for its language server. -fn is_status_state(value: &str) -> bool { - matches!(value, "preparing" | "ready" | "degraded" | "failed") -} - -/// Schemes the host or the platform resolves itself. A plugin that served -/// one would replace what the user expects the URI to open. -const RESERVED_CONTENT_SCHEMES: &[&str] = &[ - "about", - "blob", - "data", - "file", - "ftp", - "http", - "https", - "javascript", - "lithe", - "mailto", - "untitled", - "ws", - "wss", -]; - -/// A URI scheme a plugin may serve (RFC 3986, lowercase, bounded). -fn is_content_scheme(value: &str) -> bool { - let mut characters = value.chars(); - characters - .next() - .is_some_and(|first| first.is_ascii_lowercase()) - && value.len() <= 64 - && characters.all(|character| { - character.is_ascii_lowercase() - || character.is_ascii_digit() - || matches!(character, '+' | '-' | '.') - }) - && !RESERVED_CONTENT_SCHEMES.contains(&value) -} - -const CONTEXT_REFERENCE_MESSAGE: &str = - "A context is { contextID, generation }: an ID of 1-128 ASCII letters, digits, '.', '_', ':' or '-', and a positive generation."; - -/// `{ contextID, generation }` naming one generation of one workspace -/// context. Only the shape is checked here; whether it is still current is -/// the host's answer (`plugin.host.contextExpired`). -pub(crate) fn is_context_reference(value: &Value) -> bool { - let Some(reference) = value.as_object() else { - return false; - }; - reference.len() == 2 - && reference - .get("contextID") - .and_then(Value::as_str) - .is_some_and(is_credential_key) - && reference - .get("generation") - .and_then(Value::as_u64) - .is_some_and(|generation| generation >= 1) -} - -/// Adds the workspace context the host named to the params of one -/// `lithe/function/invoke`. A malformed reference is refused, and a call -/// without a context is written unchanged. -pub(crate) fn attach_context( - invoke_params: &mut Value, - context: Option<&Value>, -) -> Result<(), CoreError> { - let Some(context) = context else { - return Ok(()); - }; - if !is_context_reference(context) { - return Err(refusal( - ErrorCode::InvalidRequest, - "The workspace context reference is malformed.", - codes::INVALID_PARAMS, - )); - } - if let Some(params) = invoke_params.as_object_mut() { - params.insert("context".to_string(), context.clone()); - } - Ok(()) -} - -/// Writes the calling plugin into the params of a relayed -/// `lithe/function/invoke` as `caller: { "pluginID": ... }`, a sibling of -/// `functionID`, `operationID`, `params` and `context`. Only Core writes it, -/// from the identity of the session that made the request, so a callee can -/// key state by caller without trusting anything in the params. A call the -/// host itself starts (`caller` is `None`) never carries one: its absence -/// means the host. -pub(crate) fn attach_caller(invoke_params: &mut Value, caller: Option<&str>) { - let Some(caller) = caller else { - return; - }; - if let Some(params) = invoke_params.as_object_mut() { - params.insert("caller".to_string(), json!({ "pluginID": caller })); - } -} - -/// The JSON-RPC error code of a host-service code. Each code has its own -/// number, so a backend can branch on the number alone: codes with a -/// standard meaning keep the JSON-RPC or LSP number, and the rest take -/// numbers from the server-defined range, after -32000, which backends use -/// for their own failures. -pub(crate) fn host_rpc_code(code: &str) -> i64 { - match code { - codes::HOST_UNAVAILABLE => -32601, - codes::HOST_INVALID_PARAMS => -32602, - codes::HOST_CANCELLED => -32800, - codes::HOST_PERMISSION_DENIED => -32010, - codes::HOST_CONTEXT_EXPIRED => -32011, - codes::HOST_AUTHORITY_UNSUPPORTED => -32012, - codes::HOST_VERSION_CONFLICT => -32013, - codes::HOST_LIMIT_EXCEEDED => -32014, - codes::HOST_TIMEOUT => -32015, - codes::HOST_NOT_FOUND => -32016, - codes::HOST_READ_ONLY => -32017, - codes::HOST_UNSUPPORTED_OPTION => -32018, - codes::HOST_PRECONDITION_FAILED => -32019, - codes::HOST_BUSY => -32020, - codes::HOST_RATE_LIMITED => -32021, - codes::HOST_LANGUAGE_SERVER_UNAVAILABLE => -32022, - codes::HOST_LANGUAGE_SERVER_ERROR => -32023, - codes::HOST_METHOD_NOT_DECLARED => -32024, - codes::HOST_TOOL_UNRESOLVED => -32025, - codes::HOST_FOREIGN_CONTRIBUTION => -32026, - codes::NOT_DECLARED => -32027, - codes::NOT_EXPORTED => -32028, - codes::PROVIDER_DISABLED => -32029, - codes::VERSION_MISMATCH => -32030, - codes::CALL_CYCLE => -32065, - codes::DEPTH_EXCEEDED => -32066, - codes::TOO_MANY_CALLS => -32067, - codes::PAYLOAD_TOO_LARGE => -32068, - codes::PROVIDER_UNAVAILABLE => -32069, - // A relayed call's own failure is not a host failure: the caller - // must tell the provider's error from Core's by the number alone. - codes::FAILED => -32070, - codes::INVALID_RESULT => -32071, - _ => -32603, - } -} - -/// The `details` of a host failure a backend receives in -/// `error.data.details`: an object of at most `MAX_HOST_DETAILS_BYTES` -/// serialized, such as the current version of a `versionConflict` or the -/// index of the edit a range error names. Anything else is dropped, so a -/// host cannot inflate the reply or send a value a backend must not parse. -pub(crate) fn bounded_host_details(details: Option<&Value>) -> Option { - let details = details?; - if !details.is_object() { - return None; - } - let size = serde_json::to_vec(details).map(|bytes| bytes.len()).ok()?; - (size <= MAX_HOST_DETAILS_BYTES).then(|| details.clone()) -} - -/// The code and JSON-RPC error code a backend receives when the -/// host failed a forwarded request. Only the codes a host may report pass -/// through; anything else, including none, is `plugin.host.failed`, so a -/// host cannot impersonate Core's own refusals of other kinds. -pub(crate) fn host_failure(code: Option<&str>) -> (&'static str, i64) { - let code = match code { - Some(codes::HOST_UNAVAILABLE) => codes::HOST_UNAVAILABLE, - Some(codes::HOST_PERMISSION_DENIED) => codes::HOST_PERMISSION_DENIED, - Some(codes::HOST_INVALID_PARAMS) => codes::HOST_INVALID_PARAMS, - Some(codes::HOST_CONTEXT_EXPIRED) => codes::HOST_CONTEXT_EXPIRED, - Some(codes::HOST_AUTHORITY_UNSUPPORTED) => codes::HOST_AUTHORITY_UNSUPPORTED, - Some(codes::HOST_VERSION_CONFLICT) => codes::HOST_VERSION_CONFLICT, - Some(codes::HOST_LIMIT_EXCEEDED) => codes::HOST_LIMIT_EXCEEDED, - Some(codes::HOST_CANCELLED) => codes::HOST_CANCELLED, - Some(codes::HOST_TIMEOUT) => codes::HOST_TIMEOUT, - Some(codes::HOST_NOT_FOUND) => codes::HOST_NOT_FOUND, - Some(codes::HOST_READ_ONLY) => codes::HOST_READ_ONLY, - Some(codes::HOST_UNSUPPORTED_OPTION) => codes::HOST_UNSUPPORTED_OPTION, - Some(codes::HOST_PRECONDITION_FAILED) => codes::HOST_PRECONDITION_FAILED, - Some(codes::HOST_BUSY) => codes::HOST_BUSY, - Some(codes::HOST_RATE_LIMITED) => codes::HOST_RATE_LIMITED, - Some(codes::HOST_LANGUAGE_SERVER_UNAVAILABLE) => codes::HOST_LANGUAGE_SERVER_UNAVAILABLE, - Some(codes::HOST_LANGUAGE_SERVER_ERROR) => codes::HOST_LANGUAGE_SERVER_ERROR, - Some(codes::HOST_METHOD_NOT_DECLARED) => codes::HOST_METHOD_NOT_DECLARED, - Some(codes::HOST_TOOL_UNRESOLVED) => codes::HOST_TOOL_UNRESOLVED, - Some(codes::HOST_FOREIGN_CONTRIBUTION) => codes::HOST_FOREIGN_CONTRIBUTION, - // A backend exit is reported to pages, never to the backend itself. - Some(codes::BACKEND_EXITED) => codes::HOST_FAILED, - _ => codes::HOST_FAILED, - }; - (code, host_rpc_code(code)) -} - -/// Key of a stored secret, also the alphabet of subscription and context -/// IDs. It excludes path separators so a host may use the value in a store -/// item or file name without escaping. -fn is_credential_key(value: &str) -> bool { - !value.is_empty() - && value.len() <= MAX_CREDENTIAL_KEY_BYTES - && value.chars().all(|character| { - character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | ':' | '-') - }) -} - -/// Truncates a host's failure message like a backend's. -pub(crate) fn bounded_host_message(value: &str) -> String { - bounded_message(value) -} - -/// IDs of a descriptor array, refusing malformed descriptors, duplicates, -/// IDs already in `existing`, and a registry beyond the session limit. -fn descriptor_ids( - descriptors: &Value, - existing: &BTreeSet, -) -> Result, &'static str> { - let descriptors = descriptors - .as_array() - .ok_or("`functions` must be an array.")?; - let mut ids = BTreeSet::new(); - for descriptor in descriptors { - // Unknown descriptor fields are ignored within a version, as for - // every negotiated object, so a backend can add optional metadata. - let id = descriptor - .get("id") - .and_then(Value::as_str) - .filter(|id| is_function_id(id)) - .ok_or("Every function needs a valid `id`.")?; - if existing.contains(id) || !ids.insert(id.to_string()) { - return Err("A function ID is registered twice."); - } - } - if existing.len() + ids.len() > MAX_FUNCTIONS { - return Err("Too many functions are registered."); - } - Ok(ids) -} - -/// Dot-separated lowerCamelCase segments, for example `snapshot.refresh`. -pub(crate) fn is_function_id(value: &str) -> bool { - !value.is_empty() - && value.len() <= MAX_FUNCTION_ID_BYTES - && value.split('.').all(|segment| { - let mut characters = segment.chars(); - characters - .next() - .is_some_and(|first| first.is_ascii_lowercase()) - && characters.all(|character| character.is_ascii_alphanumeric()) - }) -} - -/// The manifest's plugin ID alphabet: lowercase letters, digits, `.` and `-`. -pub(crate) fn is_plugin_id(value: &str) -> bool { - !value.is_empty() - && value.len() <= MAX_PLUGIN_ID_BYTES - && value.chars().all(|character| { - character.is_ascii_lowercase() - || character.is_ascii_digit() - || character == '.' - || character == '-' - }) -} - -fn is_plugin_error_code(value: &str) -> bool { - let mut characters = value.chars(); - value.len() <= MAX_PLUGIN_ERROR_CODE_BYTES - && characters - .next() - .is_some_and(|first| first.is_ascii_alphabetic()) - && characters.all(|character| { - character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-') - }) -} - -/// Truncates at a character boundary so a hostile message cannot inflate -/// every event that carries it. -fn bounded_message(value: &str) -> String { - if value.len() <= MAX_MESSAGE_BYTES { - return value.to_string(); - } - let mut end = MAX_MESSAGE_BYTES; - while !value.is_char_boundary(end) { - end -= 1; - } - value[..end].to_string() -} - -fn invalid_result(message: &str) -> FunctionFailure { - FunctionFailure { - code: codes::INVALID_RESULT, - message: message.to_string(), - plugin_error_code: None, - } -} - -fn refusal(category: ErrorCode, message: &str, code: &str) -> CoreError { - CoreError::new(category, message).with_details(code) -} - -#[cfg(test)] -mod tests { - //! Golden cases from `shared/plugins/contract/v1/fixtures/backend/function-protocol.json`. - //! Timing behavior (cancellation, deadlines, late replies, revocation of a - //! running call) is covered against a scripted backend in the engine tests. - - use super::*; - - const FIXTURE: &str = include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../../shared/plugins/contract/v1/fixtures/backend/function-protocol.json" - )); - - fn fixture() -> Value { - serde_json::from_str(FIXTURE).expect("the function-protocol fixture is JSON") - } - - fn cases(section: &str) -> Vec { - fixture()[section] - .as_array() - .unwrap_or_else(|| panic!("fixture section {section} is missing")) - .clone() - } - - fn id_set(value: &Value) -> BTreeSet { - value - .as_array() - .expect("an ID list") - .iter() - .map(|id| id.as_str().expect("a string ID").to_string()) - .collect() - } - - fn name(case: &Value) -> &str { - case["name"].as_str().expect("every case is named") - } - - #[test] - fn the_offer_matches_the_fixture() { - let fixture = fixture(); - let (key, offer) = client_experimental_capabilities(); - assert_eq!(fixture["protocol"], key); - assert_eq!(fixture["offer"], offer); - } - - #[test] - fn negotiation_follows_the_fixture() { - for case in cases("negotiation") { - let negotiated = negotiate(&case["capabilities"]); - let expected = &case["expected"]; - match expected["functions"].as_array() { - Some(_) => assert_eq!( - negotiated, - Some(( - expected["version"].as_u64().unwrap_or(0), - id_set(&expected["functions"]) - )), - "{}", - name(&case) - ), - None => assert_eq!(negotiated, None, "{}", name(&case)), - } - } - } - - #[test] - fn registration_follows_the_fixture() { - for case in cases("registration") { - let current = id_set(&case["registered"]); - let params = case.get("params"); - let expected = &case["expected"]; - let outcome = match case["method"].as_str() { - Some(REGISTER_METHOD) => { - apply_registration(¤t, params).map(|registry| (registry, Vec::new())) - } - Some(UNREGISTER_METHOD) => apply_unregistration(¤t, params), - other => panic!("{}: unknown method {other:?}", name(&case)), - }; - if expected["status"] == "accepted" { - let (registry, removed) = - outcome.unwrap_or_else(|reason| panic!("{}: {reason}", name(&case))); - assert_eq!(registry, id_set(&expected["functions"]), "{}", name(&case)); - let expected_removed: Vec = - id_set(&expected["removed"]).into_iter().collect(); - assert_eq!(removed, expected_removed, "{}", name(&case)); - } else { - assert_eq!(expected["code"], codes::INVALID_REGISTRATION); - assert!(outcome.is_err(), "{} should be refused", name(&case)); - } - } - } - - #[test] - fn invocation_planning_follows_the_fixture() { - for case in cases("invocations") { - let session = &case["session"]; - let functions = id_set(&session["functions"]); - let reference: FunctionReference = - serde_json::from_value(case["request"]["function"].clone()) - .unwrap_or_else(|error| panic!("{}: {error}", name(&case))); - let planned = plan_invocation( - &FunctionSession { - plugin_id: session["pluginID"].as_str(), - negotiated: session["negotiated"].as_bool().unwrap_or(false), - functions: &functions, - }, - &reference, - "operation-1", - &case["request"]["params"], - ); - let expected = &case["expected"]; - if expected["status"] == "sent" { - let params = planned.unwrap_or_else(|error| panic!("{}: {error:?}", name(&case))); - assert_eq!(expected["method"], INVOKE_METHOD, "{}", name(&case)); - assert_eq!(params, expected["params"], "{}", name(&case)); - } else { - let error = planned.expect_err(name(&case)); - assert_eq!( - error.details.as_deref(), - expected["code"].as_str(), - "{}", - name(&case) - ); - } - } - } - - #[test] - fn outcomes_follow_the_fixture() { - for case in cases("outcomes") { - let expected = &case["expected"]; - match invocation_outcome(&case["response"]) { - Ok(result) => assert_eq!(result, expected["result"], "{}", name(&case)), - Err(failure) => { - let error = &expected["error"]; - assert_eq!(failure.code, error["code"], "{}", name(&case)); - assert_eq!(failure.message, error["message"], "{}", name(&case)); - assert_eq!( - failure.plugin_error_code.as_deref(), - error["pluginErrorCode"].as_str(), - "{}", - name(&case) - ); - } - } - } - } - - #[test] - fn progress_follows_the_fixture() { - for case in cases("progress") { - let parsed = parse_progress(case.get("params")); - let expected = &case["expected"]; - if expected.is_null() { - assert_eq!(parsed, None, "{}", name(&case)); - } else { - assert_eq!( - parsed, - Some(FunctionProgress { - operation_id: expected["operationID"].as_str().unwrap().to_string(), - message: expected["message"].as_str().map(ToString::to_string), - percentage: expected["percentage"].as_u64(), - }), - "{}", - name(&case) - ); - } - } - } - - #[test] - fn backend_requests_follow_the_fixture() { - for case in cases("backendRequests") { - assert_eq!( - refused_backend_request(case["method"].as_str().unwrap()), - case["expected"]["code"].as_str(), - "{}", - name(&case) - ); - } - } - - #[test] - fn host_requests_follow_the_fixture() { - for case in cases("hostRequests") { - let granted: BTreeSet = id_set(&case["hostServices"]); - let outcome = host_request( - case["method"].as_str().unwrap(), - case.get("params"), - Some(0), - &granted, - ); - let expected = &case["expected"]; - if expected["relay"].as_bool() == Some(true) { - assert_eq!(outcome, Ok(HostRoute::Relay), "{}", name(&case)); - continue; - } - if let Some(service) = expected["forward"].as_str() { - assert_eq!( - outcome, - Ok(HostRoute::Forward( - HOST_METHODS - .iter() - .map(|entry| entry.service) - .find(|known| *known == service) - .expect("a known service") - )), - "{}", - name(&case) - ); - continue; - } - match expected.get("answer") { - Some(answer) => { - assert_eq!( - outcome, - Ok(HostRoute::Answer(answer.clone())), - "{}", - name(&case) - ) - } - None => { - let refusal = outcome.expect_err(name(&case)); - assert_eq!(refusal.code, expected["code"], "{}", name(&case)); - assert_eq!(refusal.rpc_code, expected["rpcCode"], "{}", name(&case)); - assert_eq!( - refusal.details.as_ref(), - expected.get("details"), - "{}", - name(&case) - ); - } - } - } - } - - #[test] - fn invocation_contexts_follow_the_fixture() { - for case in cases("invocationContexts") { - let mut params = case["params"].clone(); - let outcome = attach_context(&mut params, case.get("context")); - let expected = &case["expected"]; - match expected["code"].as_str() { - Some(code) => assert_eq!( - outcome.expect_err(name(&case)).details.as_deref(), - Some(code), - "{}", - name(&case) - ), - None => { - outcome.unwrap_or_else(|error| panic!("{}: {error:?}", name(&case))); - assert_eq!(params, expected["params"], "{}", name(&case)); - } - } - } - } - - #[test] - fn invocation_callers_follow_the_fixture() { - for case in cases("invocationCallers") { - let mut params = case["params"].clone(); - attach_caller(&mut params, case["caller"].as_str()); - assert_eq!(params, case["expected"]["params"], "{}", name(&case)); - } - } - - #[test] - fn relay_errors_follow_the_fixture() { - for case in cases("relayErrors") { - let code = case["code"].as_str().unwrap(); - assert_eq!(host_rpc_code(code), case["rpcCode"], "{}", name(&case)); - } - } - - /// Every failure a relayed call can end with has its own number, so a - /// caller can branch on either the string or the number. - #[test] - fn relay_refusals_have_distinct_numbers() { - let relay_codes = [ - codes::FAILED, - codes::INVALID_RESULT, - codes::NOT_DECLARED, - codes::NOT_EXPORTED, - codes::PROVIDER_DISABLED, - codes::VERSION_MISMATCH, - codes::CALL_CYCLE, - codes::DEPTH_EXCEEDED, - codes::TOO_MANY_CALLS, - codes::PAYLOAD_TOO_LARGE, - codes::PROVIDER_UNAVAILABLE, - ]; - let numbers: BTreeSet = relay_codes.iter().map(|code| host_rpc_code(code)).collect(); - assert_eq!(numbers.len(), relay_codes.len()); - assert!(!numbers.contains(&-32603)); - } - - /// The relay's discovery and invocation requests are not the recursive - /// call: a backend cannot send `lithe/function/invoke`. - #[test] - fn relay_methods_do_not_weaken_the_recursive_call_refusal() { - assert_eq!( - refused_backend_request(INVOKE_METHOD), - Some(codes::RECURSIVE_CALL) - ); - assert_eq!( - refused_backend_request("lithe/build/model"), - Some(codes::RECURSIVE_CALL) - ); - } - - #[test] - fn host_failures_follow_the_fixture() { - for case in cases("hostFailures") { - let (code, rpc_code) = host_failure(case["code"].as_str()); - assert_eq!(code, case["expected"]["code"], "{}", name(&case)); - assert_eq!(rpc_code, case["expected"]["rpcCode"], "{}", name(&case)); - } - } - - #[test] - fn host_notifications_follow_the_fixture() { - for case in cases("hostNotifications") { - let granted: BTreeSet = case["hostServices"] - .as_array() - .unwrap() - .iter() - .map(|service| service.as_str().unwrap().to_string()) - .collect(); - let outcome = host_notification( - case["method"].as_str().unwrap(), - &case["params"], - Some(0), - &granted, - ); - let expected = &case["expected"]; - match expected["code"].as_str() { - Some(code) => assert_eq!( - outcome.expect_err(name(&case)).details.as_deref(), - Some(code), - "{}", - name(&case) - ), - None => assert_eq!( - outcome.unwrap_or_else(|error| panic!("{}: {error:?}", name(&case))), - expected["delivered"].as_bool().unwrap(), - "{}", - name(&case) - ), - } - } - } - - /// Details pass only as a bounded object, so a host cannot hand a - /// backend an unbounded or untyped payload through a failure. - #[test] - fn host_failure_details_are_a_bounded_object() { - let details = json!({ "currentVersion": 7 }); - assert_eq!(bounded_host_details(Some(&details)), Some(details)); - assert_eq!(bounded_host_details(Some(&json!("text"))), None); - let oversized = json!({ "text": "x".repeat(MAX_HOST_DETAILS_BYTES) }); - assert_eq!(bounded_host_details(Some(&oversized)), None); - assert_eq!(bounded_host_details(None), None); - } - - /// An oversized notification is refused before it reaches a backend. - #[test] - fn host_notifications_are_bounded() { - let params = json!({ - "subscriptionID": "s1", - "sequence": 1, - "event": { "text": "x".repeat(MAX_HOST_NOTIFICATION_BYTES) } - }); - let refusal = host_notification(SUBSCRIPTIONS_NOTIFY, ¶ms, Some(0), &BTreeSet::new()) - .expect_err("the notification is too large"); - assert_eq!(refusal.details.as_deref(), Some(codes::HOST_INVALID_PARAMS)); - } - - /// A backend that negotiated no protocol version is offered no host - /// service and receives no host notification, whatever it was granted. - #[test] - fn an_un_negotiated_backend_gets_no_host_services() { - let granted = BTreeSet::from([HOST_SERVICE_WORKSPACE.to_string()]); - let refusal = host_request(GET_CAPABILITIES, None, None, &granted) - .expect_err("no negotiated version"); - assert_eq!(refusal.code, codes::HOST_UNAVAILABLE); - assert_eq!(refusal.rpc_code, -32601); - assert!(host_request(GET_CAPABILITIES, None, Some(0), &granted).is_ok()); - let params = json!({ "subscriptionID": "s1", "sequence": 1, "event": {} }); - assert!( - !host_notification(SUBSCRIPTIONS_NOTIFY, ¶ms, None, &granted) - .expect("a well-formed notification is skipped, not refused") - ); - assert!( - host_notification(SUBSCRIPTIONS_NOTIFY, ¶ms, Some(0), &granted) - .expect("a negotiated backend receives it") - ); - } - - /// Capability discovery lists only what the session may call. - #[test] - fn capabilities_list_only_granted_services() { - let granted = BTreeSet::from([HOST_SERVICE_WORKSPACE.to_string()]); - let answer = capabilities_answer(0, &granted); - let services = answer["services"].as_object().expect("services"); - assert!(services.contains_key(HOST_SERVICE_WORKSPACE)); - assert!(services.contains_key(HOST_SERVICE_HOST)); - assert!(services.contains_key(HOST_SERVICE_SUBSCRIPTIONS)); - assert!(!services.contains_key(HOST_SERVICE_CREDENTIALS)); - } - - /// Every grantable service has at least one method, so a grant never - /// admits nothing. - #[test] - fn grantable_services_have_methods() { - for service in GRANTABLE_HOST_SERVICES { - assert!( - HOST_METHODS.iter().any(|entry| entry.service == *service), - "{service}" - ); - } - } - - /// The secret bound is inclusive and measured in UTF-8 bytes. - #[test] - fn credential_secrets_are_bounded() { - let granted = BTreeSet::from([HOST_SERVICE_CREDENTIALS.to_string()]); - let set = |secret: String| { - host_request( - CREDENTIALS_SET, - Some(&json!({ "key": "k", "secret": secret })), - Some(0), - &granted, - ) - }; - assert!(set("x".repeat(MAX_CREDENTIAL_SECRET_BYTES)).is_ok()); - assert_eq!( - set("x".repeat(MAX_CREDENTIAL_SECRET_BYTES + 1)) - .unwrap_err() - .code, - codes::HOST_INVALID_PARAMS - ); - } - - /// Snapshot reads are separately granted and may name only bounded opaque references. - #[test] - fn keymap_snapshot_host_requests_require_grants_and_bounded_references() { - let method = "lithe/host/keymapImport/readChunk"; - let granted = BTreeSet::from([method.to_string()]); - let valid = json!({ "snapshotID": "abc-123", "providerID": "editor", "fileID": "file-1", "offset": 0, "maximumBytes": 131072 }); - assert!(matches!( - host_request(method, Some(&valid), Some(0), &granted), - Ok(HostRoute::Forward("keymapImport")) - )); - assert!(host_request(method, Some(&valid), Some(0), &BTreeSet::new()).is_err()); - for invalid in [ - json!({ "snapshotID": "../private", "providerID": "editor", "fileID": "file-1", "offset": 0, "maximumBytes": 1 }), - json!({ "snapshotID": "abc", "providerID": "editor", "fileID": "file-1", "offset": 0, "maximumBytes": 131073 }), - json!({ "snapshotID": "abc", "providerID": "editor", "fileID": "file-1", "offset": -1, "maximumBytes": 1 }), - json!({ "snapshotID": "abc", "providerID": "editor", "fileID": "file-1", "offset": 0, "maximumBytes": 1, "path": "/private" }), - ] { - assert!(host_request(method, Some(&invalid), Some(0), &granted).is_err()); - } - let list = "lithe/host/keymapImport/listInputs"; - let list_grant = BTreeSet::from([list.to_string()]); - assert!(host_request( - list, - Some(&json!({ "snapshotID": "abc", "providerID": "editor" })), - Some(0), - &list_grant - ) - .is_ok()); - assert!(host_request( - list, - Some(&json!({ "snapshotID": "abc", "providerID": "editor", "path": "secret" })), - Some(0), - &list_grant - ) - .is_err()); - } - - /// A message over the bound is cut at a character boundary, never inside - /// a multi-byte character. - #[test] - fn long_failure_messages_are_truncated_on_character_boundaries() { - let message = format!("{}é", "a".repeat(MAX_MESSAGE_BYTES - 1)); - let failure = invocation_outcome(&json!({ "error": { "code": 1, "message": message } })) - .expect_err("an error response fails"); - assert_eq!(failure.message, "a".repeat(MAX_MESSAGE_BYTES - 1)); - } - - #[test] - fn oversized_results_are_invalid() { - let result = "x".repeat(MAX_RESULT_BYTES); - let failure = invocation_outcome(&json!({ "result": result })) - .expect_err("a result over the bound is refused"); - assert_eq!(failure.code, codes::INVALID_RESULT); - } - - /// A status message is cut at the progress bound before it reaches the - /// host, at a character boundary; other methods pass untouched. - #[test] - fn forwarded_status_messages_are_bounded() { - let message = format!("{}é", "a".repeat(MAX_MESSAGE_BYTES - 1)); - let params = json!({ "serverID": "metals", "state": "preparing", "message": message }); - let forwarded = forwarded_host_params(LANGUAGE_SERVER_SET_STATUS, Some(¶ms)); - assert_eq!(forwarded["message"], "a".repeat(MAX_MESSAGE_BYTES - 1)); - assert_eq!(forwarded["state"], "preparing"); - let request = json!({ "method": "x", "message": "m".repeat(MAX_MESSAGE_BYTES + 1) }); - assert_eq!( - forwarded_host_params(LANGUAGE_SERVER_REQUEST, Some(&request)), - request - ); - assert_eq!( - forwarded_host_params(LANGUAGE_SERVER_REQUEST, None), - Value::Null - ); - } - - /// A content scheme is a lowercase RFC 3986 scheme the host does not own. - #[test] - fn content_schemes_exclude_host_owned_ones() { - for accepted in ["metals-library", "rust-std", "a", "x1+y.z"] { - assert!(is_content_scheme(accepted), "{accepted}"); - } - for refused in [ - "", "File", "1abc", "-abc", "a b", "a_b", "file", "https", "data", "lithe", "untitled", - ] { - assert!(!is_content_scheme(refused), "{refused}"); - } - assert!(is_content_scheme(&"a".repeat(64))); - assert!(!is_content_scheme(&"a".repeat(65))); - } - - /// Only a session granted the language server methods lists them, and a - /// session without the grant still lists nothing of the service. - #[test] - fn capabilities_list_language_server_methods_only_when_granted() { - let services = |granted: &[&str]| { - let granted = granted.iter().map(|method| method.to_string()).collect(); - capabilities_answer(0, &granted)["services"].clone() - }; - assert!(services(&[]).get(HOST_SERVICE_LANGUAGE_SERVER).is_none()); - let granted = services(&[LANGUAGE_SERVER_REQUEST, LANGUAGE_SERVER_SET_STATUS]); - let entry = &granted[HOST_SERVICE_LANGUAGE_SERVER]; - assert_eq!( - entry["methods"], - json!([LANGUAGE_SERVER_REQUEST, LANGUAGE_SERVER_SET_STATUS]) - ); - assert_eq!( - entry["notifications"], - json!([ - LANGUAGE_SERVER_DID_CHANGE_STATE, - LANGUAGE_SERVER_DID_RECEIVE_NOTIFICATION - ]) - ); - } - - #[test] - fn the_registry_is_bounded() { - let current: BTreeSet = (0..MAX_FUNCTIONS) - .map(|index| format!("f{index}")) - .collect(); - let error = - apply_registration(¤t, Some(&json!({ "functions": [{ "id": "extra" }] }))) - .expect_err("the session limit holds"); - assert!(error.contains("Too many")); - } -} diff --git a/rust/crates/core/src/lsp/languages/mod.rs b/rust/crates/core/src/lsp/languages/mod.rs deleted file mode 100644 index f69c0402..00000000 --- a/rust/crates/core/src/lsp/languages/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -//! Dynamic provider metadata and host-model adapters for individual languages. - -pub(crate) mod indexing; -#[cfg(test)] -pub(crate) mod swift; diff --git a/rust/crates/core/src/lsp/languages/swift.rs b/rust/crates/core/src/lsp/languages/swift.rs deleted file mode 100644 index 5a8b7db6..00000000 --- a/rust/crates/core/src/lsp/languages/swift.rs +++ /dev/null @@ -1,179 +0,0 @@ -//! Translation between SourceKit-LSP extensions and the generic LSP model. - -use crate::lsp::interface::ClientFeatureRequest; -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 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/crates/core/src/lsp/lightweight/mod.rs b/rust/crates/core/src/lsp/lightweight/mod.rs deleted file mode 100644 index 9bf3c923..00000000 --- a/rust/crates/core/src/lsp/lightweight/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -//! 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/crates/core/src/lsp/mod.rs b/rust/crates/core/src/lsp/mod.rs deleted file mode 100644 index 9e44b41e..00000000 --- a/rust/crates/core/src/lsp/mod.rs +++ /dev/null @@ -1,24 +0,0 @@ -//! Language tooling is split by responsibility while this facade keeps the Core command API stable. - -pub(crate) mod declaration_or_usages; -pub(crate) mod document_identity; -pub(crate) mod host_policy; -pub(crate) mod interface; -mod languages; -pub(crate) mod lightweight; -pub(crate) mod navigation_target; -pub(crate) mod workspace_edit_plan; -mod workspace_scope; - -pub(crate) use interface::*; -pub(crate) use lightweight::*; - -#[cfg(test)] -pub(crate) fn client_feature_request( - request: ClientFeatureRequest, -) -> Result { - interface::client_feature_request_canonical(languages::swift::adapt_feature_request(request)) -} - -#[cfg(test)] -mod tests; diff --git a/rust/crates/core/src/plugins/declarative.rs b/rust/crates/core/src/plugins/declarative.rs deleted file mode 100644 index 6dcd03e6..00000000 --- a/rust/crates/core/src/plugins/declarative.rs +++ /dev/null @@ -1,4738 +0,0 @@ -//! Declarative plugin contract: manifest validation and deterministic -//! expansion of run/test command templates and language-server launch plans. -//! -//! A declarative plugin carries no host-loaded code. The host reads its -//! `plugin.json`, asks this module to validate and normalize it, and launches -//! the declared external processes itself. Both products must produce the -//! same outcome for the fixtures in `shared/plugins/contract/v1/`. -//! -//! Content problems in a manifest or request are reported as `rejected` -//! outcomes with a stable `plugin.*` code instead of Core failures, so hosts -//! can show them next to the affected plugin. Only a malformed command -//! payload is a Core `invalid_request` failure. - -use super::capabilities::{self, HostCapabilities, Requires}; -use super::language_server_sessions; -pub(crate) use super::settings::is_setting_identifier; -use super::settings::{ - DeclaredSettings, ResolvedSetting, SettingAction, SettingDeclaration, SettingLayers, - MAX_SETTING_ACTIONS, -}; -use crate::protocol::{CoreError, ErrorCode}; -use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; -use std::collections::{BTreeMap, BTreeSet}; - -/// `plugin.json` schema version that identifies a declarative manifest. -pub const DECLARATIVE_MANIFEST_SCHEMA_VERSION: u32 = 0; -/// Base-contract versions (`pluginAPIVersion`) this Core build can host. The -/// version is independent of the Lithe application version, and a later one -/// is appended to this list. -pub const SUPPORTED_PLUGIN_API_VERSIONS: &[u32] = &[0]; - -/// ID prefix reserved to plugins published and signed by Lithe. -pub const OFFICIAL_PLUGIN_NAMESPACE: &str = "dev.lithe"; - -const PERMISSION_LANGUAGE_SERVER: &str = "languageServer.launch"; -const PERMISSION_RUN: &str = "process.run"; -const PERMISSION_TEST: &str = "process.test"; -const PERMISSION_UI: &str = "ui.contribute"; -/// Running the plugin's own scripts in a host-managed web view. -const PERMISSION_UI_WEB: &str = "ui.web"; -/// Running a declared build system's executable: model, task, test, and -/// dependency commands. -const PERMISSION_BUILD: &str = "process.build"; -/// Letting a VCS provider change the working copy and contact its server. -const PERMISSION_VCS: &str = "process.vcs"; -/// Creating projects: writing template files, running scaffolding tools, -/// and downloading project archives. -const PERMISSION_PROJECT_GENERATE: &str = "project.generate"; -/// Starting the plugin's own backend process. -const PERMISSION_BACKEND: &str = "backend.launch"; -/// Supplying facts that can change the executable selected for a defined tool. -const PERMISSION_TOOLCHAINS: &str = "toolchains.provide"; -/// Providing normalized shortcuts during an explicit import. -const PERMISSION_KEYMAPS: &str = "keymaps.provide"; -/// Reading declared files from the explicitly selected import source. -const PERMISSION_KEYMAP_SOURCES: &str = "keymaps.readSelectedSources"; -/// Letting the backend keep secrets in the host's credential store, private -/// to the plugin. -const PERMISSION_CREDENTIALS: &str = "credentials.store"; -/// Letting the backend list the open workspaces and read their folders and -/// its per-workspace state directory. -const PERMISSION_WORKSPACE_METADATA: &str = "workspace.metadata"; -/// Starting a debug session the plugin's adapter serves. -const PERMISSION_DEBUG_LAUNCH: &str = "debug.launch"; -/// Attaching the plugin's adapter to a running process. -const PERMISSION_DEBUG_ATTACH: &str = "debug.attach"; -/// Letting the backend start a debug session on one of its own adapters -/// (`lithe/host/debug/start`), a host-method permission of the backend. -const PERMISSION_DEBUG_START: &str = "debug.start"; -const KNOWN_PERMISSIONS: &[&str] = &[ - PERMISSION_BACKEND, - PERMISSION_BUILD, - PERMISSION_CREDENTIALS, - PERMISSION_DEBUG_ATTACH, - PERMISSION_DEBUG_LAUNCH, - PERMISSION_LANGUAGE_SERVER, - PERMISSION_KEYMAPS, - PERMISSION_KEYMAP_SOURCES, - PERMISSION_PROJECT_GENERATE, - PERMISSION_RUN, - PERMISSION_TEST, - PERMISSION_TOOLCHAINS, - PERMISSION_UI, - PERMISSION_UI_WEB, - PERMISSION_VCS, - PERMISSION_WORKSPACE_METADATA, -]; -/// Runtime tools the host discovers and manages itself for build-system -/// runtime dependencies: it exports each as `LITHE__PATH` and puts its -/// directory on the process's `PATH`. Server processes never use this list; -/// they name tool definitions from `requiresTools`. -pub(crate) const KNOWN_RUNTIME_TOOLS: &[&str] = &["go", "node"]; -/// Most tool definitions one plugin may require. -const MAX_REQUIRED_TOOLS: usize = 32; -/// Native host integrations a language server may ask for. The Core -/// implements none: a language server's run, test and debug support comes -/// from the plugin's own backend and providers, so every name is rejected -/// with `plugin.integration.unsupported`. The declaration stays in the -/// contract so a manifest naming one fails validation instead of parsing. -const KNOWN_HOST_INTEGRATIONS: &[&str] = &[]; -/// Host capability: running a program of the language. -const HOST_CAPABILITY_RUN: &str = "run"; -/// Host capability: discovering and running tests of the language. -const HOST_CAPABILITY_TESTING: &str = "testing"; -/// CPU architectures a host may report for `${arch}`. -const KNOWN_ARCHITECTURES: &[&str] = &["arm64", "x86_64"]; -/// Most settings one plugin may declare, so its pages stay pages. -pub(crate) const MAX_SETTINGS: usize = 32; -/// Prefix of a launch template variable that names a plugin setting. -const SETTING_VARIABLE_PREFIX: &str = "setting:"; - -/// Stable reason a declarative manifest or plan request was refused. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct PluginRejection { - /// Stable `plugin.*` code listed in the contract README. - pub code: &'static str, - /// User-facing English summary without host paths. - pub message: String, - /// JSON path of the offending manifest field, when one applies. - #[serde(skip_serializing_if = "Option::is_none")] - pub field: Option, -} - -impl PluginRejection { - #[cfg(test)] - pub(crate) fn code(&self) -> &'static str { - self.code - } - - #[cfg(test)] - pub(crate) fn field(&self) -> Option<&str> { - self.field.as_deref() - } - - pub(crate) fn new(code: &'static str, message: impl Into) -> Self { - Self { - code, - message: message.into(), - field: None, - } - } - - pub(crate) fn at( - code: &'static str, - field: impl Into, - message: impl Into, - ) -> Self { - Self { - code, - message: message.into(), - field: Some(field.into()), - } - } -} - -pub(crate) type Validation = Result; - -/// Root of a declarative `plugin.json`. Unknown fields are refused so a newer -/// manifest feature cannot be silently ignored by an older host. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct DeclarativePluginManifest { - schema_version: u32, - id: String, - display_name: String, - version: String, - #[serde(rename = "pluginAPIVersion")] - plugin_api_version: u32, - /// Capability versions the manifest was written against; see - /// `super::capabilities`. - requires: Requires, - vendor: PluginVendor, - #[serde(default)] - permissions: Vec, - #[serde(default)] - activation_events: Vec, - #[serde(default)] - languages: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - ui: Option, - #[serde(default)] - language_servers: Vec, - #[serde(default)] - execution_providers: Vec, - /// User-adjustable options of the plugin; see `super::settings`. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - settings: Vec, - /// Buttons on settings pages that invoke the plugin's functions. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - setting_actions: Vec, - /// Project-level build tools the plugin describes. The shape and its - /// validation live in `crate::build`. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - build_systems: Vec, - /// Version control systems the plugin implements. The shape and its - /// validation live in `crate::vcs`. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - vcs_providers: Vec, - /// Debug adapters the plugin implements. The shape and its validation - /// live in `crate::debug::adapters`. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - debug_adapters: Vec, - /// Entries of the New Project window. The shape and its validation live - /// in `crate::project_generators`. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - project_generators: Vec, - /// The plugin's own process serving its pages; see `super::backend`. - #[serde(default, skip_serializing_if = "Option::is_none")] - backend: Option, - /// Tool policies owned by plugins, never a host-maintained definition table. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - tool_definitions: Vec, - /// Fact providers served by the plugin's existing backend process. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - toolchain_providers: Vec, - /// Independent shortcut importers implemented by this plugin's backend. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - keymap_import_providers: Vec, - /// Tool-definition IDs the plugin's processes and Run requests name; - /// hosts resolve them through plugin-owned toolchains. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - requires_tools: Vec, - /// Patterns that turn console output into links; see - /// `super::output_links`. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - output_link_matchers: Vec, - /// Functions this plugin exports to other plugins and the exported - /// functions it calls; see `super::plugin_functions`. - #[serde(default, skip_serializing_if = "Option::is_none")] - plugin_functions: Option, -} - -/// UI supplied by plugins. The host owns every window shell: docking, title, -/// activity bar, order, visibility, layout and lifecycle. A window body is -/// text, web content that the host loads from the package into a sandboxed -/// web view inside that shell, or host-rendered native content. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct UIContributions { - #[serde(default)] - tool_windows: Vec, - #[serde(default)] - commands: Vec, -} - -/// A stable tool-window identity and plain-text content rendered by the host. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct UIToolWindow { - id: String, - title: String, - /// Initial dock only; saved user layout takes precedence. - placement: UIDock, - /// Sort rank within the dock; ties fall back to the identifier. - #[serde(default)] - order: i32, - /// Plain text shown in the window body; the host never interprets markup. - /// Exactly one of `text` and `web`. - #[serde(default, skip_serializing_if = "Option::is_none")] - text: Option, - /// The plugin's own page shown in the window body. - #[serde(default, skip_serializing_if = "Option::is_none")] - web: Option, - /// Host-rendered tree/list content. - #[serde(default, skip_serializing_if = "Option::is_none")] - native: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - icon: Option, -} - -/// A page from the plugin package. The host serves only package files to it, -/// grants no network, navigation or storage, and talks to it through the -/// plugin web bridge, which reaches only this plugin's own declarations. -/// -/// Note: the decision is recorded in .agents/notes/implemented/architecture/2026-10-18-plugin-web-content-in-host-windows.md -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct UIWebContent { - /// Package-relative HTML entry, e.g. `ui/index.html`. - entry: String, -} - -/// Edge of the workbench where a tool window first docks. -#[derive(Debug, Clone, Copy, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -enum UIDock { - Left, - Bottom, - Right, -} - -/// The initial controlled action opens one of this plugin's own windows. -/// Commands cannot invoke arbitrary host actions or execute a process. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct UICommand { - id: String, - title: String, - /// Absent for a command the plugin backend handles. - #[serde( - default, - rename = "toolWindowID", - skip_serializing_if = "Option::is_none" - )] - tool_window_id: Option, - #[serde(default)] - order: i32, - placements: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - icon: Option, -} - -/// Icon identity is portable data; hosts own asset resolution, sizing and states. -/// Custom assets are package-relative and are decoded only by the platform adapter. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct UIIcon { - #[serde(default, skip_serializing_if = "Option::is_none")] - host: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - path: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - dark_path: Option, - #[serde(default)] - mode: UIIconMode, -} - -/// How the host paints an icon resource. -#[derive(Debug, Clone, Copy, Default, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -enum UIIconMode { - /// Tinted with the current theme foreground, like built-in icons. - #[default] - Template, - /// Drawn with its own colors, untinted. - Original, -} - -impl UIIcon { - /// Native actions reuse the host vocabulary, without package resource loading. - pub(crate) fn is_valid_host_icon(&self) -> bool { - self.host.is_some() && self.validate().is_ok() - } - - fn validate(&self) -> Validation<()> { - self.validate_at("ui.icon") - } - - /// Validates the icon and reports a failure at `field`; build systems - /// reuse the UI icon vocabulary for their tool windows and toolbar toggles. - pub(crate) fn validate_at(&self, field: &str) -> Validation<()> { - const HOST_IDS: &[&str] = &[ - "tool-window", - "plugin", - "database", - "services", - "terminal", - "search", - "refresh", - "reset", - "run", - "debug", - "settings", - "add", - "remove", - "git", - "problems", - ]; - let valid_path = |path: &str| { - is_safe_relative_path(path) - && path.len() <= 256 - && !path.contains(':') - && !path.chars().any(char::is_control) - && path.split('/').all(|part| !part.is_empty() && part != ".") - && (path.ends_with(".svg") || path.ends_with(".png")) - }; - let valid = match (&self.host, &self.path) { - (Some(host), None) => { - HOST_IDS.contains(&host.as_str()) - && self.dark_path.is_none() - && self.mode == UIIconMode::Template - } - (None, Some(path)) => { - valid_path(path) && self.dark_path.as_deref().is_none_or(valid_path) - } - _ => false, - }; - if !valid { - return Err(invalid( - field, - "An icon must name a supported host icon or safe package SVG/PNG resources.", - )); - } - Ok(()) - } -} - -/// Host surface that may list a plugin command. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -enum UICommandPlacement { - CommandPalette, - Toolbar, - Menu, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct PluginVendor { - id: String, - display_name: String, - signature_requirement: SignatureRequirement, - /// Optional trust for the host's sealed built-in copy only. Installation - /// from every other origin still requires the primary publisher signature. - #[serde(default, skip_serializing_if = "Option::is_none")] - bundled_signature_requirement: Option, -} - -/// How a package proves its origin. `sameTeamAsHost` is only honored by a -/// host for packages shipped inside its own signed installation. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -enum SignatureRequirement { - SameTeamAsHost, - PublisherPackage, - /// Community package without a Lithe signature. Its integrity comes from - /// the marketplace index checksum and its trust from the user's explicit - /// confirmation, so the official namespace can never use it. - None, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct LanguageDeclaration { - id: String, - display_name: String, - #[serde(default)] - file_extensions: Vec, - #[serde(default)] - file_names: Vec, - #[serde(default)] - project_file_names: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - dependencies: Option, - /// Syntax highlighting from a package file; see `super::grammar`. - #[serde(default, skip_serializing_if = "Option::is_none")] - grammar: Option, - /// Comment and bracket behavior of the editor. - #[serde(default, skip_serializing_if = "Option::is_none")] - configuration: Option, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct DependencyDeclaration { - #[serde(default)] - management_file_names: Vec, - #[serde(default)] - project_dependency_paths: Vec, - #[serde(default)] - project_binary_paths: Vec, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct LanguageServerDeclaration { - #[serde(rename = "languageID")] - language_id: String, - /// LSP `languageId` sent for documents; defaults to the language ID. - #[serde(default, skip_serializing_if = "Option::is_none")] - language_identifier: Option, - #[serde(flatten)] - process: ServerProcess, - /// Native host integration this server needs, with the package files it - /// consumes. - #[serde(default, skip_serializing_if = "Option::is_none")] - host_integration: Option, - /// Settings the server reads through `workspace/configuration` and - /// receives again in `workspace/didChangeConfiguration` whenever one of - /// the settings it names changes. String values are templates like - /// `initializationOptions`, and may also name settings whose `effect` - /// is `immediate`. - #[serde(default, skip_serializing_if = "Option::is_none")] - workspace_configuration: Option, - /// Fields that govern what the plugin backend may send to its own server - /// and how the host keeps the server's state. - #[serde(flatten)] - session: language_server_sessions::SessionDeclaration, -} - -/// How the host starts a process that speaks the LSP base protocol. A -/// language server and a build-system provider are the same kind of process, -/// so both declare it with these fields and share their validation, install -/// review entries, and launch plan. Flattened into its owner, which rejects -/// unknown fields. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct ServerProcess { - launch: LanguageServerLaunch, - #[serde(default)] - arguments: Vec, - /// Arguments of a probe that must exit successfully before the host - /// trusts a candidate executable; empty means no probe. - #[serde(default)] - validation_arguments: Vec, - #[serde(default)] - runtime_dependencies: Vec, - /// LSP `initializationOptions` sent verbatim after template expansion of - /// its string values. - #[serde(default, skip_serializing_if = "Option::is_none")] - initialization_options: Option, -} - -/// What a server process is for, which names it in rejection messages. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ServerRole { - LanguageServer, - BuildProvider, - VcsProvider, - Backend, - DebugAdapter, -} - -/// Tools a server process may name for its launch and dependencies: the -/// tool-definition IDs the manifest lists in `requiresTools`. -#[derive(Debug, Clone, Copy)] -pub(crate) struct RuntimeTools<'a>(&'a BTreeSet); - -impl<'a> RuntimeTools<'a> { - /// The tools a manifest lists in `requiresTools`. - pub(crate) fn new(tools: &'a BTreeSet) -> Self { - Self(tools) - } - - /// Checks that one tool a process names is listed in `requiresTools`. - pub(crate) fn check(self, tool: &str, field: String) -> Validation<()> { - if self.0.contains(tool) { - Ok(()) - } else { - Err(PluginRejection::at( - "plugin.runtime.undeclared", - field, - format!("The tool {tool} is not listed in requiresTools."), - )) - } - } -} - -impl ServerRole { - fn noun(self) -> &'static str { - match self { - Self::LanguageServer => "language server", - Self::BuildProvider => "build provider", - Self::VcsProvider => "VCS provider", - Self::Backend => "plugin backend", - Self::DebugAdapter => "debug adapter", - } - } -} - -/// A named piece of host code and the package resources it reads. The plugin -/// owns the files; the host owns the behavior and is told where they are. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct HostIntegrationDeclaration { - id: String, - /// Process-backed features of the server's language that the host's own - /// implementation provides through this integration. A language owned by - /// a plugin gets none of them unless the plugin names it here or supplies - /// an execution provider for it. Sorted and unique once validated. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - capabilities: Vec, - /// Resource name to package-relative path templates. - #[serde(default)] - resources: BTreeMap, -} - -/// One package path, or an ordered list of them. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(untagged)] -enum ResourcePaths { - One(String), - Many(Vec), -} - -impl ResourcePaths { - fn paths(&self) -> &[String] { - match self { - Self::One(path) => std::slice::from_ref(path), - Self::Many(paths) => paths, - } - } -} - -/// Where the language-server executable comes from. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)] -enum LanguageServerLaunch { - /// An executable inside the plugin package, relative to the package root. - Packaged { path: String }, - /// Bare command names the host resolves through its tool discovery. - SystemCommand { commands: Vec }, - /// A packaged entry file started by a host-discovered runtime, so the - /// package needs no launcher script. - Runtime { - /// Tool-definition ID, listed in `requiresTools`, whose launch - /// template starts the entry. - tool: String, - /// Lowest acceptable runtime version, for example `21` or `18.17`. - #[serde( - default, - rename = "minimumVersion", - skip_serializing_if = "Option::is_none" - )] - minimum_version: Option, - /// Runtime executable shipped inside the package, relative to its - /// root; it may vary by `${arch}`. When set, the host starts exactly - /// this file instead of discovering the tool on the machine. - #[serde( - default, - rename = "packagedRuntime", - skip_serializing_if = "Option::is_none" - )] - packaged_runtime: Option, - /// Runtime options placed before the entry. - #[serde(default, rename = "runtimeArguments")] - runtime_arguments: Vec, - /// Entry file inside the package, such as a script for `node`. - entry: String, - }, - /// The plugin backend resolves the command line each time the server - /// starts: the host calls `function` and passes its answer to - /// `plugins.languageServers.decodeLaunch`. Language servers only. - Backend { - /// ID of a function the plugin backend registers. - function: String, - /// How long the host waits for the function, within - /// `language_server_sessions::RESOLVE_TIMEOUT_MILLISECONDS`. - #[serde( - default, - rename = "timeoutMilliseconds", - skip_serializing_if = "Option::is_none" - )] - timeout_milliseconds: Option, - }, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct RuntimeDependency { - tool: String, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct ExecutionProviderDeclaration { - #[serde(rename = "languageID")] - language_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - run: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - test: Option, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct RunTemplate { - executable: ExecutableDeclaration, - arguments: Vec, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct TestTemplate { - #[serde(rename = "frameworkID")] - framework_id: String, - workspace_label: String, - project_markers: Vec, - test_file_patterns: Vec, - executable: ExecutableDeclaration, - workspace_arguments: Vec, - file_arguments: Vec, - test_case_arguments: Vec, -} - -/// Exactly one of a bare command resolved on the host's tool path or a -/// host-owned project toolchain ID such as `project-go`. -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct ExecutableDeclaration { - #[serde(default, skip_serializing_if = "Option::is_none")] - command: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - toolchain: Option, -} - -/// One argv element. A conditional element is emitted only when the request -/// selects (or does not select) a file. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(untagged)] -enum ArgumentTemplate { - Literal(String), - Conditional { - value: String, - when: ArgumentCondition, - }, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -enum ArgumentCondition { - FileSelected, - NoFileSelected, -} - -/// Template variables. `${userArguments}` expands to zero or more elements; -/// the others substitute into a single element. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum TemplateVariable { - File, - FileDirectory, - TestName, - TestNameRegex, - UserArguments, -} - -impl TemplateVariable { - fn parse(name: &str) -> Option { - match name { - "file" => Some(Self::File), - "fileDirectory" => Some(Self::FileDirectory), - "testName" => Some(Self::TestName), - "testName:regex" => Some(Self::TestNameRegex), - "userArguments" => Some(Self::UserArguments), - _ => None, - } - } - - fn needs_file(self) -> bool { - matches!(self, Self::File | Self::FileDirectory) - } -} - -/// One piece of a parsed `${...}` template: literal text or a variable of the -/// template family that parsed it. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum TemplatePart { - Text(String), - Variable(Variable), -} - -/// Variables of language-server launch templates. They name host-owned -/// locations, so a package never embeds a machine path. -#[derive(Debug, Clone, PartialEq, Eq)] -enum LaunchVariable { - /// Root of the installed, read-only plugin package. - PluginDir, - /// Writable directory the host keeps for this plugin's server and removes - /// when the plugin is uninstalled. - StateDir, - /// Writable directory for this server's state of the current workspace. - WorkspaceStateDir, - /// Absolute session plugin files directory supplied by the writable storage adapter. - LogDir, - /// Host CPU architecture, one of `KNOWN_ARCHITECTURES`. - Arch, - /// Current value of the plugin setting with this ID. - Setting(String), -} - -impl LaunchVariable { - fn parse(name: &str) -> Option { - match name { - "pluginDir" => Some(Self::PluginDir), - "stateDir" => Some(Self::StateDir), - "workspaceStateDir" => Some(Self::WorkspaceStateDir), - "logDir" => Some(Self::LogDir), - "arch" => Some(Self::Arch), - _ => name - .strip_prefix(SETTING_VARIABLE_PREFIX) - .filter(|id| is_setting_identifier(id)) - .map(|id| Self::Setting(id.to_string())), - } - } -} - -/// Which launch value a template belongs to. The validation probe runs before -/// any workspace is open, so it cannot name state directories. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum LaunchTemplateContext { - Start, - ValidationProbe, -} - -/// Which command template an argument list belongs to; each allows a -/// different set of variables. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum TemplateContext { - Run, - TestWorkspace, - TestFile, - TestCase, -} - -impl TemplateContext { - fn allows(self, variable: TemplateVariable) -> bool { - use TemplateVariable::*; - match self { - Self::Run => matches!(variable, File | FileDirectory | UserArguments), - Self::TestWorkspace => false, - Self::TestFile => matches!(variable, File | FileDirectory), - Self::TestCase => matches!(variable, File | FileDirectory | TestName | TestNameRegex), - } - } -} - -fn parse_template(value: &str) -> Option> { - parse_template_with(value, TemplateVariable::parse) -} - -/// Splits a template into text and variables, using `parse` for the variable -/// names of one template family. `None` for an unknown or unterminated -/// variable. Build-system templates share this parser with plugin templates. -pub(crate) fn parse_template_with( - value: &str, - parse: fn(&str) -> Option, -) -> Option>> { - let mut parts = Vec::new(); - let mut rest = value; - while let Some(start) = rest.find("${") { - if start > 0 { - parts.push(TemplatePart::Text(rest[..start].to_string())); - } - let after = &rest[start + 2..]; - let end = after.find('}')?; - parts.push(TemplatePart::Variable(parse(&after[..end])?)); - rest = &after[end + 1..]; - } - if !rest.is_empty() { - parts.push(TemplatePart::Text(rest.to_string())); - } - Some(parts) -} - -/// Validates a raw manifest value and returns its normalized form. -pub fn validate_manifest_value(value: Value) -> Validation { - // Check the version gates first so a future schema reports the precise - // reason instead of an unknown-field decoding failure. - if let Some(version) = value.get("schemaVersion").and_then(Value::as_u64) { - if version != u64::from(DECLARATIVE_MANIFEST_SCHEMA_VERSION) { - return Err(PluginRejection::at( - "plugin.schema.unsupported", - "schemaVersion", - format!("Plugin manifest schema {version} is not supported."), - )); - } - } - if let Some(version) = value.get("pluginAPIVersion").and_then(Value::as_u64) { - if !SUPPORTED_PLUGIN_API_VERSIONS - .iter() - .any(|supported| u64::from(*supported) == version) - { - return Err(PluginRejection::at( - "plugin.api.unsupported", - "pluginAPIVersion", - format!("Plugin API {version} is not supported by this version of Lithe."), - )); - } - capabilities::check_manifest_requires(&value)?; - } - for (field, limit) in [ - ("toolDefinitions", 32), - ("toolchainProviders", 8), - ("keymapImportProviders", super::keymap_import::MAX_PROVIDERS), - ] { - if value.get(field).is_some_and(|section| { - !section - .as_array() - .is_some_and(|items| !items.is_empty() && items.len() <= limit) - }) { - return Err(invalid( - field, - "A declared provider section must be nonempty and bounded.", - )); - } - } - let manifest: DeclarativePluginManifest = serde_json::from_value(value).map_err(|error| { - PluginRejection::new( - "plugin.manifest.invalid", - format!("The plugin manifest is invalid: {error}"), - ) - })?; - manifest.validated() -} - -impl DeclarativePluginManifest { - fn validated(mut self) -> Validation { - if !is_plugin_id(&self.id) { - return Err(invalid( - "id", - "The plugin ID must use lowercase letters, digits, dots, and hyphens.", - )); - } - if !is_semantic_version(&self.version) { - return Err(invalid( - "version", - "The plugin version must use major.minor.patch.", - )); - } - if self.display_name.trim().is_empty() { - return Err(invalid("displayName", "The plugin display name is empty.")); - } - if self.vendor.id.trim().is_empty() || self.vendor.display_name.trim().is_empty() { - return Err(invalid("vendor", "The plugin vendor is incomplete.")); - } - if self.vendor.signature_requirement == SignatureRequirement::None - && is_official_namespace(&self.id) - { - return Err(invalid( - "vendor.signatureRequirement", - "Official plugin IDs must carry a Lithe signature.", - )); - } - if let Some(bundled) = self.vendor.bundled_signature_requirement { - if self.vendor.signature_requirement != SignatureRequirement::PublisherPackage - || bundled != SignatureRequirement::SameTeamAsHost - || !is_official_namespace(&self.id) - { - return Err(invalid( - "vendor.bundledSignatureRequirement", - "Bundled host-seal trust requires an official plugin ID and publisherPackage for installed copies.", - )); - } - } - capabilities::check_used(&self.requires, &self.used_capabilities())?; - if self.languages.is_empty() - && self.ui.is_none() - && self.build_systems.is_empty() - && self.vcs_providers.is_empty() - && self.debug_adapters.is_empty() - && self.project_generators.is_empty() - && self.tool_definitions.is_empty() - && self.toolchain_providers.is_empty() - && self.keymap_import_providers.is_empty() - && self.output_link_matchers.is_empty() - // A backend may be the whole plugin: it starts on a workspace. - && self.backend.is_none() - { - return Err(invalid("languages", "The plugin declares no languages.")); - } - - let mut language_ids = BTreeSet::new(); - for (index, language) in self.languages.iter_mut().enumerate() { - language.normalize(index)?; - if !language_ids.insert(language.id.clone()) { - return Err(invalid( - format!("languages[{index}].id"), - "A language is declared more than once.", - )); - } - } - self.languages.sort_by(|left, right| left.id.cmp(&right.id)); - - let declared_settings = self.validate_settings()?; - let required_tools = self.validate_required_tools()?; - let runtime_tools = RuntimeTools::new(&required_tools); - super::output_links::validate_declarations( - &mut self.output_link_matchers, - self.backend.is_some(), - )?; - - let mut server_languages = BTreeSet::new(); - let mut referenced_immediate_settings = BTreeSet::new(); - for (index, server) in self.language_servers.iter_mut().enumerate() { - let field = format!("languageServers[{index}]"); - if let Some(integration) = &mut server.host_integration { - integration.capabilities = - sorted_unique(std::mem::take(&mut integration.capabilities)); - } - if !language_ids.contains(&server.language_id) { - return Err(invalid( - format!("{field}.languageID"), - "The language server references an undeclared language.", - )); - } - if !server_languages.insert(server.language_id.clone()) { - return Err(invalid( - format!("{field}.languageID"), - "A language declares more than one language server.", - )); - } - if let Some(configuration) = &server.workspace_configuration { - collect_setting_references(configuration, &mut referenced_immediate_settings); - } - server.validate(&field, &declared_settings, runtime_tools)?; - } - if let Some(id) = - declared_settings.first_unreferenced_immediate(&referenced_immediate_settings) - { - return Err(invalid( - "settings", - format!("The immediate setting {id} must be used by workspaceConfiguration."), - )); - } - self.language_servers - .sort_by(|left, right| left.language_id.cmp(&right.language_id)); - - let mut execution_languages = BTreeSet::new(); - for (index, provider) in self.execution_providers.iter_mut().enumerate() { - let field = format!("executionProviders[{index}]"); - if !language_ids.contains(&provider.language_id) { - return Err(invalid( - format!("{field}.languageID"), - "The execution provider references an undeclared language.", - )); - } - if !execution_languages.insert(provider.language_id.clone()) { - return Err(invalid( - format!("{field}.languageID"), - "A language declares more than one execution provider.", - )); - } - provider.normalize(&field)?; - } - self.execution_providers - .sort_by(|left, right| left.language_id.cmp(&right.language_id)); - - // One feature of a language has one owner. Two would leave it to - // host wiring order whether the plugin's command or the host's - // implementation runs. - for (index, server) in self.language_servers.iter().enumerate() { - let Some(integration) = &server.host_integration else { - continue; - }; - let Some(provider) = self - .execution_providers - .iter() - .find(|provider| provider.language_id == server.language_id) - else { - continue; - }; - let declared_twice = [ - (HOST_CAPABILITY_RUN, provider.run.is_some()), - (HOST_CAPABILITY_TESTING, provider.test.is_some()), - ] - .into_iter() - .find(|(capability, provided)| { - *provided - && integration - .capabilities - .iter() - .any(|name| name == capability) - }); - if let Some((capability, _)) = declared_twice { - return Err(invalid( - format!("languageServers[{index}].hostIntegration.capabilities"), - format!( - "The {capability} capability is already provided by the execution provider of {}.", - server.language_id - ), - )); - } - } - - if self.language_servers.is_empty() - && self.execution_providers.is_empty() - && self.ui.is_none() - && self.build_systems.is_empty() - && self.vcs_providers.is_empty() - && self.debug_adapters.is_empty() - && self.project_generators.is_empty() - && self.tool_definitions.is_empty() - && self.toolchain_providers.is_empty() - && self.keymap_import_providers.is_empty() - // Console links alone are a complete plugin: Core matches them. - && self.output_link_matchers.is_empty() - && self.backend.is_none() - // A grammar alone is a complete highlighting plugin. - && !self - .languages - .iter() - .any(|language| language.grammar.is_some() || language.configuration.is_some()) - { - return Err(invalid( - "languages", - "The plugin declares no language server or execution provider.", - )); - } - self.validate_ui()?; - crate::project_generators::validate_declarations(&self.project_generators)?; - crate::build::validate_declarations( - &mut self.build_systems, - &crate::build::DeclarationContext { - debug_adapters: &self.debug_adapters, - official: is_official_namespace(&self.id), - declared_settings: &declared_settings, - language_servers: &server_languages, - runtime_tools, - }, - )?; - crate::vcs::validate_declarations( - &mut self.vcs_providers, - &crate::vcs::DeclarationContext { - declared_settings: &declared_settings, - runtime_tools, - }, - )?; - self.validate_debug_adapters(&declared_settings, runtime_tools)?; - self.validate_backend_launches()?; - self.validate_toolchains()?; - if !self.keymap_import_providers.is_empty() { - super::keymap_import::validate_declarations(&mut self.keymap_import_providers)?; - self.validate_packaged_provider_backend()?; - } - self.validate_plugin_functions()?; - if let Some(backend) = &self.backend { - backend.validate(&super::backend::DeclarationContext { - declared_settings: &declared_settings, - has_web_window: self - .ui - .as_ref() - .is_some_and(|ui| ui.tool_windows.iter().any(|window| window.web.is_some())), - has_command: self.ui.as_ref().is_some_and(|ui| !ui.commands.is_empty()), - activates_on_workspace_open: self - .activation_events - .iter() - .any(|event| event == super::backend::ON_WORKSPACE_OPEN), - // A language server the backend launches starts the backend - // when the host asks it for the server's command line; an - // exported function starts it when another plugin calls it. - // Neither needs a page, command or workspace event. - provider_query_trigger: !self.toolchain_providers.is_empty() - || !self.keymap_import_providers.is_empty() - || self - .language_servers - .iter() - .any(|server| server.process.is_backend_launch()) - || self.plugin_functions.as_ref().is_some_and( - super::plugin_functions::PluginFunctionsDeclaration::exports_any, - ), - runtime_tools, - })?; - } - self.validate_permissions()?; - self.validate_activation_events()?; - Ok(self) - } - - /// Capabilities whose sections the manifest declares. Launches, tool - /// references, and executable candidates belong to the base contract and - /// are never listed. - fn used_capabilities(&self) -> BTreeSet<&'static str> { - let sections = [ - ( - capabilities::PACKAGE_TRUST, - self.vendor.bundled_signature_requirement.is_some(), - ), - (capabilities::LANGUAGES, !self.languages.is_empty()), - ( - capabilities::LANGUAGE_SERVERS, - !self.language_servers.is_empty(), - ), - ( - capabilities::EXECUTION_PROVIDERS, - !self.execution_providers.is_empty(), - ), - ( - capabilities::SETTINGS, - !self.settings.is_empty() || !self.setting_actions.is_empty(), - ), - (capabilities::UI, self.ui.is_some()), - (capabilities::BUILD_SYSTEMS, !self.build_systems.is_empty()), - ( - capabilities::PROJECT_GENERATORS, - !self.project_generators.is_empty(), - ), - ( - capabilities::BUILD_PROVIDERS, - self.build_systems - .iter() - .any(|system| system.provider.is_some()), - ), - ( - capabilities::BUILD_MODEL_INPUTS, - self.build_systems.iter().any(|system| { - system.model_sharing.is_some() || !system.model_inputs.is_empty() - }), - ), - (capabilities::VCS_PROVIDERS, !self.vcs_providers.is_empty()), - ( - capabilities::DEBUG_ADAPTERS, - !self.debug_adapters.is_empty(), - ), - (capabilities::BACKEND, self.backend.is_some()), - ( - capabilities::TOOL_DEFINITIONS, - !self.tool_definitions.is_empty(), - ), - ( - capabilities::TOOLCHAIN_PROVIDERS, - !self.toolchain_providers.is_empty(), - ), - ( - capabilities::KEYMAP_IMPORT_PROVIDERS, - !self.keymap_import_providers.is_empty(), - ), - ( - capabilities::OUTPUT_LINK_MATCHERS, - !self.output_link_matchers.is_empty(), - ), - ( - capabilities::PLUGIN_FUNCTIONS, - self.plugin_functions.is_some(), - ), - ]; - sections - .into_iter() - .filter_map(|(capability, used)| used.then_some(capability)) - .collect() - } - - /// Checks the debug adapters against the plugin's settings, language - /// servers, and `requiresTools`. - fn validate_debug_adapters( - &mut self, - declared_settings: &DeclaredSettings, - runtime_tools: RuntimeTools<'_>, - ) -> Validation<()> { - let language_servers: BTreeSet = self - .language_servers - .iter() - .map(|server| server.language_id.clone()) - .collect(); - crate::debug::adapters::validate_declarations( - &mut self.debug_adapters, - &crate::debug::adapters::DeclarationContext { - declared_settings, - language_servers: &language_servers, - runtime_tools, - }, - ) - } - - /// A language server the plugin backend launches asks the backend for its - /// command line, so the manifest must declare that backend. - fn validate_backend_launches(&self) -> Validation<()> { - if self.backend.is_none() - && self - .language_servers - .iter() - .any(|server| server.process.is_backend_launch()) - { - return Err(invalid( - "backend", - "A language server launched by the plugin backend requires the backend section.", - )); - } - Ok(()) - } - - fn validate_toolchains(&self) -> Validation<()> { - if self.tool_definitions.is_empty() && self.toolchain_providers.is_empty() { - return Ok(()); - } - crate::toolchain::definitions::validate_declarations( - &self.id, - &self.tool_definitions, - &self.toolchain_providers, - )?; - if !self.toolchain_providers.is_empty() { - self.validate_packaged_provider_backend()?; - } - Ok(()) - } - - /// Checks `pluginFunctions`: only the plugin backend registers functions - /// for the protocol that relays calls. - fn validate_plugin_functions(&mut self) -> Validation<()> { - let Some(section) = self.plugin_functions.as_mut() else { - return Ok(()); - }; - if self.backend.is_none() { - return Err(invalid( - "pluginFunctions", - "Exported and called plugin functions need the plugin backend.", - )); - } - super::plugin_functions::validate(section) - } - - /// Host-queried providers must start without relying on the discovery they provide. - fn validate_packaged_provider_backend(&self) -> Validation<()> { - let backend = self - .backend - .as_ref() - .ok_or_else(|| invalid("backend", "A provider requires the plugin backend."))?; - let value = serde_json::to_value(backend) - .map_err(|_| invalid("backend", "The backend cannot be encoded."))?; - let launch = &value["launch"]; - let packaged = launch["kind"] == "packaged" - || (launch["kind"] == "runtime" && launch["packagedRuntime"].as_str().is_some()); - if !packaged - || value["runtimeDependencies"] - .as_array() - .is_some_and(|values| !values.is_empty()) - { - return Err(invalid("backend.launch", "Provider backends must bootstrap from packaged executables or packaged runtimes without discovered dependencies.")); - } - Ok(()) - } - - fn validate_ui(&mut self) -> Validation<()> { - let Some(ui) = &mut self.ui else { - return Ok(()); - }; - if (ui.tool_windows.is_empty() && ui.commands.is_empty()) - || ui.tool_windows.len() > 64 - || ui.commands.len() > 128 - { - return Err(invalid( - "ui", - "UI declares a tool window or a command, with at most 64 tool windows and 128 commands.", - )); - } - let prefix = format!("{}.", self.id); - let mut ids = BTreeSet::new(); - let mut windows = BTreeSet::new(); - let mut action_ids = BTreeSet::new(); - for (index, window) in ui.tool_windows.iter().enumerate() { - if let Some(icon) = &window.icon { - icon.validate()?; - } - validate_window_body(window, index)?; - if let Some(source) = window - .native - .as_ref() - .and_then(|native| native.source.as_ref()) - { - if !self - .build_systems - .iter() - .any(|system| system.id == source.build_system_id) - { - return Err(invalid( - "ui.toolWindows.native.source", - "The native source must belong to this plugin's build systems.", - )); - } - } - if !is_plugin_id(&window.id) - || !window.id.starts_with(&prefix) - || window.title.trim().is_empty() - || window.title.len() > 256 - || !ids.insert(window.id.clone()) - { - return Err(invalid( - "ui.toolWindows", - "Invalid, duplicate or unowned tool-window ID, title or text.", - )); - } - windows.insert(window.id.clone()); - action_ids.insert(format!("{}.toggle", window.id)); - } - for command in &mut ui.commands { - if let Some(icon) = &command.icon { - icon.validate()?; - } - if !is_plugin_id(&command.id) - || !command.id.starts_with(&prefix) - || command.title.trim().is_empty() - || command.title.len() > 256 - || !ids.insert(command.id.clone()) - || !action_ids.insert(command.id.clone()) - || match &command.tool_window_id { - Some(id) => !windows.contains(id), - // A command without a window runs on the plugin backend. - None => self.backend.is_none(), - } - || command.placements.is_empty() - { - return Err(invalid( - "ui.commands", - "Invalid or duplicate command, placement or tool-window reference.", - )); - } - command.placements.sort(); - command.placements.dedup(); - } - // Placement entries share one command action, but their contribution - // IDs must not shadow an explicitly declared window or another action. - for command in &ui.commands { - for placement in &command.placements { - let suffix = match placement { - UICommandPlacement::CommandPalette => "commandPalette", - UICommandPlacement::Toolbar => "toolbar", - UICommandPlacement::Menu => "menu", - }; - if !ids.insert(format!("{}.{suffix}", command.id)) { - return Err(invalid( - "ui.commands", - "A command placement shadows another UI identity.", - )); - } - } - } - ui.tool_windows - .sort_by(|a, b| (a.order, &a.id).cmp(&(b.order, &b.id))); - ui.commands - .sort_by(|a, b| (a.order, &a.id).cmp(&(b.order, &b.id))); - Ok(()) - } - - fn validate_permissions(&mut self) -> Validation<()> { - for permission in &self.permissions { - if !KNOWN_PERMISSIONS.contains(&permission.as_str()) - && !crate::lsp::interface::plugin_function::HOST_PERMISSIONS - .contains(&permission.as_str()) - { - return Err(PluginRejection::at( - "plugin.permission.unknown", - "permissions", - format!("The plugin requests an unknown permission: {permission}."), - )); - } - } - self.permissions = sorted_unique(std::mem::take(&mut self.permissions)); - let mut required = BTreeSet::new(); - if let Some(ui) = &self.ui { - required.insert(PERMISSION_UI); - if ui.tool_windows.iter().any(|window| window.web.is_some()) { - required.insert(PERMISSION_UI_WEB); - } - } - // A provider process of its own is a server process like a language - // server, so it needs the same consent. - if !self.language_servers.is_empty() - || !self.vcs_providers.is_empty() - || self - .build_systems - .iter() - .any(crate::build::BuildSystemDeclaration::starts_own_provider) - { - required.insert(PERMISSION_LANGUAGE_SERVER); - } - for provider in &self.execution_providers { - if provider.run.is_some() { - required.insert(PERMISSION_RUN); - } - if provider.test.is_some() { - required.insert(PERMISSION_TEST); - } - } - if !self.build_systems.is_empty() { - required.insert(PERMISSION_BUILD); - } - if !self.toolchain_providers.is_empty() { - required.insert(PERMISSION_TOOLCHAINS); - } else if self - .permissions - .iter() - .any(|permission| permission == PERMISSION_TOOLCHAINS) - { - return Err(invalid( - "permissions", - "toolchains.provide requires a toolchain provider.", - )); - } - if !self.keymap_import_providers.is_empty() { - required.insert(PERMISSION_KEYMAPS); - required.insert(PERMISSION_KEYMAP_SOURCES); - } else if self.permissions.iter().any(|permission| { - permission == PERMISSION_KEYMAPS || permission == PERMISSION_KEYMAP_SOURCES - }) { - return Err(invalid( - "permissions", - "Shortcut import permissions require a shortcut import provider.", - )); - } - if !self.vcs_providers.is_empty() { - required.insert(PERMISSION_VCS); - } - if !self.debug_adapters.is_empty() { - required.insert(PERMISSION_DEBUG_LAUNCH); - } - if crate::debug::adapters::serves_attach(&self.debug_adapters) { - required.insert(PERMISSION_DEBUG_ATTACH); - } else if self - .permissions - .iter() - .any(|p| p == PERMISSION_DEBUG_ATTACH) - { - return Err(invalid( - "permissions", - "debug.attach is used by an adapter that serves attach; declare one or remove the permission.", - )); - } - if !self.project_generators.is_empty() { - required.insert(PERMISSION_PROJECT_GENERATE); - } - if self.backend.is_some() { - required.insert(PERMISSION_BACKEND); - } else if self.permissions.iter().any(|p| p == PERMISSION_CREDENTIALS) { - // Only a backend can use the store; listing the permission - // without one would show the user a grant nothing exercises. - return Err(invalid( - "permissions", - "credentials.store is used by a plugin backend; declare one or remove the permission.", - )); - } - // Only the plugin backend receives workspace contexts, so the grant - // the user reviewed would otherwise never be exercised. - if self - .permissions - .iter() - .any(|p| p == PERMISSION_WORKSPACE_METADATA) - && self.backend.is_none() - { - return Err(invalid( - "permissions", - "workspace.metadata is used by the plugin backend; declare one or remove the permission.", - )); - } - if self - .permissions - .iter() - .any(|p| crate::lsp::interface::plugin_function::HOST_PERMISSIONS.contains(&p.as_str())) - && self.backend.is_none() - { - return Err(invalid( - "permissions", - "Host API permissions require the plugin backend.", - )); - } - // Actions and providers both read the live document they annotate. - if self - .permissions - .iter() - .any(|permission| permission == "editor.actions" || permission == "editor.providers") - && !self - .permissions - .iter() - .any(|permission| permission == "documents.read") - { - return Err(invalid( - "permissions", - "Editor actions and providers require documents.read for live snapshots.", - )); - } - // `lithe/host/debug/start` names one of the plugin's own adapters, so - // the grant is useless without one. - if self - .permissions - .iter() - .any(|permission| permission == PERMISSION_DEBUG_START) - && self.debug_adapters.is_empty() - { - return Err(invalid( - "permissions", - "debug.start starts the plugin's own debug adapters; declare one or remove the permission.", - )); - } - // The backend reaches only the servers whose session fields expose - // them; the grant without one would show the user a capability - // nothing exercises. - let language_server_request = - crate::lsp::interface::plugin_function::PERMISSION_LANGUAGE_SERVER_REQUEST; - if self - .language_servers - .iter() - .any(|server| server.session.exposes_server()) - { - required.insert(language_server_request); - } else if self - .permissions - .iter() - .any(|permission| permission == language_server_request) - { - return Err(invalid( - "permissions", - "languageServer.request is used by a server that lists requestMethods or forwardNotifications; declare one or remove the permission.", - )); - } - for permission in required { - if !self - .permissions - .iter() - .any(|declared| declared == permission) - { - return Err(PluginRejection::at( - "plugin.permission.undeclared", - "permissions", - format!("The plugin uses {permission} without declaring the permission."), - )); - } - } - Ok(()) - } - - fn validate_activation_events(&mut self) -> Validation<()> { - let mut expected = BTreeSet::new(); - for server in &self.language_servers { - expected.insert(format!("onLanguage:{}", server.language_id)); - } - for provider in &self.execution_providers { - if provider.run.is_some() { - expected.insert(format!("onRun:{}", provider.language_id)); - } - if provider.test.is_some() { - expected.insert(format!("onTest:{}", provider.language_id)); - } - } - for build_system in &self.build_systems { - expected.insert(crate::build::activation_event(build_system)); - } - for provider in &self.vcs_providers { - expected.insert(crate::vcs::activation_event(provider)); - } - for adapter in &self.debug_adapters { - expected.insert(crate::debug::adapters::activation_event(adapter)); - } - if let Some(ui) = &self.ui { - for window in &ui.tool_windows { - expected.insert(format!("onView:{}", window.id)); - } - for command in &ui.commands { - expected.insert(format!("onCommand:{}", command.id)); - } - } - self.activation_events = sorted_unique(std::mem::take(&mut self.activation_events)); - let mut declared: BTreeSet = self.activation_events.iter().cloned().collect(); - // The backend may start once a workspace opens. The event is optional - // because pages and commands can start it too, so it is checked here - // and kept out of the exact provider match below. - if self.backend.is_some() { - declared.remove(super::backend::ON_WORKSPACE_OPEN); - } - // Every provider needs an event, and every event needs a provider: - // an event without one would promise activation the host cannot do. - if let Some(missing) = expected.difference(&declared).next() { - return Err(PluginRejection::at( - "plugin.activation.invalid", - "activationEvents", - format!("The plugin must declare the activation event {missing}."), - )); - } - if let Some(extra) = declared.difference(&expected).next() { - return Err(PluginRejection::at( - "plugin.activation.invalid", - "activationEvents", - format!("The activation event {extra} has no matching provider."), - )); - } - Ok(()) - } - - fn execution_provider(&self, language_id: &str) -> Option<&ExecutionProviderDeclaration> { - self.execution_providers - .iter() - .find(|provider| provider.language_id == language_id) - } -} - -impl DeclarativePluginManifest { - /// Stable plugin ID. - pub(crate) fn id(&self) -> &str { - &self.id - } - - /// Package-relative HTML entry of this plugin's web tool window - /// `window_id`, or `None` when the plugin declares no such web window. - pub(crate) fn web_window_entry(&self, window_id: &str) -> Option<&str> { - self.ui - .as_ref()? - .tool_windows - .iter() - .find(|window| window.id == window_id)? - .web - .as_ref() - .map(|web| web.entry.as_str()) - } - - /// Plugin version, `major.minor.patch`. - pub(crate) fn version(&self) -> &str { - &self.version - } - - /// The version of `capability` the manifest requires. Validation has - /// already checked that every used capability is listed. - pub(crate) fn capability_version(&self, capability: &str) -> u32 { - self.requires.get(capability).copied().unwrap_or_default() - } - - /// Name shown to the user. - pub(crate) fn display_name(&self) -> &str { - &self.display_name - } - - /// IDs of the languages the plugin declares. - pub(crate) fn language_ids(&self) -> impl Iterator { - self.languages.iter().map(|language| language.id.as_str()) - } - - /// Whether the plugin declares a definition that `tool` names. A - /// definition is addressed by its short ID or as `:`; both - /// spellings count as a declaration here. Whether the host adopts the - /// short ID is its registry's decision: when several enabled plugins define - /// the same short ID, it belongs to none of them. - pub(crate) fn declares_tool(&self, tool: &str) -> bool { - // `Some(local)` when `tool` is spelled `:`. - let local_name = tool - .strip_prefix(self.id.as_str()) - .and_then(|rest| rest.strip_prefix(':')); - self.tool_definitions - .iter() - .filter_map(|definition| definition["id"].as_str()) - .any(|id| tool == id || local_name == Some(id)) - } - - /// Validated project generators, in declaration order. - pub(crate) fn project_generators( - &self, - ) -> &[crate::project_generators::ProjectGeneratorDeclaration] { - &self.project_generators - } - - /// Normalized, sorted permission list. - pub(crate) fn permissions(&self) -> &[String] { - &self.permissions - } - - /// Capability versions the manifest requires. - pub(crate) fn requires(&self) -> &Requires { - &self.requires - } - - /// Validated build systems, sorted by ID. - pub(crate) fn build_systems(&self) -> &[crate::build::BuildSystemDeclaration] { - &self.build_systems - } - - /// Validated debug adapters, in declaration order. - pub(crate) fn debug_adapters(&self) -> &[crate::debug::adapters::DebugAdapterDeclaration] { - &self.debug_adapters - } - - /// Validated VCS providers, sorted by VCS ID. - pub(crate) fn vcs_providers(&self) -> &[crate::vcs::VcsProviderDeclaration] { - &self.vcs_providers - } - - /// Host services the plugin's backend may request, sorted; empty without - /// a backend. Each is granted by one permission. - pub(crate) fn backend_host_services(&self) -> Vec<&'static str> { - let mut services = Vec::new(); - if self.backend.is_none() { - return services; - } - if self.permissions.iter().any(|p| p == PERMISSION_CREDENTIALS) { - services.push(super::backend::HOST_SERVICE_CREDENTIALS); - } - if self - .permissions - .iter() - .any(|p| p == PERMISSION_WORKSPACE_METADATA) - { - services.push(super::backend::HOST_SERVICE_WORKSPACE); - } - for permission in &self.permissions { - services - .extend(crate::lsp::interface::plugin_function::methods_for_permission(permission)); - } - // Calling another plugin's exported function takes no permission: the - // declaration, and the receiver's own export, are the consent. The - // service lets the backend ask Core to find and call a provider. - if self - .plugin_functions - .as_ref() - .is_some_and(super::plugin_functions::PluginFunctionsDeclaration::calls_any) - { - services.push(crate::lsp::interface::plugin_function::HOST_SERVICE_FUNCTIONS); - } - services.sort_unstable(); - services.dedup(); - services - } - - /// Tool-definition IDs the plugin may start processes with, sorted. - pub(crate) fn required_tools(&self) -> &[String] { - &self.requires_tools - } - - /// Checks `requiresTools` and returns its IDs. Every tool a process names - /// is one of them. - fn validate_required_tools(&mut self) -> Validation> { - if self.requires_tools.is_empty() { - return Ok(BTreeSet::new()); - } - if self.requires_tools.len() > MAX_REQUIRED_TOOLS { - return Err(invalid( - "requiresTools", - format!("A plugin requires at most {MAX_REQUIRED_TOOLS} tools."), - )); - } - let mut tools = BTreeSet::new(); - for (index, tool) in self.requires_tools.iter().enumerate() { - if !crate::toolchain::definitions::tool_id(tool) { - return Err(invalid( - format!("requiresTools[{index}]"), - "A required tool is a short or plugin-qualified tool-definition ID.", - )); - } - if !tools.insert(tool.clone()) { - return Err(invalid( - format!("requiresTools[{index}]"), - "A tool is required more than once.", - )); - } - } - self.requires_tools = tools.iter().cloned().collect(); - Ok(tools) - } - - /// Checks the declared settings, sorts them by ID, and returns the IDs - /// launch templates may refer to. - fn validate_settings(&mut self) -> Validation { - self.validate_setting_actions()?; - if self.settings.is_empty() { - return Ok(DeclaredSettings::default()); - } - if self.settings.len() > MAX_SETTINGS { - return Err(invalid( - "settings", - "The plugin declares too many settings.", - )); - } - let mut ids = BTreeSet::new(); - for (index, setting) in self.settings.iter().enumerate() { - let field = format!("settings[{index}]"); - setting.validate(&field)?; - if !ids.insert(setting.id.clone()) { - return Err(invalid( - format!("{field}.id"), - "A setting is declared more than once.", - )); - } - } - self.settings.sort_by(|left, right| left.id.cmp(&right.id)); - Ok(super::settings::declared(&self.settings)) - } - - /// Checks the settings-page actions and sorts them by ID. - fn validate_setting_actions(&mut self) -> Validation<()> { - if self.setting_actions.is_empty() { - return Ok(()); - } - if self.setting_actions.len() > MAX_SETTING_ACTIONS { - return Err(invalid( - "settingActions", - "The plugin declares too many settings actions.", - )); - } - let mut ids = BTreeSet::new(); - for (index, action) in self.setting_actions.iter().enumerate() { - let field = format!("settingActions[{index}]"); - action.validate(&field)?; - if !ids.insert(action.id.clone()) { - return Err(invalid( - format!("{field}.id"), - "An action is declared more than once.", - )); - } - } - self.setting_actions - .sort_by(|left, right| left.id.cmp(&right.id)); - Ok(()) - } - - /// Strict executable selection preserves invalid stored values instead of - /// applying the UI settings resolver's tolerant fallback-to-default rule. - pub(crate) fn executable_setting_value( - &self, - id: &str, - values: &BTreeMap, - ) -> Validation> { - let setting = self - .settings - .iter() - .find(|setting| setting.id == id) - .ok_or_else(|| { - invalid( - "executables.settingID", - "The executable setting is not declared.", - ) - })?; - setting.executable_value(values.get(id)) - } - - /// Effective value of every declared setting and the layer it came from. - pub(crate) fn resolve_settings(&self, layers: SettingLayers<'_>) -> Vec { - super::settings::resolve(&self.settings, layers) - } - - /// Value of every declared setting for one launch; see - /// `super::settings::resolve` for the fallback order. - fn effective_settings(&self, layers: SettingLayers<'_>) -> BTreeMap { - super::settings::effective_values(&self.settings, layers) - } - - /// Every program the host may start on this plugin's behalf, in a stable - /// order. Each entry carries everything that decides what actually runs: - /// the executable plus its arguments, the language server's validation - /// probe, and the run/test argument templates. An update that changes any - /// of these, even with the same executable, is a new program and must be - /// approved again. - pub(crate) fn programs(&self) -> Vec { - let mut programs = Vec::new(); - for server in &self.language_servers { - let mut program = server.process.program(ServerRole::LanguageServer); - program["languageID"] = json!(server.language_id); - // Integration resources are files host code loads or runs for - // the plugin, so repointing one is a new program as well. - if let Some(integration) = &server.host_integration { - program["hostIntegration"] = json!(integration); - } - programs.push(program); - programs.extend(server.process.runtime_programs()); - } - for provider in &self.execution_providers { - if let Some(run) = &provider.run { - programs.push(json!({ - "kind": "run", - "languageID": provider.language_id, - "executable": run.executable, - "arguments": run.arguments, - })); - } - if let Some(test) = &provider.test { - programs.push(json!({ - "kind": "test", - "languageID": provider.language_id, - "executable": test.executable, - "workspaceArguments": test.workspace_arguments, - "fileArguments": test.file_arguments, - "testCaseArguments": test.test_case_arguments, - })); - } - } - for build_system in &self.build_systems { - programs.extend(crate::build::programs(build_system)); - } - for provider in &self.vcs_providers { - programs.extend(crate::vcs::programs(provider)); - } - for adapter in &self.debug_adapters { - programs.extend(crate::debug::adapters::programs(adapter)); - } - for generator in &self.project_generators { - programs.extend(crate::project_generators::programs(generator)); - } - if let Some(backend) = &self.backend { - programs.extend(super::backend::programs(backend)); - } - // Order by kind, then language or tool, then the full serialized - // value as a total tie-breaker; duplicates (two servers needing the - // same runtime) collapse to one entry. - programs.sort_by_key(|program| { - let text = |key: &str| program[key].as_str().unwrap_or_default().to_string(); - ( - text("kind"), - text("languageID"), - text("tool"), - program.to_string(), - ) - }); - programs.dedup(); - programs - } -} - -impl LanguageDeclaration { - fn normalize(&mut self, index: usize) -> Validation<()> { - let field = format!("languages[{index}]"); - if let Some(grammar) = &self.grammar { - grammar.validate(&format!("{field}.grammar"))?; - } - if let Some(configuration) = &self.configuration { - configuration.validate(&format!("{field}.configuration"))?; - } - let id = self.id.trim().to_lowercase(); - if id.is_empty() - || id != self.id - || !id.chars().all(|character| { - character.is_ascii_lowercase() - || character.is_ascii_digit() - || "+-_".contains(character) - }) - { - return Err(invalid( - format!("{field}.id"), - "The language ID must be lowercase.", - )); - } - if self.display_name.trim().is_empty() { - return Err(invalid( - format!("{field}.displayName"), - "The language display name is empty.", - )); - } - self.file_extensions = normalized_extensions(&self.file_extensions); - self.file_names = normalized_names(&self.file_names); - self.project_file_names = normalized_names(&self.project_file_names); - if self.file_extensions.is_empty() - && self.file_names.is_empty() - && self.project_file_names.is_empty() - { - return Err(invalid( - field, - "The language declares no file extensions or file names.", - )); - } - if self - .file_extensions - .iter() - .chain(&self.file_names) - .chain(&self.project_file_names) - .any(|name| name.contains('/') || name.contains('\\')) - { - return Err(invalid( - field, - "Language file names must not contain path separators.", - )); - } - if let Some(dependencies) = &mut self.dependencies { - dependencies.management_file_names = - normalized_names(&dependencies.management_file_names); - dependencies.project_dependency_paths = - sorted_unique(dependencies.project_dependency_paths.clone()); - dependencies.project_binary_paths = - sorted_unique(dependencies.project_binary_paths.clone()); - if dependencies - .management_file_names - .iter() - .chain(&dependencies.project_dependency_paths) - .chain(&dependencies.project_binary_paths) - .any(|path| !is_safe_relative_path(path)) - { - return Err(invalid( - format!("{field}.dependencies"), - "Dependency paths must stay inside the project.", - )); - } - } - Ok(()) - } -} - -impl LanguageServerDeclaration { - fn validate( - &self, - field: &str, - declared_settings: &DeclaredSettings, - runtime_tools: RuntimeTools<'_>, - ) -> Validation<()> { - self.process.validate( - field, - declared_settings, - ServerRole::LanguageServer, - runtime_tools, - )?; - self.session.validate(field)?; - if self.process.is_backend_launch() && self.host_integration.is_some() { - return Err(invalid( - format!("{field}.hostIntegration"), - "A language server started through the plugin backend has no host integration.", - )); - } - if let Some(configuration) = &self.workspace_configuration { - let field = format!("{field}.workspaceConfiguration"); - if !configuration.is_object() { - return Err(invalid(field, "workspaceConfiguration is a JSON object.")); - } - validate_launch_value( - configuration, - &declared_settings.for_configuration(), - &field, - )?; - // The host re-expands it on every change without a launch, so - // only setting values may vary. - if mentions_launch_directory(configuration) { - return Err(template( - field, - "workspaceConfiguration may only use ${setting:} variables.", - )); - } - } - if let Some(identifier) = &self.language_identifier { - if identifier.trim().is_empty() { - return Err(invalid( - format!("{field}.languageIdentifier"), - "The LSP language identifier is empty.", - )); - } - } - if let Some(integration) = &self.host_integration { - self.validate_host_integration(integration, field)?; - } - Ok(()) - } - - fn validate_host_integration( - &self, - integration: &HostIntegrationDeclaration, - field: &str, - ) -> Validation<()> { - let field = format!("{field}.hostIntegration"); - if !KNOWN_HOST_INTEGRATIONS.contains(&integration.id.as_str()) { - return Err(PluginRejection::at( - "plugin.integration.unsupported", - format!("{field}.id"), - format!("The host has no integration named {}.", integration.id), - )); - } - // (capability, resources the host code behind it reads); empty - // because no integration exists. - let capabilities: &[(&str, &[&str])] = &[]; - for name in &integration.capabilities { - let capabilities_field = format!("{field}.capabilities"); - let Some((_, needed)) = capabilities.iter().find(|(known, _)| known == name) else { - return Err(invalid( - capabilities_field, - format!("The integration does not provide the capability {name}."), - )); - }; - if let Some(missing) = needed - .iter() - .find(|resource| !integration.resources.contains_key(**resource)) - { - return Err(invalid( - capabilities_field, - format!("The capability {name} requires the resource {missing}."), - )); - } - } - // (name, required, accepts a list); empty because no integration exists. - let known: &[(&str, bool, bool)] = &[]; - for (name, paths) in &integration.resources { - let resource_field = format!("{field}.resources.{name}"); - let Some((_, _, accepts_list)) = known.iter().find(|(known, _, _)| known == name) - else { - return Err(invalid( - resource_field, - "The integration does not use this resource.", - )); - }; - if matches!(paths, ResourcePaths::Many(_)) != *accepts_list { - return Err(invalid( - resource_field, - "The resource has the wrong shape for this integration.", - )); - } - if paths - .paths() - .iter() - .any(|path| !is_packaged_runtime_path(path)) - { - return Err(invalid( - resource_field, - "Integration resources must be paths inside the plugin package that vary only by ${arch}.", - )); - } - } - if let Some((name, _, _)) = known - .iter() - .find(|(name, required, _)| *required && !integration.resources.contains_key(*name)) - { - return Err(invalid( - format!("{field}.resources"), - format!("The integration requires the resource {name}."), - )); - } - Ok(()) - } -} - -impl ServerProcess { - /// Package files the launch starts, as (launch field, package-relative - /// path): a `packaged` executable, or a `runtime` entry and its shipped - /// runtime. A `${arch}` runtime path varies per machine, so only the - /// installer can resolve it and it is left out. - pub(crate) fn packaged_files(&self) -> Vec<(&'static str, &str)> { - match &self.launch { - LanguageServerLaunch::Packaged { path } => vec![("path", path)], - LanguageServerLaunch::Runtime { - entry, - packaged_runtime, - .. - } => { - let mut files = vec![("entry", entry.as_str())]; - if let Some(runtime) = packaged_runtime - .as_deref() - .filter(|path| !path.contains("${")) - { - files.push(("packagedRuntime", runtime)); - } - files - } - LanguageServerLaunch::SystemCommand { .. } | LanguageServerLaunch::Backend { .. } => { - Vec::new() - } - } - } - - /// Whether the plugin backend resolves the command line at start. - pub(crate) fn is_backend_launch(&self) -> bool { - matches!(self.launch, LanguageServerLaunch::Backend { .. }) - } - - /// Checks the launch, its arguments and probe, the initialization - /// options, and the runtime dependencies. Arguments and options are - /// launch templates. - pub(crate) fn validate( - &self, - field: &str, - declared_settings: &DeclaredSettings, - role: ServerRole, - runtime_tools: RuntimeTools<'_>, - ) -> Validation<()> { - let noun = role.noun(); - match &self.launch { - LanguageServerLaunch::Packaged { path } => { - if !is_safe_relative_path(path) { - return Err(invalid( - format!("{field}.launch.path"), - format!("The packaged {noun} must stay inside the plugin package."), - )); - } - } - LanguageServerLaunch::SystemCommand { commands } => { - if commands.is_empty() || commands.iter().any(|command| !is_bare_command(command)) { - return Err(invalid( - format!("{field}.launch.commands"), - "System commands must be bare executable names.", - )); - } - } - LanguageServerLaunch::Runtime { - tool, - minimum_version, - packaged_runtime, - runtime_arguments, - entry, - } => { - runtime_tools.check(tool, format!("{field}.launch.tool"))?; - if let Some(version) = minimum_version { - validate_minimum_version(version, &format!("{field}.launch.minimumVersion"))?; - } - if let Some(path) = packaged_runtime { - let runtime_field = format!("{field}.launch.packagedRuntime"); - if !is_packaged_runtime_path(path) { - return Err(invalid( - runtime_field, - "The packaged runtime must be a path inside the plugin package that varies only by ${arch}.", - )); - } - // The plugin ships the runtime it needs, so there is no - // discovered candidate whose version could be checked. - if minimum_version.is_some() { - return Err(invalid( - runtime_field, - "A packaged runtime cannot also state a minimum version.", - )); - } - } - if !is_safe_relative_path(entry) { - return Err(invalid( - format!("{field}.launch.entry"), - format!("The {noun} entry must stay inside the plugin package."), - )); - } - validate_launch_templates( - runtime_arguments, - LaunchTemplateContext::Start, - declared_settings, - &format!("{field}.launch.runtimeArguments"), - )?; - // The host's probe of a runtime is its version check; a second - // probe would have no defined command line. - if !self.validation_arguments.is_empty() { - return Err(invalid( - format!("{field}.validationArguments"), - "A runtime launch is verified by its minimum version, not by a probe.", - )); - } - } - LanguageServerLaunch::Backend { - function, - timeout_milliseconds, - } => { - return self.validate_backend_launch(field, function, *timeout_milliseconds, role); - } - } - if self - .arguments - .iter() - .chain(&self.validation_arguments) - .any(|argument| argument.contains('\0')) - { - return Err(invalid( - format!("{field}.arguments"), - format!("{} arguments must not contain NUL.", capitalized(noun)), - )); - } - validate_launch_templates( - &self.arguments, - LaunchTemplateContext::Start, - declared_settings, - &format!("{field}.arguments"), - )?; - validate_launch_templates( - &self.validation_arguments, - LaunchTemplateContext::ValidationProbe, - declared_settings, - &format!("{field}.validationArguments"), - )?; - if let Some(options) = &self.initialization_options { - let options_field = format!("{field}.initializationOptions"); - if !options.is_object() { - return Err(invalid( - options_field, - "initializationOptions must be a JSON object.", - )); - } - validate_launch_value(options, declared_settings, &options_field)?; - } - for dependency in &self.runtime_dependencies { - let dependencies_field = format!("{field}.runtimeDependencies"); - runtime_tools.check(&dependency.tool, dependencies_field.clone())?; - // The launch runtime is already discovered for the process, and - // only the launch declaration can state its version requirement. - if matches!(&self.launch, LanguageServerLaunch::Runtime { tool, .. } if *tool == dependency.tool) - { - return Err(invalid( - dependencies_field, - "The launch runtime must not be repeated as a runtime dependency.", - )); - } - } - Ok(()) - } - - /// Checks a launch the plugin backend resolves. Only a language server - /// may use it, and its command line, probe, options, and runtimes all - /// come from the backend's answer, so the manifest declares none of them. - fn validate_backend_launch( - &self, - field: &str, - function: &str, - timeout_milliseconds: Option, - role: ServerRole, - ) -> Validation<()> { - if role != ServerRole::LanguageServer { - return Err(invalid( - format!("{field}.launch.kind"), - format!( - "A {} cannot be started through the plugin backend.", - role.noun() - ), - )); - } - if !crate::lsp::interface::plugin_function::is_function_id(function) { - return Err(invalid( - format!("{field}.launch.function"), - "The launch function must be a plugin function ID.", - )); - } - language_server_sessions::validate_resolve_timeout( - timeout_milliseconds, - &format!("{field}.launch.timeoutMilliseconds"), - )?; - let declared = [ - ("arguments", !self.arguments.is_empty()), - ("validationArguments", !self.validation_arguments.is_empty()), - ("runtimeDependencies", !self.runtime_dependencies.is_empty()), - ( - "initializationOptions", - self.initialization_options.is_some(), - ), - ]; - if let Some((name, _)) = declared.iter().find(|(_, present)| *present) { - return Err(invalid( - format!("{field}.{name}"), - "A backend launch takes its command line and options from the backend's answer.", - )); - } - Ok(()) - } - - /// Install-review entry of the process, without its owner's ID: the - /// executable, its arguments and probe, and the initialization options. - pub(crate) fn program(&self, role: ServerRole) -> Value { - let suffix = match role { - ServerRole::LanguageServer => "LanguageServer", - ServerRole::BuildProvider => "BuildProvider", - ServerRole::VcsProvider => "VcsProvider", - ServerRole::Backend => "Backend", - ServerRole::DebugAdapter => "DebugAdapter", - }; - let mut program = match &self.launch { - LanguageServerLaunch::Packaged { path } => json!({ - "kind": format!("packaged{suffix}"), - "path": path, - "arguments": self.arguments, - "validationArguments": self.validation_arguments, - }), - LanguageServerLaunch::SystemCommand { commands } => json!({ - "kind": format!("system{suffix}"), - "commands": commands, - "arguments": self.arguments, - "validationArguments": self.validation_arguments, - }), - LanguageServerLaunch::Runtime { - tool, - packaged_runtime, - runtime_arguments, - entry, - .. - } => json!({ - "kind": format!("runtime{suffix}"), - "tool": tool, - "packagedRuntime": packaged_runtime, - "entry": entry, - "runtimeArguments": runtime_arguments, - "arguments": self.arguments, - "validationArguments": self.validation_arguments, - }), - // The backend chooses the command line when the server starts, - // so the review shows the function that will choose it. - LanguageServerLaunch::Backend { - function, - timeout_milliseconds, - } => json!({ - "kind": format!("backend{suffix}"), - "function": function, - "timeoutMilliseconds": timeout_milliseconds - .unwrap_or(language_server_sessions::RESOLVE_TIMEOUT_MILLISECONDS), - }), - }; - // Initialization options can name code the server loads (bundles or - // extensions, for example), so changing them is a new program too. - if let Some(options) = &self.initialization_options { - program["initializationOptions"] = options.clone(); - } - program - } - - /// Whether any start template names `${workspaceStateDir}`, which only - /// a process bound to one workspace can be given. - pub(crate) fn uses_workspace_state_directory(&self) -> bool { - let runtime_arguments = match &self.launch { - LanguageServerLaunch::Runtime { - runtime_arguments, .. - } => runtime_arguments.as_slice(), - _ => &[], - }; - let mentions = |text: &str| { - parse_template_with(text, LaunchVariable::parse) - .unwrap_or_default() - .iter() - .any(|part| { - matches!( - part, - TemplatePart::Variable(LaunchVariable::WorkspaceStateDir) - ) - }) - }; - fn strings<'a>(value: &'a Value, out: &mut Vec<&'a str>) { - match value { - Value::String(text) => out.push(text), - Value::Array(items) => items.iter().for_each(|item| strings(item, out)), - Value::Object(members) => members.values().for_each(|item| strings(item, out)), - _ => {} - } - } - let mut option_strings = Vec::new(); - if let Some(options) = &self.initialization_options { - strings(options, &mut option_strings); - } - self.arguments - .iter() - .chain(runtime_arguments) - .map(String::as_str) - .chain(option_strings) - .any(mentions) - } - - /// Install-review entries of the runtimes the host discovers for it. - pub(crate) fn runtime_programs(&self) -> Vec { - self.runtime_requirements() - .into_iter() - .map(|mut runtime| { - runtime["kind"] = json!("runtime"); - runtime - }) - .collect() - } - - /// Runtimes the host must discover for this process, sorted by tool. The - /// launch runtime comes first in declaration terms but is merged here so - /// hosts resolve every tool the same way. Every entry names a tool - /// definition, so every entry carries `kind: "tool"`. - fn runtime_requirements(&self) -> Vec { - let mut requirements: Vec<(&str, Option<&str>)> = self - .runtime_dependencies - .iter() - .map(|dependency| (dependency.tool.as_str(), None)) - .collect(); - // A packaged runtime is part of the plugin, not a host requirement. - if let LanguageServerLaunch::Runtime { - tool, - minimum_version, - packaged_runtime: None, - .. - } = &self.launch - { - requirements.push((tool.as_str(), minimum_version.as_deref())); - } - requirements.sort(); - requirements.dedup(); - requirements - .into_iter() - .map(|(tool, minimum_version)| { - let mut requirement = json!({ "tool": tool, "kind": "tool" }); - if let Some(version) = minimum_version { - requirement["minimumVersion"] = json!(version); - } - requirement - }) - .collect() - } -} - -fn capitalized(text: &str) -> String { - let mut characters = text.chars(); - characters - .next() - .map(|first| first.to_uppercase().chain(characters).collect()) - .unwrap_or_default() -} - -/// A packaged runtime path is package-relative text whose only variable is -/// `${arch}`, so one package can carry a runtime per architecture. -fn is_packaged_runtime_path(value: &str) -> bool { - let Some(parts) = parse_template_with(value, LaunchVariable::parse) else { - return false; - }; - parts.iter().all(|part| { - matches!( - part, - TemplatePart::Text(_) | TemplatePart::Variable(LaunchVariable::Arch) - ) - }) && is_safe_relative_path(&value.replace("${arch}", "arch")) -} - -/// A window body is literal text, one package HTML page, or host-rendered -/// native content. -fn validate_window_body(window: &UIToolWindow, index: usize) -> Validation<()> { - let field = format!("ui.toolWindows[{index}]"); - if let Some(native) = &window.native { - if window.text.is_some() || window.web.is_some() { - return Err(invalid( - &field, - "Native content cannot coexist with text or web.", - )); - } - return native - .validate() - .map_err(|message| invalid(&field, message)); - } - match (&window.text, &window.web) { - (Some(text), None) if text.len() <= 65536 => Ok(()), - (Some(_), None) => Err(invalid( - format!("{field}.text"), - "The window text is too long.", - )), - (None, Some(web)) => { - let entry = &web.entry; - let valid = is_safe_relative_path(entry) - && entry.len() <= 256 - && !entry.contains(':') - && !entry.chars().any(char::is_control) - && entry.split('/').all(|part| !part.is_empty() && part != ".") - && entry.ends_with(".html"); - if valid { - Ok(()) - } else { - Err(invalid( - format!("{field}.web.entry"), - "The web entry must be a package-relative .html file.", - )) - } - } - _ => Err(invalid( - field, - "A tool window shows exactly one of text and web content.", - )), - } -} - -/// A minimum version is one to three dot-separated numbers; hosts compare it -/// numerically against the leading components of the runtime's own version. -pub(crate) fn validate_minimum_version(value: &str, field: &str) -> Validation<()> { - let parts: Vec<&str> = value.split('.').collect(); - let numeric = parts.iter().all(|part| { - !part.is_empty() - && part.len() <= 9 - && part.chars().all(|character| character.is_ascii_digit()) - }); - if numeric && parts.len() <= 3 { - Ok(()) - } else { - Err(invalid( - field.to_string(), - "A minimum version must be one to three dot-separated numbers.", - )) - } -} - -/// Checks the start and probe arguments of a process that has no launch of -/// its own to validate, such as a debug adapter on the selected runtime. -pub(crate) fn validate_process_arguments( - arguments: &[String], - validation_arguments: &[String], - declared_settings: &DeclaredSettings, - field: &str, -) -> Validation<()> { - if arguments - .iter() - .chain(validation_arguments) - .any(|argument| argument.contains('\0')) - { - return Err(invalid( - format!("{field}.arguments"), - "Process arguments must not contain NUL.", - )); - } - validate_launch_templates( - arguments, - LaunchTemplateContext::Start, - declared_settings, - &format!("{field}.arguments"), - )?; - validate_launch_templates( - validation_arguments, - LaunchTemplateContext::ValidationProbe, - declared_settings, - &format!("{field}.validationArguments"), - ) -} - -fn validate_launch_templates( - values: &[String], - context: LaunchTemplateContext, - declared_settings: &DeclaredSettings, - field: &str, -) -> Validation<()> { - for (index, value) in values.iter().enumerate() { - validate_launch_template( - value, - context, - declared_settings, - &format!("{field}[{index}]"), - )?; - } - Ok(()) -} - -/// Collects setting IDs referenced by every template string in a JSON value. -fn collect_setting_references(value: &Value, references: &mut BTreeSet) { - match value { - Value::String(text) => { - if let Some(parts) = parse_template_with(text, LaunchVariable::parse) { - for part in parts { - if let TemplatePart::Variable(LaunchVariable::Setting(id)) = part { - references.insert(id); - } - } - } - } - Value::Array(items) => { - for item in items { - collect_setting_references(item, references); - } - } - Value::Object(members) => { - for item in members.values() { - collect_setting_references(item, references); - } - } - _ => {} - } -} - -/// Checks every string inside a JSON value as a start-time launch template. -fn validate_launch_value( - value: &Value, - declared_settings: &DeclaredSettings, - field: &str, -) -> Validation<()> { - match value { - Value::String(text) => { - validate_launch_template(text, LaunchTemplateContext::Start, declared_settings, field) - } - Value::Array(items) => items.iter().enumerate().try_for_each(|(index, item)| { - validate_launch_value(item, declared_settings, &format!("{field}[{index}]")) - }), - Value::Object(members) => members.iter().try_for_each(|(key, item)| { - validate_launch_value(item, declared_settings, &format!("{field}.{key}")) - }), - _ => Ok(()), - } -} - -fn validate_launch_template( - value: &str, - context: LaunchTemplateContext, - declared_settings: &DeclaredSettings, - field: &str, -) -> Validation<()> { - if value.contains('\0') { - return Err(template( - field.to_string(), - "Launch values must not contain NUL.", - )); - } - let Some(parts) = parse_template_with(value, LaunchVariable::parse) else { - return Err(template( - field.to_string(), - "The value uses an unknown or unterminated template variable.", - )); - }; - let mut package_reference = false; - for (index, part) in parts.iter().enumerate() { - match part { - // A setting must exist, may not shape a package path, and is not - // available to the probe, which runs before settings are read. - TemplatePart::Variable(LaunchVariable::Setting(id)) => { - if context == LaunchTemplateContext::ValidationProbe { - return Err(template( - field.to_string(), - "The validation probe cannot use settings.", - )); - } - if declared_settings.is_immediate(id) { - return Err(template( - field.to_string(), - format!( - "The setting {id} applies immediately, so only workspaceConfiguration may use it." - ), - )); - } - if !declared_settings.contains(id) { - return Err(template( - field.to_string(), - format!("The value refers to the undeclared setting {id}."), - )); - } - // Text and paths are a whole value, or the value of one - // `name=` argument, never part of a longer one. - if declared_settings.requires_whole_value(id) { - let whole = match parts.as_slice() { - [_] => true, - [TemplatePart::Text(prefix), _] => prefix.ends_with('='), - _ => false, - }; - if !whole || index + 1 != parts.len() { - return Err(template( - field.to_string(), - format!( - "The text or path setting {id} must be a whole value or follow a name= prefix." - ), - )); - } - } - if package_reference { - return Err(template( - field.to_string(), - "A package path may only vary by ${arch}.", - )); - } - } - TemplatePart::Variable( - LaunchVariable::StateDir - | LaunchVariable::WorkspaceStateDir - | LaunchVariable::LogDir, - ) if context == LaunchTemplateContext::ValidationProbe => { - return Err(template( - field.to_string(), - "The validation probe runs without a workspace and cannot use state directories.", - )); - } - // Everything after `${pluginDir}` is a package path the host - // verifies before launch, so it has to be `/`-rooted text and may - // only vary by architecture. - TemplatePart::Variable(LaunchVariable::PluginDir) => { - let rooted = matches!( - parts.get(index + 1), - Some(TemplatePart::Text(text)) if text.starts_with('/') && text.len() > 1 - ); - if package_reference || !rooted { - return Err(template( - field.to_string(), - "${pluginDir} must be used once and be followed by a path inside the package.", - )); - } - package_reference = true; - } - TemplatePart::Variable(variable) - if package_reference && *variable != LaunchVariable::Arch => - { - return Err(template( - field.to_string(), - "A package path may only vary by ${arch}.", - )); - } - TemplatePart::Text(text) - if package_reference - && (text.contains('\\') - || text.split('/').any(|component| component == "..")) => - { - return Err(template( - field.to_string(), - "A package path must stay inside the plugin package.", - )); - } - _ => {} - } - } - Ok(()) -} - -impl ExecutionProviderDeclaration { - fn normalize(&mut self, field: &str) -> Validation<()> { - if self.run.is_none() && self.test.is_none() { - return Err(invalid( - field.to_string(), - "The execution provider declares neither run nor test.", - )); - } - if let Some(run) = &self.run { - validate_executable(&run.executable, &format!("{field}.run.executable"))?; - validate_arguments( - &run.arguments, - TemplateContext::Run, - &format!("{field}.run.arguments"), - )?; - } - if let Some(test) = &mut self.test { - let test_field = format!("{field}.test"); - if !is_identifier(&test.framework_id) { - return Err(invalid( - format!("{test_field}.frameworkID"), - "The test framework ID is invalid.", - )); - } - if test.workspace_label.trim().is_empty() { - return Err(invalid( - format!("{test_field}.workspaceLabel"), - "The workspace test label is empty.", - )); - } - test.project_markers = normalized_names(&test.project_markers); - test.test_file_patterns = normalized_names(&test.test_file_patterns); - if test.project_markers.is_empty() - || test.project_markers.iter().any(|name| name.contains('/')) - { - return Err(invalid( - format!("{test_field}.projectMarkers"), - "Project markers must be file names.", - )); - } - if test.test_file_patterns.is_empty() - || test.test_file_patterns.iter().any(|pattern| { - pattern.contains('/') || pattern.contains('?') || pattern.contains('[') - }) - { - return Err(invalid( - format!("{test_field}.testFilePatterns"), - "Test file patterns must be file names using only *.", - )); - } - validate_executable(&test.executable, &format!("{test_field}.executable"))?; - validate_arguments( - &test.workspace_arguments, - TemplateContext::TestWorkspace, - &format!("{test_field}.workspaceArguments"), - )?; - validate_arguments( - &test.file_arguments, - TemplateContext::TestFile, - &format!("{test_field}.fileArguments"), - )?; - validate_arguments( - &test.test_case_arguments, - TemplateContext::TestCase, - &format!("{test_field}.testCaseArguments"), - )?; - let names_test = test.test_case_arguments.iter().any(|argument| { - parse_template(argument.value()).is_some_and(|parts| { - parts.iter().any(|part| { - matches!( - part, - TemplatePart::Variable( - TemplateVariable::TestName | TemplateVariable::TestNameRegex - ) - ) - }) - }) - }); - if !names_test { - return Err(template( - format!("{test_field}.testCaseArguments"), - "The test case command must reference the test name.", - )); - } - } - Ok(()) - } -} - -impl ArgumentTemplate { - fn value(&self) -> &str { - match self { - Self::Literal(value) | Self::Conditional { value, .. } => value, - } - } - - fn condition(&self) -> Option { - match self { - Self::Literal(_) => None, - Self::Conditional { when, .. } => Some(*when), - } - } -} - -fn validate_executable(executable: &ExecutableDeclaration, field: &str) -> Validation<()> { - match (&executable.command, &executable.toolchain) { - (Some(command), None) if is_bare_command(command) => Ok(()), - (None, Some(toolchain)) if is_identifier(toolchain) => Ok(()), - _ => Err(invalid( - field.to_string(), - "The executable must be exactly one bare command or toolchain ID.", - )), - } -} - -fn validate_arguments( - arguments: &[ArgumentTemplate], - context: TemplateContext, - field: &str, -) -> Validation<()> { - for (index, argument) in arguments.iter().enumerate() { - let element = format!("{field}[{index}]"); - let value = argument.value(); - if value.contains('\0') { - return Err(template(element, "Arguments must not contain NUL.")); - } - let Some(parts) = parse_template(value) else { - return Err(template( - element, - "The argument uses an unknown or unterminated template variable.", - )); - }; - let condition = argument.condition(); - if condition.is_some() && context != TemplateContext::TestCase { - return Err(template( - element, - "Conditional arguments are only allowed in test case commands.", - )); - } - for part in &parts { - let TemplatePart::Variable(variable) = part else { - continue; - }; - if !context.allows(*variable) { - return Err(template( - element, - "The argument uses a variable that is not available in this command.", - )); - } - if *variable == TemplateVariable::UserArguments && parts.len() != 1 { - return Err(template( - element, - "${userArguments} must be a whole argument.", - )); - } - // A test case may run without a selected file, so file variables - // must be guarded instead of expanding to an empty argument. - if context == TemplateContext::TestCase - && variable.needs_file() - && condition != Some(ArgumentCondition::FileSelected) - { - return Err(template( - element, - "File variables in a test case command require \"when\": \"fileSelected\".", - )); - } - } - } - Ok(()) -} - -// MARK: Plans - -/// A run request for one workspace-relative file. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct RunPlanRequest { - relative_file_path: String, - #[serde(default)] - arguments: Vec, -} - -/// A test discovery request over the workspace's relative file list. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TestDiscoveryRequest { - relative_project_file_paths: Vec, -} - -/// A test launch request for a workspace, file, or single test case. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TestPlanRequest { - scope: TestScope, - relative_project_file_paths: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(tag = "kind", rename_all = "camelCase")] -enum TestScope { - Workspace, - File { - #[serde(rename = "relativeFilePath")] - relative_file_path: String, - }, - TestCase { - identifier: String, - #[serde(default, rename = "relativeFilePath")] - relative_file_path: Option, - }, -} - -/// Values substituted into one command template. -struct ExpansionValues<'a> { - file: Option<&'a str>, - test_name: Option<&'a str>, - user_arguments: &'a [String], -} - -fn run_plan( - manifest: &DeclarativePluginManifest, - language_id: &str, - request: &RunPlanRequest, -) -> Validation { - let run = manifest - .execution_provider(language_id) - .and_then(|provider| provider.run.as_ref()) - .ok_or_else(|| unavailable(language_id, "run"))?; - let path = checked_path(&request.relative_file_path)?; - let arguments = expand( - &run.arguments, - &ExpansionValues { - file: Some(&path), - test_name: None, - user_arguments: &request.arguments, - }, - ); - Ok(json!({ - "executable": run.executable, - "arguments": arguments, - "workingDirectory": "." - })) -} - -fn discover_tests( - manifest: &DeclarativePluginManifest, - language_id: &str, - request: &TestDiscoveryRequest, -) -> Validation { - let test = test_template(manifest, language_id)?; - let paths = checked_paths(&request.relative_project_file_paths)?; - if !supports_project(test, &paths) { - return Ok(json!([])); - } - let mut files: Vec<&String> = paths - .iter() - .filter(|path| { - let name = file_name(path).to_lowercase(); - test.test_file_patterns - .iter() - .any(|pattern| matches_pattern(pattern, &name)) - }) - .collect(); - files.sort(); - files.dedup(); - let mut items = vec![json!({ - "id": format!("{language_id}:workspace"), - "label": test.workspace_label, - "kind": "workspace" - })]; - items.extend(files.into_iter().map(|path| { - json!({ - "id": format!("{language_id}:file:{path}"), - "label": path, - "kind": "file", - "relativeFilePath": path - }) - })); - Ok(Value::Array(items)) -} - -fn test_plan( - manifest: &DeclarativePluginManifest, - language_id: &str, - request: &TestPlanRequest, -) -> Validation { - let test = test_template(manifest, language_id)?; - let paths = checked_paths(&request.relative_project_file_paths)?; - if !supports_project(test, &paths) { - return Err(PluginRejection::new( - "plugin.test.unsupportedProject", - format!("The workspace is not a supported {language_id} test project."), - )); - } - let (label, arguments) = match &request.scope { - TestScope::Workspace => ( - test.workspace_label.clone(), - expand( - &test.workspace_arguments, - &ExpansionValues { - file: None, - test_name: None, - user_arguments: &[], - }, - ), - ), - TestScope::File { relative_file_path } => { - let path = checked_path(relative_file_path)?; - let arguments = expand( - &test.file_arguments, - &ExpansionValues { - file: Some(&path), - test_name: None, - user_arguments: &[], - }, - ); - (file_name(&path).to_string(), arguments) - } - TestScope::TestCase { - identifier, - relative_file_path, - } => { - let name = identifier.trim(); - if name.is_empty() || name.contains('\n') || name.contains('\r') { - return Err(PluginRejection::new( - "plugin.test.invalidIdentifier", - "The selected test identifier is invalid.", - )); - } - let path = relative_file_path - .as_deref() - .map(checked_path) - .transpose()?; - let arguments = expand( - &test.test_case_arguments, - &ExpansionValues { - file: path.as_deref(), - test_name: Some(name), - user_arguments: &[], - }, - ); - (name.to_string(), arguments) - } - }; - Ok(json!({ - "label": label, - "frameworkID": test.framework_id, - "executable": test.executable, - "arguments": arguments, - "workingDirectory": "." - })) -} - -/// Host facts substituted into a language server's launch templates. The -/// host owns every directory; Core only joins text and never touches disk. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct LanguageServerLaunchPlanRequest { - /// Absolute root of the installed plugin package. - plugin_directory: String, - /// Absolute writable directory the host keeps for this plugin's server. - state_directory: String, - /// Absolute writable directory for the server's state of this workspace. - /// A host without a workspace, such as one starting a plugin backend, - /// omits it; a launch that names it then has no usable plan. - #[serde(default)] - workspace_state_directory: Option, - /// Absolute current-session `plugins//files` directory. The host creates it - /// outside installed resources; Core never infers a writable path. - #[serde(default)] - log_directory: Option, - /// Absolute writable scratch directory for one start of the server. Only - /// a launch the backend resolves needs it. - #[serde(default)] - temporary_directory: Option, - /// Host CPU architecture, one of `KNOWN_ARCHITECTURES`. - architecture: String, - /// Values the user stored for the plugin's settings, by setting ID. A - /// missing or no longer acceptable value falls back to the default. - #[serde(default)] - settings: BTreeMap, - /// The current workspace's overrides of project-scoped settings. - #[serde(default)] - project_settings: BTreeMap, -} - -impl LanguageServerLaunchPlanRequest { - fn layers(&self) -> SettingLayers<'_> { - SettingLayers { - global: &self.settings, - project: &self.project_settings, - } - } - - /// The four writable directories a backend-resolved launch receives. - /// Each is required, because the answer may name paths in any of them. - fn backend_launch_directories( - &self, - ) -> Validation> { - fn required<'a>(value: Option<&'a String>, name: &str) -> Validation<&'a str> { - match value { - Some(directory) => launch_directory(directory, name), - None => Err(PluginRejection::new( - "plugin.launch.invalidRequest", - format!("The launch request has no usable {name}."), - )), - } - } - Ok(language_server_sessions::LaunchDirectories { - state: launch_directory(&self.state_directory, "stateDirectory")?, - workspace_state: required( - self.workspace_state_directory.as_ref(), - "workspaceStateDirectory", - )?, - log: required(self.log_directory.as_ref(), "logDirectory")?, - temporary: required(self.temporary_directory.as_ref(), "temporaryDirectory")?, - }) - } -} - -/// Validated substitution values of one launch plan. -struct LaunchValues<'a> { - plugin_directory: &'a str, - state_directory: &'a str, - workspace_state_directory: &'a str, - log_directory: &'a str, - architecture: &'a str, - /// Effective value of every declared setting. - settings: &'a BTreeMap, -} - -/// What a launch plan needs from the host besides the command line. -#[derive(Default)] -struct LaunchRequirements { - /// Package-relative files or directories the templates point at. - package_paths: BTreeSet, - uses_state_directory: bool, - uses_workspace_state_directory: bool, - uses_log_directory: bool, -} - -fn language_server_launch_plan( - manifest: &DeclarativePluginManifest, - language_id: &str, - request: &LanguageServerLaunchPlanRequest, -) -> Validation { - let server = manifest - .language_servers - .iter() - .find(|server| server.language_id == language_id) - .ok_or_else(|| { - PluginRejection::new( - "plugin.launch.unavailable", - format!("The plugin does not provide a language server for {language_id}."), - ) - })?; - let mut plan = match &server.process.launch { - LanguageServerLaunch::Backend { - function, - timeout_milliseconds, - } => backend_resolved_launch_plan( - manifest, - &server.language_id, - function, - *timeout_milliseconds, - request, - )?, - _ => server_launch_plan( - manifest, - &server.process, - server.host_integration.as_ref(), - request, - )?, - }; - if let Some(configuration) = &server.workspace_configuration { - plan["workspaceConfiguration"] = expand_configuration( - configuration, - &manifest.effective_settings(request.layers()), - ); - } - // The engine enforces the session fields. - plan["sessionPolicy"] = server.session.policy(&server.language_id); - Ok(plan) -} - -/// The first step of a language server whose backend resolves its launch: -/// the function to call and its params. The host calls -/// the function and passes the answer to `plugins.languageServers.decodeLaunch`, -/// whose plan it then starts like any other. -fn backend_resolved_launch_plan( - manifest: &DeclarativePluginManifest, - server_id: &str, - function: &str, - timeout_milliseconds: Option, - request: &LanguageServerLaunchPlanRequest, -) -> Validation { - let directories = request.backend_launch_directories()?; - if !KNOWN_ARCHITECTURES.contains(&request.architecture.as_str()) { - return Err(PluginRejection::new( - "plugin.launch.invalidRequest", - format!( - "The architecture {} is not supported.", - request.architecture - ), - )); - } - let settings = manifest.effective_settings(request.layers()); - let mut plan = json!({ - "owner": {"pluginID":manifest.id,"version":manifest.version}, - "launch": { - "kind": "backend", - "function": function, - "timeoutMilliseconds": timeout_milliseconds - .unwrap_or(language_server_sessions::RESOLVE_TIMEOUT_MILLISECONDS), - }, - "resolveParams": language_server_sessions::resolve_params( - server_id, - &request.architecture, - &directories, - &settings, - ), - "arguments": [], - "validationArguments": [], - "runtimeRequirements": [], - "requiredDirectories": ["state", "workspaceState", "log", "temporary"], - "requiredPackagePaths": [], - }); - if !settings.is_empty() { - plan["settings"] = json!(settings); - } - Ok(plan) -} - -/// `plugins.languageServers.decodeLaunch`: checks a launch function's answer -/// against the server's declaration and the directories of this start, and -/// returns the plan the host starts. A server whose launch is not resolved -/// by the backend is refused, so a host never runs an answer it did not ask -/// for. -pub fn decode_language_server_launch_command(payload: Value) -> Result { - const COMMAND: &str = "plugins.languageServers.decodeLaunch"; - #[derive(Deserialize)] - #[serde(rename_all = "camelCase")] - struct Payload { - manifest: Value, - #[serde(rename = "languageID")] - language_id: String, - request: LanguageServerLaunchPlanRequest, - result: Value, - } - let payload: Payload = decode_payload(payload, COMMAND)?; - Ok(outcome(validate_manifest_value(payload.manifest).and_then( - |manifest| { - let server = manifest - .language_servers - .iter() - .find(|server| server.language_id == payload.language_id) - .ok_or_else(|| { - PluginRejection::new( - "plugin.launch.unavailable", - format!( - "The plugin does not provide a language server for {}.", - payload.language_id - ), - ) - })?; - if !server.process.is_backend_launch() { - return Err(PluginRejection::new( - "plugin.launch.notBackend", - format!( - "The language server for {} is not launched by the plugin backend.", - payload.language_id - ), - )); - } - let directories = payload.request.backend_launch_directories()?; - let tool_ids: BTreeSet<&str> = manifest - .tool_definitions - .iter() - .filter_map(|definition| definition["id"].as_str()) - .collect(); - let (mut plan, gates) = - language_server_sessions::decode_answer(payload.result, &directories, &tool_ids)?; - let settings = manifest.effective_settings(payload.request.layers()); - if !settings.is_empty() { - plan["settings"] = json!(settings); - } - if plan.get("workspaceConfiguration").is_none() { - if let Some(configuration) = &server.workspace_configuration { - plan["workspaceConfiguration"] = expand_configuration(configuration, &settings); - } - } - let mut policy = server.session.policy(&server.language_id); - policy["gates"] = json!(gates); - plan["sessionPolicy"] = policy; - Ok(("plan", plan)) - }, - ))) -} - -/// Whether any string in a JSON value names a variable other than a setting. -fn mentions_launch_directory(value: &Value) -> bool { - match value { - Value::String(text) => parse_template_with(text, LaunchVariable::parse) - .unwrap_or_default() - .iter() - .any(|part| { - matches!(part, TemplatePart::Variable(variable) if !matches!(variable, LaunchVariable::Setting(_))) - }), - Value::Array(items) => items.iter().any(mentions_launch_directory), - Value::Object(members) => members.values().any(mentions_launch_directory), - _ => false, - } -} - -/// Expands a validated `workspaceConfiguration` with effective setting -/// values. A string that is exactly one setting keeps the setting's JSON type. -fn expand_configuration(value: &Value, settings: &BTreeMap) -> Value { - match value { - Value::String(text) => { - if let Some(id) = sole_setting_reference(text) { - return settings.get(&id).cloned().unwrap_or(Value::Null); - } - let parts = parse_template_with(text, LaunchVariable::parse).unwrap_or_default(); - Value::String( - parts - .into_iter() - .map(|part| match part { - TemplatePart::Text(text) => text, - TemplatePart::Variable(LaunchVariable::Setting(id)) => { - super::settings::setting_text(settings.get(&id)) - } - TemplatePart::Variable(_) => String::new(), - }) - .collect(), - ) - } - Value::Array(items) => Value::Array( - items - .iter() - .map(|item| expand_configuration(item, settings)) - .collect(), - ), - Value::Object(members) => Value::Object( - members - .iter() - .map(|(key, item)| (key.clone(), expand_configuration(item, settings))) - .collect(), - ), - other => other.clone(), - } -} - -/// The launch plan of a build system's provider. A provider that shares a -/// language server gets that server's plan; `buildProvider.languageServer` -/// then tells the host to send build requests to the session it already -/// keeps for the language instead of starting a second process. -fn build_provider_launch_plan( - manifest: &DeclarativePluginManifest, - build_system_id: &str, - request: &LanguageServerLaunchPlanRequest, -) -> Validation { - let unavailable = |message: String| PluginRejection::new("plugin.launch.unavailable", message); - let build_system = manifest - .build_systems - .iter() - .find(|build_system| build_system.id == build_system_id) - .ok_or_else(|| { - unavailable(format!( - "The plugin declares no build system {build_system_id}." - )) - })?; - let provider = build_system.provider.as_ref().ok_or_else(|| { - unavailable(format!( - "The build system {build_system_id} has no provider process." - )) - })?; - let mut description = json!({ - "buildSystemID": build_system.id, - "executesProjectCode": provider.executes_project_code(), - "mayUseNetwork": provider.may_use_network(), - }); - let mut plan = match provider.process() { - crate::build::ProviderProcess::LanguageServer(language_id) => { - description["languageServer"] = json!(language_id); - language_server_launch_plan(manifest, language_id, request)? - } - crate::build::ProviderProcess::Own(process) => { - server_launch_plan(manifest, process, None, request)? - } - }; - plan["buildProvider"] = description; - Ok(plan) -} - -/// The launch plan of a VCS provider. `vcsProvider` repeats what the host -/// needs to resolve the client tool and to check the provider's answer in -/// `initialize`, which must name the same VCS. -fn vcs_provider_launch_plan( - manifest: &DeclarativePluginManifest, - vcs_id: &str, - request: &LanguageServerLaunchPlanRequest, -) -> Validation { - let provider = manifest - .vcs_providers - .iter() - .find(|provider| provider.id == vcs_id) - .ok_or_else(|| { - PluginRejection::new( - "plugin.launch.unavailable", - format!("The plugin declares no VCS provider {vcs_id}."), - ) - })?; - let mut plan = server_launch_plan(manifest, &provider.process, None, request)?; - plan["vcsProvider"] = provider.descriptor(&manifest.id); - Ok(plan) -} - -/// The launch plan of the plugin's backend. `backend.hostServices` lists the -/// host services its permissions grant, which the host passes to -/// `lsp.startServer` so Core admits only those requests. -fn backend_launch_plan( - manifest: &DeclarativePluginManifest, - request: &LanguageServerLaunchPlanRequest, -) -> Validation { - let backend = manifest.backend.as_ref().ok_or_else(|| { - PluginRejection::new( - "plugin.launch.unavailable", - "The plugin declares no backend.", - ) - })?; - let mut plan = server_launch_plan(manifest, &backend.process, None, request)?; - plan["backend"] = super::backend::plan_descriptor( - manifest.capability_version(capabilities::BACKEND), - &manifest.backend_host_services(), - ); - Ok(plan) -} - -/// The launch plan of a debug adapter. `debugAdapter` repeats what the host -/// needs besides the process: the transport and what to show. An adapter on -/// the runtime Run selected has no process of its own to plan yet. An adapter -/// inside the plugin's language server plans that server, as a build provider -/// on a shared server does. -fn debug_adapter_launch_plan( - manifest: &DeclarativePluginManifest, - adapter_id: &str, - request: &LanguageServerLaunchPlanRequest, -) -> Validation { - use crate::debug::adapters::DebugProcess; - let adapter = manifest - .debug_adapters - .iter() - .find(|adapter| adapter.id == adapter_id) - .ok_or_else(|| { - PluginRejection::new( - "plugin.launch.unavailable", - format!("The plugin declares no debug adapter {adapter_id}."), - ) - })?; - let mut plan = match &adapter.launch.process { - Some(DebugProcess::Server(process)) => { - server_launch_plan(manifest, process, None, request)? - } - Some(DebugProcess::SelectedRuntime(_)) => { - return Err(PluginRejection::new( - "plugin.launch.unavailable", - format!("The debug adapter {adapter_id} starts on the selected runtime."), - )); - } - None => { - let language_id = adapter - .launch - .endpoint - .as_ref() - .and_then(|endpoint| endpoint.language_id.as_deref()) - .unwrap_or_default(); - hosting_language_server_plan(manifest, language_id, request)? - } - }; - plan["debugAdapter"] = adapter.plan_descriptor(&manifest.id); - Ok(plan) -} - -/// The plan of the language server a `tcpLanguageServer` adapter runs in. -/// The server's own session starts it; this plan only names the server the -/// adapter belongs to. A server the backend launches is -/// asked by that session with the scratch directories of its own start, so -/// here its plan names the function and demands no directory. -fn hosting_language_server_plan( - manifest: &DeclarativePluginManifest, - language_id: &str, - request: &LanguageServerLaunchPlanRequest, -) -> Validation { - let server = manifest - .language_servers - .iter() - .find(|server| server.language_id == language_id) - .ok_or_else(|| { - PluginRejection::new( - "plugin.launch.unavailable", - format!("The plugin does not provide a language server for {language_id}."), - ) - })?; - match &server.process.launch { - LanguageServerLaunch::Backend { - function, - timeout_milliseconds, - } => Ok(json!({ - "launch": { - "kind": "backend", - "function": function, - "timeoutMilliseconds": timeout_milliseconds - .unwrap_or(language_server_sessions::RESOLVE_TIMEOUT_MILLISECONDS), - }, - "arguments": [], - "validationArguments": [], - "runtimeRequirements": [], - "requiredDirectories": [], - "requiredPackagePaths": [], - })), - _ => language_server_launch_plan(manifest, language_id, request), - } -} - -/// `debug.adapters.startArguments`: checks one start against the adapter's -/// declaration and returns the DAP arguments its configuration defaults and -/// target bindings give. -pub fn debug_start_arguments_command(payload: Value) -> Result { - const COMMAND: &str = "debug.adapters.startArguments"; - #[derive(Deserialize)] - #[serde(rename_all = "camelCase")] - struct Payload { - manifest: Value, - #[serde(rename = "adapterID")] - adapter_id: String, - #[serde(flatten)] - start: crate::debug::adapters::StartRequest, - } - let payload: Payload = decode_payload(payload, COMMAND)?; - let started = validate_manifest_value(payload.manifest).and_then(|manifest| { - let adapter = manifest - .debug_adapters - .iter() - .find(|adapter| adapter.id == payload.adapter_id) - .ok_or_else(|| { - PluginRejection::new( - "plugin.launch.unavailable", - format!( - "The plugin declares no debug adapter {}.", - payload.adapter_id - ), - ) - })?; - crate::debug::adapters::start_arguments(adapter, &payload.start) - }); - // `connection` is present only for a remote target, so a host that never - // attaches to one can ignore it. - let connection = started - .as_ref() - .ok() - .and_then(|started| started.connection.clone()); - let mut result = - outcome(started.map(|started| ("arguments", Value::Object(started.arguments)))); - if let Some(connection) = connection { - result["connection"] = json!(connection); - } - Ok(result) -} - -/// `debug.adapters.executablePlan`: where a host looks for the program a -/// launch debugs, when the adapter's configuration binds `program` to -/// `target.executable`. `plan` is `null` for an adapter with no such lookup. -/// The host owns the file system, so it checks `configured` and then each -/// candidate; Core only says what to look at and in which order. -pub fn debug_executable_plan_command(payload: Value) -> Result { - const COMMAND: &str = "debug.adapters.executablePlan"; - #[derive(Deserialize)] - #[serde(rename_all = "camelCase")] - struct Payload { - manifest: Value, - #[serde(rename = "adapterID")] - adapter_id: String, - #[serde(default)] - settings: BTreeMap, - #[serde(default)] - project_settings: BTreeMap, - /// Absolute root of the open project. - #[serde(default)] - workspace: Option, - /// Absolute path of the source file being debugged, when there is one. - #[serde(default)] - target_path: Option, - } - let payload: Payload = decode_payload(payload, COMMAND)?; - Ok(outcome(validate_manifest_value(payload.manifest).and_then( - |manifest| { - let adapter = manifest - .debug_adapters - .iter() - .find(|adapter| adapter.id == payload.adapter_id) - .ok_or_else(|| { - PluginRejection::new( - "plugin.launch.unavailable", - format!( - "The plugin declares no debug adapter {}.", - payload.adapter_id - ), - ) - })?; - let Some(lookup) = adapter - .configurations - .as_ref() - .and_then(|configurations| configurations.launch.as_ref()) - .and_then(|configuration| configuration.executable.as_ref()) - else { - return Ok(("plan", Value::Null)); - }; - let layers = SettingLayers { - global: &payload.settings, - project: &payload.project_settings, - }; - let configured = manifest - .resolve_settings(layers) - .into_iter() - .find(|setting| setting.id == lookup.setting) - .and_then(|setting| match &setting.value { - Value::String(path) if !path.is_empty() => { - Some(json!({"path": path, "source": setting.source})) - } - _ => None, - }); - Ok(( - "plan", - json!({ - "setting": lookup.setting, - "configured": configured, - "candidates": lookup.expanded_candidates( - payload.workspace.as_deref(), - payload.target_path.as_deref(), - ), - }), - )) - }, - ))) -} - -fn server_launch_plan( - manifest: &DeclarativePluginManifest, - process: &ServerProcess, - host_integration: Option<&HostIntegrationDeclaration>, - request: &LanguageServerLaunchPlanRequest, -) -> Validation { - let effective_settings = manifest.effective_settings(request.layers()); - let values = LaunchValues { - plugin_directory: launch_directory(&request.plugin_directory, "pluginDirectory")?, - state_directory: launch_directory(&request.state_directory, "stateDirectory")?, - workspace_state_directory: match &request.workspace_state_directory { - Some(directory) => launch_directory(directory, "workspaceStateDirectory")?, - None if process.uses_workspace_state_directory() => { - return Err(PluginRejection::new( - "plugin.launch.invalidRequest", - "The launch request has no usable workspaceStateDirectory.", - )) - } - None => "", - }, - log_directory: match request.log_directory.as_deref() { - Some(directory) => { - let directory = launch_directory(directory, "logDirectory")?; - // Recognize absolute native paths on both platforms without using - // the build machine's Path::is_absolute semantics for the other OS. - let absolute = directory.starts_with('/') - || directory.starts_with("\\\\") - || (directory.as_bytes().get(1) == Some(&b':') - && directory - .as_bytes() - .first() - .is_some_and(u8::is_ascii_alphabetic) - && directory - .as_bytes() - .get(2) - .is_some_and(|b| b"/\\".contains(b))); - if !absolute { - return Err(PluginRejection::new( - "plugin.launch.invalidRequest", - "The launch request has no usable absolute logDirectory.", - )); - } - directory - } - None => "", - }, - architecture: &request.architecture, - settings: &effective_settings, - }; - if !KNOWN_ARCHITECTURES.contains(&values.architecture) { - return Err(PluginRejection::new( - "plugin.launch.invalidRequest", - format!("The architecture {} is not supported.", values.architecture), - )); - } - - let mut requirements = LaunchRequirements::default(); - let expand_all = |templates: &[String], requirements: &mut LaunchRequirements| { - templates - .iter() - .map(|value| expand_launch_template(value, &values, requirements)) - .collect::>() - }; - - let launch = match &process.launch { - // A tool definition owns how its executable starts an entry, so the - // plan hands the host the parts for `toolchains.instantiateLaunch`. - LanguageServerLaunch::Runtime { - tool, - minimum_version, - packaged_runtime, - runtime_arguments, - entry, - } => { - requirements.package_paths.insert(entry.clone()); - let mut launch = json!({ - "kind": "tool", - "tool": tool, - "entry": format!("{}/{entry}", values.plugin_directory), - "runtimeArguments": expand_all(runtime_arguments, &mut requirements), - }); - if let Some(version) = minimum_version { - launch["minimumVersion"] = json!(version); - } - if let Some(path) = packaged_runtime { - let path = path.replace("${arch}", values.architecture); - requirements.package_paths.insert(path.clone()); - launch["packagedRuntime"] = json!(path); - } - launch - } - LanguageServerLaunch::Packaged { path } => { - requirements.package_paths.insert(path.clone()); - json!({ "kind": "packaged", "path": path }) - } - LanguageServerLaunch::SystemCommand { commands } => { - json!({ "kind": "systemCommand", "commands": commands }) - } - // Validation admits a backend launch only for a language server, - // whose plan `backend_resolved_launch_plan` builds instead. - LanguageServerLaunch::Backend { .. } => { - return Err(PluginRejection::new( - "plugin.launch.unavailable", - "This process is launched by the plugin backend.", - )); - } - }; - let arguments = expand_all(&process.arguments, &mut requirements); - let validation_arguments = expand_all(&process.validation_arguments, &mut requirements); - - let mut plan = json!({ - "owner": {"pluginID":manifest.id,"version":manifest.version}, - "launch": launch, - "arguments": arguments, - "validationArguments": validation_arguments, - "runtimeRequirements": process.runtime_requirements(), - }); - if let Some(options) = &process.initialization_options { - plan["initializationOptions"] = expand_launch_value(options, &values, &mut requirements); - } - if let Some(integration) = host_integration { - let mut resources = serde_json::Map::new(); - for (name, paths) in &integration.resources { - let expanded: Vec = paths - .paths() - .iter() - .map(|path| path.replace("${arch}", values.architecture)) - .collect(); - requirements.package_paths.extend(expanded.iter().cloned()); - resources.insert( - name.clone(), - match paths { - ResourcePaths::One(_) => json!(expanded[0]), - ResourcePaths::Many(_) => json!(expanded), - }, - ); - } - plan["hostIntegration"] = json!({ "id": integration.id, "resources": resources }); - } - let mut directories = Vec::new(); - if requirements.uses_state_directory { - directories.push("state"); - } - if requirements.uses_workspace_state_directory { - directories.push("workspaceState"); - } - if !effective_settings.is_empty() { - plan["settings"] = json!(effective_settings); - } - if requirements.uses_log_directory { - if values.log_directory.is_empty() { - return Err(PluginRejection::new( - "plugin.launch.invalidRequest", - "The launch request has no usable logDirectory.", - )); - } - directories.push("log"); - } - plan["requiredDirectories"] = json!(directories); - plan["requiredPackagePaths"] = json!(requirements.package_paths); - Ok(plan) -} - -/// Accepts a host directory as substitution text. A trailing separator is -/// dropped so `${pluginDir}/x` never yields a doubled separator. -fn launch_directory<'a>(value: &'a str, name: &str) -> Validation<&'a str> { - let trimmed = value.trim_end_matches(['/', '\\']); - if trimmed.is_empty() || trimmed.contains('\0') { - return Err(PluginRejection::new( - "plugin.launch.invalidRequest", - format!("The launch request has no usable {name}."), - )); - } - Ok(trimmed) -} - -/// The setting ID when the whole template is exactly one `${setting:}`. -/// Such a value keeps the setting's JSON type, so an LSP option can receive a -/// number or a boolean rather than its text. -fn sole_setting_reference(template: &str) -> Option { - match parse_template_with(template, LaunchVariable::parse)?.as_slice() { - [TemplatePart::Variable(LaunchVariable::Setting(id))] => Some(id.clone()), - _ => None, - } -} - -fn expand_launch_value( - value: &Value, - values: &LaunchValues<'_>, - requirements: &mut LaunchRequirements, -) -> Value { - match value { - Value::String(text) => sole_setting_reference(text) - .and_then(|id| values.settings.get(&id).cloned()) - .unwrap_or_else(|| Value::String(expand_launch_template(text, values, requirements))), - Value::Array(items) => Value::Array( - items - .iter() - .map(|item| expand_launch_value(item, values, requirements)) - .collect(), - ), - Value::Object(members) => Value::Object( - members - .iter() - .map(|(key, item)| (key.clone(), expand_launch_value(item, values, requirements))) - .collect(), - ), - other => other.clone(), - } -} - -fn expand_launch_template( - value: &str, - values: &LaunchValues<'_>, - requirements: &mut LaunchRequirements, -) -> String { - // Validation guarantees the template parses and that the text after - // `${pluginDir}` is a `/`-rooted path inside the package. - let parts = parse_template_with(value, LaunchVariable::parse).unwrap_or_default(); - let mut expanded = String::new(); - let mut package_path_start = None; - for part in parts { - match part { - TemplatePart::Text(text) => expanded.push_str(&text), - TemplatePart::Variable(LaunchVariable::PluginDir) => { - expanded.push_str(values.plugin_directory); - package_path_start = Some(expanded.len() + 1); - } - TemplatePart::Variable(LaunchVariable::StateDir) => { - requirements.uses_state_directory = true; - expanded.push_str(values.state_directory); - } - TemplatePart::Variable(LaunchVariable::WorkspaceStateDir) => { - requirements.uses_workspace_state_directory = true; - expanded.push_str(values.workspace_state_directory); - } - TemplatePart::Variable(LaunchVariable::LogDir) => { - requirements.uses_log_directory = true; - expanded.push_str(values.log_directory); - } - TemplatePart::Variable(LaunchVariable::Arch) => expanded.push_str(values.architecture), - TemplatePart::Variable(LaunchVariable::Setting(id)) => { - expanded.push_str(&super::settings::setting_text(values.settings.get(&id))); - } - } - } - if let Some(start) = package_path_start { - requirements - .package_paths - .insert(expanded[start..].trim_end_matches('/').to_string()); - } - expanded -} - -pub(crate) fn version_components(value: &str) -> Vec { - value - .split('.') - .filter_map(|part| part.parse().ok()) - .collect() -} - -/// First dotted number in a version probe's output, at most three -/// components: `20.11.0` from `v20.11.0`, `1.22.4` from -/// `go version go1.22.4 darwin/arm64`. A tool whose output needs more than -/// this is checked through its tool definition instead. -pub(crate) fn reported_version(output: &str) -> Option> { - let line = output - .lines() - .find(|line| line.contains(|character: char| character.is_ascii_digit()))?; - let start = line.find(|character: char| character.is_ascii_digit())?; - let text: String = line[start..] - .chars() - .take_while(|character| character.is_ascii_digit() || *character == '.') - .collect(); - // A sentence-ending period is not a component. - text.split('.') - .take_while(|part| !part.is_empty()) - .take(3) - .map(|part| part.parse().ok()) - .collect() -} - -fn test_template<'a>( - manifest: &'a DeclarativePluginManifest, - language_id: &str, -) -> Validation<&'a TestTemplate> { - manifest - .execution_provider(language_id) - .and_then(|provider| provider.test.as_ref()) - .ok_or_else(|| unavailable(language_id, "test")) -} - -fn supports_project(test: &TestTemplate, paths: &[String]) -> bool { - paths.iter().any(|path| { - test.project_markers - .contains(&file_name(path).to_lowercase()) - }) -} - -fn expand(arguments: &[ArgumentTemplate], values: &ExpansionValues<'_>) -> Vec { - let mut result = Vec::new(); - for argument in arguments { - match argument.condition() { - Some(ArgumentCondition::FileSelected) if values.file.is_none() => continue, - Some(ArgumentCondition::NoFileSelected) if values.file.is_some() => continue, - _ => {} - } - // Validation guarantees the template parses and that every variable - // it uses has a value in this context. - let parts = parse_template(argument.value()).unwrap_or_default(); - if parts == [TemplatePart::Variable(TemplateVariable::UserArguments)] { - result.extend(values.user_arguments.iter().cloned()); - continue; - } - let mut element = String::new(); - for part in parts { - match part { - TemplatePart::Text(text) => element.push_str(&text), - TemplatePart::Variable(TemplateVariable::File) => { - element.push_str(&command_path(values.file.unwrap_or_default())) - } - TemplatePart::Variable(TemplateVariable::FileDirectory) => { - element.push_str(&directory_argument(values.file.unwrap_or_default())) - } - TemplatePart::Variable(TemplateVariable::TestName) => { - element.push_str(values.test_name.unwrap_or_default()) - } - TemplatePart::Variable(TemplateVariable::TestNameRegex) => { - element.push_str(®ex_literal(values.test_name.unwrap_or_default())) - } - TemplatePart::Variable(TemplateVariable::UserArguments) => {} - } - } - result.push(element); - } - result -} - -/// A file name beginning with `-` would be parsed as an option by most -/// tools, so it is passed as an explicit relative path. -fn command_path(path: &str) -> String { - if path.starts_with('-') { - format!("./{path}") - } else { - path.to_string() - } -} - -/// The selected file's directory as a `./`-relative argument; `.` for a file -/// at the workspace root. -fn directory_argument(path: &str) -> String { - match path.rsplit_once('/') { - Some((directory, _)) if !directory.is_empty() => format!("./{directory}"), - _ => ".".to_string(), - } -} - -/// Escapes regular-expression metacharacters so a test name matches only -/// literally, in both PCRE (PHPUnit) and RE2 (Go) syntax. -pub(crate) fn regex_literal(value: &str) -> String { - let mut escaped = String::with_capacity(value.len()); - for character in value.chars() { - if "\\*?+[](){}^$|.".contains(character) { - escaped.push('\\'); - } - escaped.push(character); - } - escaped -} - -fn matches_pattern(pattern: &str, name: &str) -> bool { - let pieces: Vec<&str> = pattern.split('*').collect(); - if pieces.len() == 1 { - return pattern == name; - } - let (first, last) = (pieces[0], pieces[pieces.len() - 1]); - if !name.starts_with(first) || !name[first.len()..].ends_with(last) { - return false; - } - let mut remainder = &name[first.len()..name.len() - last.len()]; - for piece in &pieces[1..pieces.len() - 1] { - match remainder.find(piece) { - Some(index) => remainder = &remainder[index + piece.len()..], - None => return false, - } - } - true -} - -fn checked_path(value: &str) -> Validation { - // Paths are not trimmed: surrounding spaces are part of a real file name. - if is_safe_relative_path(value) { - Ok(value.to_string()) - } else { - Err(PluginRejection::new( - "plugin.path.invalid", - "A path must stay inside the current workspace.", - )) - } -} - -fn checked_paths(values: &[String]) -> Validation> { - values.iter().map(|value| checked_path(value)).collect() -} - -fn unavailable(language_id: &str, operation: &str) -> PluginRejection { - PluginRejection::new( - "plugin.execution.unavailable", - format!("The plugin does not provide {operation} support for {language_id}."), - ) -} - -pub(crate) fn invalid(field: impl Into, message: impl Into) -> PluginRejection { - PluginRejection::at("plugin.manifest.invalid", field, message) -} - -pub(crate) fn template(field: impl Into, message: impl Into) -> PluginRejection { - PluginRejection::at("plugin.template.invalid", field, message) -} - -fn file_name(path: &str) -> &str { - path.rsplit('/').next().unwrap_or(path) -} - -pub(crate) fn is_safe_relative_path(value: &str) -> bool { - !value.is_empty() - && !value.starts_with('/') - && !value.contains('\0') - && !value.contains('\\') - && !value.split('/').any(|component| component == "..") -} - -pub(crate) fn is_bare_command(value: &str) -> bool { - !value.is_empty() - && value != "." - && value != ".." - && !value.contains('/') - && !value.contains('\\') - && !value.contains('\0') - && !value.chars().any(char::is_whitespace) -} - -pub(crate) fn is_identifier(value: &str) -> bool { - !value.is_empty() - && value.chars().all(|character| { - character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-' - }) -} - -/// True for IDs reserved to Lithe's official plugins (`dev.lithe` and -/// `dev.lithe.*`). Third-party sources and unsigned packages may not use it. -pub(crate) fn is_official_namespace(id: &str) -> bool { - id == OFFICIAL_PLUGIN_NAMESPACE || id.starts_with(&format!("{OFFICIAL_PLUGIN_NAMESPACE}.")) -} - -pub(crate) fn is_plugin_id(value: &str) -> bool { - !value.is_empty() - && value != "." - && value != ".." - && value.chars().all(|character| { - character.is_ascii_lowercase() || character.is_ascii_digit() || ".-".contains(character) - }) -} - -pub(crate) fn is_semantic_version(value: &str) -> bool { - let parts: Vec<&str> = value.split('.').collect(); - parts.len() == 3 - && parts.iter().all(|part| { - !part.is_empty() && part.chars().all(|character| character.is_ascii_digit()) - }) -} - -/// Trims and lowercases complete file names and markers. A leading `.` is -/// part of the name (`.toolrc`, `.env.example`) and is kept. -fn normalized_names(values: &[String]) -> Vec { - sorted_unique( - values - .iter() - .filter_map(|value| { - let name = value.trim().to_lowercase(); - (!name.is_empty()).then_some(name) - }) - .collect(), - ) -} - -/// Normalizes file extensions, where a leading `.` is optional notation: -/// `.php` and `php` both declare the `php` extension. -fn normalized_extensions(values: &[String]) -> Vec { - let names = normalized_names(values); - sorted_unique( - names - .iter() - .filter_map(|name| { - let extension = name.strip_prefix('.').unwrap_or(name); - (!extension.is_empty()).then(|| extension.to_string()) - }) - .collect(), - ) -} - -pub(crate) fn sorted_unique(values: Vec) -> Vec { - values - .into_iter() - .collect::>() - .into_iter() - .collect() -} - -// MARK: Command entry points - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct ValidatePayload { - manifest: Value, - /// Capability versions the host runs. When sent, a manifest that - /// requires anything else is refused. - #[serde(default)] - host_capabilities: Option, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct PlanPayload { - manifest: Value, - #[serde(rename = "languageID")] - language_id: String, - request: Request, -} - -/// A launch plan names the process by the language whose server it is, by -/// the build system whose provider it is, or by the VCS it provides. -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct LaunchPlanPayload { - manifest: Value, - #[serde(default, rename = "languageID")] - language_id: Option, - #[serde(default, rename = "buildSystemID")] - build_system_id: Option, - #[serde(default, rename = "vcsProviderID")] - vcs_provider_id: Option, - /// Selects the plugin's backend. - #[serde(default)] - backend: bool, - #[serde(default, rename = "debugAdapterID")] - debug_adapter_id: Option, - request: LanguageServerLaunchPlanRequest, -} - -enum LaunchSelection<'a> { - Language(&'a str), - BuildSystem(&'a str), - VcsProvider(&'a str), - Backend, - DebugAdapter(&'a str), -} - -pub(crate) fn decode_payload Deserialize<'de>>( - payload: Value, - command: &str, -) -> Result { - serde_json::from_value(payload).map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - format!("Invalid {command} payload"), - ) - .with_details(error.to_string()) - }) -} - -pub(crate) fn outcome(result: Validation<(&'static str, Value)>) -> Value { - match result { - Ok((key, value)) => { - let mut object = serde_json::Map::new(); - object.insert("status".to_string(), json!("ok")); - object.insert(key.to_string(), value); - Value::Object(object) - } - Err(rejection) => json!({ "status": "rejected", "error": rejection }), - } -} - -/// `plugins.declarative.validate`: returns `{status: "ok", plugin}` with the -/// normalized manifest, or `{status: "rejected", error}`. -pub fn validate_command(payload: Value) -> Result { - let payload: ValidatePayload = decode_payload(payload, "plugins.declarative.validate")?; - Ok(outcome(validate_manifest_value(payload.manifest).and_then( - |manifest| { - if let Some(host) = &payload.host_capabilities { - capabilities::check_hosted(manifest.requires(), host)?; - } - serde_json::to_value(manifest) - .map(|value| ("plugin", value)) - .map_err(|error| PluginRejection::new("plugin.manifest.invalid", error.to_string())) - }, - ))) -} - -/// `plugins.declarative.runPlan`: expands the run template for one file. -pub fn run_plan_command(payload: Value) -> Result { - let payload: PlanPayload = - decode_payload(payload, "plugins.declarative.runPlan")?; - Ok(outcome(validate_manifest_value(payload.manifest).and_then( - |manifest| { - run_plan(&manifest, &payload.language_id, &payload.request).map(|plan| ("plan", plan)) - }, - ))) -} - -/// `plugins.declarative.discoverTests`: lists workspace and file test items. -pub fn discover_tests_command(payload: Value) -> Result { - let payload: PlanPayload = - decode_payload(payload, "plugins.declarative.discoverTests")?; - Ok(outcome(validate_manifest_value(payload.manifest).and_then( - |manifest| { - discover_tests(&manifest, &payload.language_id, &payload.request) - .map(|items| ("items", items)) - }, - ))) -} - -/// `plugins.declarative.testPlan`: expands the test template for one scope. -pub fn test_plan_command(payload: Value) -> Result { - let payload: PlanPayload = - decode_payload(payload, "plugins.declarative.testPlan")?; - Ok(outcome(validate_manifest_value(payload.manifest).and_then( - |manifest| { - test_plan(&manifest, &payload.language_id, &payload.request).map(|plan| ("plan", plan)) - }, - ))) -} - -/// `plugins.declarative.languageServerLaunchPlan`: expands a language -/// server's launch declaration with the host's directories and architecture. -pub fn language_server_launch_plan_command(payload: Value) -> Result { - const COMMAND: &str = "plugins.declarative.languageServerLaunchPlan"; - let payload: LaunchPlanPayload = decode_payload(payload, COMMAND)?; - let selected = match ( - &payload.language_id, - &payload.build_system_id, - &payload.vcs_provider_id, - payload.backend, - &payload.debug_adapter_id, - ) { - (Some(language_id), None, None, false, None) => LaunchSelection::Language(language_id), - (None, Some(build_system_id), None, false, None) => { - LaunchSelection::BuildSystem(build_system_id) - } - (None, None, Some(vcs_id), false, None) => LaunchSelection::VcsProvider(vcs_id), - (None, None, None, true, None) => LaunchSelection::Backend, - (None, None, None, false, Some(adapter_id)) => LaunchSelection::DebugAdapter(adapter_id), - _ => { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - format!("Invalid {COMMAND} payload"), - ) - .with_details( - "Name exactly one of languageID, buildSystemID, vcsProviderID, backend, and debugAdapterID.", - )) - } - }; - Ok(outcome(validate_manifest_value(payload.manifest).and_then( - |manifest| { - match selected { - LaunchSelection::Language(language_id) => { - language_server_launch_plan(&manifest, language_id, &payload.request) - } - LaunchSelection::BuildSystem(build_system_id) => { - build_provider_launch_plan(&manifest, build_system_id, &payload.request) - } - LaunchSelection::VcsProvider(vcs_id) => { - vcs_provider_launch_plan(&manifest, vcs_id, &payload.request) - } - LaunchSelection::Backend => backend_launch_plan(&manifest, &payload.request), - LaunchSelection::DebugAdapter(adapter_id) => { - debug_adapter_launch_plan(&manifest, adapter_id, &payload.request) - } - } - .map(|plan| ("plan", plan)) - }, - ))) -} - -/// Stored setting layers of one plugin, as a host sends them. -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct SettingsPayload { - manifest: Value, - #[serde(default)] - settings: BTreeMap, - #[serde(default)] - project_settings: BTreeMap, - /// Names the language server whose `workspaceConfiguration` to expand. - #[serde(default, rename = "languageID")] - language_id: Option, -} - -impl SettingsPayload { - fn layers(&self) -> SettingLayers<'_> { - SettingLayers { - global: &self.settings, - project: &self.project_settings, - } - } -} - -/// `plugins.declarative.resolveSettings`: the effective value of every -/// declared setting and the layer it came from (`default`, `global`, or -/// `project`), so hosts show and launch with the same value. -pub fn resolve_settings_command(payload: Value) -> Result { - let payload: SettingsPayload = decode_payload(payload, "plugins.declarative.resolveSettings")?; - Ok(outcome( - validate_manifest_value(payload.manifest.clone()).and_then(|manifest| { - serde_json::to_value(manifest.resolve_settings(payload.layers())) - .map(|settings| ("settings", settings)) - .map_err(|error| PluginRejection::new("plugin.manifest.invalid", error.to_string())) - }), - )) -} - -#[path = "settings_surfaces.rs"] -mod settings_surfaces; -pub use settings_surfaces::{ - settings_catalog_command, settings_delivery_command, settings_navigation_command, -}; - -/// Installed package identity captured by the native registration adapter. -#[derive(Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct SettingsOwner { - #[serde(rename = "pluginID")] - plugin_id: String, - version: String, -} - -/// Complete stored setting documents, captured by the native storage adapter. -#[derive(Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct SettingsStatePayload { - manifest: Value, - /// Optional native registration facts; this is identity binding, not admission. - owner: Option, - /// Missing files are null; an existing empty or malformed file is an error. - global_document: Option, - project_document: Option, -} - -/// `plugins.declarative.settingsState`: checked UI rows and raw declared launch -/// layers. Reading a state neither edits storage nor admits plugin capabilities. -pub fn settings_state_command(payload: Value) -> Result { - let payload: SettingsStatePayload = - decode_payload(payload, "plugins.declarative.settingsState")?; - Ok(outcome(validate_manifest_value(payload.manifest).and_then( - |manifest| { - if payload.owner.as_ref().is_some_and(|owner| { - owner.plugin_id != manifest.id || owner.version != manifest.version - }) { - return Err(PluginRejection::at( - "plugin.settings.ownerChanged", - "owner", - "The manifest does not match the captured installed package identity.", - )); - } - super::settings::document_state( - &manifest.id, - &manifest.settings, - &manifest.setting_actions, - payload.global_document.as_deref(), - payload.project_document.as_deref(), - ) - .map(|mut state| { - state["metadata"] = json!({"displayName": manifest.display_name, "version":manifest.version, "vendor":manifest.vendor.display_name}); - ("state", state) - }) - }, - ))) -} - -/// Native facts for one explicitly selected installed setting action. -#[derive(Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct SettingsActionPayload { - manifest: Value, - owner: SettingsOwner, - #[serde(rename = "actionID")] - action_id: String, - /// Captured by the native confirmation UI, not inferred from saved preferences. - confirmed: bool, - /// Current native enablement choice; a stored draft cannot activate a plugin. - enabled: bool, - /// Describes the requesting window; runtime ownership is checked on invocation. - workspace_available: bool, -} - -/// Plans a declared settings action without starting or admitting its provider. -/// Native callers retain package/project ownership and confirmation checks. -pub fn settings_action_plan_command(payload: Value) -> Result { - let payload: SettingsActionPayload = - decode_payload(payload, "plugins.declarative.settingsActionPlan")?; - Ok(outcome(validate_manifest_value(payload.manifest).and_then( - |manifest| { - if payload.owner.plugin_id != manifest.id || payload.owner.version != manifest.version { - return Err(PluginRejection::at( - "plugin.settings.ownerChanged", - "owner", - "The installed package identity changed before the action was planned.", - )); - } - super::settings::plan_action( - &manifest.id, - &manifest.setting_actions, - &payload.action_id, - payload.confirmed, - payload.workspace_available, - payload.enabled, - ) - .map(|mut plan| { - plan["version"] = json!(manifest.version); - ("plan", plan) - }) - }, - ))) -} - -/// One layer and one atomic edit batch; the host owns its captured byte baseline. -#[derive(Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct SettingsEditPayload { - manifest: Value, - scope: super::settings::SettingScope, - document: Option, - edits: Vec, -} - -/// `plugins.declarative.editSettings`: validates all edits and returns complete -/// text, deletion eligibility and required effects, without performing any I/O. -pub fn edit_settings_command(payload: Value) -> Result { - let payload: SettingsEditPayload = decode_payload(payload, "plugins.declarative.editSettings")?; - Ok(outcome(validate_manifest_value(payload.manifest).and_then( - |manifest| { - super::settings::edit_document( - &manifest.id, - &manifest.settings, - payload.scope, - payload.document.as_deref(), - payload.edits, - ) - .map(|edit| ("edit", edit)) - }, - ))) -} - -/// `plugins.declarative.workspaceConfiguration`: a language server's -/// `workspaceConfiguration` expanded with the current values, which the host -/// sends to the running server after an `immediate` setting changed. -/// `configuration` is `null` when the server declares none. -pub fn workspace_configuration_command(payload: Value) -> Result { - const COMMAND: &str = "plugins.declarative.workspaceConfiguration"; - let payload: SettingsPayload = decode_payload(payload, COMMAND)?; - let Some(language_id) = payload.language_id.clone() else { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - format!("Invalid {COMMAND} payload"), - ) - .with_details("languageID is required.")); - }; - Ok(outcome( - validate_manifest_value(payload.manifest.clone()).and_then(|manifest| { - let server = manifest - .language_servers - .iter() - .find(|server| server.language_id == language_id) - .ok_or_else(|| { - PluginRejection::new( - "plugin.launch.unavailable", - format!("The plugin does not provide a language server for {language_id}."), - ) - })?; - let configuration = server - .workspace_configuration - .as_ref() - .map(|value| { - expand_configuration(value, &manifest.effective_settings(payload.layers())) - }) - .unwrap_or(Value::Null); - Ok(("configuration", configuration)) - }), - )) -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct GrammarPayload { - manifest: Value, - #[serde(rename = "languageID")] - language_id: String, - /// Text of the grammar file the host read from the package. - grammar: String, -} - -/// `plugins.declarative.decodeGrammar`: validates the grammar file of one -/// language and returns what the editor registers for it. -pub fn decode_grammar_command(payload: Value) -> Result { - let payload: GrammarPayload = decode_payload(payload, "plugins.declarative.decodeGrammar")?; - Ok(outcome(validate_manifest_value(payload.manifest).and_then( - |manifest| { - let language = manifest - .languages - .iter() - .find(|language| language.id == payload.language_id) - .filter(|language| language.grammar.is_some() || language.configuration.is_some()) - .ok_or_else(|| { - PluginRejection::new( - "plugin.grammar.unavailable", - format!( - "The plugin declares no grammar for {}.", - payload.language_id - ), - ) - })?; - let grammar = language - .grammar - .as_ref() - .map(|grammar| match grammar.kind { - // The editor's engine parses the original text; Core only - // proves that it can and names the scope it registers. - super::grammar::GrammarKind::Textmate => { - super::grammar::decode_textmate(&payload.grammar).map(|scope_name| { - json!({ - "kind": grammar.kind, - "scopeName": scope_name, - "source": payload.grammar, - }) - }) - } - }) - .transpose()?; - Ok(( - "language", - json!({ - "id": language.id, - "fileExtensions": language.file_extensions, - "fileNames": language.file_names, - "grammar": grammar, - "configuration": language.configuration, - }), - )) - }, - ))) -} diff --git a/rust/crates/core/src/plugins/mod.rs b/rust/crates/core/src/plugins/mod.rs deleted file mode 100644 index 9f93948f..00000000 --- a/rust/crates/core/src/plugins/mod.rs +++ /dev/null @@ -1,53 +0,0 @@ -//! Plugin manifests, capabilities, installation and the services plugins contribute. - -pub(crate) mod backend; -pub mod capabilities; -/// Read-only documents a plugin backend serves as a URI scheme. -pub(crate) mod content_providers; -pub mod declarative; -/// Independent, host-rendered editor actions of ordinary tool plugins. -pub mod editor_actions; -pub mod editor_providers; -pub(crate) mod executables; -pub(crate) mod grammar; -/// Public host-method catalog and authorization for platform dispatchers. -pub(crate) mod host_api; -/// Numeric version selection over bounded native installation facts. -pub mod installed; -pub(crate) mod keymap_import; -/// Session fields of plugin-owned language servers and the launch their -/// backend resolves. -pub(crate) mod language_server_sessions; -pub mod marketplace; -pub(crate) mod native_ui; -/// Plugin-declared console link patterns and their resolution. -pub(crate) mod output_links; -pub(crate) mod plugin_functions; -pub(crate) mod settings; -/// Plugin pages in host tool windows: loading rules and the web bridge. -pub(crate) mod web_content; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -/// Strict three-component semantic version used for compatibility comparisons. -pub struct PluginVersion { - /// Breaking-change component. - pub major: u32, - /// Backward-compatible feature component. - pub minor: u32, - /// Backward-compatible fix component. - pub patch: u32, -} - -impl PluginVersion { - /// Parses exactly `major.minor.patch`; prerelease tags and missing parts are - /// rejected because the manifest contract does not define their ordering. - pub fn parse(value: &str) -> Option { - let mut parts = value.split('.'); - let version = Self { - major: parts.next()?.parse().ok()?, - minor: parts.next()?.parse().ok()?, - patch: parts.next()?.parse().ok()?, - }; - parts.next().is_none().then_some(version) - } -} diff --git a/rust/crates/core/src/project/files.rs b/rust/crates/core/src/project/files.rs deleted file mode 100644 index 07d96aae..00000000 --- a/rust/crates/core/src/project/files.rs +++ /dev/null @@ -1,2398 +0,0 @@ -//! Workspace traversal, file operations, search, and replacement previews. - -use super::encoding::{self, DocumentEncoding}; -use super::file_name_order::FileNameKey; -use super::search_index::{self, UpdateOutcome, WorkspaceSearchIndex}; -use super::search_paging::SearchPage; -use crate::protocol::{invalid_relative_path, CoreError, ErrorCode}; -use crate::protocol::{ - DirectoryListing, DirectoryListingResponse, FileReadResponse, FileWriteResponse, - ReplacementPreviewResponse, SearchMatch, SearchResponse, WorkspaceNode, - WorkspaceSnapshotResponse, -}; -use crate::remote::workspace_io as fs; -use regex::{Regex, RegexBuilder}; -use serde::{Deserialize, Serialize}; -use std::collections::{HashMap, HashSet}; -use std::path::{Path, PathBuf}; - -/// Default workspace exclusions, also used by Git discovery for generated directories. -/// The list lives in `lithe-core-rules`, the only definition every host shares. -pub(crate) use lithe_core_rules::visibility::BUILT_IN_HIDDEN_DIRECTORIES; -use lithe_core_rules::visibility::{ - self as shared_visibility, disabled_built_ins, glob_matches, is_disabled_directory, -}; -/// Whether an ordinary workspace event belongs to visible project inputs. -/// Callers first relativize to the most specific explicitly watched root; native -/// event-loss flags always bypass this filter and request recovery. -pub fn is_workspace_event_relevant(relative: &str) -> bool { - is_workspace_event_relevant_with(relative, &[]) -} - -/// Like [`is_workspace_event_relevant`], but a built-in folder the user turned -/// off with a `!` hidden-path entry is visible, so its events are kept. -fn is_workspace_event_relevant_with(relative: &str, hidden_file_patterns: &[String]) -> bool { - let disabled = disabled_built_ins(hidden_file_patterns); - !relative.split(['/', '\\']).any(|component| { - BUILT_IN_HIDDEN_DIRECTORIES.iter().any(|name| { - component.eq_ignore_ascii_case(name) && !is_disabled_directory(name, &disabled) - }) - }) -} - -/// Changed paths for `workspace.filterChanges`: a bare path list, or the -/// paths with the hidden-path entries whose `!` lines turn built-in folders off. -#[derive(Debug, Deserialize)] -#[serde(untagged)] -pub enum WorkspaceChangeFilterRequest { - Paths(Vec), - #[serde(rename_all = "camelCase")] - WithRules { - paths: Vec, - #[serde(default)] - hidden_file_patterns: Vec, - }, -} - -/// Keeps the changed paths outside the effective built-in hidden folders. -pub fn filter_workspace_changes(request: WorkspaceChangeFilterRequest) -> Vec { - let (paths, patterns) = match request { - WorkspaceChangeFilterRequest::Paths(paths) => (paths, Vec::new()), - WorkspaceChangeFilterRequest::WithRules { - paths, - hidden_file_patterns, - } => (paths, hidden_file_patterns), - }; - paths - .into_iter() - .filter(|path| is_workspace_event_relevant_with(path, &patterns)) - .collect() -} - -const MAX_FILE_SIZE: u64 = 2 * 1024 * 1024; -const MAX_OPEN_FILE_SIZE: u64 = 32 * 1024 * 1024; - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Workspace root and visibility overrides used to build a file-tree snapshot. -pub struct WorkspaceSnapshotRequest { - pub root: String, - /// Explicit roots of this workspace; nested roots own their inventories independently. - #[serde(default)] - pub workspace_roots: Vec, - #[serde(default)] - pub hidden_directory_names: Vec, - #[serde(default)] - pub hidden_file_patterns: Vec, - /// When false, the response keeps only the file list and returns `root` - /// without children; hosts that load the tree per directory skip encoding - /// and decoding a node for every path. - #[serde(default = "default_include_tree")] - pub include_tree: bool, -} - -fn default_include_tree() -> bool { - true -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Workspace-relative directories whose visible children a host is loading. -pub struct DirectoryListingRequest { - pub root: String, - /// Workspace-relative directories; an empty string is the root itself. - #[serde(default)] - pub paths: Vec, - #[serde(default)] - pub hidden_directory_names: Vec, - #[serde(default)] - pub hidden_file_patterns: Vec, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -/// Bounded file, content, and symbol search options. -pub struct SearchRequest { - /// Workspace-relative scopes, applied before result caps without changing policy ownership. - #[serde(default)] - pub scope_paths: Vec, - pub root: String, - /// Request-local ownership boundaries; omitted roots preserve single-root behavior. - #[serde(default)] - pub workspace_roots: Vec, - pub query: String, - #[serde(default)] - pub case_sensitive: bool, - #[serde(default)] - pub whole_words: bool, - #[serde(default)] - pub regular_expression: bool, - #[serde(default = "default_max_results")] - pub max_results: usize, - #[serde(default)] - pub max_file_results: Option, - #[serde(default)] - pub max_content_results: Option, - #[serde(default)] - pub max_symbol_results: Option, - /// Search Everywhere may discover generated file names without indexing their contents. - #[serde(default)] - pub include_generated_file_names: bool, - #[serde(default)] - pub hidden_directory_names: Vec, - #[serde(default)] - pub hidden_file_patterns: Vec, - /// Comma-separated file masks such as `*.md, *.txt`; empty means no filter. - #[serde(default)] - pub file_mask: String, - /// Case-insensitive glob fragments that a candidate path must match. - #[serde(default)] - pub include_path_patterns: Vec, - /// Case-insensitive glob fragments that exclude matching candidate paths. - #[serde(default)] - pub exclude_path_patterns: Vec, - /// Open and recent files that break ties between equal file-name matches. Paths are - /// relative to `root` with `/` separators; without it ordering is unchanged. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub context: Option, - /// Optional root prefix used solely for include/exclude matching. - #[serde(default)] - pub path_filter_prefix: String, - /// Include untrimmed lines and UTF-16 ranges for content matches. - #[serde(default)] - pub include_content_ranges: bool, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Workspace root and visibility rules used to warm or invalidate an index. -pub struct SearchIndexRequest { - pub root: String, - /// Explicit workspace roots used when warming this index variant. - #[serde(default)] - pub workspace_roots: Vec, - #[serde(default)] - pub hidden_directory_names: Vec, - #[serde(default)] - pub hidden_file_patterns: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Changed workspace-relative paths to apply to a cached search index. -pub struct SearchIndexUpdateRequest { - pub root: String, - /// Explicit workspace roots used when rebuilding after an update. - #[serde(default)] - pub workspace_roots: Vec, - #[serde(default)] - pub paths: Vec, - #[serde(default)] - pub hidden_directory_names: Vec, - #[serde(default)] - pub hidden_file_patterns: Vec, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -/// Search-index size and whether an incremental request required a rebuild. -pub struct SearchIndexStatusResponse { - pub file_count: usize, - /// Always zero: Core no longer indexes language symbols. The field stays - /// because the macOS bridge still decodes it as required. - pub symbol_count: usize, - pub posting_count: usize, - pub rebuilt: bool, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Search and replacement options used to produce a non-mutating preview. -pub struct ReplacementPreviewRequest { - pub root: String, - /// Nested roots are previewed through their own root and policy. - #[serde(default)] - pub workspace_roots: Vec, - pub query: String, - pub replacement: String, - #[serde(default)] - pub case_sensitive: bool, - #[serde(default)] - pub whole_words: bool, - #[serde(default)] - pub regular_expression: bool, - /// Preserve all-uppercase or initial-uppercase shape from literal matches. - #[serde(default)] - pub preserve_case: bool, - #[serde(default)] - pub file_mask: String, - #[serde(default)] - pub paths: Vec, - #[serde(default)] - pub text_overrides: HashMap, - #[serde(default)] - pub hidden_directory_names: Vec, - #[serde(default)] - pub hidden_file_patterns: Vec, -} - -/// Non-mutating replacement against an already captured text snapshot. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ReplacementTextRequest { - /// Original content; the host retains its identity and controls any write. - pub text: String, - /// Search expression interpreted identically to workspace search. - pub query: String, - /// Replacement text, including capture expansion for regex searches. - pub replacement: String, - /// Whether letter case must match exactly. - #[serde(default)] - pub case_sensitive: bool, - /// Require the shared word-boundary rules for literal searches. - #[serde(default)] - pub whole_words: bool, - /// Treat the query as a regular expression. - #[serde(default)] - pub regular_expression: bool, - /// When present, replace one match touching or following this location. - pub from: Option, -} - -/// A one-based source line and UTF-16 column. -#[derive(Debug, Deserialize)] -pub struct ReplacementTextPosition { - /// One-based line index. - pub line: usize, - /// One-based UTF-16 column. - pub column: usize, -} - -/// Computes replacement text without touching storage or editor state. -pub fn replace_text(request: ReplacementTextRequest) -> Result { - let query = request.query.trim(); - if query.is_empty() { - return Ok(serde_json::json!({"text": request.text, "count": 0})); - } - let matcher = Matcher::new( - query, - request.case_sensitive, - request.whole_words, - request.regular_expression, - )?; - let mut count = 0usize; - let mut lines = Vec::new(); - for (index, line) in request.text.split('\n').enumerate() { - crate::protocol::cancellation::check()?; - if let Some(position) = &request.from { - if count == 0 && index + 1 >= position.line.max(1) { - let column = if index + 1 == position.line.max(1) { - position.column.saturating_sub(1) - } else { - 0 - }; - if let Some(replaced) = - matcher.replace_first_from(line, &request.replacement, column) - { - lines.push(replaced); - count = 1; - continue; - } - } - lines.push(line.to_string()); - } else { - let (replaced, occurrences) = - matcher.replace_with_options(line, &request.replacement, false); - count += occurrences; - lines.push(replaced); - } - } - Ok(serde_json::json!({"text": lines.join("\n"), "count": count})) -} - -/// Provider-supplied snapshots for content search without filesystem access. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SearchTextBatchRequest { - /// User query, interpreted with the same rules as workspace search. - pub query: String, - /// Whether case must match exactly. - #[serde(default)] - pub case_sensitive: bool, - /// Restrict literal queries to shared word boundaries. - #[serde(default)] - pub whole_words: bool, - /// Interpret the query as a regular expression. - #[serde(default)] - pub regular_expression: bool, - /// Adjacent lines supplied for each displayed result. - #[serde(default)] - pub context_lines: usize, - /// Bounded host-read batch; Core does not resolve these opaque paths. - pub files: Vec, -} - -/// One provider-owned path and its captured content. -#[derive(Debug, Deserialize)] -pub struct SearchTextSnapshot { - /// Opaque identity returned without modification. - pub path: String, - /// Text read by the provider. - pub text: String, -} - -/// Applies workspace matching to provider snapshots in one bounded request. -pub fn search_text_batch(request: SearchTextBatchRequest) -> Result { - if request.files.len() > 32 - || request - .files - .iter() - .any(|file| file.text.len() > MAX_FILE_SIZE as usize) - || request - .files - .iter() - .map(|file| file.text.len()) - .sum::() - > 16 * 1024 * 1024 - { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Text search batch is too large", - )); - } - let query = request.query.trim(); - if query.is_empty() { - return Ok(serde_json::json!({"files": []})); - } - let matcher = Matcher::new( - query, - request.case_sensitive, - request.whole_words, - request.regular_expression, - )?; - let context = request.context_lines.min(20); - let mut files = Vec::new(); - for file in request.files { - crate::protocol::cancellation::check()?; - if file.text.contains('\0') { - continue; - } - let lines: Vec<_> = file.text.split('\n').collect(); - let mut matches = Vec::new(); - for (index, line) in lines.iter().enumerate() { - if !matcher.matches(line) { - continue; - } - let ranges = matcher.content_ranges(line); - matches.push( - serde_json::json!({"line": index + 1, "lineText": line, "ranges": ranges, - "contextBefore": &lines[index.saturating_sub(context)..index], - "contextAfter": &lines[index + 1..(index + 1 + context).min(lines.len())]}), - ); - } - if !matches.is_empty() { - files.push(serde_json::json!({"path": file.path, "matches": matches})); - } - } - Ok(serde_json::json!({"files": files})) -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Request to read one validated workspace-relative text file. -pub struct FileReadRequest { - pub root: String, - pub path: String, - /// Stable encoding id or alias to decode with; absent means detect. - #[serde(default)] - pub encoding: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Request to replace one validated workspace-relative text file. -pub struct FileWriteRequest { - pub root: String, - pub path: String, - pub text: String, - /// Stable encoding id or alias to encode with; absent means UTF-8. - #[serde(default)] - pub encoding: Option, - /// Overrides the encoding's own byte-order-mark choice (Unicode encodings only). - #[serde(default)] - pub bom: Option, -} - -fn default_max_results() -> usize { - 200 -} - -/// Scans visible workspace entries and invalidates any stale search index. -pub fn snapshot(request: WorkspaceSnapshotRequest) -> Result { - let root = existing_root(&request.root)?; - let indexing_policy = super::indexing::context(&root); - search_index::invalidate_root(&root); - let rules = VisibilityRules::new(request.hidden_directory_names, request.hidden_file_patterns) - .with_workspace_roots(&root, &request.workspace_roots); - let mut files = Vec::new(); - let mut nested_worktrees = Vec::new(); - let mut incomplete_directories = Vec::new(); - let node = if request.include_tree { - scan_node( - &root, - &root, - &rules, - &mut files, - &mut nested_worktrees, - &mut incomplete_directories, - )? - } else { - collect_snapshot_files( - &root, - &root, - &rules, - &mut files, - &mut nested_worktrees, - &mut incomplete_directories, - )?; - WorkspaceNode { - path: String::new(), - name: node_name(&root), - is_directory: true, - children: None, - } - }; - if let Some(policy) = indexing_policy { - policy.check()?; - } - Ok(WorkspaceSnapshotResponse { - root: node, - files, - nested_worktrees, - incomplete_directories, - }) -} - -/// Lists the visible children of each requested directory, one level deep. -/// -/// The project tree loads directories as they are expanded, so a workspace -/// with many files no longer builds a node for each of them up front. Paths -/// that no longer exist, are hidden, or lie inside a nested linked worktree -/// are left out of the response instead of failing the whole request, because -/// a directory can disappear between the host's expand and this call. -pub fn list_directories( - request: DirectoryListingRequest, -) -> Result { - let root = existing_root(&request.root)?; - let rules = VisibilityRules::new(request.hidden_directory_names, request.hidden_file_patterns); - let mut directories = Vec::new(); - for relative in request.paths { - crate::protocol::cancellation::check()?; - let Some(directory) = listable_directory(&root, &relative, &rules) else { - continue; - }; - let listing = match visible_children(&directory, &root, &rules, ChildOrder::Tree) { - Ok(listing) => listing, - Err(error) - if matches!( - error.code, - ErrorCode::PermissionDenied | ErrorCode::WorkspaceNotFound - ) => - { - continue - } - Err(error) => return Err(error), - }; - let children = listing - .children - .into_iter() - .map(|(path, is_directory)| WorkspaceNode { - path: relative_path(&path, &root), - name: node_name(&path), - is_directory, - children: if is_directory - && !super::indexing::is_excluded(&root, &path, true).unwrap_or(true) - && directory_is_visibly_empty(&path, &root, &rules) - { - Some(Vec::new()) - } else { - None - }, - }) - .collect(); - directories.push(DirectoryListing { - path: relative_path(&directory, &root), - children, - nested_worktrees: listing - .nested_worktrees - .iter() - .map(|worktree| relative_path(worktree, &root)) - .collect(), - }); - } - Ok(DirectoryListingResponse { directories }) -} - -/// Resolves a requested directory without following symlinks, so a listing -/// never reaches outside the workspace or into a directory scans skip. -fn listable_directory(root: &Path, relative: &str, rules: &VisibilityRules) -> Option { - if relative.is_empty() { - return Some(root.to_path_buf()); - } - if Path::new(relative).is_absolute() || invalid_relative_path(relative) { - return None; - } - let mut current = root.to_path_buf(); - for component in relative.split('/') { - current.push(component); - let metadata = fs::symlink_metadata(¤t).ok()?; - if !metadata.is_dir() || rules.is_hidden(&relative_path(¤t, root), true) { - return None; - } - } - let nested = - is_inside_nested_linked_worktree(root, relative) || is_nested_linked_worktree(¤t); - (!nested).then_some(current) -} - -/// Searches visible file paths and current file contents using the shared index. -pub fn search(request: SearchRequest) -> Result { - let root = existing_root(&request.root)?; - let indexing_policy = super::indexing::context(&root); - let query = request.query.trim().to_string(); - if query.is_empty() { - return Ok(SearchResponse { - matches: Vec::new(), - }); - } - - let rules = VisibilityRules::new( - request.hidden_directory_names.clone(), - request.hidden_file_patterns.clone(), - ) - .with_workspace_roots(&root, &request.workspace_roots); - let index = search_index::get_or_build(&root, &rules)?; - let index = index - .read() - .map_err(|_| CoreError::new(ErrorCode::Unknown, "Search index lock was poisoned"))?; - let result = search_with_index(&root, &request, &query, &index, None)?; - if let Some(policy) = &indexing_policy { - policy.check()?; - } - Ok(result) -} - -fn scope_allows(scopes: &[String], path: &str) -> bool { - scopes.is_empty() - || scopes.iter().any(|scope| { - #[cfg(windows)] - let (scope, path) = (scope.to_lowercase(), path.to_lowercase()); - scope.is_empty() - || scope == "." - || path == scope.as_str() - || path - .strip_prefix(scope.as_str()) - .is_some_and(|suffix| suffix.starts_with('/')) - }) -} - -fn search_with_index( - root: &Path, - request: &SearchRequest, - query: &str, - index: &WorkspaceSearchIndex, - page: Option<&SearchPage>, -) -> Result { - let matcher = Matcher::new( - query, - request.case_sensitive, - request.whole_words, - request.regular_expression, - )?; - let masks = parse_file_mask(&request.file_mask); - let path_filters = SearchPathFilters::new( - &request.include_path_patterns, - &request.exclude_path_patterns, - &request.path_filter_prefix, - )?; - let limit = request.max_results.max(1).min(10_000); - let file_limit = request.max_file_results.unwrap_or(limit).min(limit); - let content_limit = request.max_content_results.unwrap_or(limit).min(limit); - let mut matches = Vec::new(); - let mut file_matches = 0; - - for id in index.all_file_ids() { - crate::protocol::cancellation::check()?; - if matches.len() >= limit || file_matches >= file_limit { - break; - } - let Some(indexed_file) = index.file(id) else { - continue; - }; - let path = &indexed_file.path; - if !scope_allows(&request.scope_paths, path) - || !file_mask_allows(&masks, path) - || !path_filters.allows(path) - { - continue; - } - if matcher.matches(path) { - matches.push(SearchMatch { - kind: "file".to_string(), - path: path.clone(), - line: None, - preview: path.clone(), - symbol_name: None, - name_match_ranges: Vec::new(), - source_line: None, - content_match_ranges: Vec::new(), - matching_degree: None, - is_ignored: None, - }); - file_matches += 1; - } - } - - let mut content_matches = 0; - let mut candidates = index.candidate_ids(query, request.regular_expression); - if page.is_some() { - candidates.sort_by_key(|id| index.file(*id).map(|file| file.path.as_str())); - } - for id in candidates { - crate::protocol::cancellation::check()?; - if matches.len() >= limit || content_matches >= content_limit { - break; - } - let Some(indexed_file) = index.file(id) else { - continue; - }; - let path = &indexed_file.path; - if page.is_some_and(|page| !page.accepts_path(path)) - || !scope_allows(&request.scope_paths, path) - || !file_mask_allows(&masks, path) - || !path_filters.allows(path) - { - continue; - } - let file = root.join(path); - let Some(text) = read_searchable_text(&file) else { - continue; - }; - for (index, line) in text.split('\n').enumerate() { - crate::protocol::cancellation::check()?; - if matcher.matches(line) { - let result = SearchMatch { - kind: "content".to_string(), - path: path.clone(), - line: Some(index + 1), - preview: line.trim().to_string(), - symbol_name: None, - name_match_ranges: Vec::new(), - source_line: request.include_content_ranges.then(|| line.to_string()), - content_match_ranges: if request.include_content_ranges { - matcher.content_ranges(line) - } else { - Vec::new() - }, - matching_degree: None, - is_ignored: None, - }; - if page.is_some_and(|page| !page.accepts(&result)) { - continue; - } - matches.push(result); - content_matches += 1; - if matches.len() >= limit || content_matches >= content_limit { - break; - } - } - } - } - - Ok(SearchResponse { matches }) -} - -/// Combines file and content results for Search Everywhere. -/// -/// The `type` and `symbol` buckets stay in the paging contract but Core no -/// longer fills them: symbol knowledge belongs to language servers and -/// plugins, so a source-specific regex here would favour one language. -pub fn search_everywhere(request: SearchRequest) -> Result { - search_everywhere_with_page(request, None) -} - -/// Searches one bounded page when a continuation state is supplied. -pub(crate) fn search_everywhere_with_page( - request: SearchRequest, - mut page: Option<&mut SearchPage>, -) -> Result { - let root = existing_root(&request.root)?; - let indexing_policy = super::indexing::context(&root); - let query = request.query.trim().to_string(); - if query.is_empty() { - return Ok(SearchResponse { - matches: Vec::new(), - }); - } - let rules = VisibilityRules::new( - request.hidden_directory_names.clone(), - request.hidden_file_patterns.clone(), - ) - .with_workspace_roots(&root, &request.workspace_roots); - let matcher = Matcher::new( - &query, - request.case_sensitive, - request.whole_words, - request.regular_expression, - )?; - let masks = parse_file_mask(&request.file_mask); - let path_filters = SearchPathFilters::new( - &request.include_path_patterns, - &request.exclude_path_patterns, - &request.path_filter_prefix, - )?; - let limit = request.max_results.max(1).min(10_000); - let file_limit = if page.as_ref().is_some_and(|page| page.exhausted("file")) { - 0 - } else { - request.max_file_results.unwrap_or(limit).min(limit) - }; - let symbol_limit = if page - .as_ref() - .is_some_and(|page| page.exhausted("type") && page.exhausted("symbol")) - { - 0 - } else { - request.max_symbol_results.unwrap_or(limit).min(limit) - }; - let content_limit = if page.as_ref().is_some_and(|page| page.exhausted("content")) { - 0 - } else { - request.max_content_results.unwrap_or(limit).min(limit) - }; - let context_index = request - .context - .as_ref() - .map(super::name_ranking::ContextIndex::new); - let mut files = Vec::new(); - let mut types = Vec::new(); - let mut symbols = Vec::new(); - let mut last_flush = None; - if file_limit > 0 { - let name_rules = VisibilityRules { - // Generated output is a file-name source, not a content/symbol index. - hidden_directories: rules - .hidden_directories - .iter() - .filter(|name| { - !request.include_generated_file_names - || !BUILT_IN_HIDDEN_DIRECTORIES.contains(&name.as_str()) - || [".git", ".svn", ".worktree", ".worktrees"].contains(&name.as_str()) - }) - .cloned() - .collect(), - hidden_file_patterns: rules.hidden_file_patterns.clone(), - delegated_roots: rules.delegated_roots.clone(), - }; - let names = search_index::get_names(&root, &name_rules)?; - let names = names - .read() - .map_err(|_| CoreError::new(ErrorCode::Unknown, "Search index lock was poisoned"))?; - for id in names.all_file_ids() { - crate::protocol::cancellation::check()?; - let Some(file) = names.file(id) else { continue }; - if !scope_allows(&request.scope_paths, &file.path) - || !file_mask_allows(&masks, &file.path) - || !path_filters.allows(&file.path) - { - continue; - } - let name = file.path.rsplit('/').next().unwrap_or(&file.path); - let mut result = SearchMatch { - kind: "file".to_string(), - path: file.path.clone(), - line: None, - preview: file.path.clone(), - symbol_name: None, - name_match_ranges: Vec::new(), - source_line: None, - content_match_ranges: Vec::new(), - matching_degree: None, - is_ignored: None, - }; - if match_everywhere_name(&request, &matcher, name, &mut result) { - result.matching_degree.get_or_insert(0); - } else if match_everywhere_name(&request, &matcher, &file.path, &mut result) { - // Directory-only matches remain candidates (including Agent mentions), below every basename hit. - result.matching_degree = Some(-1); - result.name_match_ranges.clear(); - } else { - continue; - } - if let Some(index) = &context_index { - result.matching_degree = result - .matching_degree - .map(|degree| index.contextual_degree(degree, &file.path)); - } - if let Some(page) = page.as_ref() { - page.push(&mut files, result, file_limit); - } else { - files.push(result); - } - emit_search_results( - [ - (&mut files, file_limit), - (&mut types, symbol_limit), - (&mut symbols, symbol_limit), - ], - limit, - &mut last_flush, - std::time::Instant::now(), - false, - ); - } - } - emit_search_results( - [ - (&mut files, file_limit), - (&mut types, symbol_limit), - (&mut symbols, symbol_limit), - ], - limit, - &mut last_flush, - std::time::Instant::now(), - true, - ); - // Publish names before content parsing starts. - // Zero source caps are execution boundaries, not just post-scan truncation. - let shared_index = if content_limit > 0 { - Some(search_index::get_for_text(&root, &rules)?) - } else { - None - }; - let index = shared_index - .as_ref() - .map(|index| { - index - .read() - .map_err(|_| CoreError::new(ErrorCode::Unknown, "Search index lock was poisoned")) - }) - .transpose()?; - if let Some(page) = page.as_mut() { - page.finish("file", &mut files, file_limit); - page.finish("type", &mut types, symbol_limit); - page.finish("symbol", &mut symbols, symbol_limit); - } else { - for (bucket, cap) in [ - (&mut files, file_limit), - (&mut types, symbol_limit), - (&mut symbols, symbol_limit), - ] { - rank_search_bucket(bucket, cap); - } - } - let mut content_request = request.clone(); - content_request.max_file_results = Some(0); - if page.is_some() { - content_request.max_results = content_limit + 1; - content_request.max_content_results = Some(content_limit + 1); - } - let mut content = if content_limit > 0 { - search_with_index( - &root, - &content_request, - &query, - index - .as_ref() - .expect("content source requires content index"), - page.as_deref(), - )? - .matches - } else { - Vec::new() - }; - if let Some(page) = page.as_mut() { - page.finish("content", &mut content, content_limit); - } - let mut matches = files; - matches.extend(types); - matches.extend(symbols); - matches.extend(content); - matches.truncate(limit); - drop(index); - // Ask Git once for the bounded result set, not once per row or indexed file. - let paths: Vec = matches.iter().map(|item| item.path.clone()).collect(); - if let Some(ignored) = crate::git::ignored_paths(&root, &paths)? { - for item in &mut matches { - item.is_ignored = Some(ignored.contains(&item.path)); - } - } - if let Some(policy) = &indexing_policy { - policy.check()?; - } - Ok(SearchResponse { matches }) -} - -/// Stable total ordering shared by bounded collection and cursor continuation. -pub(crate) fn compare_search_matches(a: &SearchMatch, b: &SearchMatch) -> std::cmp::Ordering { - b.matching_degree - .cmp(&a.matching_degree) - .then_with(|| a.path.cmp(&b.path)) - .then_with(|| a.line.cmp(&b.line)) - .then_with(|| a.symbol_name.cmp(&b.symbol_name)) -} - -fn rank_search_bucket(bucket: &mut Vec, cap: usize) { - bucket.sort_by(compare_search_matches); - bucket.truncate(cap); -} - -// Replaceable snapshots let slow hosts keep only the newest batch. Ranking -// before each cap preserves the final top matches even when better hits arrive late. -fn emit_search_results( - buckets: [(&mut Vec, usize); 3], - limit: usize, - last_flush: &mut Option, - now: std::time::Instant, - force: bool, -) { - if !crate::git::execution_events::has_sink() - || buckets.iter().all(|(bucket, _)| bucket.is_empty()) - || (!force - && last_flush.is_some_and(|last| { - now.duration_since(last) < std::time::Duration::from_millis(100) - })) - { - return; - } - let mut matches = Vec::new(); - for (bucket, cap) in buckets { - rank_search_bucket(bucket, cap + 1); - matches.extend( - bucket - .iter() - .take(cap.min(limit.saturating_sub(matches.len()))) - .cloned(), - ); - } - *last_flush = Some(now); - crate::git::execution_events::emit(serde_json::json!({ - "type": "searchResults", "results": SearchResponse { matches } - })); -} - -fn match_everywhere_name( - request: &SearchRequest, - literal: &Matcher, - name: &str, - result: &mut SearchMatch, -) -> bool { - if request.regular_expression || request.whole_words || request.case_sensitive { - return literal.matches(name); - } - let Some(hit) = super::name_matching::match_name(request.query.trim(), name) else { - return false; - }; - result.name_match_ranges = hit.ranges; - result.matching_degree = Some(hit.degree); - true -} - -/// Computes per-file replacements without writing any workspace content. -pub fn replace_preview( - request: ReplacementPreviewRequest, -) -> Result { - let root = existing_root(&request.root)?; - let indexing_policy = super::indexing::context(&root); - let query = request.query.trim().to_string(); - if query.is_empty() { - return Ok(ReplacementPreviewResponse { files: Vec::new() }); - } - let rules = VisibilityRules::new( - request.hidden_directory_names.clone(), - request.hidden_file_patterns.clone(), - ) - .with_workspace_roots(&root, &request.workspace_roots); - let shared_index = search_index::get_or_build(&root, &rules)?; - let index = shared_index - .read() - .map_err(|_| CoreError::new(ErrorCode::Unknown, "Search index lock was poisoned"))?; - let matcher = Matcher::new( - &query, - request.case_sensitive, - request.whole_words, - request.regular_expression, - )?; - let candidate_ids = index.candidate_ids(&query, request.regular_expression); - let candidate_set = candidate_ids.iter().copied().collect::>(); - let paths = if request.paths.is_empty() { - candidate_ids - .into_iter() - .filter_map(|id| index.file(id).map(|file| file.path.clone())) - .collect::>() - } else { - let mut paths = Vec::new(); - for value in &request.paths { - let relative = safe_relative_path_string(value)?; - let include = request.text_overrides.contains_key(&relative) - || index - .id_for_path(&relative) - .map(|id| candidate_set.contains(&id)) - .unwrap_or(true); - if include { - paths.push(relative); - } - } - paths - }; - drop(index); - let masks = parse_file_mask(&request.file_mask); - let mut files = Vec::new(); - for path in paths { - crate::protocol::cancellation::check()?; - let relative = safe_relative_path_string(&path)?; - if !file_mask_allows(&masks, &relative) { - continue; - } - let file = root.join(&relative); - if rules.is_delegated(&file) || super::indexing::is_excluded(&root, &file, false)? { - continue; - } - let text = request - .text_overrides - .get(&relative) - .cloned() - .or_else(|| read_searchable_text(&file)); - let Some(text) = text else { continue }; - let mut matches = Vec::new(); - let mut replaced_lines = Vec::new(); - for (index, line) in text.split('\n').enumerate() { - crate::protocol::cancellation::check()?; - let (after, occurrence_count) = - matcher.replace_with_options(line, &request.replacement, request.preserve_case); - replaced_lines.push(after.clone()); - if occurrence_count > 0 { - matches.push(crate::protocol::ReplacementMatch { - line: index + 1, - before: line.to_string(), - after, - occurrence_count, - }); - } - } - if !matches.is_empty() { - files.push(crate::protocol::ReplacementFile { - path: relative, - matches, - replacement_text: replaced_lines.join("\n"), - }); - } - } - files.sort_by(|left, right| left.path.cmp(&right.path)); - if let Some(policy) = &indexing_policy { - policy.check()?; - } - Ok(ReplacementPreviewResponse { files }) -} - -/// Builds or reuses the search index and reports its current size. -pub fn warm_search_index( - request: SearchIndexRequest, -) -> Result { - let root = existing_root(&request.root)?; - let rules = VisibilityRules::new(request.hidden_directory_names, request.hidden_file_patterns) - .with_workspace_roots(&root, &request.workspace_roots); - let index = search_index::get_or_build(&root, &rules)?; - let index = index - .read() - .map_err(|_| CoreError::new(ErrorCode::Unknown, "Search index lock was poisoned"))?; - let stats = index.stats(); - Ok(SearchIndexStatusResponse { - file_count: stats.file_count, - symbol_count: 0, - posting_count: stats.posting_count, - rebuilt: false, - }) -} - -/// Applies changed paths to a cached index, rebuilding when its rules changed. -pub fn update_search_index( - request: SearchIndexUpdateRequest, -) -> Result { - let root = existing_root(&request.root)?; - let rules = VisibilityRules::new(request.hidden_directory_names, request.hidden_file_patterns) - .with_workspace_roots(&root, &request.workspace_roots); - if request.paths.iter().any(|path| { - path.ends_with(".gitignore") - || path.contains(".git/") - || path.ends_with(".git") - || root.join(path).is_dir() - }) { - super::indexing::refresh(&root)?; - } - let outcome = search_index::update_paths(&root, &rules, &request.paths); - let rebuilt = matches!(outcome, UpdateOutcome::RequiresRebuild); - if rebuilt { - search_index::invalidate_root(&root); - } - let index = search_index::get_or_build(&root, &rules)?; - // Refresh starts with no Git decisions. Publish only after traversal has - // resolved them, otherwise providers briefly (or permanently) lose exclusions. - super::super::lsp::indexing_changed(&root)?; - let index = index - .read() - .map_err(|_| CoreError::new(ErrorCode::Unknown, "Search index lock was poisoned"))?; - let stats = index.stats(); - Ok(SearchIndexStatusResponse { - file_count: stats.file_count, - symbol_count: 0, - posting_count: stats.posting_count, - rebuilt: rebuilt || matches!(outcome, UpdateOutcome::NotIndexed), - }) -} - -/// Drops the cached index for one validated workspace root. -pub fn invalidate_search_index(request: SearchIndexRequest) -> Result<(), CoreError> { - let requested_root = PathBuf::from(&request.root); - let root = search_index::canonicalize_with_missing_components(&requested_root) - .unwrap_or(requested_root); - search_index::invalidate_root(&root); - Ok(()) -} - -/// Reads one bounded, workspace-contained text file. -/// -/// Without `encoding` the codec is detected from the BOM and bytes (see -/// [`super::encoding`]); with one, the bytes are decoded leniently through it. -pub fn read_file(request: FileReadRequest) -> Result { - let root = existing_root(&request.root)?; - let path = safe_relative_path(&root, &request.path)?; - let requested = request - .encoding - .as_deref() - .map(DocumentEncoding::parse) - .transpose()?; - let metadata = fs::metadata(&path)?; - if metadata.len() > MAX_OPEN_FILE_SIZE { - return Err(too_large()); - } - let bytes = fs::read(&path)?; - let decoded = decode_text(&bytes, requested)?; - Ok(FileReadResponse { - path: relative_path(&path, &root), - text: decoded.text, - encoding: decoded.encoding, - has_bom: decoded.has_bom, - lossy: decoded.lossy, - identity: decoded.identity, - }) -} - -/// Bytes a host already holds, as base64, to decode as `file.read` would. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct TextDecodeRequest { - /// Standard base64 of the exact bytes. - pub bytes: String, - /// Stable encoding id or alias to decode with; absent means detect. - #[serde(default)] - pub encoding: Option, -} - -/// `file.read`'s answer without a path. -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct TextDecodeResponse { - pub text: String, - /// Stable id of the encoding used to decode `text`. - pub encoding: String, - /// A leading byte order mark was present and is not part of `text`. - pub has_bom: bool, - /// Invalid bytes were replaced with U+FFFD (explicit `encoding` only). - pub lossy: bool, - /// SHA-256 (lowercase hex) of the exact bytes decoded. - pub identity: String, -} - -/// Text to encode as `file.write` would, without a file. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct TextEncodeRequest { - pub text: String, - /// Stable encoding id or alias to encode with; absent means UTF-8. - #[serde(default)] - pub encoding: Option, - /// Overrides the encoding's own byte-order-mark choice (Unicode encodings only). - #[serde(default)] - pub bom: Option, -} - -/// `file.write`'s answer with the encoded bytes in place of a path. -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct TextEncodeResponse { - /// Standard base64 of the exact encoded bytes, including any byte order mark. - pub bytes: String, - /// Stable id of the encoding the text was encoded with. - pub encoding: String, - /// A byte order mark was written. - pub has_bom: bool, - /// SHA-256 (lowercase hex) of the encoded bytes. - pub identity: String, -} - -/// `text.decode`: the decoding of `file.read` for bytes the host read itself, -/// so a host that guards its own file access needs no temporary file. -pub fn decode_text_bytes(request: TextDecodeRequest) -> Result { - use base64::Engine as _; - // Base64 is 4 characters per 3 bytes: anything longer is over the limit - // before it is decoded into memory. - if request.bytes.len() as u64 > MAX_OPEN_FILE_SIZE.div_ceil(3) * 4 { - return Err(too_large()); - } - let bytes = base64::engine::general_purpose::STANDARD - .decode(request.bytes.as_bytes()) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid text decode request") - .with_details(error.to_string()) - })?; - if bytes.len() as u64 > MAX_OPEN_FILE_SIZE { - return Err(too_large()); - } - let requested = request - .encoding - .as_deref() - .map(DocumentEncoding::parse) - .transpose()?; - decode_text(&bytes, requested) -} - -/// `text.encode`: the encoding of `file.write`, returning the bytes instead of -/// writing them. An unmappable character fails the same way. -pub fn encode_text(request: TextEncodeRequest) -> Result { - use base64::Engine as _; - let selected = request - .encoding - .as_deref() - .map(DocumentEncoding::parse) - .transpose()? - .unwrap_or(DocumentEncoding::Utf8); - let bytes = encoding::encode(&request.text, selected, request.bom)?; - Ok(TextEncodeResponse { - encoding: selected.id().to_string(), - has_bom: writes_bom(selected, request.bom), - identity: encoding::bytes_identity(&bytes), - bytes: base64::engine::general_purpose::STANDARD.encode(&bytes), - }) -} - -/// Decodes document bytes and refuses anything that is not plain text. -fn decode_text( - bytes: &[u8], - requested: Option, -) -> Result { - let decoded = encoding::decode(bytes, requested)?; - if !is_plain_text(&decoded.text) { - return Err(CoreError::new( - ErrorCode::ParseFailed, - "File is not plain text", - )); - } - Ok(TextDecodeResponse { - text: decoded.text, - encoding: decoded.encoding.id().to_string(), - has_bom: decoded.had_bom, - lossy: decoded.lossy, - identity: encoding::bytes_identity(bytes), - }) -} - -fn too_large() -> CoreError { - CoreError::new(ErrorCode::ParseFailed, "File is too large to open") - .with_details("The maximum supported file size is 32 MB") -} - -/// Whether encoding with `selected` and the `bom` override writes a byte order mark. -fn writes_bom(selected: DocumentEncoding, bom: Option) -> bool { - bom.unwrap_or(matches!( - selected, - DocumentEncoding::Utf8Bom | DocumentEncoding::Utf16LeBom | DocumentEncoding::Utf16BeBom - )) -} - -/// Replaces one workspace-contained file and refreshes its cached search entry. -/// -/// Without `encoding`/`bom` the text is written as plain UTF-8, as before. -pub fn write_file(request: FileWriteRequest) -> Result { - let root = existing_root(&request.root)?; - let path = writable_relative_path(&root, &request.path)?; - let selected = request - .encoding - .as_deref() - .map(DocumentEncoding::parse) - .transpose()? - .unwrap_or(DocumentEncoding::Utf8); - // Encode before touching the file system so an unmappable character leaves - // the existing file and directory tree untouched. - let bytes = encoding::encode(&request.text, selected, request.bom)?; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - fs::write(&path, &bytes).map_err(|error| { - CoreError::new(ErrorCode::PermissionDenied, "Could not write file") - .with_details(error.to_string()) - })?; - search_index::update_cached_file(&root, &path); - let has_bom = writes_bom(selected, request.bom); - Ok(FileWriteResponse { - path: relative_path(&path, &root), - bytes_written: bytes.len(), - encoding: selected.id().to_string(), - has_bom, - identity: encoding::bytes_identity(&bytes), - }) -} - -/// Effective hidden-path rules plus the nested roots this request delegates. -/// -/// Matching is `lithe-core-rules`' [`shared_visibility::VisibilityRules`], the same code the -/// hosts reach through `workspace.classifyVisibility`; only the delegated roots, which depend on -/// the file system, are added here. -pub(crate) struct VisibilityRules { - pub(crate) hidden_directories: Vec, - pub(crate) hidden_file_patterns: Vec, - /// Canonical nested roots delegated by this request, never by the global policy registry. - pub(crate) delegated_roots: Vec, -} - -impl VisibilityRules { - /// Adds the built-in rules except those turned off by a `!` entry. - pub(crate) fn new(hidden_directories: Vec, hidden_files: Vec) -> Self { - let rules = shared_visibility::VisibilityRules::new(hidden_directories, hidden_files); - Self { - hidden_directories: rules.hidden_directories, - hidden_file_patterns: rules.hidden_file_patterns, - delegated_roots: Vec::new(), - } - } - - /// Binds scan ownership to the caller's workspace without changing directory browsing. - fn with_workspace_roots(mut self, root: &Path, roots: &[String]) -> Self { - for value in roots { - // Disconnected siblings must not fail an otherwise valid scoped request. - // Missing descendants still retain their boundary until the host removes the root. - let candidate = search_index::canonicalize_with_missing_components(Path::new(value)) - .unwrap_or_else(|| PathBuf::from(value)); - if candidate != root && candidate.starts_with(root) { - self.delegated_roots.push(candidate); - } - } - self.delegated_roots.sort(); - self.delegated_roots.dedup(); - self - } - - pub(crate) fn is_delegated(&self, path: &Path) -> bool { - self.delegated_roots - .iter() - .any(|root| path.starts_with(root)) - } - - pub(crate) fn is_hidden(&self, path: &str, is_directory: bool) -> bool { - shared_visibility::is_path_hidden( - &self.hidden_directories, - &self.hidden_file_patterns, - path, - Some(is_directory), - ) - } -} - -fn scan_node( - path: &Path, - root: &Path, - rules: &VisibilityRules, - files: &mut Vec, - nested_worktrees: &mut Vec, - incomplete_directories: &mut Vec, -) -> Result { - crate::protocol::cancellation::check()?; - let metadata = fs::symlink_metadata(path)?; - let relative = relative_path(path, root); - let name = path - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or("Workspace") - .to_string(); - if rules.is_delegated(path) || super::indexing::is_excluded(root, path, metadata.is_dir())? { - return Ok(WorkspaceNode { - path: relative, - name, - is_directory: metadata.is_dir(), - children: None, - }); - } - if !metadata.is_dir() { - files.push(relative.clone()); - return Ok(WorkspaceNode { - path: relative, - name, - is_directory: false, - children: None, - }); - } - - let listing = visible_children(path, root, rules, ChildOrder::Traversal)?; - nested_worktrees.extend( - listing - .nested_worktrees - .iter() - .map(|worktree| relative_path(worktree, root)), - ); - let mut visible_children = Vec::new(); - for (child_path, _) in listing.children { - crate::protocol::cancellation::check()?; - match scan_node( - &child_path, - root, - rules, - files, - nested_worktrees, - incomplete_directories, - ) { - Ok(child) => visible_children.push(child), - Err(error) - if matches!( - error.code, - ErrorCode::PermissionDenied | ErrorCode::WorkspaceNotFound - ) => - { - incomplete_directories.push(relative_path(&child_path, root)); - crate::diagnostics::core_events::directory_skipped( - "app.workspace.directory_skipped", - &relative_path(&child_path, root), - &error.code, - ); - } - Err(error) => return Err(error), - } - } - Ok(WorkspaceNode { - path: relative, - name, - is_directory: true, - children: Some(visible_children), - }) -} - -/// Collects the snapshot file list without building tree nodes. -fn collect_snapshot_files( - path: &Path, - root: &Path, - rules: &VisibilityRules, - files: &mut Vec, - nested_worktrees: &mut Vec, - incomplete_directories: &mut Vec, -) -> Result<(), CoreError> { - crate::protocol::cancellation::check()?; - if rules.is_delegated(path) || super::indexing::is_excluded(root, path, true)? { - return Ok(()); - } - let listing = visible_children(path, root, rules, ChildOrder::Traversal)?; - nested_worktrees.extend( - listing - .nested_worktrees - .iter() - .map(|worktree| relative_path(worktree, root)), - ); - for (child_path, is_directory) in listing.children { - if is_directory { - // A directory that vanished mid-scan is skipped, as in `scan_node`. - if let Err(error) = collect_snapshot_files( - &child_path, - root, - rules, - files, - nested_worktrees, - incomplete_directories, - ) { - if !matches!( - error.code, - ErrorCode::PermissionDenied | ErrorCode::WorkspaceNotFound - ) { - return Err(error); - } - incomplete_directories.push(relative_path(&child_path, root)); - crate::diagnostics::core_events::directory_skipped( - "app.workspace.directory_skipped", - &relative_path(&child_path, root), - &error.code, - ); - } - } else { - if super::indexing::is_excluded(root, &child_path, false)? { - continue; - } - files.push(relative_path(&child_path, root)); - } - } - Ok(()) -} - -fn node_name(path: &Path) -> String { - path.file_name() - .and_then(|value| value.to_str()) - .unwrap_or("Workspace") - .to_string() -} - -/// How `visible_children` orders the siblings it returns. Directories always come first. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum ChildOrder { - /// The rows the project tree displays: IDEA's natural file-name order, where digit - /// runs compare by value (see `file_name_order`). - Tree, - /// The sequence behind flat file lists and the search index: lowercased names in - /// byte order. Consumers that keep only the first N entries of that sequence, such - /// as Windows Quick Open (the first 100 matches of the snapshot list), depend on it, - /// so a display-order change must not reorder it. - Traversal, -} - -/// Visible children of one directory in the requested order, plus the nested linked -/// worktrees that were left out of it. -pub(crate) struct VisibleDirectory { - /// Child paths and whether each is a directory, sorted by the requested `ChildOrder`. - pub(crate) children: Vec<(PathBuf, bool)>, - pub(crate) nested_worktrees: Vec, -} - -#[cfg(test)] -thread_local! { - /// Scoped enumeration trace for pruning regressions; absent in production builds. - pub(crate) static DIRECTORY_VISITS: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; -} - -/// Lists the children that workspace scans descend into. Snapshot and search -/// share this so the tree, the file list, and the index always agree. -pub(crate) fn visible_children( - path: &Path, - root: &Path, - rules: &VisibilityRules, - order: ChildOrder, -) -> Result { - #[cfg(test)] - DIRECTORY_VISITS.with_borrow_mut(|visits| { - if let Some(visits) = visits { - visits.push(path.to_path_buf()); - } - }); - let mut nested_worktrees = Vec::new(); - let mut children = fs::read_dir(path) - .map_err(CoreError::from)? - .filter_map(Result::ok) - .filter_map(|entry| visible_child(entry, root, rules, &mut nested_worktrees)) - .collect::>(); - match order { - // Siblings follow IDEA's Project view: directories first, then natural file-name - // order, so `v0.1.4.md` precedes `v0.1.12.md`. - ChildOrder::Tree => children.sort_by_cached_key(|(path, is_directory)| { - ( - std::cmp::Reverse(*is_directory), - FileNameKey::new( - path.file_name() - .and_then(|value| value.to_str()) - .unwrap_or_default(), - ), - ) - }), - ChildOrder::Traversal => children.sort_by(|left, right| { - right.1.cmp(&left.1).then_with(|| { - left.0 - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or_default() - .to_lowercase() - .cmp( - &right - .0 - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or_default() - .to_lowercase(), - ) - }) - }), - } - nested_worktrees.sort(); - Ok(VisibleDirectory { - children, - nested_worktrees, - }) -} - -/// Checks only until the first visible entry; unreadable directories remain expandable. -fn directory_is_visibly_empty(path: &Path, root: &Path, rules: &VisibilityRules) -> bool { - let Ok(mut entries) = fs::read_dir(path) else { - return false; - }; - !entries.any(|entry| match entry { - Ok(entry) => visible_child(entry, root, rules, &mut Vec::new()).is_some(), - Err(_) => true, - }) -} - -/// Applies the same visibility boundary to full listings and leaf probes. -fn visible_child( - entry: fs::DirEntry, - root: &Path, - rules: &VisibilityRules, - nested_worktrees: &mut Vec, -) -> Option<(PathBuf, bool)> { - let child_path = entry.path(); - // Both native and remote readDir already supplied the non-following type; - // re-statting every file would cost one network round trip per entry. - let file_type = entry.file_type().ok()?; - if file_type.is_symlink() - || rules.is_hidden(&relative_path(&child_path, root), file_type.is_dir()) - { - return None; - } - if file_type.is_dir() && is_nested_linked_worktree(&child_path) { - nested_worktrees.push(child_path); - return None; - } - Some((child_path, file_type.is_dir())) -} - -/// A `.git` pointer file is a single `gitdir:` line; anything larger is not one. -const MAX_GIT_POINTER_FILE_SIZE: u64 = 4 * 1024; - -/// Returns whether `directory` is a linked worktree checked out inside the main -/// worktree of the repository that owns it, such as `.claude/worktrees/`. -/// -/// Such a checkout repeats the project's own files, and agent tools can create -/// several of them, so scanning them multiplies the tree, index, and watcher -/// load. Git records a linked worktree as `/.git/worktrees/`; -/// submodules (`.git/modules/...`), bare repositories, and worktrees placed -/// outside their main checkout do not match and stay visible. -pub(crate) fn is_nested_linked_worktree(directory: &Path) -> bool { - let marker = directory.join(".git"); - let Ok(metadata) = fs::symlink_metadata(&marker) else { - return false; - }; - if !metadata.is_file() || metadata.len() > MAX_GIT_POINTER_FILE_SIZE { - return false; - } - let Ok(text) = fs::read_to_string(&marker) else { - return false; - }; - let Some(git_directory) = text - .lines() - .next() - .and_then(|line| line.strip_prefix("gitdir:")) - .map(str::trim) - .filter(|value| !value.is_empty()) - else { - return false; - }; - let git_directory = directory.join(git_directory); - let Some(worktrees) = git_directory.parent() else { - return false; - }; - if worktrees.file_name().and_then(|name| name.to_str()) != Some("worktrees") { - return false; - } - let Some(common_directory) = worktrees.parent() else { - return false; - }; - if common_directory.file_name().and_then(|name| name.to_str()) != Some(".git") { - return false; - } - let Some(main_worktree) = common_directory.parent() else { - return false; - }; - let (Ok(main_worktree), Ok(directory)) = - (fs::canonicalize(main_worktree), fs::canonicalize(directory)) - else { - return false; - }; - directory != main_worktree && directory.starts_with(&main_worktree) -} - -/// Returns whether a workspace-relative path lies below a nested linked -/// worktree. Used for watcher-reported paths that never went through a scan. -pub(crate) fn is_inside_nested_linked_worktree(root: &Path, relative: &str) -> bool { - let components = relative - .split('/') - .filter(|value| !value.is_empty()) - .collect::>(); - let mut directory = root.to_path_buf(); - for component in components.iter().take(components.len().saturating_sub(1)) { - directory.push(component); - if is_nested_linked_worktree(&directory) { - return true; - } - } - false -} - -pub(crate) fn existing_root(value: &str) -> Result { - let path = PathBuf::from(value); - let metadata = fs::metadata(&path) - .map_err(|_| CoreError::new(ErrorCode::WorkspaceNotFound, "Workspace does not exist"))?; - if !metadata.is_dir() { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Workspace root must be a directory", - )); - } - fs::canonicalize(path).map_err(CoreError::from) -} - -fn safe_relative_path(root: &Path, value: &str) -> Result { - let relative = Path::new(value); - if relative.is_absolute() || invalid_relative_path(value) { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Path must be relative to the workspace", - )); - } - let path = root.join(relative); - let canonical = fs::canonicalize(path).map_err(CoreError::from)?; - if !canonical.starts_with(root) { - return Err(CoreError::new( - ErrorCode::PermissionDenied, - "Path is outside the workspace", - )); - } - Ok(canonical) -} - -fn writable_relative_path(root: &Path, value: &str) -> Result { - let relative = Path::new(value); - if relative.is_absolute() || invalid_relative_path(value) { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Path must be relative to the workspace", - )); - } - let path = root.join(relative); - if let Some(parent) = path.parent() { - let mut existing_parent = parent; - while !fs::try_exists(existing_parent)? { - existing_parent = existing_parent.parent().ok_or_else(|| { - CoreError::new(ErrorCode::PermissionDenied, "Path is outside the workspace") - })?; - } - let canonical_parent = fs::canonicalize(existing_parent).map_err(CoreError::from)?; - if !canonical_parent.starts_with(root) { - return Err(CoreError::new( - ErrorCode::PermissionDenied, - "Path is outside the workspace", - )); - } - } - if let Ok(metadata) = fs::symlink_metadata(&path) { - if metadata.file_type().is_symlink() { - return Err(CoreError::new( - ErrorCode::PermissionDenied, - "Path is outside the workspace", - )); - } - let canonical = fs::canonicalize(&path).map_err(CoreError::from)?; - if !canonical.starts_with(root) { - return Err(CoreError::new( - ErrorCode::PermissionDenied, - "Path is outside the workspace", - )); - } - } - Ok(path) -} - -pub(crate) fn relative_path(path: &Path, root: &Path) -> String { - path.strip_prefix(root) - .unwrap_or(path) - .to_string_lossy() - .replace('\\', "/") - .trim_matches('/') - .to_string() -} - -/// Matches IDEA's Preserve Case behavior for literal replacement text. -/// -/// All-uppercase matches uppercase the replacement, initial-uppercase matches -/// capitalize it, and camelCase or mixed-case matches leave it unchanged. -fn apply_case_pattern(matched: &str, replacement: &str) -> String { - let letters = matched.chars().filter(|value| value.is_alphabetic()); - let mut has_lower = false; - let mut has_upper = false; - for letter in letters { - if letter.is_lowercase() { - has_lower = true; - } else if letter.is_uppercase() { - has_upper = true; - } - } - // With no letters there is no case shape to preserve. - if !has_lower && !has_upper { - return replacement.to_string(); - } - // Require multiple letters so a single `F` is not mistaken for SCREAMING_CASE. - let letter_count = matched - .chars() - .filter(|value| value.is_alphabetic()) - .count(); - if has_upper && !has_lower && letter_count > 1 { - return replacement.to_uppercase(); - } - let first_is_upper = matched - .chars() - .find(|value| value.is_alphabetic()) - .is_some_and(char::is_uppercase); - if first_is_upper { - let mut characters = replacement.chars(); - return match characters.next() { - Some(first) => first.to_uppercase().collect::() + characters.as_str(), - None => String::new(), - }; - } - replacement.to_string() -} - -/// Include/exclude fragments are compiled once and applied before page caps. -struct SearchPathFilters { - includes: Vec, - excludes: Vec, - prefix: String, -} - -impl SearchPathFilters { - fn new(includes: &[String], excludes: &[String], prefix: &str) -> Result { - fn compile(patterns: &[String]) -> Result, CoreError> { - patterns - .iter() - .filter(|pattern| !pattern.trim().is_empty()) - .map(|pattern| { - let mut source = String::new(); - let mut chars = pattern.trim().chars().peekable(); - while let Some(character) = chars.next() { - match character { - '*' if chars.peek() == Some(&'*') => { - chars.next(); - source.push_str(".*"); - } - '*' => source.push_str("[^/]*"), - '?' => source.push_str("[^/]"), - other => source.push_str(®ex::escape(&other.to_string())), - } - } - RegexBuilder::new(&source) - .case_insensitive(true) - .build() - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid search path filter") - .with_details(error.to_string()) - }) - }) - .collect() - } - Ok(Self { - includes: compile(includes)?, - excludes: compile(excludes)?, - prefix: prefix.trim_end_matches('/').to_string(), - }) - } - - fn allows(&self, path: &str) -> bool { - if self.includes.is_empty() && self.excludes.is_empty() { - return true; - } - let path = if self.prefix.is_empty() { - path.to_string() - } else { - format!("{}/{}", self.prefix, path) - }; - (self.includes.is_empty() || self.includes.iter().any(|filter| filter.is_match(&path))) - && !self.excludes.iter().any(|filter| filter.is_match(&path)) - } -} - -/// Splits a mask list such as `*.md, *.txt`; an empty list disables filtering. -fn parse_file_mask(mask: &str) -> Vec { - mask.split(',') - .map(|part| part.trim()) - .filter(|part| !part.is_empty()) - .map(|part| part.to_string()) - .collect() -} - -/// Matches masks against the file name only and accepts any matching pattern. -fn file_mask_allows(masks: &[String], path: &str) -> bool { - if masks.is_empty() { - return true; - } - let name = path.rsplit('/').next().unwrap_or(path); - masks.iter().any(|mask| glob_matches(mask, name)) -} - -/// Literal or regular-expression search semantics compiled for repeated matches. -struct Matcher { - plain_query: String, - regex: Option, - case_sensitive: bool, - whole_words: bool, -} - -impl Matcher { - /// Projects original byte ranges into the editor's UTF-16 coordinate space. - fn content_ranges(&self, text: &str) -> Vec { - let ranges = if let Some(regex) = &self.regex { - regex - .find_iter(text) - .map(|item| (item.start(), item.end())) - .collect::>() - } else { - self.literal_match_ranges(text) - }; - let (mut offset, mut units) = (0, 0); - ranges - .into_iter() - .map(|(start, end)| { - units += text[offset..start].encode_utf16().count(); - let first = units; - units += text[start..end].encode_utf16().count(); - offset = end; - crate::protocol::SearchNameRange { - start: first, - length: units - first, - } - }) - .collect() - } - - fn new( - query: &str, - case_sensitive: bool, - whole_words: bool, - regular_expression: bool, - ) -> Result { - if regular_expression { - let pattern = query.to_string(); - let regex = RegexBuilder::new(&pattern) - .case_insensitive(!case_sensitive) - .build() - .map_err(|error| { - CoreError::new(ErrorCode::ParseFailed, "Invalid search expression") - .with_details(error.to_string()) - })?; - Ok(Self { - plain_query: query.to_string(), - regex: Some(regex), - case_sensitive, - whole_words: false, - }) - } else { - Ok(Self { - plain_query: query.to_string(), - regex: None, - case_sensitive, - whole_words, - }) - } - } - - fn matches(&self, text: &str) -> bool { - if let Some(regex) = &self.regex { - return regex.is_match(text); - } - let (haystack, needle) = if self.case_sensitive { - (text.to_string(), self.plain_query.clone()) - } else { - (text.to_lowercase(), self.plain_query.to_lowercase()) - }; - if !self.whole_words { - return haystack.contains(&needle); - } - haystack.match_indices(&needle).any(|(start, _)| { - let end = start + needle.len(); - let before = haystack[..start].chars().next_back(); - let after = haystack[end..].chars().next(); - !before.is_some_and(is_word_character) && !after.is_some_and(is_word_character) - }) - } - - /// Finds literal matches in the original line, preserving UTF-8 boundaries. - fn literal_match_ranges(&self, text: &str) -> Vec<(usize, usize)> { - let haystack = if self.case_sensitive { - text.to_string() - } else { - text.to_lowercase() - }; - let needle = if self.case_sensitive { - self.plain_query.clone() - } else { - self.plain_query.to_lowercase() - }; - if needle.is_empty() { - return Vec::new(); - } - let ranges = haystack - .match_indices(&needle) - .filter_map(|(start, matched)| { - let end = start + matched.len(); - if self.whole_words { - let before = haystack[..start].chars().next_back(); - let after = haystack[end..].chars().next(); - if before.is_some_and(is_word_character) || after.is_some_and(is_word_character) - { - return None; - } - } - Some((start, end)) - }) - .collect::>(); - // Lowercase expansions can change byte offsets relative to the original. - if self.case_sensitive { - ranges - } else { - original_ranges_for_lowercase_matches(text, &ranges) - } - } - - /// Preserve Case applies only to literal replacements; regex keeps captures. - fn replace_with_options( - &self, - text: &str, - replacement: &str, - preserve_case: bool, - ) -> (String, usize) { - if let Some(regex) = &self.regex { - let count = regex.find_iter(text).count(); - return (regex.replace_all(text, replacement).into_owned(), count); - } - let ranges = self.literal_match_ranges(text); - let mut result = String::with_capacity(text.len()); - let mut cursor = 0; - for (start, end) in &ranges { - result.push_str(&text[cursor..*start]); - if preserve_case { - result.push_str(&apply_case_pattern(&text[*start..*end], replacement)); - } else { - result.push_str(replacement); - } - cursor = *end; - } - result.push_str(&text[cursor..]); - (result, ranges.len()) - } - - /// Finds the first replacement touching or following a UTF-16 column. - fn replace_first_from(&self, text: &str, replacement: &str, column: usize) -> Option { - let mut utf16 = 0usize; - let mut column_byte = text.len(); - for (index, character) in text.char_indices() { - if utf16 + character.len_utf16() > column { - column_byte = index; - break; - } - utf16 += character.len_utf16(); - } - let follows = |start: usize, end: usize| { - start >= column_byte || (column_byte >= start && column_byte < end) - }; - let (start, end, value) = if let Some(regex) = &self.regex { - let captures = regex.captures_iter(text).find(|capture| { - capture - .get(0) - .is_some_and(|matched| follows(matched.start(), matched.end())) - })?; - let matched = captures.get(0)?; - let mut value = String::new(); - captures.expand(replacement, &mut value); - (matched.start(), matched.end(), value) - } else { - let (start, end) = self - .literal_match_ranges(text) - .into_iter() - .find(|(start, end)| follows(*start, *end))?; - (start, end, replacement.to_string()) - }; - Some(format!("{}{}{}", &text[..start], value, &text[end..])) - } -} - -/// Maps ordered lowercase-match byte ranges to complete original characters. -fn original_ranges_for_lowercase_matches( - text: &str, - ranges: &[(usize, usize)], -) -> Vec<(usize, usize)> { - let mut chars = text.char_indices(); - let (mut source_start, mut source_end, mut folded_end) = (0, 0, 0); - let mut result: Vec<(usize, usize)> = Vec::with_capacity(ranges.len()); - for &(start, end) in ranges { - while folded_end <= start { - let Some((index, character)) = chars.next() else { - break; - }; - source_start = index; - source_end = index + character.len_utf8(); - folded_end += character.to_lowercase().map(char::len_utf8).sum::(); - } - let original_start = source_start; - while folded_end < end { - let Some((index, character)) = chars.next() else { - break; - }; - source_end = index + character.len_utf8(); - folded_end += character.to_lowercase().map(char::len_utf8).sum::(); - } - if let Some(previous) = result - .last_mut() - .filter(|previous| original_start < previous.1) - { - previous.1 = previous.1.max(source_end); - } else { - result.push((original_start, source_end)); - } - } - result -} - -fn safe_relative_path_string(value: &str) -> Result { - let path = Path::new(value); - if path.is_absolute() || invalid_relative_path(value) { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Path must be relative to the workspace", - )); - } - Ok(value.replace('\\', "/").trim_matches('/').to_string()) -} - -pub(crate) fn read_searchable_text(path: &Path) -> Option { - let metadata = fs::metadata(path).ok()?; - if !metadata.is_file() || metadata.len() > MAX_FILE_SIZE { - return None; - } - let text = fs::read_to_string(path).ok()?; - is_plain_text(&text).then_some(text) -} - -pub(crate) fn is_plain_text(text: &str) -> bool { - text.chars().all(|character| { - let value = character as u32; - value != 0 && !(value < 0x09 || (value > 0x0D && value < 0x20) || value == 0x7F) - }) -} - -fn is_word_character(character: char) -> bool { - character.is_ascii_alphanumeric() || character == '_' || character == '$' -} - -#[cfg(test)] -mod event_filter_tests { - use super::*; - - #[test] - fn path_filters_match_the_displayed_relative_root_before_limits() { - let filter = - SearchPathFilters::new(&["src/**".into()], &["/test/".into()], "module").unwrap(); - assert!(filter.allows("src/main/a.ts")); - assert!(!filter.allows("src/test/a.ts")); - assert!(!filter.allows("other/a.ts")); - let rooted = SearchPathFilters::new(&["module/src".into()], &[], "module").unwrap(); - assert!(rooted.allows("src/a.ts")); - } - - #[test] - fn replacement_text_command_uses_search_boundaries_and_line_semantics() { - let call = |payload: serde_json::Value| -> serde_json::Value { - serde_json::from_str(&crate::execute_json( - &serde_json::json!({ - "id": "replace", "command": "workspace.replaceText", "payload": payload - }) - .to_string(), - )) - .unwrap() - }; - let whole = call(serde_json::json!({"text": "$foo foo\nfoo!", "query": "foo", - "replacement": "bar", "wholeWords": true})); - assert_eq!( - whole["data"], - serde_json::json!({"text": "$foo bar\nbar!", "count": 2}) - ); - let lines = call( - serde_json::json!({"text": "foo 1\nfoo 2", "query": "^foo (\\d)", - "replacement": "bar $1", "regularExpression": true}), - ); - assert_eq!( - lines["data"], - serde_json::json!({"text": "bar 1\nbar 2", "count": 2}) - ); - let single = call( - serde_json::json!({"text": "😀 foo foo\nfoo", "query": "foo", - "replacement": "bar", "from": {"line": 1, "column": 9}}), - ); - assert_eq!( - single["data"], - serde_json::json!({"text": "😀 foo bar\nfoo", "count": 1}) - ); - let adjacent = call(serde_json::json!({"text": "foofoo", "query": "foo", - "replacement": "bar", "from": {"line": 1, "column": 4}})); - assert_eq!( - adjacent["data"], - serde_json::json!({"text": "foobar", "count": 1}) - ); - } - - #[test] - fn provider_batch_search_uses_same_unicode_and_word_boundaries_as_replacement() { - let response: serde_json::Value = serde_json::from_str(&crate::execute_json( - &serde_json::json!({ - "id": "provider", "command": "workspace.searchTextBatch", "payload": { - "query": "foo", "wholeWords": true, "contextLines": 1, - "files": [{"path": "wsl://fixture/a", "text": "before\n😀 $foo foo\nafter"}] - } - }) - .to_string(), - )) - .unwrap(); - assert_eq!(response["ok"], true, "{response}"); - let found = &response["data"]["files"][0]["matches"][0]; - assert_eq!(found["line"], 2); - assert_eq!( - found["ranges"], - serde_json::json!([{"start": 8, "length": 3}]) - ); - assert_eq!(found["contextBefore"], serde_json::json!(["before"])); - assert_eq!(found["contextAfter"], serde_json::json!(["after"])); - let too_many: Vec<_> = (0..33) - .map(|index| serde_json::json!({"path": index.to_string(), "text": ""})) - .collect(); - let rejected: serde_json::Value = serde_json::from_str(&crate::execute_json(&serde_json::json!({ - "id": "provider-limit", "command": "workspace.searchTextBatch", "payload": {"query": "x", "files": too_many} - }).to_string())).unwrap(); - assert_eq!(rejected["ok"], false); - } - - #[test] - fn shared_text_snapshot_contract_fixture() { - let fixture: serde_json::Value = serde_json::from_str(include_str!( - "../../../../fixtures/search/text-snapshot-v1.json" - )) - .unwrap(); - for case in fixture["cases"].as_array().unwrap() { - let request = serde_json::json!({ - "id": "fixture", "command": case["command"], "payload": case["payload"] - }); - let response: serde_json::Value = - serde_json::from_str(&crate::execute_json(&request.to_string())).unwrap(); - assert_eq!(response["ok"], true, "{}: {response}", case["name"]); - assert_eq!(response["data"], case["expected"], "{}", case["name"]); - } - } - - #[test] - fn literal_replacement_uses_original_offsets_after_unicode_case_folding() { - let matcher = Matcher::new("x", false, false, false).unwrap(); - assert_eq!( - matcher.replace_with_options("İx", "y", false), - ("İy".into(), 1) - ); - assert_eq!( - matcher.replace_with_options("Kxİx", "y", false), - ("Kyİy".into(), 2) - ); - let partial = Matcher::new("i", false, false, false).unwrap(); - assert_eq!( - partial.replace_with_options("İ", "z", false), - ("z".into(), 1) - ); - let combining = Matcher::new("\u{307}", false, false, false).unwrap(); - assert_eq!( - combining.replace_with_options("İ", "z", false), - ("z".into(), 1) - ); - } - - #[test] - fn search_snapshots_merge_at_100ms_and_keep_the_best_bounded_candidates() { - use std::sync::{Arc, Mutex}; - use std::time::{Duration, Instant}; - let events = Arc::new(Mutex::new(Vec::::new())); - let received = events.clone(); - crate::git::execution_events::with_sink( - Arc::new(move |raw| { - received - .lock() - .unwrap() - .push(serde_json::from_str(raw).unwrap()); - }), - || { - let make_match = |name: &str, degree| SearchMatch { - kind: "file".into(), - path: name.into(), - line: None, - preview: name.into(), - symbol_name: None, - name_match_ranges: vec![], - source_line: None, - content_match_ranges: Vec::new(), - matching_degree: Some(degree), - is_ignored: None, - }; - let mut files = vec![make_match("First", 1)]; - let (mut types, mut symbols, mut last) = (vec![], vec![], None); - let now = Instant::now(); - let mut flush = |files: &mut Vec, milliseconds, force| { - emit_search_results( - [(files, 1), (&mut types, 1), (&mut symbols, 1)], - 1, - &mut last, - now + Duration::from_millis(milliseconds), - force, - ); - }; - flush(&mut files, 0, false); - files.push(make_match("Better", 10)); - flush(&mut files, 99, false); - assert_eq!(events.lock().unwrap().len(), 1); - flush(&mut files, 100, false); - files.push(make_match("Best", 20)); - flush(&mut files, 101, true); - }, - ); - let events = events.lock().unwrap(); - assert_eq!(events.len(), 3); - for (event, path) in events.iter().zip(["First", "Better", "Best"]) { - assert_eq!(event["type"], "searchResults"); - assert_eq!(event["results"]["matches"].as_array().unwrap().len(), 1); - assert_eq!(event["results"]["matches"][0]["path"], path); - } - } - - #[test] - fn shared_workspace_change_fixture() { - let fixture: serde_json::Value = serde_json::from_str(include_str!( - "../../../../fixtures/workspace/change-filter-v1.json" - )) - .unwrap(); - let paths: Vec = serde_json::from_value(fixture["paths"].clone()).unwrap(); - let retained: Vec<_> = paths - .iter() - .filter(|path| is_workspace_event_relevant(path)) - .collect(); - assert_eq!(serde_json::to_value(retained).unwrap(), fixture["expected"]); - let response: serde_json::Value = serde_json::from_str(&crate::execute_json( - &serde_json::json!({ - "id": "filter", "command": "workspace.filterChanges", "payload": paths - }) - .to_string(), - )) - .unwrap(); - assert_eq!(response["ok"], true, "{response}"); - assert_eq!(response["data"], fixture["expected"]); - } -} diff --git a/rust/crates/core/src/project/mod.rs b/rust/crates/core/src/project/mod.rs deleted file mode 100644 index 950745dd..00000000 --- a/rust/crates/core/src/project/mod.rs +++ /dev/null @@ -1,27 +0,0 @@ -//! Project files, search, local history, and document rendering services. - -pub(crate) mod directory_actions; -mod document_lifecycle; -pub(crate) mod document_save_batch; -mod encoding; -pub(crate) mod file_name_order; -pub(crate) mod file_operations; -pub(crate) mod files; -mod history; -pub(crate) mod indexing; -mod markdown; -mod name_matching; -pub(crate) mod name_ranking; -pub(crate) mod recent_projects; -mod search_index; -mod search_paging; -pub(crate) use search_paging::{search_page, SearchPageRequest}; - -pub(crate) use document_lifecycle::*; -pub(crate) use files::*; -pub(crate) use history::*; -pub(crate) use markdown::*; - -pub use files::{ - filter_workspace_changes, is_workspace_event_relevant, WorkspaceChangeFilterRequest, -}; diff --git a/rust/crates/core/src/protocol/command.rs b/rust/crates/core/src/protocol/command.rs index c806e3a3..69080a9f 100644 --- a/rust/crates/core/src/protocol/command.rs +++ b/rust/crates/core/src/protocol/command.rs @@ -18,10 +18,10 @@ pub struct CoreRequest { pub timeout_milliseconds: Option, /// Host-owned Git preferences, scoped to this request only. #[serde(default)] - pub git_execution: Option, + pub git_execution: Option, /// Remote host selected by the native workspace registry; absent for local workspaces. #[serde(default)] - pub(crate) remote_workspace: Option, + pub(crate) remote_workspace: Option, /// Host-reported provenance used only for diagnostics traces; never changes behavior. #[serde(default)] pub origin: Option, @@ -38,7 +38,7 @@ pub struct CoreRequest { /// so a malformed diagnostics hint can never fail the operation it describes. pub struct RequestOrigin { /// Whether a user action or an automatic refresh caused the request. - pub source: Option, + pub source: Option, /// Stable identifier of the UI action that caused the request. pub trigger: Option, /// Correlation identifier shared by the action and every request it caused. @@ -57,752 +57,852 @@ pub enum CoreCommand { Ping, /// Matches host-localized action text without workspace I/O (`search.actions`). SearchActions, + /// AI commit, pull request and inline edit planning run by `lithe-ai` (`ai.*`). + Ai(AiCommand), + /// Plugin manifests, native UI and web content, plugin settings, installed + /// plugins and the marketplace (`plugins.*`). + Plugins(PluginsCommand), + /// Debug adapters, transports, sessions, breakpoints and inspection (`debug.*`). + Debug(DebugCommand), + /// Keymap import, contexts, conflicts and schemes (`keymap.*`). + Keymap(KeymapCommand), + /// Toolchain registry, pins, consumer preferences, discovery and launch instantiation (`toolchains.*`). + Toolchains(ToolchainsCommand), + /// Validates a plugin backend's Run start request (`run.decodeStart`). + RunDecodeStart, + /// Finds the output links plugin matchers declare in console lines (`outputLinks.match`). + OutputLinksMatch, + /// Checks a plugin function's answer to an output link (`outputLinks.decodeResolution`). + OutputLinksDecodeResolution, + /// Build detection, options, plans and decoded tool output (`build.*`). + Build(BuildCommand), + /// Detects VCS roots and registered providers of a workspace (`vcs.detectRoots`). + VcsDetectRoots, + /// Assigns workspace paths to detected VCS roots (`vcs.ownerOf`). + VcsOwnerOf, + /// Validates and normalizes a VCS provider's reply (`vcs.decodeReply`). + VcsDecodeReply, + /// Lists plugin project generators and who provides their requirements (`project.generatorCatalog`). + ProjectGeneratorCatalog, + /// Derives a generator form's defaults, visibility, and field errors (`project.generatorForm`). + ProjectGeneratorForm, + /// Expands a generator's steps into files, argv, and URLs (`project.generationPlan`). + ProjectGenerationPlan, + /// Validates a new project's name against its parent directory (`project.checkName`). + ProjectCheckName, + /// Extracts a downloaded archive inside a bounded staging directory (`project.extractArchive`). + ProjectExtractArchive, + /// Workspace snapshots, indexing, directory actions, repositories and search (`workspace.*`). + Workspace(WorkspaceCommand), + /// Reads one workspace-relative UTF-8 file (`file.read`). + FileRead, + /// Replaces one workspace-relative UTF-8 file (`file.write`). + FileWrite, + /// Decodes bytes the host read, as `file.read` would (`text.decode`). + TextDecode, + /// Encodes text into bytes, as `file.write` would (`text.encode`). + TextEncode, + /// Reduces one shared document persistence event (`document.lifecycle`). + DocumentLifecycle, + /// Classifies decoded Unicode text independently of its filename (`document.classifyText`). + DocumentClassifyText, + /// Selects the open documents a save batch writes (`document.planSaveBatch`). + DocumentPlanSaveBatch, + /// Reduces save batch member outcomes and live documents (`document.reduceSaveBatch`). + DocumentReduceSaveBatch, + /// Local file history (`history.*`). + History(HistoryCommand), + /// Compares two host-supplied texts line by line (`diff.compareTexts`). + DiffCompareTexts, + /// Resolve environment precedence after platform tool discovery. + ExecutionResolveEnvironment, + /// Resolves deterministic process search-path ordering (`execution.resolveSearchPath`). + ExecutionResolveSearchPath, + /// Decides how each Lithe-owned process ends (`processLifecycle.resolve`). + ProcessLifecycleResolve, + /// Classifies a process snapshot into lifecycle items (`processLifecycle.inventory`). + ProcessLifecycleInventory, + /// Turns a plan and the user's answers into what ends (`processLifecycle.settle`). + ProcessLifecycleSettle, + /// Renders and sanitizes shared Markdown (`markdown.render`). + MarkdownRender, + /// Editor reducers: workbench state, text edits, find, navigation and preferences (`editor.*`). + Editor(EditorCommand), + /// Applies one transition to the rearrangeable workbench chrome: stripe, title bar and status bar + /// button placement and open tool panels (`workbench.chromeLayout.reduce`), from `lithe-core-rules`. + WorkbenchChromeLayoutReduce, + /// Resolves terminal link candidates into file or external targets (`terminal.parseLinkTarget`). + TerminalParseLinkTarget, + /// Language server sessions, documents, edits, requests and events (`lsp.*`). + Lsp(LspCommand), + /// Normalizes the stored proxy settings into the policy a network stack + /// must apply (`network.proxyPolicy`). + NetworkProxyPolicy, + /// Run configurations and their launch plans (`runConfig.*`). + RunConfig(RunConfigCommand), + /// Git status, writes, history, rebase, patches, diffs, references and pull requests (`git.*`). + Git(GitCommand), + /// Remote hosts: probing, authentication, connections, files and processes (`remote.*`). + Remote(RemoteCommand), + /// Parses a GitHub repository identity from a Git remote URL (`github.parseRemote`). + GitHubParseRemote, + /// Builds a platform-executable GitHub HTTP request (`github.requestPlan`). + GitHubRequestPlan, + /// Normalizes a GitHub HTTP response into the shared contract (`github.normalizeResponse`). + GitHubNormalizeResponse, + /// Plans the next bounded GitHub device-authorization transition. + GitHubDeviceAuthorizationTransition, + /// Plans a safe local branch for a GitHub pull request. + GitHubPullRequestCheckoutPlan, + /// Log events and queries, log filters and retention, redaction and diagnostic + /// bundles (`diagnostics.*`). + Diagnostics(DiagnosticsCommand), +} + +#[derive(Debug, Clone)] +/// The `ai.*` commands; their names live in [`CoreCommand::parse`]. +pub enum AiCommand { /// Budgets diff prefixes before crossing the native boundary (`ai.commitInputBudget`). - AiCommitInputBudget, + CommitInputBudget, /// Plans a credential-free commit message request (`ai.planCommit`). - AiPlanCommit, + PlanCommit, /// Decodes a provider commit message response (`ai.decodeCommitMessage`). - AiDecodeCommitMessage, + DecodeCommitMessage, /// Names the native files of one AI CLI configuration source (`ai.configuration.sources`). - AiConfigurationSources, + ConfigurationSources, /// Parses AI CLI configuration into credential-free metadata (`ai.configuration.parse`). - AiConfigurationParse, + ConfigurationParse, /// Plans a credential-free pull request description request (`ai.pullRequest.plan`). - AiPullRequestPlan, + PullRequestPlan, /// Decodes a strict pull request title and description (`ai.pullRequest.decode`). - AiPullRequestDecode, + PullRequestDecode, /// Plans a credential-free inline edit request (`ai.inlineEdit.plan`). - AiInlineEditPlan, + InlineEditPlan, /// Decodes the replacement text of an inline edit response (`ai.inlineEdit.decode`). - AiInlineEditDecode, + InlineEditDecode, +} + +#[derive(Debug, Clone)] +/// The `plugins.*` commands; their names live in [`CoreCommand::parse`]. +pub enum PluginsCommand { /// Validates and normalizes a declarative plugin manifest (`plugins.declarative.validate`). - PluginsDeclarativeValidate, + DeclarativeValidate, /// Validates native plugin UI data with declaration identity and node budgets. - PluginsNativeUIValidateData, + NativeUIValidateData, /// Registers a plugin page for a web tool window (`plugins.web.open`). - PluginsWebOpen, + WebOpen, /// Forgets a plugin page and lists its calls to cancel (`plugins.web.close`). - PluginsWebClose, + WebClose, /// Decides how a plugin page request is served (`plugins.web.resource`). - PluginsWebResource, + WebResource, /// Prepends the policy and bridge to a page document (`plugins.web.document`). - PluginsWebDocument, + WebDocument, /// Checks one bridge message of a plugin page (`plugins.web.request`). - PluginsWebRequest, + WebRequest, /// Frames the host's answer to a bridge action (`plugins.web.reply`). - PluginsWebReply, + WebReply, /// Frames an event for a plugin page (`plugins.web.event`). - PluginsWebEvent, + WebEvent, /// Describes the Core-owned backend host-method catalog. - PluginsHostApiDescribe, + HostApiDescribe, /// Rechecks a backend host request against Core's grant rules. - PluginsHostApiAuthorize, + HostApiAuthorize, /// Expands a declarative plugin's run template (`plugins.declarative.runPlan`). - PluginsDeclarativeRunPlan, + DeclarativeRunPlan, /// Lists a declarative plugin's workspace and file tests (`plugins.declarative.discoverTests`). - PluginsDeclarativeDiscoverTests, + DeclarativeDiscoverTests, /// Expands a declarative plugin's test template (`plugins.declarative.testPlan`). - PluginsDeclarativeTestPlan, + DeclarativeTestPlan, /// Expands a declarative plugin's language-server launch (`plugins.declarative.languageServerLaunchPlan`). - PluginsDeclarativeLanguageServerLaunchPlan, + DeclarativeLanguageServerLaunchPlan, /// Checks a plugin backend's language-server launch answer (`plugins.languageServers.decodeLaunch`). - PluginsLanguageServersDecodeLaunch, + LanguageServersDecodeLaunch, /// Picks the stored language-server state the host may delete (`plugins.languageServers.stateRetention`). - PluginsLanguageServersStateRetention, + LanguageServersStateRetention, /// Checks a plugin content provider's answer (`plugins.contentProviders.decode`). - PluginsContentProvidersDecode, - /// Checks a debug start and returns the adapter's DAP arguments (`debug.adapters.startArguments`). - DebugAdaptersStartArguments, - /// Validates an IDE-to-DAP TCP endpoint, not an attach target. - DebugValidateAdapterEndpoint, - /// Says where to look for the program a launch debugs (`debug.adapters.executablePlan`). - DebugAdaptersExecutablePlan, - /// Returns the default byte-stream deadlines and queue budgets (`debug.transport.policy`). - DebugTransportPolicy, - /// Applies one platform event to a byte-stream lifecycle and returns the effects to run - /// (`debug.transport.handle`). - DebugTransportHandle, - /// Forgets the lifecycle of a stopped byte stream (`debug.transport.release`). - DebugTransportRelease, + ContentProvidersDecode, /// Merges a plugin's stored setting layers into effective values (`plugins.declarative.resolveSettings`). - PluginsDeclarativeResolveSettings, + DeclarativeResolveSettings, /// Projects stored documents into plugin setting rows and launch layers. - PluginsDeclarativeSettingsState, + DeclarativeSettingsState, /// Projects enabled plugin settings pages from installed manifests. - PluginsSettingsCatalog, + SettingsCatalog, /// Projects searched settings navigation without platform policy. - PluginsSettingsNavigation, + SettingsNavigation, /// Routes committed preference effects using native ownership facts. - PluginsSettingsDelivery, + SettingsDelivery, /// Resolves an installed settings action and its native confirmation requirement. - PluginsDeclarativeSettingsActionPlan, + DeclarativeSettingsActionPlan, /// Selects numeric installed versions and reports malformed native facts. - PluginsInstalledSelect, + InstalledSelect, /// Validates an atomic plugin settings edit and preserves unrelated values. - PluginsDeclarativeEditSettings, + DeclarativeEditSettings, /// Expands a language server's `workspaceConfiguration` (`plugins.declarative.workspaceConfiguration`). - PluginsDeclarativeWorkspaceConfiguration, + DeclarativeWorkspaceConfiguration, /// Validates a language's grammar file (`plugins.declarative.decodeGrammar`). - PluginsDeclarativeDecodeGrammar, + DeclarativeDecodeGrammar, /// Validates independent editor action registration or results. - PluginsEditorActionsDecode, + EditorActionsDecode, /// Validates editor provider registrations and provider results. - PluginsEditorProvidersDecode, + EditorProvidersDecode, + /// Validates and filters a marketplace index (`plugins.marketplace.validateIndex`). + MarketplaceValidateIndex, + /// Resolves a user-entered marketplace source URL (`plugins.marketplace.resolveSource`). + MarketplaceResolveSource, + /// Lists a package's permissions and programs before installation (`plugins.marketplace.installReview`). + MarketplaceInstallReview, +} + +#[derive(Debug, Clone)] +/// The `debug.*` commands; their names live in [`CoreCommand::parse`]. +pub enum DebugCommand { + /// Checks a debug start and returns the adapter's DAP arguments (`debug.adapters.startArguments`). + AdaptersStartArguments, + /// Validates an IDE-to-DAP TCP endpoint, not an attach target. + ValidateAdapterEndpoint, + /// Says where to look for the program a launch debugs (`debug.adapters.executablePlan`). + AdaptersExecutablePlan, + /// Returns the default byte-stream deadlines and queue budgets (`debug.transport.policy`). + TransportPolicy, + /// Applies one platform event to a byte-stream lifecycle and returns the effects to run + /// (`debug.transport.handle`). + TransportHandle, + /// Forgets the lifecycle of a stopped byte stream (`debug.transport.release`). + TransportRelease, + /// Decides whether a combined debug shortcut starts, continues or does nothing. + StartOrContinue, + /// Validates a plugin backend's debug start request (`debug.decodeBackendConfiguration`). + DecodeBackendConfiguration, + /// Creates one transport-neutral Debug Adapter Protocol session (`debug.createSession`). + CreateSession, + /// Queues a launch or attach request for a debug session (`debug.launch`). + Launch, + /// Returns or normalizes portable stepping filters (`debug.steppingFilters`). + SteppingFilters, + /// Moves source breakpoints across one UTF-16 editor mutation (`debug.relocateBreakpoints`). + RelocateBreakpoints, + /// Plans the first page of one variable container (`debug.planVariablePage`). + PlanVariablePage, + /// Merges one variable page into its paging cursor (`debug.reduceVariablePage`). + ReduceVariablePage, + /// Plans the first request of one disassembly fetch (`debug.planDisassemblyWindow`). + PlanDisassemblyWindow, + /// Reduces one disassembly reply to the next bounded step (`debug.reduceDisassemblyPage`). + ReduceDisassemblyPage, + /// Replaces breakpoints for one source file (`debug.setBreakpoints`). + SetBreakpoints, + /// Replaces exception filters for one debug session (`debug.setExceptionBreakpoints`). + SetExceptionBreakpoints, + /// Replaces named function breakpoints for one debug session (`debug.setFunctionBreakpoints`). + SetFunctionBreakpoints, + /// Replaces instruction (address) breakpoints for one debug session (`debug.setInstructionBreakpoints`). + SetInstructionBreakpoints, + /// Resolves one adapter-owned data breakpoint identity (`debug.dataBreakpointInfo`). + DataBreakpointInfo, + /// Replaces data breakpoints for one debug session (`debug.setDataBreakpoints`). + SetDataBreakpoints, + /// Mutes or unmutes every breakpoint of one debug session (`debug.setBreakpointsMuted`). + SetBreakpointsMuted, + /// Places the memory view's readMemory window at an address (`debug.planMemoryWindow`). + PlanMemoryWindow, + /// Finds the byte read at in one readMemory reply (`debug.locateMemoryWindow`). + LocateMemoryWindow, + /// Replaces one visible variable value (`debug.setVariable`). + SetVariable, + /// Cancels or times out one pending debug operation (`debug.cancelOperation`). + CancelOperation, + /// Queues continue, pause, or stepping control (`debug.execute`). + Execute, + /// Queues one normalized debugger inspection request (`debug.inspect`). + Inspect, + /// Reduces bytes received from a platform-owned DAP transport (`debug.receive`). + Receive, + /// Completes one adapter-requested terminal launch (`debug.runInTerminalResponse`). + RunInTerminalResponse, + /// Completes one adapter-requested child session start (`debug.startDebuggingResponse`). + StartDebuggingResponse, + /// Begins the DAP disconnect handshake (`debug.disconnect`). + Disconnect, + /// Removes all state for a debug session (`debug.destroySession`). + DestroySession, +} + +#[derive(Debug, Clone)] +/// The `keymap.*` commands; their names live in [`CoreCommand::parse`]. +pub enum KeymapCommand { /// Validates a normalized shortcut import against the host's action catalog. - KeymapDecodeImport, + DecodeImport, /// Plans the selected provider's declared shortcut source inputs. - KeymapPlanImportSources, + PlanImportSources, /// Plans conflicts and default-key displacement for a shortcut import. - KeymapPlanImportBindings, - /// Decides whether a combined debug shortcut starts, continues or does nothing. - DebugStartOrContinue, + PlanImportBindings, + /// Compiles a bounded boolean shortcut context (`keymap.parseContext`). + ParseContext, + /// Evaluates a precompiled shortcut context (`keymap.evaluateContext`). + EvaluateContext, + /// Finds conflicts among the final layered shortcut bindings (`keymap.analyzeConflicts`). + AnalyzeConflicts, + /// Advances the double-Shift gesture recognizer by one event (`keymap.modifierGestureStep`). + /// The rule itself lives in `lithe-core-rules` so the Windows frontend can run it synchronously. + ModifierGestureStep, + /// Reduces named shortcut schemes (`keymap.schemes.reduce`). + SchemesReduce, +} + +#[derive(Debug, Clone)] +/// The `toolchains.*` commands; their names live in [`CoreCommand::parse`]. +pub enum ToolchainsCommand { /// Registers plugin-owned tools with verified package provenance. - ToolchainsRegistry, + Registry, /// Validates provider answers using native evidence supplied by the host. - ToolchainsDecodeReply, + DecodeReply, /// Builds the error reply for a provider query that timed out or found no backend. - ToolchainsFailureReply, + FailureReply, /// Resolves the nearest machine-local directory pin. - ToolchainsResolvePins, + ResolvePins, /// Edits a bounded batch of machine-local directory pins without native writes. - ToolchainsEditPins, + EditPins, /// Plans native workspace pin source paths and byte budgets. - ToolchainsPinStoragePlan, + PinStoragePlan, /// Parses a complete native pin document or its absent-source default. - ToolchainsDecodePinDocument, + DecodePinDocument, /// Plans complete pin and ignore source edits without native writes. - ToolchainsEditPinSources, + EditPinSources, /// Resolves consumer defaults while keeping workspace inheritance symbolic. - ToolchainsResolveConsumerPreferences, + ResolveConsumerPreferences, /// Edits one machine-local consumer preference and returns its effective input. - ToolchainsEditConsumerPreferences, + EditConsumerPreferences, /// Resolves the four toolchain layers from a captured value snapshot. - ToolchainsResolve, + Resolve, /// Checks a plugin-defined version probe without per-tool parsing. - ToolchainsCheckVersion, + CheckVersion, /// Plans system scans solely from enabled tool definitions. - ToolchainsDiscoveryPlan, + DiscoveryPlan, /// Lists checked installations and the automatic choice for one tool. - ToolchainsCandidates, + Candidates, /// Expands a tool definition's launch template for a selected executable. - ToolchainsInstantiateLaunch, - /// Validates a plugin backend's Run start request (`run.decodeStart`). - RunDecodeStart, - /// Validates a plugin backend's debug start request (`debug.decodeBackendConfiguration`). - DebugDecodeBackendConfiguration, - /// Finds the output links plugin matchers declare in console lines (`outputLinks.match`). - OutputLinksMatch, - /// Checks a plugin function's answer to an output link (`outputLinks.decodeResolution`). - OutputLinksDecodeResolution, + InstantiateLaunch, +} + +#[derive(Debug, Clone)] +/// The `build.*` commands; their names live in [`CoreCommand::parse`]. +pub enum BuildCommand { /// Finds the build-system instances of a workspace (`build.detect`). - BuildDetect, + Detect, /// Validates a native team's persisted Build options (`build.projectOptions`). - BuildProjectOptions, + ProjectOptions, /// Merges checked team and machine-local Build preferences without I/O. - BuildResolveStoredOptions, + ResolveStoredOptions, /// Edits one declared option in a machine-local Build document without I/O. - BuildEditStoredOption, + EditStoredOption, /// Lists a build system's executable candidates and version probes (`build.invocationPlan`). - BuildInvocationPlan, + InvocationPlan, /// Compares a build tool's or runtime's version output with its minimum (`build.checkToolVersion`). - BuildCheckToolVersion, + CheckToolVersion, /// Resolves stored build options against the declaration (`build.resolveOptions`). - BuildResolveOptions, + ResolveOptions, /// Expands the command that prints a build model (`build.modelPlan`). - BuildModelPlan, + ModelPlan, /// Expands one build task into an argv plan (`build.taskPlan`). - BuildTaskPlan, + TaskPlan, /// Expands a build test run and its result location (`build.testPlan`). - BuildTestPlan, + TestPlan, /// Plans typed provider run preparation (`build.prepareRunPlan`). - BuildPrepareRunPlan, + PrepareRunPlan, /// Plans source-backed configuration reads (`build.configurationPlan`). - BuildConfigurationPlan, + ConfigurationPlan, /// Validates non-sensitive native facts (`build.decodeConfiguration`). - BuildDecodeConfiguration, + DecodeConfiguration, /// Plans provider-owned native configuration editing (`build.configurationEditPlan`). - BuildConfigurationEditPlan, + ConfigurationEditPlan, /// Validates a public edit receipt (`build.decodeConfigurationEdit`). - BuildDecodeConfigurationEdit, + DecodeConfigurationEdit, /// Resolves optional public build inputs (`build.resolveModelInputs`). - BuildResolveModelInputs, + ResolveModelInputs, /// Plans native debug preparation (`build.prepareDebugPlan`). - BuildPrepareDebugPlan, + PrepareDebugPlan, /// Validates native debug target facts (`build.decodeDebugPreparation`). - BuildDecodeDebugPreparation, + DecodeDebugPreparation, /// Validates prepared launch data (`build.decodeRunPreparation`). - BuildDecodeRunPreparation, + DecodeRunPreparation, /// Plans provider-owned test discovery (`build.testDiscoveryPlan`). - BuildTestDiscoveryPlan, + TestDiscoveryPlan, /// Validates provider-owned test selectors (`build.decodeTestDiscovery`). - BuildDecodeTestDiscovery, + DecodeTestDiscovery, /// Expands the command that prints a project's dependencies (`build.dependencyPlan`). - BuildDependencyPlan, + DependencyPlan, /// Normalizes a build model and derives its run entries (`build.decodeModel`). - BuildDecodeModel, + DecodeModel, /// Normalizes build test results (`build.decodeTestResults`). - BuildDecodeTestResults, + DecodeTestResults, /// Normalizes build diagnostics from process output (`build.decodeDiagnostics`). - BuildDecodeDiagnostics, + DecodeDiagnostics, /// Normalizes a project's dependency tree (`build.decodeDependencies`). - BuildDecodeDependencies, + DecodeDependencies, /// Digests the files a build model depends on (`build.syncFingerprint`). - BuildSyncFingerprint, + SyncFingerprint, /// Reduces a test run's termination and decoded summary to one verdict (`build.resolveTestRunOutcome`). - BuildResolveTestRunOutcome, + ResolveTestRunOutcome, /// Merges a run's test results into the stored history inside the host-stated scope (`build.mergeTestOutcomes`). - BuildMergeTestOutcomes, - /// Detects VCS roots and registered providers of a workspace (`vcs.detectRoots`). - VcsDetectRoots, - /// Assigns workspace paths to detected VCS roots (`vcs.ownerOf`). - VcsOwnerOf, - /// Validates and normalizes a VCS provider's reply (`vcs.decodeReply`). - VcsDecodeReply, - /// Lists plugin project generators and who provides their requirements (`project.generatorCatalog`). - ProjectGeneratorCatalog, - /// Derives a generator form's defaults, visibility, and field errors (`project.generatorForm`). - ProjectGeneratorForm, - /// Expands a generator's steps into files, argv, and URLs (`project.generationPlan`). - ProjectGenerationPlan, - /// Validates a new project's name against its parent directory (`project.checkName`). - ProjectCheckName, - /// Extracts a downloaded archive inside a bounded staging directory (`project.extractArchive`). - ProjectExtractArchive, - /// Validates and filters a marketplace index (`plugins.marketplace.validateIndex`). - PluginsMarketplaceValidateIndex, - /// Resolves a user-entered marketplace source URL (`plugins.marketplace.resolveSource`). - PluginsMarketplaceResolveSource, - /// Lists a package's permissions and programs before installation (`plugins.marketplace.installReview`). - PluginsMarketplaceInstallReview, + MergeTestOutcomes, +} + +#[derive(Debug, Clone)] +/// The `workspace.*` commands; their names live in [`CoreCommand::parse`]. +pub enum WorkspaceCommand { /// Builds the visible project tree (`workspace.snapshot`). - WorkspaceSnapshot, + Snapshot, /// Configure workspace indexing independently of tree visibility. - WorkspaceConfigureIndexing, + ConfigureIndexing, /// Refreshes external indexing facts while retaining the owner's lease. - WorkspaceRefreshIndexing, + RefreshIndexing, /// Releases an indexing generation when its project closes. - WorkspaceReleaseIndexing, + ReleaseIndexing, /// Lists the visible children of project directories (`workspace.listDirectories`). - WorkspaceListDirectories, + ListDirectories, /// Orders natively enumerated directory siblings (`workspace.orderEntries`). - WorkspaceOrderEntries, + OrderEntries, /// Ranks host-supplied candidate names with the shared matcher (`workspace.rankNames`). - WorkspaceRankNames, + RankNames, /// Plans the name of a duplicated project item (`workspace.planDuplicate`). - WorkspacePlanDuplicate, + PlanDuplicate, /// Plans the destination names of pasted project items (`workspace.planPaste`). - WorkspacePlanPaste, + PlanPaste, /// Applies a directory role, exclusion or cancel action (`workspace.reduceDirectoryAction`). - WorkspaceReduceDirectoryAction, + ReduceDirectoryAction, /// Migrates directory rules after a move or delete (`workspace.relocateDirectoryRules`). - WorkspaceRelocateDirectoryRules, + RelocateDirectoryRules, /// Filter ordinary workspace-relative event paths using shared defaults. - WorkspaceFilterChanges, + FilterChanges, /// Finds Git repositories that belong to an opened workspace (`workspace.repositories`). - WorkspaceRepositories, + Repositories, /// Applies one action to the recent project list (`workspace.recentProjects.reduce`). - WorkspaceRecentProjectsReduce, + RecentProjectsReduce, /// Builds or reuses the workspace search index (`workspace.searchIndex.warm`). - WorkspaceSearchIndexWarm, + SearchIndexWarm, /// Applies changed paths to the search index (`workspace.searchIndex.update`). - WorkspaceSearchIndexUpdate, + SearchIndexUpdate, /// Drops a workspace's cached search index (`workspace.searchIndex.invalidate`). - WorkspaceSearchIndexInvalidate, + SearchIndexInvalidate, /// Searches visible file paths and contents (`workspace.search`). - WorkspaceSearch, + Search, /// Searches file paths, contents, and symbols (`workspace.searchEverywhere`). - WorkspaceSearchEverywhere, + SearchEverywhere, /// Previews replacements without writing files (`workspace.replacePreview`). - WorkspaceReplacePreview, + ReplacePreview, /// Computes replacement on a supplied snapshot without writing (`workspace.replaceText`). - WorkspaceReplaceText, + ReplaceText, /// Searches provider-owned text snapshots in one request (`workspace.searchTextBatch`). - WorkspaceSearchTextBatch, - /// Reads one workspace-relative UTF-8 file (`file.read`). - FileRead, - /// Replaces one workspace-relative UTF-8 file (`file.write`). - FileWrite, - /// Decodes bytes the host read, as `file.read` would (`text.decode`). - TextDecode, - /// Encodes text into bytes, as `file.write` would (`text.encode`). - TextEncode, - /// Reduces one shared document persistence event (`document.lifecycle`). - DocumentLifecycle, - /// Classifies decoded Unicode text independently of its filename (`document.classifyText`). - DocumentClassifyText, - /// Selects the open documents a save batch writes (`document.planSaveBatch`). - DocumentPlanSaveBatch, - /// Reduces save batch member outcomes and live documents (`document.reduceSaveBatch`). - DocumentReduceSaveBatch, + SearchTextBatch, + /// Classifies a batch of workspace-relative paths as hidden or visible (`workspace.classifyVisibility`). + /// The rule lives in `lithe-core-rules` so the Windows frontend can run it synchronously. + ClassifyVisibility, + /// Returns the built-in hidden-path lists (`workspace.visibilityDefaults`), also from `lithe-core-rules`. + VisibilityDefaults, +} + +#[derive(Debug, Clone)] +/// The `history.*` commands; their names live in [`CoreCommand::parse`]. +pub enum HistoryCommand { /// Records one local-history snapshot (`history.record`). - HistoryRecord, + Record, /// Lists retained local-history metadata (`history.entries`). - HistoryEntries, + Entries, /// Reads the contents of one history snapshot (`history.content`). - HistoryContent, + Content, /// Moves history after a workspace path relocation (`history.relocate`). - HistoryRelocate, + Relocate, /// Plans which snapshots an editing event records (`history.planEvent`). - HistoryPlanEvent, + PlanEvent, /// Changes the optional label of a history snapshot (`history.rename`). - HistoryRename, + Rename, /// Deletes one history snapshot (`history.delete`). - HistoryDelete, + Delete, /// Validates local-history ignore rules against a sample path (`history.ignorePreview`). - HistoryIgnorePreview, + IgnorePreview, /// Reports one project's history storage size (`history.usage`). - HistoryUsage, + Usage, /// Deletes every snapshot of one project (`history.clear`). - HistoryClear, - /// Compares two host-supplied texts line by line (`diff.compareTexts`). - DiffCompareTexts, - /// Resolve environment precedence after platform tool discovery. - ExecutionResolveEnvironment, - /// Resolves deterministic process search-path ordering (`execution.resolveSearchPath`). - ExecutionResolveSearchPath, - /// Decides how each Lithe-owned process ends (`processLifecycle.resolve`). - ProcessLifecycleResolve, - /// Classifies a process snapshot into lifecycle items (`processLifecycle.inventory`). - ProcessLifecycleInventory, - /// Turns a plan and the user's answers into what ends (`processLifecycle.settle`). - ProcessLifecycleSettle, - /// Renders and sanitizes shared Markdown (`markdown.render`). - MarkdownRender, - /// Compiles a bounded boolean shortcut context (`keymap.parseContext`). - KeymapParseContext, - /// Evaluates a precompiled shortcut context (`keymap.evaluateContext`). - KeymapEvaluateContext, - /// Finds conflicts among the final layered shortcut bindings (`keymap.analyzeConflicts`). - KeymapAnalyzeConflicts, - /// Advances the double-Shift gesture recognizer by one event (`keymap.modifierGestureStep`). - /// The rule itself lives in `lithe-core-rules` so the Windows frontend can run it synchronously. - KeymapModifierGestureStep, + Clear, +} + +#[derive(Debug, Clone)] +/// The `editor.*` commands; their names live in [`CoreCommand::parse`]. +pub enum EditorCommand { /// Applies one editor-group state transition: open, close, move, pin, preview, merge or normalize /// (`editor.workbenchState.reduce`). The rule lives in `lithe-core-rules` so the Windows frontend /// can run it synchronously inside store updates. - EditorWorkbenchStateReduce, - /// Classifies a batch of workspace-relative paths as hidden or visible (`workspace.classifyVisibility`). - /// The rule lives in `lithe-core-rules` so the Windows frontend can run it synchronously. - WorkspaceClassifyVisibility, - /// Returns the built-in hidden-path lists (`workspace.visibilityDefaults`), also from `lithe-core-rules`. - WorkspaceVisibilityDefaults, - /// Applies one transition to the rearrangeable workbench chrome: stripe, title bar and status bar - /// button placement and open tool panels (`workbench.chromeLayout.reduce`), from `lithe-core-rules`. - WorkbenchChromeLayoutReduce, - /// Resolves terminal link candidates into file or external targets (`terminal.parseLinkTarget`). - TerminalParseLinkTarget, - /// Creates one transport-neutral Debug Adapter Protocol session (`debug.createSession`). - DebugCreateSession, - /// Queues a launch or attach request for a debug session (`debug.launch`). - DebugLaunch, - /// Returns or normalizes portable stepping filters (`debug.steppingFilters`). - DebugSteppingFilters, - /// Moves source breakpoints across one UTF-16 editor mutation (`debug.relocateBreakpoints`). - DebugRelocateBreakpoints, - /// Plans the first page of one variable container (`debug.planVariablePage`). - DebugPlanVariablePage, - /// Merges one variable page into its paging cursor (`debug.reduceVariablePage`). - DebugReduceVariablePage, - /// Plans the first request of one disassembly fetch (`debug.planDisassemblyWindow`). - DebugPlanDisassemblyWindow, - /// Reduces one disassembly reply to the next bounded step (`debug.reduceDisassemblyPage`). - DebugReduceDisassemblyPage, - /// Replaces breakpoints for one source file (`debug.setBreakpoints`). - DebugSetBreakpoints, - /// Replaces exception filters for one debug session (`debug.setExceptionBreakpoints`). - DebugSetExceptionBreakpoints, - /// Replaces named function breakpoints for one debug session (`debug.setFunctionBreakpoints`). - DebugSetFunctionBreakpoints, - /// Replaces instruction (address) breakpoints for one debug session (`debug.setInstructionBreakpoints`). - DebugSetInstructionBreakpoints, - /// Resolves one adapter-owned data breakpoint identity (`debug.dataBreakpointInfo`). - DebugDataBreakpointInfo, - /// Replaces data breakpoints for one debug session (`debug.setDataBreakpoints`). - DebugSetDataBreakpoints, - /// Mutes or unmutes every breakpoint of one debug session (`debug.setBreakpointsMuted`). - DebugSetBreakpointsMuted, - /// Places the memory view's readMemory window at an address (`debug.planMemoryWindow`). - DebugPlanMemoryWindow, - /// Finds the byte read at in one readMemory reply (`debug.locateMemoryWindow`). - DebugLocateMemoryWindow, - /// Replaces one visible variable value (`debug.setVariable`). - DebugSetVariable, - /// Cancels or times out one pending debug operation (`debug.cancelOperation`). - DebugCancelOperation, - /// Queues continue, pause, or stepping control (`debug.execute`). - DebugExecute, - /// Queues one normalized debugger inspection request (`debug.inspect`). - DebugInspect, - /// Reduces bytes received from a platform-owned DAP transport (`debug.receive`). - DebugReceive, - /// Completes one adapter-requested terminal launch (`debug.runInTerminalResponse`). - DebugRunInTerminalResponse, - /// Completes one adapter-requested child session start (`debug.startDebuggingResponse`). - DebugStartDebuggingResponse, - /// Begins the DAP disconnect handshake (`debug.disconnect`). - DebugDisconnect, - /// Removes all state for a debug session (`debug.destroySession`). - DebugDestroySession, - /// Applies validated UTF-16 LSP text edits (`lsp.applyTextEdits`). - LspApplyTextEdits, - /// Validates workspace-edit targets and orders their commit - /// (`lsp.planWorkspaceEdit`). - LspPlanWorkspaceEdit, - /// Resolves a diagnostic identity inside its workspace (`lsp.documentIdentity`). - LspDocumentIdentity, - /// Decides whether a language-server jump target opens, opens read-only - /// or is refused (`lsp.classifyNavigationTarget`). - LspClassifyNavigationTarget, - /// Decides whether a go-to-declaration gesture opens the declaration or - /// shows its usages (`lsp.declarationOrUsages`). - LspDeclarationOrUsages, + WorkbenchStateReduce, /// Applies a deterministic line-level editing transform /// (`editor.lineEdit`). - EditorLineEdit, + LineEdit, /// Finds text or plans bounded replacements in a borrowed document (`editor.find`). - EditorFind, + Find, /// Resolves persisted editor presentation preferences (`editor.preferences.resolve`). - EditorPreferencesResolve, + PreferencesResolve, /// Reduces and queries bounded recent-file history (`editor.recent.reduce`). - EditorRecentReduce, + RecentReduce, /// Decides how a native checkpoint should recover (`editor.recovery.decide`). - EditorRecoveryDecide, - /// Reduces named shortcut schemes (`keymap.schemes.reduce`). - KeymapSchemesReduce, + RecoveryDecide, /// Validates and orders a plugin document edit batch /// (`editor.planTextEdits`). - EditorPlanTextEdits, + PlanTextEdits, /// Parses a line or line:column navigation input (`editor.parseNavigationInput`). - EditorParseNavigationInput, + ParseNavigationInput, /// Reduces one bounded back/forward navigation history event /// (`editor.navigationHistory.reduce`). - EditorNavigationHistoryReduce, + NavigationHistoryReduce, /// Resolves the line comment token for a file extension /// (`editor.lineCommentToken`). - EditorLineCommentToken, - /// Normalizes the stored proxy settings into the policy a network stack - /// must apply (`network.proxyPolicy`). - NetworkProxyPolicy, + LineCommentToken, +} + +#[derive(Debug, Clone)] +/// The `lsp.*` commands; their names live in [`CoreCommand::parse`]. +pub enum LspCommand { + /// Applies validated UTF-16 LSP text edits (`lsp.applyTextEdits`). + ApplyTextEdits, + /// Validates workspace-edit targets and orders their commit + /// (`lsp.planWorkspaceEdit`). + PlanWorkspaceEdit, + /// Resolves a diagnostic identity inside its workspace (`lsp.documentIdentity`). + DocumentIdentity, + /// Decides whether a language-server jump target opens, opens read-only + /// or is refused (`lsp.classifyNavigationTarget`). + ClassifyNavigationTarget, + /// Decides whether a go-to-declaration gesture opens the declaration or + /// shows its usages (`lsp.declarationOrUsages`). + DeclarationOrUsages, /// Reduces an LSP snippet to insertion text (`lsp.plainSnippet`). - LspPlainSnippet, + PlainSnippet, /// Provides same-document fallback completions (`lsp.builtinCompletions`). - LspBuiltinCompletions, + BuiltinCompletions, /// Provides a same-document fallback hover (`lsp.builtinHover`). - LspBuiltinHover, + BuiltinHover, /// Provides same-document fallback navigation (`lsp.builtinNavigation`). - LspBuiltinNavigation, + BuiltinNavigation, /// Merges completion lists from prioritized providers (`lsp.mergeCompletions`). - LspMergeCompletions, + MergeCompletions, /// Decides whether a language server may start automatically (`lsp.activationPolicy`). - LspActivationPolicy, + ActivationPolicy, /// Filters, orders, and groups Problems tool-window diagnostics (`lsp.problems.query`). - LspProblemsQuery, + ProblemsQuery, /// Starts and initializes a managed language server (`lsp.startServer`). - LspStartServer, + StartServer, /// Gracefully shuts down a managed server (`lsp.stopServer`). - LspStopServer, + StopServer, /// Replaces the plugin settings a plugin-provided server reads /// (`lsp.updateWorkspaceConfiguration`). - LspUpdateWorkspaceConfiguration, + UpdateWorkspaceConfiguration, /// Converts an absolute path to a file URI (`lsp.documentUri`). - LspDocumentUri, + DocumentUri, /// Opens or updates a synchronized document (`lsp.syncDocument`). - LspSyncDocument, + SyncDocument, /// Reports a successful native document save (`lsp.saveDocument`). - LspSaveDocument, + SaveDocument, /// Publishes external workspace file changes (`lsp.workspaceFilesChanged`). - LspWorkspaceFilesChanged, + WorkspaceFilesChanged, /// Closes a synchronized document (`lsp.closeDocument`). - LspCloseDocument, + CloseDocument, /// Queues one semantic server request (`lsp.request`). - LspRequest, + Request, /// Queues one build-provider request (`lsp.buildRequest`). - LspBuildRequest, + BuildRequest, /// Queues one VCS-provider request (`lsp.vcsRequest`). - LspVcsRequest, + VcsRequest, /// Queues one toolchain-provider query (`lsp.toolchainRequest`). - LspToolchainRequest, + ToolchainRequest, /// Queues a shortcut import against a negotiated provider. - LspKeymapImportRequest, + KeymapImportRequest, /// Invokes one plugin function (`lsp.functionRequest`). - LspFunctionRequest, + FunctionRequest, /// Selects a registered function provider strictly inside one workspace. - LspFunctionProvider, + FunctionProvider, /// Replaces the plugins and workspace contexts the cross-plugin function /// relay checks calls against (`lsp.configureFunctionRelay`). - LspConfigureFunctionRelay, + ConfigureFunctionRelay, /// Answers a plugin backend's host-service request (`lsp.hostResponse`). - LspHostResponse, + HostResponse, /// Sends a plugin backend one host notification (`lsp.hostNotify`). - LspHostNotify, + HostNotify, /// Sends a plugin-owned language server one request of its backend (`lsp.pluginServerRequest`). - LspPluginServerRequest, + PluginServerRequest, /// Sends a plugin-owned language server one notification of its backend (`lsp.pluginServerNotify`). - LspPluginServerNotify, + PluginServerNotify, /// Cancels one pending semantic request (`lsp.cancelOperation`). - LspCancelOperation, + CancelOperation, /// Drains queued session events (`lsp.pollEvents`). - LspPollEvents, + PollEvents, /// Waits for queued session events (`lsp.waitEvents`). - LspWaitEvents, + WaitEvents, /// Toggles upstream verbose trace for one session (`lsp.setDetailedDiagnostics`). - LspSetDetailedDiagnostics, + SetDetailedDiagnostics, /// Stops and removes a server session (`lsp.destroyServer`). - LspDestroyServer, + DestroyServer, +} + +#[derive(Debug, Clone)] +/// The `runConfig.*` commands; their names live in [`CoreCommand::parse`]. +pub enum RunConfigCommand { /// Validates layered run-configuration documents (`runConfig.inspect`). - RunConfigInspect, + Inspect, /// Reads effective saved tool paths for one Provider root (`runConfig.projectToolchain`). - RunConfigProjectToolchain, + ProjectToolchain, /// Regenerates detected run configurations (`runConfig.generate`). - RunConfigGenerate, + Generate, /// Formats argument vectors as reversible editor text (`runConfig.argumentText`). - RunConfigArgumentText, + ArgumentText, /// Merges configuration layers and toolchains (`runConfig.resolve`). - RunConfigResolve, + Resolve, /// Selects a configuration for a provider-supplied context identity. - RunConfigSelectContextConfiguration, + SelectContextConfiguration, /// Decides what a press on the title bar's Run or Debug button does (`runConfig.decideTitleAction`). - RunConfigDecideTitleAction, + DecideTitleAction, /// Persists editable run options (`runConfig.updateOptions`). - RunConfigUpdateOptions, + UpdateOptions, /// Replaces generic local tool preferences while preserving existing Run data. - RunConfigUpdateToolPreferences, + UpdateToolPreferences, /// Prepares one complete run-configuration editor save (`runConfig.saveEditorChanges`). - RunConfigSaveEditorChanges, + SaveEditorChanges, /// Adds a user-authored run configuration (`runConfig.createUserConfiguration`). - RunConfigCreateUserConfiguration, + CreateUserConfiguration, /// Produces the process plan for one configuration (`runConfig.createLaunchPlan`). - RunConfigCreateLaunchPlan, + CreateLaunchPlan, /// Resolves user choices for a provider-owned launch (`runConfig.providerLaunchInputs`). - RunConfigProviderLaunchInputs, + ProviderLaunchInputs, +} + +#[derive(Debug, Clone)] +/// The `git.*` commands; their names live in [`CoreCommand::parse`]. +pub enum GitCommand { /// Reads normalized repository and working-tree state (`git.status`). - GitStatus, + Status, /// Lists one level of a summarized untracked directory (`git.untrackedDirectory`). - GitUntrackedDirectory, + UntrackedDirectory, /// Reads HEAD and index preconditions for workspace commits (`git.commitState`). - GitCommitState, + CommitState, /// Builds a reviewed multi-repository plan (`git.workspaceCommitPrepare`). - GitWorkspaceCommitPrepare, + WorkspaceCommitPrepare, /// Executes one guarded workspace commit/push (`git.workspaceCommitStep`). - GitWorkspaceCommitStep, + WorkspaceCommitStep, /// Classifies a bounded page of Git watcher paths (`git.classifyMetadataChanges`). - GitClassifyMetadataChanges, + ClassifyMetadataChanges, /// Resolves paths a Git-aware watcher must observe (`git.watchContext`). - GitWatchContext, + WatchContext, /// Lists worktrees registered for the repository (`git.worktrees`). - GitWorktrees, + Worktrees, /// Describes the checked-out branch or detached worktree for PR creation (`git.pullRequestContext`). - GitPullRequestContext, + PullRequestContext, /// Executes a caller-supplied argument vector without a shell (`git.command`). - GitCommand, + Command, /// Resolves the containing repository root without acquiring a write lease (`git.repositoryRoot`). - GitRepositoryRoot, + RepositoryRoot, /// Resolves a file to its owning Git checkout (`git.resolveFileContext`). - GitResolveFileContext, + ResolveFileContext, /// Plans workspace status publication across successful and failed roots (`git.workspaceStatusMerge`). - GitWorkspaceStatusMerge, + WorkspaceStatusMerge, /// Performs one supported Git mutation (`git.write`). - GitWrite, + Write, /// Plans whole-file rollback from Git change identities (`git.rollbackPlan`). - GitRollbackPlan, + RollbackPlan, /// Rechecks Git identities and plans rollback (`git.rollbackPrepare`). - GitRollbackPrepare, + RollbackPrepare, /// Prepares the real index for an explicit selected-file commit. - GitPrepareCommitSelection, + PrepareCommitSelection, /// Reconciles missing selected additions after deletion. - GitReconcileDeletedSelection, + ReconcileDeletedSelection, /// Reduces local ChangeList metadata and projects membership and commit scope. - GitChangelists, + Changelists, /// Captures or applies one guarded Shelf layer (`git.shelf`). - GitShelf, + Shelf, /// Captures and validates AI commit evidence (`git.commitMessageContext`). - GitCommitMessageContext, + CommitMessageContext, /// Projects retained Git console records without executing Git (`git.consolePresentation`). - GitConsolePresentation, + ConsolePresentation, /// Reads an optional remote URL without a visible command (`git.remoteUrl`). - GitRemoteUrl, + RemoteUrl, /// Plans Fetch arguments without launching Git or changing configuration (`git.fetchPlan`). - GitFetchPlan, + FetchPlan, /// Resolves the executable Git will run without requiring a repository (`git.executable`). - GitExecutable, + Executable, /// Inspects Git configuration and its sources (`git.executionInspect`). - GitExecutionInspect, + ExecutionInspect, /// Saves one explicitly selected configuration field (`git.executionConfigure`). - GitExecutionConfigure, + ExecutionConfigure, /// Answers a currently owned authentication prompt (`git.authRespond`). - GitAuthRespond, - /// Checks that an SSH connection reaches a supported host (`remote.probe`). - RemoteProbe, - /// Answers a prompt relayed by a running `remote.probe` (`remote.authRespond`). - RemoteAuthRespond, - /// Opens a remote connection, installing the server when needed (`remote.connect`). - RemoteConnect, - /// Lists a directory over an open remote connection (`remote.readDir`). - RemoteReadDir, - /// Closes a remote connection (`remote.disconnect`). - RemoteDisconnect, - /// Closes every open remote connection at application exit (`remote.closeAll`). - RemoteCloseAll, - /// Reads a file over an open remote connection (`remote.readFile`). - RemoteReadFile, - /// Saves a file over an open remote connection (`remote.writeFile`). - RemoteWriteFile, - /// Performs a non-streaming operation on an existing workspace host. - RemoteHostRequest, - /// Owns a remote process and relays output until it exits or is cancelled. - RemoteRunProcess, - /// Captures a short remote process with explicit time and output budgets. - RemoteCaptureProcess, - /// Owns a remote PTY and relays output until it exits or is cancelled. - RemoteRunTerminal, - /// Owns a host watch and relays changes until cancellation or root removal. - RemoteRunWatch, - /// Streams a native source into integrity-checked host file staging. - RemoteUploadFile, - /// Returns cached handshake capabilities of one connection. - RemoteHostInfo, + AuthRespond, /// Inspects repository initialization and scoped commit identity. - GitRepositorySetup, + RepositorySetup, /// Initializes an existing folder outside any repository. - GitInitialize, + Initialize, /// Writes or clears one explicitly scoped commit identity field. - GitConfigureIdentity, + ConfigureIdentity, /// Reviews a local history mutation and its immutable checkout snapshot (`git.historyRewritePreview`). - GitHistoryRewritePreview, + HistoryRewritePreview, /// Lists configured fetch remotes. - GitRemotes, + Remotes, /// Lists tags with object and target identities. - GitTags, + Tags, /// Parses a bounded HEAD snapshot without filesystem access. - GitParseCheckoutMetadata, + ParseCheckoutMetadata, /// Parses a bounded gitdir pointer without filesystem access. - GitParseGitdirPointer, + ParseGitdirPointer, /// Resolves action eligibility against the actual checkout history. - GitHistoryActionCapabilities, + HistoryActionCapabilities, /// Preview a linear local range after an unchanged base commit. - GitRebasePreview, + RebasePreview, /// Start a reviewed native interactive rebase with a durable session. - GitRebaseStart, + RebaseStart, /// Read the current checkout's last owned interactive rebase session. - GitRebaseSession, + RebaseSession, /// Continue, skip, or abort an identified native rebase session. - GitRebaseControl, + RebaseControl, /// Exports a lossless UTF-8 exchange patch (`git.patchExport`). - GitPatchExport, + PatchExport, /// Reviews a patch with a forward applicability check (`git.patchPreview`). - GitPatchPreview, + PatchPreview, /// Applies a patch tied to its reviewed snapshot (`git.patchApply`). - GitPatchApply, + PatchApply, /// Builds a structured Git diff (`git.diff`). - GitDiff, + Diff, /// Parses a saved patch into validated per-file documents (`git.parsePatch`). - GitParsePatch, + ParsePatch, /// Applies a patch to the index or working tree (`git.apply`). - GitApply, + Apply, /// Plans commit-review blocks and their Core-owned patches (`git.planDiffBlocks`). - GitPlanDiffBlocks, + PlanDiffBlocks, /// Includes, excludes or rolls back one planned block (`git.applyDiffBlock`). - GitApplyDiffBlock, + ApplyDiffBlock, /// Stages one editor gutter change with its original bytes (`git.stageGutterChange`). - GitStageGutterChange, + StageGutterChange, /// Lists references and bounded commit history (`git.history`). - GitHistory, + History, /// Filters commit dates against explicit absolute bounds (`git.historyQuery`). - GitHistoryQuery, + HistoryQuery, /// Lists Git references without repeating commit history (`git.references`). - GitReferences, + References, /// Returns one cursor-based commit page (`git.historyPage`). - GitHistoryPage, + HistoryPage, /// Releases an incremental history cursor (`git.historyCursorClose`). - GitHistoryCursorClose, + HistoryCursorClose, /// Locates one commit while appending bounded history pages (`git.historyLocate`). - GitHistoryLocate, + HistoryLocate, /// Cleans and validates a new branch name in loaded references (`git.branchNamePreview`). - GitBranchNamePreview, + BranchNamePreview, /// Assigns palette slots to a page of ordered repository identities. - GitRepositoryColorAssignments, + RepositoryColorAssignments, /// Plans a branch Update or Pull from collected facts (`git.updatePlan`). - GitUpdatePlan, + UpdatePlan, /// Plans history refresh windows, page merges, and selection retention (`git.historyWindow`). - GitHistoryWindow, + HistoryWindow, /// Projects the commit graph's lanes, colors, routes, and labels (`git.graphProjection`). - GitGraphProjection, + GraphProjection, /// Drops targets already covered by a selected untracked summary (`git.normalizeChangeTargets`). - GitNormalizeChangeTargets, + NormalizeChangeTargets, /// Validates an interactive rebase draft and derives its messages (`git.rebasePlanPreview`). - GitRebasePlanPreview, + RebasePlanPreview, /// Groups one kind of loaded references into an ordered tree (`git.referenceTree`). - GitReferenceTree, + ReferenceTree, /// Resolves the destination and commits for a branch push (`git.pushPreview`). - GitPushPreview, + PushPreview, /// Resolves metadata for one commit (`git.commit`). - GitCommit, + Commit, /// Lists paths changed by one commit (`git.commitFiles`). - GitCommitFiles, + CommitFiles, /// Compares a reference with the current checkout (`git.comparison`). - GitComparison, + Comparison, /// Lists repository stashes (`git.stashes`). - GitStashes, + Stashes, /// Finds edits that would block checkout (`git.checkoutPreflight`). - GitCheckoutPreflight, + CheckoutPreflight, /// Reports whether the tracked branch can fast-forward (`git.pullPreflight`). - GitPullPreflight, + PullPreflight, /// Finds state that blocks merge, rebase, cherry-pick, or revert (`git.integrationPreflight`). - GitIntegrationPreflight, + IntegrationPreflight, /// Finds staged files containing conflict markers (`git.conflictMarkers`). - GitConflictMarkers, + ConflictMarkers, /// Inspects an interrupted sequential Git operation (`git.operationState`). - GitOperationState, + OperationState, /// Returns normalized line attribution (`git.blame`). - GitBlame, + Blame, /// Traces which commits touched a snapshot line range (`git.lineHistory`). - GitLineHistory, - /// Parses a GitHub repository identity from a Git remote URL (`github.parseRemote`). - GitHubParseRemote, - /// Builds a platform-executable GitHub HTTP request (`github.requestPlan`). - GitHubRequestPlan, - /// Normalizes a GitHub HTTP response into the shared contract (`github.normalizeResponse`). - GitHubNormalizeResponse, - /// Plans the next bounded GitHub device-authorization transition. - GitHubDeviceAuthorizationTransition, - /// Plans a safe local branch for a GitHub pull request. - GitHubPullRequestCheckoutPlan, + LineHistory, /// Previews a non-destructive GitHub pull-request checkout. - GitPullRequestCheckoutPreview, + PullRequestCheckoutPreview, /// Fetches and creates the reviewed pull-request branch without resetting refs. - GitPullRequestCheckout, + PullRequestCheckout, +} + +#[derive(Debug, Clone)] +/// The `remote.*` commands; their names live in [`CoreCommand::parse`]. +pub enum RemoteCommand { + /// Checks that an SSH connection reaches a supported host (`remote.probe`). + Probe, + /// Answers a prompt relayed by a running `remote.probe` (`remote.authRespond`). + AuthRespond, + /// Opens a remote connection, installing the server when needed (`remote.connect`). + Connect, + /// Lists a directory over an open remote connection (`remote.readDir`). + ReadDir, + /// Closes a remote connection (`remote.disconnect`). + Disconnect, + /// Closes every open remote connection at application exit (`remote.closeAll`). + CloseAll, + /// Reads a file over an open remote connection (`remote.readFile`). + ReadFile, + /// Saves a file over an open remote connection (`remote.writeFile`). + WriteFile, + /// Performs a non-streaming operation on an existing workspace host. + HostRequest, + /// Owns a remote process and relays output until it exits or is cancelled. + RunProcess, + /// Captures a short remote process with explicit time and output budgets. + CaptureProcess, + /// Owns a remote PTY and relays output until it exits or is cancelled. + RunTerminal, + /// Owns a host watch and relays changes until cancellation or root removal. + RunWatch, + /// Streams a native source into integrity-checked host file staging. + UploadFile, + /// Returns cached handshake capabilities of one connection. + HostInfo, +} + +#[derive(Debug, Clone)] +/// The `diagnostics.*` commands; their names live in [`CoreCommand::parse`]. +pub enum DiagnosticsCommand { /// Redacts credentials, tokens, and home-directory paths from diagnostic text (`diagnostics.redactText`). - DiagnosticsRedactText, + RedactText, /// Normalizes a bounded, redacted application log event (`diagnostics.prepareLogEvent`). - DiagnosticsPrepareLogEvent, + PrepareLogEvent, /// Filters and merges bounded host-collected log tails (`diagnostics.queryLogs`). - DiagnosticsQueryLogs, + QueryLogs, /// Encodes structured log events independently in one batch (`diagnostics.encodeLogBatch`). - DiagnosticsEncodeLogBatch, + EncodeLogBatch, /// Drains bounded process-owned Core log facts (`diagnostics.drainCoreEvents`). - DiagnosticsDrainCoreEvents, + DrainCoreEvents, /// Selects which DEBUG Core facts the host will write (`diagnostics.configureCoreEvents`). - DiagnosticsConfigureCoreEvents, + ConfigureCoreEvents, /// Records host-observed declarative contribution outcomes (`diagnostics.recordPluginOperation`). - DiagnosticsRecordPluginOperation, + RecordPluginOperation, /// Merges structured and legacy log evidence (`diagnostics.queryLogsV2`). - DiagnosticsQueryLogsV2, + QueryLogsV2, /// Parses filter terms and UTF-16 diagnostics (`diagnostics.parseLogFilter`). - DiagnosticsParseLogFilter, + ParseLogFilter, /// Composes a context-menu predicate (`diagnostics.composeLogFilter`). - DiagnosticsComposeLogFilter, + ComposeLogFilter, /// Formats canonical redacted evidence (`diagnostics.formatLogRecords`). - DiagnosticsFormatLogRecords, + FormatLogRecords, /// Plans bounded native tail reads (`diagnostics.planLogCollection`). - DiagnosticsPlanLogCollection, + PlanLogCollection, /// Plans log cleanup without filesystem access (`diagnostics.planLogRetention`). - DiagnosticsPlanLogRetention, + PlanLogRetention, /// Validates a logical channel and resolves its session-relative path (`diagnostics.resolveLogChannel`). - DiagnosticsResolveLogChannel, + ResolveLogChannel, /// Resolves the engine routing identity without starting a session (`diagnostics.resolveEngineLogSubject`). - DiagnosticsResolveEngineLogSubject, + ResolveEngineLogSubject, /// Shapes a deterministic diagnostic bundle manifest from host-gathered facts (`diagnostics.buildManifest`). - DiagnosticsBuildManifest, + BuildManifest, } impl CoreCommand { @@ -812,115 +912,163 @@ impl CoreCommand { match value { "core.ping" => Some(Self::Ping), "ideHost.control" => Some(Self::IdeHostControl), - "ai.commitInputBudget" => Some(Self::AiCommitInputBudget), - "ai.planCommit" => Some(Self::AiPlanCommit), - "ai.decodeCommitMessage" => Some(Self::AiDecodeCommitMessage), - "ai.configuration.sources" => Some(Self::AiConfigurationSources), - "ai.configuration.parse" => Some(Self::AiConfigurationParse), - "ai.pullRequest.plan" => Some(Self::AiPullRequestPlan), - "ai.pullRequest.decode" => Some(Self::AiPullRequestDecode), - "ai.inlineEdit.plan" => Some(Self::AiInlineEditPlan), - "ai.inlineEdit.decode" => Some(Self::AiInlineEditDecode), - "plugins.declarative.validate" => Some(Self::PluginsDeclarativeValidate), - "plugins.nativeUI.validateData" => Some(Self::PluginsNativeUIValidateData), - "plugins.web.open" => Some(Self::PluginsWebOpen), - "plugins.web.close" => Some(Self::PluginsWebClose), - "plugins.web.resource" => Some(Self::PluginsWebResource), - "plugins.web.document" => Some(Self::PluginsWebDocument), - "plugins.web.request" => Some(Self::PluginsWebRequest), - "plugins.web.reply" => Some(Self::PluginsWebReply), - "plugins.web.event" => Some(Self::PluginsWebEvent), - "plugins.declarative.runPlan" => Some(Self::PluginsDeclarativeRunPlan), - "plugins.declarative.discoverTests" => Some(Self::PluginsDeclarativeDiscoverTests), - "plugins.declarative.testPlan" => Some(Self::PluginsDeclarativeTestPlan), - "plugins.declarative.languageServerLaunchPlan" => { - Some(Self::PluginsDeclarativeLanguageServerLaunchPlan) + "ai.commitInputBudget" => Some(Self::Ai(AiCommand::CommitInputBudget)), + "ai.planCommit" => Some(Self::Ai(AiCommand::PlanCommit)), + "ai.decodeCommitMessage" => Some(Self::Ai(AiCommand::DecodeCommitMessage)), + "ai.configuration.sources" => Some(Self::Ai(AiCommand::ConfigurationSources)), + "ai.configuration.parse" => Some(Self::Ai(AiCommand::ConfigurationParse)), + "ai.pullRequest.plan" => Some(Self::Ai(AiCommand::PullRequestPlan)), + "ai.pullRequest.decode" => Some(Self::Ai(AiCommand::PullRequestDecode)), + "ai.inlineEdit.plan" => Some(Self::Ai(AiCommand::InlineEditPlan)), + "ai.inlineEdit.decode" => Some(Self::Ai(AiCommand::InlineEditDecode)), + "plugins.declarative.validate" => { + Some(Self::Plugins(PluginsCommand::DeclarativeValidate)) + } + "plugins.nativeUI.validateData" => { + Some(Self::Plugins(PluginsCommand::NativeUIValidateData)) + } + "plugins.web.open" => Some(Self::Plugins(PluginsCommand::WebOpen)), + "plugins.web.close" => Some(Self::Plugins(PluginsCommand::WebClose)), + "plugins.web.resource" => Some(Self::Plugins(PluginsCommand::WebResource)), + "plugins.web.document" => Some(Self::Plugins(PluginsCommand::WebDocument)), + "plugins.web.request" => Some(Self::Plugins(PluginsCommand::WebRequest)), + "plugins.web.reply" => Some(Self::Plugins(PluginsCommand::WebReply)), + "plugins.web.event" => Some(Self::Plugins(PluginsCommand::WebEvent)), + "plugins.declarative.runPlan" => { + Some(Self::Plugins(PluginsCommand::DeclarativeRunPlan)) + } + "plugins.declarative.discoverTests" => { + Some(Self::Plugins(PluginsCommand::DeclarativeDiscoverTests)) } + "plugins.declarative.testPlan" => { + Some(Self::Plugins(PluginsCommand::DeclarativeTestPlan)) + } + "plugins.declarative.languageServerLaunchPlan" => Some(Self::Plugins( + PluginsCommand::DeclarativeLanguageServerLaunchPlan, + )), "plugins.languageServers.decodeLaunch" => { - Some(Self::PluginsLanguageServersDecodeLaunch) + Some(Self::Plugins(PluginsCommand::LanguageServersDecodeLaunch)) } "plugins.languageServers.stateRetention" => { - Some(Self::PluginsLanguageServersStateRetention) - } - "plugins.contentProviders.decode" => Some(Self::PluginsContentProvidersDecode), - "debug.adapters.startArguments" => Some(Self::DebugAdaptersStartArguments), - "debug.validateAdapterEndpoint" => Some(Self::DebugValidateAdapterEndpoint), - "debug.adapters.executablePlan" => Some(Self::DebugAdaptersExecutablePlan), - "debug.transport.policy" => Some(Self::DebugTransportPolicy), - "debug.transport.handle" => Some(Self::DebugTransportHandle), - "debug.transport.release" => Some(Self::DebugTransportRelease), - "plugins.declarative.resolveSettings" => Some(Self::PluginsDeclarativeResolveSettings), - "plugins.settings.catalog" => Some(Self::PluginsSettingsCatalog), - "plugins.settings.navigation" => Some(Self::PluginsSettingsNavigation), - "plugins.settings.delivery" => Some(Self::PluginsSettingsDelivery), - "plugins.declarative.settingsState" => Some(Self::PluginsDeclarativeSettingsState), + Some(Self::Plugins(PluginsCommand::LanguageServersStateRetention)) + } + "plugins.contentProviders.decode" => { + Some(Self::Plugins(PluginsCommand::ContentProvidersDecode)) + } + "debug.adapters.startArguments" => { + Some(Self::Debug(DebugCommand::AdaptersStartArguments)) + } + "debug.validateAdapterEndpoint" => { + Some(Self::Debug(DebugCommand::ValidateAdapterEndpoint)) + } + "debug.adapters.executablePlan" => { + Some(Self::Debug(DebugCommand::AdaptersExecutablePlan)) + } + "debug.transport.policy" => Some(Self::Debug(DebugCommand::TransportPolicy)), + "debug.transport.handle" => Some(Self::Debug(DebugCommand::TransportHandle)), + "debug.transport.release" => Some(Self::Debug(DebugCommand::TransportRelease)), + "plugins.declarative.resolveSettings" => { + Some(Self::Plugins(PluginsCommand::DeclarativeResolveSettings)) + } + "plugins.settings.catalog" => Some(Self::Plugins(PluginsCommand::SettingsCatalog)), + "plugins.settings.navigation" => { + Some(Self::Plugins(PluginsCommand::SettingsNavigation)) + } + "plugins.settings.delivery" => Some(Self::Plugins(PluginsCommand::SettingsDelivery)), + "plugins.declarative.settingsState" => { + Some(Self::Plugins(PluginsCommand::DeclarativeSettingsState)) + } "plugins.declarative.settingsActionPlan" => { - Some(Self::PluginsDeclarativeSettingsActionPlan) - } - "plugins.installed.select" => Some(Self::PluginsInstalledSelect), - "plugins.declarative.editSettings" => Some(Self::PluginsDeclarativeEditSettings), - "plugins.declarative.workspaceConfiguration" => { - Some(Self::PluginsDeclarativeWorkspaceConfiguration) - } - "plugins.declarative.decodeGrammar" => Some(Self::PluginsDeclarativeDecodeGrammar), - "plugins.editorActions.decode" => Some(Self::PluginsEditorActionsDecode), - "plugins.hostApi.describe" => Some(Self::PluginsHostApiDescribe), - "plugins.hostApi.authorize" => Some(Self::PluginsHostApiAuthorize), - "plugins.editorProviders.decode" => Some(Self::PluginsEditorProvidersDecode), - "keymap.decodeImport" => Some(Self::KeymapDecodeImport), - "keymap.planImportSources" => Some(Self::KeymapPlanImportSources), - "keymap.planImportBindings" => Some(Self::KeymapPlanImportBindings), - "debug.startOrContinue" => Some(Self::DebugStartOrContinue), - "toolchains.registry" => Some(Self::ToolchainsRegistry), - "toolchains.decodeReply" => Some(Self::ToolchainsDecodeReply), - "toolchains.failureReply" => Some(Self::ToolchainsFailureReply), - "toolchains.resolvePins" => Some(Self::ToolchainsResolvePins), - "toolchains.editPins" => Some(Self::ToolchainsEditPins), - "toolchains.pinStoragePlan" => Some(Self::ToolchainsPinStoragePlan), - "toolchains.decodePinDocument" => Some(Self::ToolchainsDecodePinDocument), - "toolchains.editPinSources" => Some(Self::ToolchainsEditPinSources), - "toolchains.resolveConsumerPreferences" => { - Some(Self::ToolchainsResolveConsumerPreferences) - } - "toolchains.editConsumerPreferences" => Some(Self::ToolchainsEditConsumerPreferences), - "toolchains.resolve" => Some(Self::ToolchainsResolve), - "toolchains.checkVersion" => Some(Self::ToolchainsCheckVersion), - "toolchains.discoveryPlan" => Some(Self::ToolchainsDiscoveryPlan), - "toolchains.candidates" => Some(Self::ToolchainsCandidates), - "toolchains.instantiateLaunch" => Some(Self::ToolchainsInstantiateLaunch), + Some(Self::Plugins(PluginsCommand::DeclarativeSettingsActionPlan)) + } + "plugins.installed.select" => Some(Self::Plugins(PluginsCommand::InstalledSelect)), + "plugins.declarative.editSettings" => { + Some(Self::Plugins(PluginsCommand::DeclarativeEditSettings)) + } + "plugins.declarative.workspaceConfiguration" => Some(Self::Plugins( + PluginsCommand::DeclarativeWorkspaceConfiguration, + )), + "plugins.declarative.decodeGrammar" => { + Some(Self::Plugins(PluginsCommand::DeclarativeDecodeGrammar)) + } + "plugins.editorActions.decode" => { + Some(Self::Plugins(PluginsCommand::EditorActionsDecode)) + } + "plugins.hostApi.describe" => Some(Self::Plugins(PluginsCommand::HostApiDescribe)), + "plugins.hostApi.authorize" => Some(Self::Plugins(PluginsCommand::HostApiAuthorize)), + "plugins.editorProviders.decode" => { + Some(Self::Plugins(PluginsCommand::EditorProvidersDecode)) + } + "keymap.decodeImport" => Some(Self::Keymap(KeymapCommand::DecodeImport)), + "keymap.planImportSources" => Some(Self::Keymap(KeymapCommand::PlanImportSources)), + "keymap.planImportBindings" => Some(Self::Keymap(KeymapCommand::PlanImportBindings)), + "debug.startOrContinue" => Some(Self::Debug(DebugCommand::StartOrContinue)), + "toolchains.registry" => Some(Self::Toolchains(ToolchainsCommand::Registry)), + "toolchains.decodeReply" => Some(Self::Toolchains(ToolchainsCommand::DecodeReply)), + "toolchains.failureReply" => Some(Self::Toolchains(ToolchainsCommand::FailureReply)), + "toolchains.resolvePins" => Some(Self::Toolchains(ToolchainsCommand::ResolvePins)), + "toolchains.editPins" => Some(Self::Toolchains(ToolchainsCommand::EditPins)), + "toolchains.pinStoragePlan" => { + Some(Self::Toolchains(ToolchainsCommand::PinStoragePlan)) + } + "toolchains.decodePinDocument" => { + Some(Self::Toolchains(ToolchainsCommand::DecodePinDocument)) + } + "toolchains.editPinSources" => { + Some(Self::Toolchains(ToolchainsCommand::EditPinSources)) + } + "toolchains.resolveConsumerPreferences" => Some(Self::Toolchains( + ToolchainsCommand::ResolveConsumerPreferences, + )), + "toolchains.editConsumerPreferences" => { + Some(Self::Toolchains(ToolchainsCommand::EditConsumerPreferences)) + } + "toolchains.resolve" => Some(Self::Toolchains(ToolchainsCommand::Resolve)), + "toolchains.checkVersion" => Some(Self::Toolchains(ToolchainsCommand::CheckVersion)), + "toolchains.discoveryPlan" => Some(Self::Toolchains(ToolchainsCommand::DiscoveryPlan)), + "toolchains.candidates" => Some(Self::Toolchains(ToolchainsCommand::Candidates)), + "toolchains.instantiateLaunch" => { + Some(Self::Toolchains(ToolchainsCommand::InstantiateLaunch)) + } "run.decodeStart" => Some(Self::RunDecodeStart), - "debug.decodeBackendConfiguration" => Some(Self::DebugDecodeBackendConfiguration), + "debug.decodeBackendConfiguration" => { + Some(Self::Debug(DebugCommand::DecodeBackendConfiguration)) + } "outputLinks.match" => Some(Self::OutputLinksMatch), "outputLinks.decodeResolution" => Some(Self::OutputLinksDecodeResolution), - "build.detect" => Some(Self::BuildDetect), - "build.projectOptions" => Some(Self::BuildProjectOptions), - "build.resolveStoredOptions" => Some(Self::BuildResolveStoredOptions), - "build.editStoredOption" => Some(Self::BuildEditStoredOption), - "build.invocationPlan" => Some(Self::BuildInvocationPlan), - "build.checkToolVersion" => Some(Self::BuildCheckToolVersion), - "build.resolveOptions" => Some(Self::BuildResolveOptions), - "build.modelPlan" => Some(Self::BuildModelPlan), - "build.taskPlan" => Some(Self::BuildTaskPlan), - "build.testPlan" => Some(Self::BuildTestPlan), - "build.prepareDebugPlan" => Some(Self::BuildPrepareDebugPlan), - "build.decodeDebugPreparation" => Some(Self::BuildDecodeDebugPreparation), - "build.resolveModelInputs" => Some(Self::BuildResolveModelInputs), - "build.configurationPlan" => Some(Self::BuildConfigurationPlan), - "build.configurationEditPlan" => Some(Self::BuildConfigurationEditPlan), - "build.decodeConfigurationEdit" => Some(Self::BuildDecodeConfigurationEdit), - "build.decodeConfiguration" => Some(Self::BuildDecodeConfiguration), - "build.prepareRunPlan" => Some(Self::BuildPrepareRunPlan), - "build.decodeRunPreparation" => Some(Self::BuildDecodeRunPreparation), - "build.testDiscoveryPlan" => Some(Self::BuildTestDiscoveryPlan), - "build.decodeTestDiscovery" => Some(Self::BuildDecodeTestDiscovery), - "build.dependencyPlan" => Some(Self::BuildDependencyPlan), - "build.decodeModel" => Some(Self::BuildDecodeModel), - "build.decodeTestResults" => Some(Self::BuildDecodeTestResults), - "build.decodeDiagnostics" => Some(Self::BuildDecodeDiagnostics), - "build.decodeDependencies" => Some(Self::BuildDecodeDependencies), - "build.syncFingerprint" => Some(Self::BuildSyncFingerprint), - "build.resolveTestRunOutcome" => Some(Self::BuildResolveTestRunOutcome), - "build.mergeTestOutcomes" => Some(Self::BuildMergeTestOutcomes), + "build.detect" => Some(Self::Build(BuildCommand::Detect)), + "build.projectOptions" => Some(Self::Build(BuildCommand::ProjectOptions)), + "build.resolveStoredOptions" => Some(Self::Build(BuildCommand::ResolveStoredOptions)), + "build.editStoredOption" => Some(Self::Build(BuildCommand::EditStoredOption)), + "build.invocationPlan" => Some(Self::Build(BuildCommand::InvocationPlan)), + "build.checkToolVersion" => Some(Self::Build(BuildCommand::CheckToolVersion)), + "build.resolveOptions" => Some(Self::Build(BuildCommand::ResolveOptions)), + "build.modelPlan" => Some(Self::Build(BuildCommand::ModelPlan)), + "build.taskPlan" => Some(Self::Build(BuildCommand::TaskPlan)), + "build.testPlan" => Some(Self::Build(BuildCommand::TestPlan)), + "build.prepareDebugPlan" => Some(Self::Build(BuildCommand::PrepareDebugPlan)), + "build.decodeDebugPreparation" => { + Some(Self::Build(BuildCommand::DecodeDebugPreparation)) + } + "build.resolveModelInputs" => Some(Self::Build(BuildCommand::ResolveModelInputs)), + "build.configurationPlan" => Some(Self::Build(BuildCommand::ConfigurationPlan)), + "build.configurationEditPlan" => Some(Self::Build(BuildCommand::ConfigurationEditPlan)), + "build.decodeConfigurationEdit" => { + Some(Self::Build(BuildCommand::DecodeConfigurationEdit)) + } + "build.decodeConfiguration" => Some(Self::Build(BuildCommand::DecodeConfiguration)), + "build.prepareRunPlan" => Some(Self::Build(BuildCommand::PrepareRunPlan)), + "build.decodeRunPreparation" => Some(Self::Build(BuildCommand::DecodeRunPreparation)), + "build.testDiscoveryPlan" => Some(Self::Build(BuildCommand::TestDiscoveryPlan)), + "build.decodeTestDiscovery" => Some(Self::Build(BuildCommand::DecodeTestDiscovery)), + "build.dependencyPlan" => Some(Self::Build(BuildCommand::DependencyPlan)), + "build.decodeModel" => Some(Self::Build(BuildCommand::DecodeModel)), + "build.decodeTestResults" => Some(Self::Build(BuildCommand::DecodeTestResults)), + "build.decodeDiagnostics" => Some(Self::Build(BuildCommand::DecodeDiagnostics)), + "build.decodeDependencies" => Some(Self::Build(BuildCommand::DecodeDependencies)), + "build.syncFingerprint" => Some(Self::Build(BuildCommand::SyncFingerprint)), + "build.resolveTestRunOutcome" => Some(Self::Build(BuildCommand::ResolveTestRunOutcome)), + "build.mergeTestOutcomes" => Some(Self::Build(BuildCommand::MergeTestOutcomes)), "vcs.detectRoots" => Some(Self::VcsDetectRoots), "vcs.ownerOf" => Some(Self::VcsOwnerOf), "vcs.decodeReply" => Some(Self::VcsDecodeReply), @@ -929,32 +1077,54 @@ impl CoreCommand { "project.generationPlan" => Some(Self::ProjectGenerationPlan), "project.checkName" => Some(Self::ProjectCheckName), "project.extractArchive" => Some(Self::ProjectExtractArchive), - "plugins.marketplace.validateIndex" => Some(Self::PluginsMarketplaceValidateIndex), - "plugins.marketplace.resolveSource" => Some(Self::PluginsMarketplaceResolveSource), - "plugins.marketplace.installReview" => Some(Self::PluginsMarketplaceInstallReview), - "workspace.snapshot" => Some(Self::WorkspaceSnapshot), - "workspace.releaseIndexing" => Some(Self::WorkspaceReleaseIndexing), - "workspace.configureIndexing" => Some(Self::WorkspaceConfigureIndexing), - "workspace.refreshIndexing" => Some(Self::WorkspaceRefreshIndexing), - "workspace.filterChanges" => Some(Self::WorkspaceFilterChanges), - "workspace.listDirectories" => Some(Self::WorkspaceListDirectories), - "workspace.orderEntries" => Some(Self::WorkspaceOrderEntries), - "workspace.rankNames" => Some(Self::WorkspaceRankNames), - "workspace.planDuplicate" => Some(Self::WorkspacePlanDuplicate), - "workspace.planPaste" => Some(Self::WorkspacePlanPaste), - "workspace.reduceDirectoryAction" => Some(Self::WorkspaceReduceDirectoryAction), - "workspace.relocateDirectoryRules" => Some(Self::WorkspaceRelocateDirectoryRules), - "workspace.repositories" => Some(Self::WorkspaceRepositories), - "workspace.recentProjects.reduce" => Some(Self::WorkspaceRecentProjectsReduce), - "workspace.searchIndex.warm" => Some(Self::WorkspaceSearchIndexWarm), - "workspace.searchIndex.update" => Some(Self::WorkspaceSearchIndexUpdate), - "workspace.searchIndex.invalidate" => Some(Self::WorkspaceSearchIndexInvalidate), + "plugins.marketplace.validateIndex" => { + Some(Self::Plugins(PluginsCommand::MarketplaceValidateIndex)) + } + "plugins.marketplace.resolveSource" => { + Some(Self::Plugins(PluginsCommand::MarketplaceResolveSource)) + } + "plugins.marketplace.installReview" => { + Some(Self::Plugins(PluginsCommand::MarketplaceInstallReview)) + } + "workspace.snapshot" => Some(Self::Workspace(WorkspaceCommand::Snapshot)), + "workspace.releaseIndexing" => Some(Self::Workspace(WorkspaceCommand::ReleaseIndexing)), + "workspace.configureIndexing" => { + Some(Self::Workspace(WorkspaceCommand::ConfigureIndexing)) + } + "workspace.refreshIndexing" => Some(Self::Workspace(WorkspaceCommand::RefreshIndexing)), + "workspace.filterChanges" => Some(Self::Workspace(WorkspaceCommand::FilterChanges)), + "workspace.listDirectories" => Some(Self::Workspace(WorkspaceCommand::ListDirectories)), + "workspace.orderEntries" => Some(Self::Workspace(WorkspaceCommand::OrderEntries)), + "workspace.rankNames" => Some(Self::Workspace(WorkspaceCommand::RankNames)), + "workspace.planDuplicate" => Some(Self::Workspace(WorkspaceCommand::PlanDuplicate)), + "workspace.planPaste" => Some(Self::Workspace(WorkspaceCommand::PlanPaste)), + "workspace.reduceDirectoryAction" => { + Some(Self::Workspace(WorkspaceCommand::ReduceDirectoryAction)) + } + "workspace.relocateDirectoryRules" => { + Some(Self::Workspace(WorkspaceCommand::RelocateDirectoryRules)) + } + "workspace.repositories" => Some(Self::Workspace(WorkspaceCommand::Repositories)), + "workspace.recentProjects.reduce" => { + Some(Self::Workspace(WorkspaceCommand::RecentProjectsReduce)) + } + "workspace.searchIndex.warm" => { + Some(Self::Workspace(WorkspaceCommand::SearchIndexWarm)) + } + "workspace.searchIndex.update" => { + Some(Self::Workspace(WorkspaceCommand::SearchIndexUpdate)) + } + "workspace.searchIndex.invalidate" => { + Some(Self::Workspace(WorkspaceCommand::SearchIndexInvalidate)) + } "search.actions" => Some(Self::SearchActions), - "workspace.search" => Some(Self::WorkspaceSearch), - "workspace.searchEverywhere" => Some(Self::WorkspaceSearchEverywhere), - "workspace.replacePreview" => Some(Self::WorkspaceReplacePreview), - "workspace.replaceText" => Some(Self::WorkspaceReplaceText), - "workspace.searchTextBatch" => Some(Self::WorkspaceSearchTextBatch), + "workspace.search" => Some(Self::Workspace(WorkspaceCommand::Search)), + "workspace.searchEverywhere" => { + Some(Self::Workspace(WorkspaceCommand::SearchEverywhere)) + } + "workspace.replacePreview" => Some(Self::Workspace(WorkspaceCommand::ReplacePreview)), + "workspace.replaceText" => Some(Self::Workspace(WorkspaceCommand::ReplaceText)), + "workspace.searchTextBatch" => Some(Self::Workspace(WorkspaceCommand::SearchTextBatch)), "file.read" => Some(Self::FileRead), "file.write" => Some(Self::FileWrite), "text.decode" => Some(Self::TextDecode), @@ -963,16 +1133,16 @@ impl CoreCommand { "document.classifyText" => Some(Self::DocumentClassifyText), "document.planSaveBatch" => Some(Self::DocumentPlanSaveBatch), "document.reduceSaveBatch" => Some(Self::DocumentReduceSaveBatch), - "history.record" => Some(Self::HistoryRecord), - "history.entries" => Some(Self::HistoryEntries), - "history.content" => Some(Self::HistoryContent), - "history.relocate" => Some(Self::HistoryRelocate), - "history.planEvent" => Some(Self::HistoryPlanEvent), - "history.rename" => Some(Self::HistoryRename), - "history.delete" => Some(Self::HistoryDelete), - "history.ignorePreview" => Some(Self::HistoryIgnorePreview), - "history.usage" => Some(Self::HistoryUsage), - "history.clear" => Some(Self::HistoryClear), + "history.record" => Some(Self::History(HistoryCommand::Record)), + "history.entries" => Some(Self::History(HistoryCommand::Entries)), + "history.content" => Some(Self::History(HistoryCommand::Content)), + "history.relocate" => Some(Self::History(HistoryCommand::Relocate)), + "history.planEvent" => Some(Self::History(HistoryCommand::PlanEvent)), + "history.rename" => Some(Self::History(HistoryCommand::Rename)), + "history.delete" => Some(Self::History(HistoryCommand::Delete)), + "history.ignorePreview" => Some(Self::History(HistoryCommand::IgnorePreview)), + "history.usage" => Some(Self::History(HistoryCommand::Usage)), + "history.clear" => Some(Self::History(HistoryCommand::Clear)), "diff.compareTexts" => Some(Self::DiffCompareTexts), "execution.resolveEnvironment" => Some(Self::ExecutionResolveEnvironment), "execution.resolveSearchPath" => Some(Self::ExecutionResolveSearchPath), @@ -980,195 +1150,235 @@ impl CoreCommand { "processLifecycle.inventory" => Some(Self::ProcessLifecycleInventory), "processLifecycle.settle" => Some(Self::ProcessLifecycleSettle), "markdown.render" => Some(Self::MarkdownRender), - "keymap.parseContext" => Some(Self::KeymapParseContext), - "keymap.evaluateContext" => Some(Self::KeymapEvaluateContext), - "keymap.analyzeConflicts" => Some(Self::KeymapAnalyzeConflicts), - "keymap.modifierGestureStep" => Some(Self::KeymapModifierGestureStep), - "editor.workbenchState.reduce" => Some(Self::EditorWorkbenchStateReduce), - "workspace.classifyVisibility" => Some(Self::WorkspaceClassifyVisibility), - "workspace.visibilityDefaults" => Some(Self::WorkspaceVisibilityDefaults), + "keymap.parseContext" => Some(Self::Keymap(KeymapCommand::ParseContext)), + "keymap.evaluateContext" => Some(Self::Keymap(KeymapCommand::EvaluateContext)), + "keymap.analyzeConflicts" => Some(Self::Keymap(KeymapCommand::AnalyzeConflicts)), + "keymap.modifierGestureStep" => Some(Self::Keymap(KeymapCommand::ModifierGestureStep)), + "editor.workbenchState.reduce" => { + Some(Self::Editor(EditorCommand::WorkbenchStateReduce)) + } + "workspace.classifyVisibility" => { + Some(Self::Workspace(WorkspaceCommand::ClassifyVisibility)) + } + "workspace.visibilityDefaults" => { + Some(Self::Workspace(WorkspaceCommand::VisibilityDefaults)) + } "workbench.chromeLayout.reduce" => Some(Self::WorkbenchChromeLayoutReduce), "terminal.parseLinkTarget" => Some(Self::TerminalParseLinkTarget), - "debug.createSession" => Some(Self::DebugCreateSession), - "debug.launch" => Some(Self::DebugLaunch), - "debug.steppingFilters" => Some(Self::DebugSteppingFilters), - "debug.relocateBreakpoints" => Some(Self::DebugRelocateBreakpoints), - "debug.planVariablePage" => Some(Self::DebugPlanVariablePage), - "debug.reduceVariablePage" => Some(Self::DebugReduceVariablePage), - "debug.planDisassemblyWindow" => Some(Self::DebugPlanDisassemblyWindow), - "debug.reduceDisassemblyPage" => Some(Self::DebugReduceDisassemblyPage), - "debug.setBreakpoints" => Some(Self::DebugSetBreakpoints), - "debug.setExceptionBreakpoints" => Some(Self::DebugSetExceptionBreakpoints), - "debug.setFunctionBreakpoints" => Some(Self::DebugSetFunctionBreakpoints), - "debug.setInstructionBreakpoints" => Some(Self::DebugSetInstructionBreakpoints), - "debug.dataBreakpointInfo" => Some(Self::DebugDataBreakpointInfo), - "debug.setDataBreakpoints" => Some(Self::DebugSetDataBreakpoints), - "debug.setBreakpointsMuted" => Some(Self::DebugSetBreakpointsMuted), - "debug.planMemoryWindow" => Some(Self::DebugPlanMemoryWindow), - "debug.locateMemoryWindow" => Some(Self::DebugLocateMemoryWindow), - "debug.setVariable" => Some(Self::DebugSetVariable), - "debug.cancelOperation" => Some(Self::DebugCancelOperation), - "debug.execute" => Some(Self::DebugExecute), - "debug.inspect" => Some(Self::DebugInspect), - "debug.receive" => Some(Self::DebugReceive), - "debug.runInTerminalResponse" => Some(Self::DebugRunInTerminalResponse), - "debug.startDebuggingResponse" => Some(Self::DebugStartDebuggingResponse), - "debug.disconnect" => Some(Self::DebugDisconnect), - "debug.destroySession" => Some(Self::DebugDestroySession), - "lsp.applyTextEdits" => Some(Self::LspApplyTextEdits), - "lsp.planWorkspaceEdit" => Some(Self::LspPlanWorkspaceEdit), - "lsp.documentIdentity" => Some(Self::LspDocumentIdentity), - "lsp.classifyNavigationTarget" => Some(Self::LspClassifyNavigationTarget), - "lsp.declarationOrUsages" => Some(Self::LspDeclarationOrUsages), - "editor.lineEdit" => Some(Self::EditorLineEdit), - "editor.find" => Some(Self::EditorFind), - "editor.preferences.resolve" => Some(Self::EditorPreferencesResolve), - "editor.recent.reduce" => Some(Self::EditorRecentReduce), - "editor.recovery.decide" => Some(Self::EditorRecoveryDecide), - "keymap.schemes.reduce" => Some(Self::KeymapSchemesReduce), - "editor.planTextEdits" => Some(Self::EditorPlanTextEdits), - "editor.parseNavigationInput" => Some(Self::EditorParseNavigationInput), - "editor.navigationHistory.reduce" => Some(Self::EditorNavigationHistoryReduce), - "editor.lineCommentToken" => Some(Self::EditorLineCommentToken), + "debug.createSession" => Some(Self::Debug(DebugCommand::CreateSession)), + "debug.launch" => Some(Self::Debug(DebugCommand::Launch)), + "debug.steppingFilters" => Some(Self::Debug(DebugCommand::SteppingFilters)), + "debug.relocateBreakpoints" => Some(Self::Debug(DebugCommand::RelocateBreakpoints)), + "debug.planVariablePage" => Some(Self::Debug(DebugCommand::PlanVariablePage)), + "debug.reduceVariablePage" => Some(Self::Debug(DebugCommand::ReduceVariablePage)), + "debug.planDisassemblyWindow" => Some(Self::Debug(DebugCommand::PlanDisassemblyWindow)), + "debug.reduceDisassemblyPage" => Some(Self::Debug(DebugCommand::ReduceDisassemblyPage)), + "debug.setBreakpoints" => Some(Self::Debug(DebugCommand::SetBreakpoints)), + "debug.setExceptionBreakpoints" => { + Some(Self::Debug(DebugCommand::SetExceptionBreakpoints)) + } + "debug.setFunctionBreakpoints" => { + Some(Self::Debug(DebugCommand::SetFunctionBreakpoints)) + } + "debug.setInstructionBreakpoints" => { + Some(Self::Debug(DebugCommand::SetInstructionBreakpoints)) + } + "debug.dataBreakpointInfo" => Some(Self::Debug(DebugCommand::DataBreakpointInfo)), + "debug.setDataBreakpoints" => Some(Self::Debug(DebugCommand::SetDataBreakpoints)), + "debug.setBreakpointsMuted" => Some(Self::Debug(DebugCommand::SetBreakpointsMuted)), + "debug.planMemoryWindow" => Some(Self::Debug(DebugCommand::PlanMemoryWindow)), + "debug.locateMemoryWindow" => Some(Self::Debug(DebugCommand::LocateMemoryWindow)), + "debug.setVariable" => Some(Self::Debug(DebugCommand::SetVariable)), + "debug.cancelOperation" => Some(Self::Debug(DebugCommand::CancelOperation)), + "debug.execute" => Some(Self::Debug(DebugCommand::Execute)), + "debug.inspect" => Some(Self::Debug(DebugCommand::Inspect)), + "debug.receive" => Some(Self::Debug(DebugCommand::Receive)), + "debug.runInTerminalResponse" => Some(Self::Debug(DebugCommand::RunInTerminalResponse)), + "debug.startDebuggingResponse" => { + Some(Self::Debug(DebugCommand::StartDebuggingResponse)) + } + "debug.disconnect" => Some(Self::Debug(DebugCommand::Disconnect)), + "debug.destroySession" => Some(Self::Debug(DebugCommand::DestroySession)), + "lsp.applyTextEdits" => Some(Self::Lsp(LspCommand::ApplyTextEdits)), + "lsp.planWorkspaceEdit" => Some(Self::Lsp(LspCommand::PlanWorkspaceEdit)), + "lsp.documentIdentity" => Some(Self::Lsp(LspCommand::DocumentIdentity)), + "lsp.classifyNavigationTarget" => Some(Self::Lsp(LspCommand::ClassifyNavigationTarget)), + "lsp.declarationOrUsages" => Some(Self::Lsp(LspCommand::DeclarationOrUsages)), + "editor.lineEdit" => Some(Self::Editor(EditorCommand::LineEdit)), + "editor.find" => Some(Self::Editor(EditorCommand::Find)), + "editor.preferences.resolve" => Some(Self::Editor(EditorCommand::PreferencesResolve)), + "editor.recent.reduce" => Some(Self::Editor(EditorCommand::RecentReduce)), + "editor.recovery.decide" => Some(Self::Editor(EditorCommand::RecoveryDecide)), + "keymap.schemes.reduce" => Some(Self::Keymap(KeymapCommand::SchemesReduce)), + "editor.planTextEdits" => Some(Self::Editor(EditorCommand::PlanTextEdits)), + "editor.parseNavigationInput" => { + Some(Self::Editor(EditorCommand::ParseNavigationInput)) + } + "editor.navigationHistory.reduce" => { + Some(Self::Editor(EditorCommand::NavigationHistoryReduce)) + } + "editor.lineCommentToken" => Some(Self::Editor(EditorCommand::LineCommentToken)), "network.proxyPolicy" => Some(Self::NetworkProxyPolicy), - "lsp.plainSnippet" => Some(Self::LspPlainSnippet), - "lsp.builtinCompletions" => Some(Self::LspBuiltinCompletions), - "lsp.builtinHover" => Some(Self::LspBuiltinHover), - "lsp.builtinNavigation" => Some(Self::LspBuiltinNavigation), - "lsp.mergeCompletions" => Some(Self::LspMergeCompletions), - "lsp.activationPolicy" => Some(Self::LspActivationPolicy), - "lsp.problems.query" => Some(Self::LspProblemsQuery), - "lsp.startServer" => Some(Self::LspStartServer), - "lsp.stopServer" => Some(Self::LspStopServer), - "lsp.updateWorkspaceConfiguration" => Some(Self::LspUpdateWorkspaceConfiguration), - "lsp.documentUri" => Some(Self::LspDocumentUri), - "lsp.syncDocument" => Some(Self::LspSyncDocument), - "lsp.saveDocument" => Some(Self::LspSaveDocument), - "lsp.workspaceFilesChanged" => Some(Self::LspWorkspaceFilesChanged), - "lsp.closeDocument" => Some(Self::LspCloseDocument), - "lsp.request" => Some(Self::LspRequest), - "lsp.buildRequest" => Some(Self::LspBuildRequest), - "lsp.vcsRequest" => Some(Self::LspVcsRequest), - "lsp.toolchainRequest" => Some(Self::LspToolchainRequest), - "lsp.keymapImportRequest" => Some(Self::LspKeymapImportRequest), - "lsp.functionRequest" => Some(Self::LspFunctionRequest), - "lsp.functionProvider" => Some(Self::LspFunctionProvider), - "lsp.configureFunctionRelay" => Some(Self::LspConfigureFunctionRelay), - "lsp.hostResponse" => Some(Self::LspHostResponse), - "lsp.hostNotify" => Some(Self::LspHostNotify), - "lsp.pluginServerRequest" => Some(Self::LspPluginServerRequest), - "lsp.pluginServerNotify" => Some(Self::LspPluginServerNotify), - "lsp.cancelOperation" => Some(Self::LspCancelOperation), - "lsp.pollEvents" => Some(Self::LspPollEvents), - "lsp.setDetailedDiagnostics" => Some(Self::LspSetDetailedDiagnostics), - "lsp.waitEvents" => Some(Self::LspWaitEvents), - "lsp.destroyServer" => Some(Self::LspDestroyServer), - "runConfig.inspect" => Some(Self::RunConfigInspect), - "runConfig.projectToolchain" => Some(Self::RunConfigProjectToolchain), - "runConfig.generate" => Some(Self::RunConfigGenerate), - "runConfig.argumentText" => Some(Self::RunConfigArgumentText), - "runConfig.resolve" => Some(Self::RunConfigResolve), - "runConfig.selectContextConfiguration" => { - Some(Self::RunConfigSelectContextConfiguration) - } - "runConfig.decideTitleAction" => Some(Self::RunConfigDecideTitleAction), - "runConfig.updateOptions" => Some(Self::RunConfigUpdateOptions), - "runConfig.updateToolPreferences" => Some(Self::RunConfigUpdateToolPreferences), - "runConfig.saveEditorChanges" => Some(Self::RunConfigSaveEditorChanges), - "runConfig.createUserConfiguration" => Some(Self::RunConfigCreateUserConfiguration), - "runConfig.createLaunchPlan" => Some(Self::RunConfigCreateLaunchPlan), - "runConfig.providerLaunchInputs" => Some(Self::RunConfigProviderLaunchInputs), - "git.status" => Some(Self::GitStatus), - "git.untrackedDirectory" => Some(Self::GitUntrackedDirectory), - "git.commitState" => Some(Self::GitCommitState), - "git.workspaceCommitPrepare" => Some(Self::GitWorkspaceCommitPrepare), - "git.workspaceCommitStep" => Some(Self::GitWorkspaceCommitStep), - "git.classifyMetadataChanges" => Some(Self::GitClassifyMetadataChanges), - "git.watchContext" => Some(Self::GitWatchContext), - "git.worktrees" => Some(Self::GitWorktrees), - "git.pullRequestContext" => Some(Self::GitPullRequestContext), - "git.command" => Some(Self::GitCommand), - "git.repositoryRoot" => Some(Self::GitRepositoryRoot), - "git.resolveFileContext" => Some(Self::GitResolveFileContext), - "git.workspaceStatusMerge" => Some(Self::GitWorkspaceStatusMerge), - "git.write" => Some(Self::GitWrite), - "git.rollbackPlan" => Some(Self::GitRollbackPlan), - "git.rollbackPrepare" => Some(Self::GitRollbackPrepare), - "git.prepareCommitSelection" => Some(Self::GitPrepareCommitSelection), - "git.reconcileDeletedSelection" => Some(Self::GitReconcileDeletedSelection), - "git.changelists" => Some(Self::GitChangelists), - "git.shelf" => Some(Self::GitShelf), - "git.commitMessageContext" => Some(Self::GitCommitMessageContext), - "git.consolePresentation" => Some(Self::GitConsolePresentation), - "git.remoteUrl" => Some(Self::GitRemoteUrl), - "git.fetchPlan" => Some(Self::GitFetchPlan), - "git.executable" => Some(Self::GitExecutable), - "git.executionInspect" => Some(Self::GitExecutionInspect), - "git.executionConfigure" => Some(Self::GitExecutionConfigure), - "git.authRespond" => Some(Self::GitAuthRespond), - "remote.probe" => Some(Self::RemoteProbe), - "remote.authRespond" => Some(Self::RemoteAuthRespond), - "remote.connect" => Some(Self::RemoteConnect), - "remote.readDir" => Some(Self::RemoteReadDir), - "remote.disconnect" => Some(Self::RemoteDisconnect), - "remote.closeAll" => Some(Self::RemoteCloseAll), - "remote.readFile" => Some(Self::RemoteReadFile), - "remote.writeFile" => Some(Self::RemoteWriteFile), - "remote.hostRequest" => Some(Self::RemoteHostRequest), - "remote.runProcess" => Some(Self::RemoteRunProcess), - "remote.captureProcess" => Some(Self::RemoteCaptureProcess), - "remote.runTerminal" => Some(Self::RemoteRunTerminal), - "remote.runWatch" => Some(Self::RemoteRunWatch), - "remote.uploadFile" => Some(Self::RemoteUploadFile), - "remote.hostInfo" => Some(Self::RemoteHostInfo), - "git.historyRewritePreview" => Some(Self::GitHistoryRewritePreview), - "git.remotes" => Some(Self::GitRemotes), - "git.tags" => Some(Self::GitTags), - "git.parseCheckoutMetadata" => Some(Self::GitParseCheckoutMetadata), - "git.parseGitdirPointer" => Some(Self::GitParseGitdirPointer), - "git.historyActionCapabilities" => Some(Self::GitHistoryActionCapabilities), - "git.rebasePreview" => Some(Self::GitRebasePreview), - "git.repositorySetup" => Some(Self::GitRepositorySetup), - "git.initialize" => Some(Self::GitInitialize), - "git.configureIdentity" => Some(Self::GitConfigureIdentity), - "git.rebaseStart" => Some(Self::GitRebaseStart), - "git.rebaseSession" => Some(Self::GitRebaseSession), - "git.rebaseControl" => Some(Self::GitRebaseControl), - "git.patchExport" => Some(Self::GitPatchExport), - "git.patchPreview" => Some(Self::GitPatchPreview), - "git.patchApply" => Some(Self::GitPatchApply), - "git.diff" => Some(Self::GitDiff), - "git.parsePatch" => Some(Self::GitParsePatch), - "git.apply" => Some(Self::GitApply), - "git.planDiffBlocks" => Some(Self::GitPlanDiffBlocks), - "git.applyDiffBlock" => Some(Self::GitApplyDiffBlock), - "git.stageGutterChange" => Some(Self::GitStageGutterChange), - "git.history" => Some(Self::GitHistory), - "git.historyQuery" => Some(Self::GitHistoryQuery), - "git.references" => Some(Self::GitReferences), - "git.historyPage" => Some(Self::GitHistoryPage), - "git.historyCursorClose" => Some(Self::GitHistoryCursorClose), - "git.historyLocate" => Some(Self::GitHistoryLocate), - "git.branchNamePreview" => Some(Self::GitBranchNamePreview), - "git.repositoryColorAssignments" => Some(Self::GitRepositoryColorAssignments), - "git.updatePlan" => Some(Self::GitUpdatePlan), - "git.historyWindow" => Some(Self::GitHistoryWindow), - "git.graphProjection" => Some(Self::GitGraphProjection), - "git.normalizeChangeTargets" => Some(Self::GitNormalizeChangeTargets), - "git.rebasePlanPreview" => Some(Self::GitRebasePlanPreview), - "git.referenceTree" => Some(Self::GitReferenceTree), - "git.pushPreview" => Some(Self::GitPushPreview), - "git.commit" => Some(Self::GitCommit), - "git.commitFiles" => Some(Self::GitCommitFiles), - "git.comparison" => Some(Self::GitComparison), - "git.stashes" => Some(Self::GitStashes), - "git.checkoutPreflight" => Some(Self::GitCheckoutPreflight), - "git.pullPreflight" => Some(Self::GitPullPreflight), - "git.integrationPreflight" => Some(Self::GitIntegrationPreflight), - "git.conflictMarkers" => Some(Self::GitConflictMarkers), - "git.operationState" => Some(Self::GitOperationState), - "git.blame" => Some(Self::GitBlame), - "git.lineHistory" => Some(Self::GitLineHistory), + "lsp.plainSnippet" => Some(Self::Lsp(LspCommand::PlainSnippet)), + "lsp.builtinCompletions" => Some(Self::Lsp(LspCommand::BuiltinCompletions)), + "lsp.builtinHover" => Some(Self::Lsp(LspCommand::BuiltinHover)), + "lsp.builtinNavigation" => Some(Self::Lsp(LspCommand::BuiltinNavigation)), + "lsp.mergeCompletions" => Some(Self::Lsp(LspCommand::MergeCompletions)), + "lsp.activationPolicy" => Some(Self::Lsp(LspCommand::ActivationPolicy)), + "lsp.problems.query" => Some(Self::Lsp(LspCommand::ProblemsQuery)), + "lsp.startServer" => Some(Self::Lsp(LspCommand::StartServer)), + "lsp.stopServer" => Some(Self::Lsp(LspCommand::StopServer)), + "lsp.updateWorkspaceConfiguration" => { + Some(Self::Lsp(LspCommand::UpdateWorkspaceConfiguration)) + } + "lsp.documentUri" => Some(Self::Lsp(LspCommand::DocumentUri)), + "lsp.syncDocument" => Some(Self::Lsp(LspCommand::SyncDocument)), + "lsp.saveDocument" => Some(Self::Lsp(LspCommand::SaveDocument)), + "lsp.workspaceFilesChanged" => Some(Self::Lsp(LspCommand::WorkspaceFilesChanged)), + "lsp.closeDocument" => Some(Self::Lsp(LspCommand::CloseDocument)), + "lsp.request" => Some(Self::Lsp(LspCommand::Request)), + "lsp.buildRequest" => Some(Self::Lsp(LspCommand::BuildRequest)), + "lsp.vcsRequest" => Some(Self::Lsp(LspCommand::VcsRequest)), + "lsp.toolchainRequest" => Some(Self::Lsp(LspCommand::ToolchainRequest)), + "lsp.keymapImportRequest" => Some(Self::Lsp(LspCommand::KeymapImportRequest)), + "lsp.functionRequest" => Some(Self::Lsp(LspCommand::FunctionRequest)), + "lsp.functionProvider" => Some(Self::Lsp(LspCommand::FunctionProvider)), + "lsp.configureFunctionRelay" => Some(Self::Lsp(LspCommand::ConfigureFunctionRelay)), + "lsp.hostResponse" => Some(Self::Lsp(LspCommand::HostResponse)), + "lsp.hostNotify" => Some(Self::Lsp(LspCommand::HostNotify)), + "lsp.pluginServerRequest" => Some(Self::Lsp(LspCommand::PluginServerRequest)), + "lsp.pluginServerNotify" => Some(Self::Lsp(LspCommand::PluginServerNotify)), + "lsp.cancelOperation" => Some(Self::Lsp(LspCommand::CancelOperation)), + "lsp.pollEvents" => Some(Self::Lsp(LspCommand::PollEvents)), + "lsp.setDetailedDiagnostics" => Some(Self::Lsp(LspCommand::SetDetailedDiagnostics)), + "lsp.waitEvents" => Some(Self::Lsp(LspCommand::WaitEvents)), + "lsp.destroyServer" => Some(Self::Lsp(LspCommand::DestroyServer)), + "runConfig.inspect" => Some(Self::RunConfig(RunConfigCommand::Inspect)), + "runConfig.projectToolchain" => { + Some(Self::RunConfig(RunConfigCommand::ProjectToolchain)) + } + "runConfig.generate" => Some(Self::RunConfig(RunConfigCommand::Generate)), + "runConfig.argumentText" => Some(Self::RunConfig(RunConfigCommand::ArgumentText)), + "runConfig.resolve" => Some(Self::RunConfig(RunConfigCommand::Resolve)), + "runConfig.selectContextConfiguration" => Some(Self::RunConfig( + RunConfigCommand::SelectContextConfiguration, + )), + "runConfig.decideTitleAction" => { + Some(Self::RunConfig(RunConfigCommand::DecideTitleAction)) + } + "runConfig.updateOptions" => Some(Self::RunConfig(RunConfigCommand::UpdateOptions)), + "runConfig.updateToolPreferences" => { + Some(Self::RunConfig(RunConfigCommand::UpdateToolPreferences)) + } + "runConfig.saveEditorChanges" => { + Some(Self::RunConfig(RunConfigCommand::SaveEditorChanges)) + } + "runConfig.createUserConfiguration" => { + Some(Self::RunConfig(RunConfigCommand::CreateUserConfiguration)) + } + "runConfig.createLaunchPlan" => { + Some(Self::RunConfig(RunConfigCommand::CreateLaunchPlan)) + } + "runConfig.providerLaunchInputs" => { + Some(Self::RunConfig(RunConfigCommand::ProviderLaunchInputs)) + } + "git.status" => Some(Self::Git(GitCommand::Status)), + "git.untrackedDirectory" => Some(Self::Git(GitCommand::UntrackedDirectory)), + "git.commitState" => Some(Self::Git(GitCommand::CommitState)), + "git.workspaceCommitPrepare" => Some(Self::Git(GitCommand::WorkspaceCommitPrepare)), + "git.workspaceCommitStep" => Some(Self::Git(GitCommand::WorkspaceCommitStep)), + "git.classifyMetadataChanges" => Some(Self::Git(GitCommand::ClassifyMetadataChanges)), + "git.watchContext" => Some(Self::Git(GitCommand::WatchContext)), + "git.worktrees" => Some(Self::Git(GitCommand::Worktrees)), + "git.pullRequestContext" => Some(Self::Git(GitCommand::PullRequestContext)), + "git.command" => Some(Self::Git(GitCommand::Command)), + "git.repositoryRoot" => Some(Self::Git(GitCommand::RepositoryRoot)), + "git.resolveFileContext" => Some(Self::Git(GitCommand::ResolveFileContext)), + "git.workspaceStatusMerge" => Some(Self::Git(GitCommand::WorkspaceStatusMerge)), + "git.write" => Some(Self::Git(GitCommand::Write)), + "git.rollbackPlan" => Some(Self::Git(GitCommand::RollbackPlan)), + "git.rollbackPrepare" => Some(Self::Git(GitCommand::RollbackPrepare)), + "git.prepareCommitSelection" => Some(Self::Git(GitCommand::PrepareCommitSelection)), + "git.reconcileDeletedSelection" => { + Some(Self::Git(GitCommand::ReconcileDeletedSelection)) + } + "git.changelists" => Some(Self::Git(GitCommand::Changelists)), + "git.shelf" => Some(Self::Git(GitCommand::Shelf)), + "git.commitMessageContext" => Some(Self::Git(GitCommand::CommitMessageContext)), + "git.consolePresentation" => Some(Self::Git(GitCommand::ConsolePresentation)), + "git.remoteUrl" => Some(Self::Git(GitCommand::RemoteUrl)), + "git.fetchPlan" => Some(Self::Git(GitCommand::FetchPlan)), + "git.executable" => Some(Self::Git(GitCommand::Executable)), + "git.executionInspect" => Some(Self::Git(GitCommand::ExecutionInspect)), + "git.executionConfigure" => Some(Self::Git(GitCommand::ExecutionConfigure)), + "git.authRespond" => Some(Self::Git(GitCommand::AuthRespond)), + "remote.probe" => Some(Self::Remote(RemoteCommand::Probe)), + "remote.authRespond" => Some(Self::Remote(RemoteCommand::AuthRespond)), + "remote.connect" => Some(Self::Remote(RemoteCommand::Connect)), + "remote.readDir" => Some(Self::Remote(RemoteCommand::ReadDir)), + "remote.disconnect" => Some(Self::Remote(RemoteCommand::Disconnect)), + "remote.closeAll" => Some(Self::Remote(RemoteCommand::CloseAll)), + "remote.readFile" => Some(Self::Remote(RemoteCommand::ReadFile)), + "remote.writeFile" => Some(Self::Remote(RemoteCommand::WriteFile)), + "remote.hostRequest" => Some(Self::Remote(RemoteCommand::HostRequest)), + "remote.runProcess" => Some(Self::Remote(RemoteCommand::RunProcess)), + "remote.captureProcess" => Some(Self::Remote(RemoteCommand::CaptureProcess)), + "remote.runTerminal" => Some(Self::Remote(RemoteCommand::RunTerminal)), + "remote.runWatch" => Some(Self::Remote(RemoteCommand::RunWatch)), + "remote.uploadFile" => Some(Self::Remote(RemoteCommand::UploadFile)), + "remote.hostInfo" => Some(Self::Remote(RemoteCommand::HostInfo)), + "git.historyRewritePreview" => Some(Self::Git(GitCommand::HistoryRewritePreview)), + "git.remotes" => Some(Self::Git(GitCommand::Remotes)), + "git.tags" => Some(Self::Git(GitCommand::Tags)), + "git.parseCheckoutMetadata" => Some(Self::Git(GitCommand::ParseCheckoutMetadata)), + "git.parseGitdirPointer" => Some(Self::Git(GitCommand::ParseGitdirPointer)), + "git.historyActionCapabilities" => { + Some(Self::Git(GitCommand::HistoryActionCapabilities)) + } + "git.rebasePreview" => Some(Self::Git(GitCommand::RebasePreview)), + "git.repositorySetup" => Some(Self::Git(GitCommand::RepositorySetup)), + "git.initialize" => Some(Self::Git(GitCommand::Initialize)), + "git.configureIdentity" => Some(Self::Git(GitCommand::ConfigureIdentity)), + "git.rebaseStart" => Some(Self::Git(GitCommand::RebaseStart)), + "git.rebaseSession" => Some(Self::Git(GitCommand::RebaseSession)), + "git.rebaseControl" => Some(Self::Git(GitCommand::RebaseControl)), + "git.patchExport" => Some(Self::Git(GitCommand::PatchExport)), + "git.patchPreview" => Some(Self::Git(GitCommand::PatchPreview)), + "git.patchApply" => Some(Self::Git(GitCommand::PatchApply)), + "git.diff" => Some(Self::Git(GitCommand::Diff)), + "git.parsePatch" => Some(Self::Git(GitCommand::ParsePatch)), + "git.apply" => Some(Self::Git(GitCommand::Apply)), + "git.planDiffBlocks" => Some(Self::Git(GitCommand::PlanDiffBlocks)), + "git.applyDiffBlock" => Some(Self::Git(GitCommand::ApplyDiffBlock)), + "git.stageGutterChange" => Some(Self::Git(GitCommand::StageGutterChange)), + "git.history" => Some(Self::Git(GitCommand::History)), + "git.historyQuery" => Some(Self::Git(GitCommand::HistoryQuery)), + "git.references" => Some(Self::Git(GitCommand::References)), + "git.historyPage" => Some(Self::Git(GitCommand::HistoryPage)), + "git.historyCursorClose" => Some(Self::Git(GitCommand::HistoryCursorClose)), + "git.historyLocate" => Some(Self::Git(GitCommand::HistoryLocate)), + "git.branchNamePreview" => Some(Self::Git(GitCommand::BranchNamePreview)), + "git.repositoryColorAssignments" => { + Some(Self::Git(GitCommand::RepositoryColorAssignments)) + } + "git.updatePlan" => Some(Self::Git(GitCommand::UpdatePlan)), + "git.historyWindow" => Some(Self::Git(GitCommand::HistoryWindow)), + "git.graphProjection" => Some(Self::Git(GitCommand::GraphProjection)), + "git.normalizeChangeTargets" => Some(Self::Git(GitCommand::NormalizeChangeTargets)), + "git.rebasePlanPreview" => Some(Self::Git(GitCommand::RebasePlanPreview)), + "git.referenceTree" => Some(Self::Git(GitCommand::ReferenceTree)), + "git.pushPreview" => Some(Self::Git(GitCommand::PushPreview)), + "git.commit" => Some(Self::Git(GitCommand::Commit)), + "git.commitFiles" => Some(Self::Git(GitCommand::CommitFiles)), + "git.comparison" => Some(Self::Git(GitCommand::Comparison)), + "git.stashes" => Some(Self::Git(GitCommand::Stashes)), + "git.checkoutPreflight" => Some(Self::Git(GitCommand::CheckoutPreflight)), + "git.pullPreflight" => Some(Self::Git(GitCommand::PullPreflight)), + "git.integrationPreflight" => Some(Self::Git(GitCommand::IntegrationPreflight)), + "git.conflictMarkers" => Some(Self::Git(GitCommand::ConflictMarkers)), + "git.operationState" => Some(Self::Git(GitCommand::OperationState)), + "git.blame" => Some(Self::Git(GitCommand::Blame)), + "git.lineHistory" => Some(Self::Git(GitCommand::LineHistory)), "github.parseRemote" => Some(Self::GitHubParseRemote), "github.requestPlan" => Some(Self::GitHubRequestPlan), "github.normalizeResponse" => Some(Self::GitHubNormalizeResponse), @@ -1176,24 +1386,52 @@ impl CoreCommand { Some(Self::GitHubDeviceAuthorizationTransition) } "github.pullRequestCheckoutPlan" => Some(Self::GitHubPullRequestCheckoutPlan), - "git.pullRequestCheckoutPreview" => Some(Self::GitPullRequestCheckoutPreview), - "git.pullRequestCheckout" => Some(Self::GitPullRequestCheckout), - "diagnostics.redactText" => Some(Self::DiagnosticsRedactText), - "diagnostics.prepareLogEvent" => Some(Self::DiagnosticsPrepareLogEvent), - "diagnostics.queryLogs" => Some(Self::DiagnosticsQueryLogs), - "diagnostics.recordPluginOperation" => Some(Self::DiagnosticsRecordPluginOperation), - "diagnostics.drainCoreEvents" => Some(Self::DiagnosticsDrainCoreEvents), - "diagnostics.configureCoreEvents" => Some(Self::DiagnosticsConfigureCoreEvents), - "diagnostics.encodeLogBatch" => Some(Self::DiagnosticsEncodeLogBatch), - "diagnostics.parseLogFilter" => Some(Self::DiagnosticsParseLogFilter), - "diagnostics.composeLogFilter" => Some(Self::DiagnosticsComposeLogFilter), - "diagnostics.formatLogRecords" => Some(Self::DiagnosticsFormatLogRecords), - "diagnostics.queryLogsV2" => Some(Self::DiagnosticsQueryLogsV2), - "diagnostics.planLogCollection" => Some(Self::DiagnosticsPlanLogCollection), - "diagnostics.planLogRetention" => Some(Self::DiagnosticsPlanLogRetention), - "diagnostics.resolveLogChannel" => Some(Self::DiagnosticsResolveLogChannel), - "diagnostics.resolveEngineLogSubject" => Some(Self::DiagnosticsResolveEngineLogSubject), - "diagnostics.buildManifest" => Some(Self::DiagnosticsBuildManifest), + "git.pullRequestCheckoutPreview" => { + Some(Self::Git(GitCommand::PullRequestCheckoutPreview)) + } + "git.pullRequestCheckout" => Some(Self::Git(GitCommand::PullRequestCheckout)), + "diagnostics.redactText" => Some(Self::Diagnostics(DiagnosticsCommand::RedactText)), + "diagnostics.prepareLogEvent" => { + Some(Self::Diagnostics(DiagnosticsCommand::PrepareLogEvent)) + } + "diagnostics.queryLogs" => Some(Self::Diagnostics(DiagnosticsCommand::QueryLogs)), + "diagnostics.recordPluginOperation" => { + Some(Self::Diagnostics(DiagnosticsCommand::RecordPluginOperation)) + } + "diagnostics.drainCoreEvents" => { + Some(Self::Diagnostics(DiagnosticsCommand::DrainCoreEvents)) + } + "diagnostics.configureCoreEvents" => { + Some(Self::Diagnostics(DiagnosticsCommand::ConfigureCoreEvents)) + } + "diagnostics.encodeLogBatch" => { + Some(Self::Diagnostics(DiagnosticsCommand::EncodeLogBatch)) + } + "diagnostics.parseLogFilter" => { + Some(Self::Diagnostics(DiagnosticsCommand::ParseLogFilter)) + } + "diagnostics.composeLogFilter" => { + Some(Self::Diagnostics(DiagnosticsCommand::ComposeLogFilter)) + } + "diagnostics.formatLogRecords" => { + Some(Self::Diagnostics(DiagnosticsCommand::FormatLogRecords)) + } + "diagnostics.queryLogsV2" => Some(Self::Diagnostics(DiagnosticsCommand::QueryLogsV2)), + "diagnostics.planLogCollection" => { + Some(Self::Diagnostics(DiagnosticsCommand::PlanLogCollection)) + } + "diagnostics.planLogRetention" => { + Some(Self::Diagnostics(DiagnosticsCommand::PlanLogRetention)) + } + "diagnostics.resolveLogChannel" => { + Some(Self::Diagnostics(DiagnosticsCommand::ResolveLogChannel)) + } + "diagnostics.resolveEngineLogSubject" => Some(Self::Diagnostics( + DiagnosticsCommand::ResolveEngineLogSubject, + )), + "diagnostics.buildManifest" => { + Some(Self::Diagnostics(DiagnosticsCommand::BuildManifest)) + } _ => None, } } @@ -1201,7 +1439,7 @@ impl CoreCommand { #[cfg(test)] mod tests { - use super::CoreCommand; + use super::{CoreCommand, GitCommand, WorkspaceCommand}; use std::collections::BTreeSet; use std::path::Path; @@ -1387,7 +1625,7 @@ mod tests { fn parses_git_push_preview_command() { assert!(matches!( CoreCommand::parse("git.pushPreview"), - Some(CoreCommand::GitPushPreview) + Some(CoreCommand::Git(GitCommand::PushPreview)) )); } @@ -1395,7 +1633,7 @@ mod tests { fn parses_workspace_repositories_command() { assert!(matches!( CoreCommand::parse("workspace.repositories"), - Some(CoreCommand::WorkspaceRepositories) + Some(CoreCommand::Workspace(WorkspaceCommand::Repositories)) )); } @@ -1403,7 +1641,7 @@ mod tests { fn parses_git_repository_root_command() { assert!(matches!( CoreCommand::parse("git.repositoryRoot"), - Some(CoreCommand::GitRepositoryRoot) + Some(CoreCommand::Git(GitCommand::RepositoryRoot)) )); } } diff --git a/rust/crates/core/src/protocol/event.rs b/rust/crates/core/src/protocol/event.rs deleted file mode 100644 index 3e0d8556..00000000 --- a/rust/crates/core/src/protocol/event.rs +++ /dev/null @@ -1,24 +0,0 @@ -//! Events emitted by asynchronous shared-core operations. - -use crate::protocol::CoreError; -use crate::protocol::{GitStatusResponse, SearchResponse, WorkspaceSnapshotResponse}; -use serde::Serialize; - -#[derive(Debug, Clone, Serialize)] -#[serde(tag = "type", content = "payload", rename_all = "camelCase")] -/// Tagged asynchronous events delivered to host applications. -pub enum CoreEvent { - /// A workspace snapshot finished loading. - WorkspaceLoaded(WorkspaceSnapshotResponse), - /// An asynchronous search produced its final result set. - SearchCompleted(SearchResponse), - /// Observed Git state changed for the active repository. - GitStatusChanged(GitStatusResponse), - /// A workspace-relative path changed on disk. - FileChanged { - /// Forward-slashed path relative to the workspace root. - path: String, - }, - /// An asynchronous Core operation failed before producing data. - OperationFailed(CoreError), -} diff --git a/rust/crates/core/src/protocol/mod.rs b/rust/crates/core/src/protocol/mod.rs index 8dcf468d..939b3d4b 100644 --- a/rust/crates/core/src/protocol/mod.rs +++ b/rust/crates/core/src/protocol/mod.rs @@ -4,14 +4,10 @@ //! here, so every `crate::protocol::...` path names the same type a domain crate returns. mod command; -mod contracts; -mod event; pub use command::*; -pub use contracts::*; -pub use event::*; -pub(crate) use lithe_protocol::{cancellation, json_document}; -pub use lithe_protocol::{invalid_relative_path, CoreError, CoreResponse, ErrorCode, ResponseData}; +pub(crate) use lithe_protocol::cancellation; +pub use lithe_protocol::{CoreError, CoreResponse, ErrorCode, ResponseData}; /// Maps an `ai.*` failure onto the Core error of the same category name. pub(crate) fn ai_error(error: lithe_ai::AiError) -> CoreError { diff --git a/rust/crates/core/src/runtime/dispatcher.rs b/rust/crates/core/src/runtime/dispatcher.rs index 8199186e..4fdecd2c 100644 --- a/rust/crates/core/src/runtime/dispatcher.rs +++ b/rust/crates/core/src/runtime/dispatcher.rs @@ -1,43 +1,39 @@ //! Validation and routing from versioned command names to their owning domains. +//! +//! Each command family with its own enum in [`crate::protocol`] dispatches in the +//! child module named after it; the remaining small families dispatch here. -use crate::diagnostics::{BuildManifestRequest, RedactTextRequest}; -use crate::git::{ - self, GitApplyRequest, GitBlameRequest, GitBranchNamePreviewRequest, - GitCheckoutPreflightRequest, GitCommandRequest, GitCommitFilesRequest, GitCommitRequest, - GitComparisonRequest, GitConflictMarkerRequest, GitDiffRequest, GitHistoryCursorCloseRequest, - GitHistoryLocateRequest, GitHistoryPageRequest, GitHistoryRequest, - GitHistoryRewritePreviewRequest, GitIntegrationPreflightRequest, GitLineHistoryRequest, - GitOperationStateRequest, GitPullPreflightRequest, GitPullRequestContextRequest, - GitPushPreviewRequest, GitRebaseControlRequest, GitRebasePreviewRequest, - GitRebaseSessionRequest, GitRebaseStartRequest, GitReferencesRequest, GitRepositoryRootRequest, - GitStashesRequest, GitStatusRequest, GitUntrackedDirectoryRequest, GitWatchContextRequest, - GitWorktreesRequest, GitWriteRequest, PatchApplyRequest, PatchExportRequest, - PatchPreviewRequest, WorkspaceRepositoriesRequest, -}; -use crate::project::MarkdownRenderRequest; -use crate::project::{ - self, DirectoryListingRequest, DocumentLifecycleRequest, FileReadRequest, FileWriteRequest, - ReplacementPreviewRequest, SearchIndexRequest, SearchIndexUpdateRequest, SearchRequest, - WorkspaceSnapshotRequest, -}; use crate::project::{ - HistoryContentRequest, HistoryDeleteRequest, HistoryEntriesRequest, - HistoryIgnorePreviewRequest, HistoryRecordRequest, HistoryRelocateRequest, - HistoryRenameRequest, HistoryStorageRequest, + self, DocumentLifecycleRequest, FileReadRequest, FileWriteRequest, MarkdownRenderRequest, }; -use crate::protocol::CoreResponse; -use crate::protocol::{CoreCommand, CoreRequest}; -use crate::protocol::{CoreError, ErrorCode}; +use crate::protocol::{CoreCommand, CoreError, CoreRequest, CoreResponse, ErrorCode, GitCommand}; use lithe_github::{NormalizeResponseRequest, ParseRemoteRequest, RequestPlanRequest}; +/// The manifest type every domain command validates plugin manifests with. +use lithe_plugin_manifest::DeclarativePluginManifest as Manifest; use serde_json::json; +mod ai; +mod build; +mod debug; +mod diagnostics; +mod editor; +mod git; +mod history; +mod keymap; +mod lsp; +mod plugins; +mod remote; +mod run_config; +mod toolchains; +mod workspace; + pub fn execute_json(request: &str) -> String { execute_document_json(request, None) } /// Borrows an immutable document for one request; no text is retained after dispatch. pub(crate) fn execute_document_json(request: &str, document: Option<&str>) -> String { - crate::diagnostics::helper_failures::install(); + crate::runtime::helper_failures::install(); let response = execute(request, document); serde_json::to_string(&response).unwrap_or_else(|_| { serde_json::to_string(&CoreResponse::failure( @@ -62,14 +58,6 @@ fn plugin_response( } } -/// Runs one `ai.*` command in `lithe-ai`. -fn ai_response(id: Option, command: &str, payload: serde_json::Value) -> CoreResponse { - match lithe_ai::execute(command, payload) { - Ok(data) => CoreResponse::success(id, data), - Err(error) => CoreResponse::failure(id, crate::protocol::ai_error(error)), - } -} - /// Runs one rule in `lithe-core-rules`. fn rules_response(id: Option, command: &str, payload: serde_json::Value) -> CoreResponse { match lithe_core_rules::execute(command, payload) { @@ -101,24 +89,6 @@ fn reducer_response( } } -/// Host detailed-diagnostics state; `roots: None` enables DEBUG for every root. -#[derive(serde::Deserialize)] -#[serde(deny_unknown_fields)] -struct CoreEventConfiguration { - debug: bool, - roots: Option>, -} - -// Domain failures retain their unique numeric logging codes inside the existing Core envelope. -fn log_v2_result( - result: Result, -) -> serde_json::Value { - match result { - Ok(value) => serde_json::json!({"result": value}), - Err(error) => serde_json::json!({"error": error}), - } -} - fn execute(request: &str, document: Option<&str>) -> CoreResponse { let parsed: CoreRequest = match serde_json::from_str(request) { Ok(request) => request, @@ -131,7 +101,7 @@ fn execute(request: &str, document: Option<&str>) -> CoreResponse { } }; // The trace spans every scope below and records once, after the final response. - let trace = crate::diagnostics::request_trace::Request::begin(&parsed); + let trace = crate::runtime::request_trace::Request::begin(&parsed); let response = execute_parsed(parsed, document); trace.finish(&response); response @@ -145,16 +115,24 @@ fn execute_parsed(mut parsed: CoreRequest, document: Option<&str>) -> CoreRespon operation_id.clone(), parsed.timeout_milliseconds, ); - crate::git::execution_events::request_started(operation_id.as_deref()); + lithe_request_context::events::request_started(operation_id.as_deref()); if let Err(error) = crate::protocol::cancellation::check() { return CoreResponse::failure(id, error); } - let _workspace = - match crate::remote::workspace::Scope::begin(parsed.remote_workspace, &parsed.command) { - Ok(scope) => scope, - Err(error) => return CoreResponse::failure(id, error), - }; - let _git_policy = match crate::git::execution_policy::Scope::begin(parsed.git_execution) { + if parsed.remote_workspace.is_some() && !has_remote_workspace_adapter(&parsed.command) { + return CoreResponse::failure( + id, + CoreError::new( + ErrorCode::NotSupported, + "This command has no remote workspace adapter", + ), + ); + } + let _workspace = match lithe_workspace::Scope::begin(parsed.remote_workspace) { + Ok(scope) => scope, + Err(error) => return CoreResponse::failure(id, error), + }; + let _git_policy = match lithe_git::execution_policy::Scope::begin(parsed.git_execution) { Ok(scope) => scope, Err(error) => return CoreResponse::failure(id, error), }; @@ -166,11 +144,14 @@ fn execute_parsed(mut parsed: CoreRequest, document: Option<&str>) -> CoreRespon ); }; - if let Err(error) = crate::lsp::resolve_request_paths(&parsed.command, &mut parsed.payload) { + if let Err(error) = + lithe_document_uri::resolve_request_paths(&parsed.command, &mut parsed.payload) + { return CoreResponse::failure(id, error); } - let preserve_workspace_outcome = matches!(command, CoreCommand::GitWorkspaceCommitStep); + let preserve_workspace_outcome = + matches!(command, CoreCommand::Git(GitCommand::WorkspaceCommitStep)); let response = match command { CoreCommand::IdeHostControl => { let action = parsed.payload["action"].as_str().unwrap_or(""); @@ -188,3371 +169,314 @@ fn execute_parsed(mut parsed: CoreRequest, document: Option<&str>) -> CoreRespon "coreVersion": env!("CARGO_PKG_VERSION") }), ), - CoreCommand::AiCommitInputBudget => ai_response(id, "ai.commitInputBudget", parsed.payload), - CoreCommand::AiPlanCommit => ai_response(id, "ai.planCommit", parsed.payload), - CoreCommand::AiDecodeCommitMessage => { - ai_response(id, "ai.decodeCommitMessage", parsed.payload) - } - CoreCommand::AiConfigurationSources => { - ai_response(id, "ai.configuration.sources", parsed.payload) - } - CoreCommand::AiConfigurationParse => { - ai_response(id, "ai.configuration.parse", parsed.payload) - } - CoreCommand::AiPullRequestPlan => ai_response(id, "ai.pullRequest.plan", parsed.payload), - CoreCommand::AiPullRequestDecode => { - ai_response(id, "ai.pullRequest.decode", parsed.payload) - } - CoreCommand::AiInlineEditPlan => ai_response(id, "ai.inlineEdit.plan", parsed.payload), - CoreCommand::AiInlineEditDecode => ai_response(id, "ai.inlineEdit.decode", parsed.payload), - CoreCommand::PluginsNativeUIValidateData => plugin_response( - id, - parsed.payload, - crate::plugins::native_ui::validate_data_command, - ), - CoreCommand::PluginsWebOpen => plugin_response( - id, - parsed.payload, - crate::plugins::web_content::open_command, - ), - CoreCommand::PluginsWebClose => plugin_response( - id, - parsed.payload, - crate::plugins::web_content::close_command, - ), - CoreCommand::PluginsWebResource => plugin_response( - id, - parsed.payload, - crate::plugins::web_content::resource_command, - ), - CoreCommand::PluginsWebDocument => plugin_response( - id, - parsed.payload, - crate::plugins::web_content::document_command, - ), - CoreCommand::PluginsWebRequest => plugin_response( - id, - parsed.payload, - crate::plugins::web_content::request_command, - ), - CoreCommand::PluginsWebReply => plugin_response( - id, - parsed.payload, - crate::plugins::web_content::reply_command, - ), - CoreCommand::PluginsWebEvent => plugin_response( + CoreCommand::Ai(command) => ai::dispatch(command, id, parsed.payload), + CoreCommand::Plugins(command) => plugins::dispatch(command, id, parsed.payload), + CoreCommand::Debug(command) => debug::dispatch(command, id, parsed.payload), + CoreCommand::Keymap(command) => keymap::dispatch(command, id, parsed.payload), + CoreCommand::Toolchains(command) => toolchains::dispatch(command, id, parsed.payload), + CoreCommand::RunDecodeStart => plugin_response( id, parsed.payload, - crate::plugins::web_content::event_command, + crate::build::run_decode_start_command::, ), - CoreCommand::PluginsDeclarativeValidate => plugin_response( - id, - parsed.payload, - crate::plugins::declarative::validate_command, - ), - CoreCommand::PluginsDeclarativeRunPlan => plugin_response( + CoreCommand::OutputLinksMatch => { + plugin_response(id, parsed.payload, lithe_plugin_output_links::match_lines) + } + CoreCommand::OutputLinksDecodeResolution => plugin_response( id, parsed.payload, - crate::plugins::declarative::run_plan_command, + lithe_plugin_output_links::decode_resolution, ), - CoreCommand::PluginsDeclarativeDiscoverTests => plugin_response( + CoreCommand::Build(command) => build::dispatch(command, id, parsed.payload), + CoreCommand::VcsDetectRoots => plugin_response( id, parsed.payload, - crate::plugins::declarative::discover_tests_command, + crate::vcs::vcs_detect_roots_command::, ), - CoreCommand::PluginsDeclarativeTestPlan => plugin_response( + CoreCommand::VcsOwnerOf => { + plugin_response(id, parsed.payload, crate::vcs::vcs_owner_of_command) + } + CoreCommand::VcsDecodeReply => { + plugin_response(id, parsed.payload, crate::vcs::vcs_decode_reply_command) + } + CoreCommand::ProjectGeneratorCatalog => plugin_response( id, parsed.payload, - crate::plugins::declarative::test_plan_command, + crate::project_generators::project_generator_catalog_command::, ), - CoreCommand::PluginsDeclarativeLanguageServerLaunchPlan => plugin_response( + CoreCommand::ProjectGeneratorForm => plugin_response( id, parsed.payload, - crate::plugins::declarative::language_server_launch_plan_command, + crate::project_generators::project_generator_form_command::, ), - CoreCommand::PluginsLanguageServersDecodeLaunch => plugin_response( + CoreCommand::ProjectGenerationPlan => plugin_response( id, parsed.payload, - crate::plugins::declarative::decode_language_server_launch_command, + crate::project_generators::project_generation_plan_command::, ), - CoreCommand::PluginsLanguageServersStateRetention => plugin_response( + CoreCommand::ProjectCheckName => plugin_response( id, parsed.payload, - crate::plugins::language_server_sessions::state_retention_command, + crate::project_generators::project_check_name_command, ), - CoreCommand::PluginsContentProvidersDecode => plugin_response( + CoreCommand::ProjectExtractArchive => plugin_response( id, parsed.payload, - crate::plugins::content_providers::decode_content_command, + crate::project_generators::project_extract_archive_command, ), - CoreCommand::DebugValidateAdapterEndpoint => { - match crate::debug::validate_adapter_endpoint(parsed.payload) { - Ok(data) => CoreResponse::success(id, data), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::DebugTransportPolicy => match crate::debug::transport_policy(parsed.payload) { - Ok(data) => CoreResponse::success(id, data), + CoreCommand::Workspace(command) => workspace::dispatch(command, id, parsed.payload), + CoreCommand::SearchActions => rules_response(id, "search.actions", parsed.payload), + CoreCommand::FileRead => match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid file read request") + .with_details(error.to_string()) + }) + .and_then(project::read_file) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("file response should encode"), + ), Err(error) => CoreResponse::failure(id, error), }, - CoreCommand::DebugTransportHandle => { - match crate::debug::handle_transport_event(parsed.payload) { - Ok(data) => CoreResponse::success(id, data), + CoreCommand::FileWrite => match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid file write request") + .with_details(error.to_string()) + }) + .and_then(project::write_file) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("file response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + }, + CoreCommand::TextDecode => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid text decode request") + .with_details(error.to_string()) + }) + .and_then(project::decode_text_bytes) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("text response should encode"), + ), Err(error) => CoreResponse::failure(id, error), } } - CoreCommand::DebugTransportRelease => { - match crate::debug::release_transport(parsed.payload) { - Ok(data) => CoreResponse::success(id, data), + CoreCommand::TextEncode => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid text encode request") + .with_details(error.to_string()) + }) + .and_then(project::encode_text) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("text response should encode"), + ), Err(error) => CoreResponse::failure(id, error), } } - CoreCommand::DebugAdaptersStartArguments => plugin_response( - id, - parsed.payload, - crate::plugins::declarative::debug_start_arguments_command, - ), - CoreCommand::DebugAdaptersExecutablePlan => plugin_response( - id, - parsed.payload, - crate::plugins::declarative::debug_executable_plan_command, - ), - CoreCommand::PluginsDeclarativeResolveSettings => plugin_response( - id, - parsed.payload, - crate::plugins::declarative::resolve_settings_command, - ), - CoreCommand::PluginsInstalledSelect => plugin_response( - id, - parsed.payload, - crate::plugins::installed::select_command, - ), - CoreCommand::PluginsSettingsCatalog => plugin_response( - id, - parsed.payload, - crate::plugins::declarative::settings_catalog_command, - ), - CoreCommand::PluginsSettingsNavigation => plugin_response( - id, - parsed.payload, - crate::plugins::declarative::settings_navigation_command, - ), - CoreCommand::PluginsSettingsDelivery => plugin_response( - id, - parsed.payload, - crate::plugins::declarative::settings_delivery_command, - ), - CoreCommand::PluginsDeclarativeSettingsState => plugin_response( - id, - parsed.payload, - crate::plugins::declarative::settings_state_command, - ), - CoreCommand::PluginsDeclarativeSettingsActionPlan => plugin_response( - id, - parsed.payload, - crate::plugins::declarative::settings_action_plan_command, - ), - CoreCommand::PluginsDeclarativeEditSettings => plugin_response( - id, - parsed.payload, - crate::plugins::declarative::edit_settings_command, - ), - CoreCommand::PluginsDeclarativeWorkspaceConfiguration => plugin_response( - id, - parsed.payload, - crate::plugins::declarative::workspace_configuration_command, - ), - CoreCommand::PluginsDeclarativeDecodeGrammar => plugin_response( - id, - parsed.payload, - crate::plugins::declarative::decode_grammar_command, - ), - CoreCommand::PluginsEditorActionsDecode => plugin_response( - id, - parsed.payload, - crate::plugins::editor_actions::decode_command, - ), - CoreCommand::PluginsHostApiDescribe => plugin_response( - id, - parsed.payload, - crate::plugins::host_api::describe_command, - ), - CoreCommand::PluginsHostApiAuthorize => plugin_response( - id, - parsed.payload, - crate::plugins::host_api::authorize_command, - ), - CoreCommand::PluginsEditorProvidersDecode => plugin_response( - id, - parsed.payload, - crate::plugins::editor_providers::decode_command, - ), - CoreCommand::KeymapDecodeImport => plugin_response( - id, - parsed.payload, - crate::plugins::keymap_import::decode_command, - ), - CoreCommand::KeymapPlanImportSources => plugin_response( + CoreCommand::DocumentClassifyText => { + match parsed + .payload + .get("text") + .and_then(serde_json::Value::as_str) + { + Some(text) => CoreResponse::success( + id, + serde_json::json!({ + "isPlainText": project::is_plain_text(text) + }), + ), + None => CoreResponse::failure( + id, + CoreError::new( + ErrorCode::InvalidRequest, + "Decoded document text is required", + ), + ), + } + } + CoreCommand::DocumentPlanSaveBatch => plugin_response( id, parsed.payload, - crate::plugins::keymap_import::plan_sources_command, + project::document_save_batch::plan_command, ), - CoreCommand::KeymapPlanImportBindings => plugin_response( + CoreCommand::DocumentReduceSaveBatch => plugin_response( id, parsed.payload, - crate::plugins::keymap_import::plan_bindings_command, + project::document_save_batch::reduce_command, ), - CoreCommand::DebugStartOrContinue => { - match crate::debug::start_or_continue(parsed.payload) { - Ok(data) => CoreResponse::success(id, data), + CoreCommand::DocumentLifecycle => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid document lifecycle request", + ) + .with_details(error.to_string()) + }) + .and_then(project::decide_document_lifecycle) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("document lifecycle decision should encode"), + ), Err(error) => CoreResponse::failure(id, error), } } - CoreCommand::ToolchainsRegistry => { - plugin_response(id, parsed.payload, crate::toolchain::registry_command) - } - CoreCommand::ToolchainsDecodeReply => { - plugin_response(id, parsed.payload, crate::toolchain::decode_reply_command) - } - CoreCommand::ToolchainsFailureReply => { - plugin_response(id, parsed.payload, crate::toolchain::failure_reply_command) + CoreCommand::History(command) => history::dispatch(command, id, parsed.payload), + CoreCommand::DiffCompareTexts => { + plugin_response(id, parsed.payload, lithe_diff::compare_texts_command) } - CoreCommand::ToolchainsResolvePins => { - plugin_response(id, parsed.payload, crate::toolchain::resolve_pins_command) + CoreCommand::ExecutionResolveSearchPath => { + match serde_json::from_value::(parsed.payload) { + Ok(request) => CoreResponse::success( + id, + serde_json::json!(crate::execution::resolve_search_path(request)), + ), + Err(error) => CoreResponse::failure( + id, + CoreError::new(ErrorCode::InvalidRequest, "Invalid search path request") + .with_details(error.to_string()), + ), + } } - CoreCommand::ToolchainsEditPins => { - plugin_response(id, parsed.payload, crate::toolchain::edit_pins_command) + CoreCommand::ExecutionResolveEnvironment => { + match serde_json::from_value::( + parsed.payload, + ) { + Ok(request) => CoreResponse::success( + id, + serde_json::to_value(crate::execution::resolve_launch_environment(request)) + .expect("environment encodes"), + ), + Err(error) => CoreResponse::failure( + id, + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid launch environment request", + ) + .with_details(error.to_string()), + ), + } } - CoreCommand::ToolchainsPinStoragePlan => plugin_response( - id, - parsed.payload, - crate::toolchain::pin_storage_plan_command, - ), - CoreCommand::ToolchainsDecodePinDocument => plugin_response( - id, - parsed.payload, - crate::toolchain::decode_pin_document_command, - ), - CoreCommand::ToolchainsEditPinSources => plugin_response( - id, - parsed.payload, - crate::toolchain::edit_pin_sources_command, - ), - CoreCommand::ToolchainsResolveConsumerPreferences => plugin_response( - id, - parsed.payload, - crate::toolchain::resolve_consumer_preferences_command, - ), - CoreCommand::ToolchainsEditConsumerPreferences => plugin_response( - id, - parsed.payload, - crate::toolchain::edit_consumer_preferences_command, - ), - CoreCommand::ToolchainsResolve => { - plugin_response(id, parsed.payload, crate::toolchain::resolve_command) - } - CoreCommand::ToolchainsCheckVersion => { - plugin_response(id, parsed.payload, crate::toolchain::check_version_command) - } - CoreCommand::ToolchainsDiscoveryPlan => { - plugin_response(id, parsed.payload, crate::toolchain::discovery_plan_command) - } - CoreCommand::ToolchainsCandidates => { - plugin_response(id, parsed.payload, crate::toolchain::candidates_command) - } - CoreCommand::ToolchainsInstantiateLaunch => plugin_response( - id, - parsed.payload, - crate::toolchain::instantiate_launch_command, - ), - CoreCommand::RunDecodeStart => { - plugin_response(id, parsed.payload, crate::build::run_decode_start_command) - } - CoreCommand::DebugDecodeBackendConfiguration => plugin_response( - id, - parsed.payload, - crate::debug::decode_backend_configuration_command, - ), - CoreCommand::OutputLinksMatch => plugin_response( - id, - parsed.payload, - crate::plugins::output_links::match_lines, - ), - CoreCommand::OutputLinksDecodeResolution => plugin_response( - id, - parsed.payload, - crate::plugins::output_links::decode_resolution, - ), - CoreCommand::BuildDetect => { - plugin_response(id, parsed.payload, crate::build::build_detect_command) - } - CoreCommand::BuildProjectOptions => { - plugin_response(id, parsed.payload, crate::build::project_options_command) - } - CoreCommand::BuildResolveStoredOptions => plugin_response( - id, - parsed.payload, - crate::build::resolve_stored_options_command, - ), - CoreCommand::BuildEditStoredOption => plugin_response( - id, - parsed.payload, - crate::build::build_edit_stored_option_command, - ), - CoreCommand::BuildInvocationPlan => plugin_response( - id, - parsed.payload, - crate::build::build_invocation_plan_command, - ), - CoreCommand::BuildCheckToolVersion => plugin_response( - id, - parsed.payload, - crate::build::build_check_tool_version_command, - ), - CoreCommand::BuildResolveOptions => plugin_response( - id, - parsed.payload, - crate::build::build_resolve_options_command, - ), - CoreCommand::BuildModelPlan => { - plugin_response(id, parsed.payload, crate::build::build_model_plan_command) - } - CoreCommand::BuildTaskPlan => { - plugin_response(id, parsed.payload, crate::build::build_task_plan_command) - } - CoreCommand::BuildPrepareDebugPlan => plugin_response( - id, - parsed.payload, - crate::build::build_prepare_debug_plan_command, - ), - CoreCommand::BuildDecodeDebugPreparation => plugin_response( - id, - parsed.payload, - crate::build::build_decode_debug_preparation_command, - ), - CoreCommand::BuildResolveModelInputs => plugin_response( - id, - parsed.payload, - crate::build::build_resolve_model_inputs_command, - ), - CoreCommand::BuildConfigurationEditPlan => plugin_response( - id, - parsed.payload, - crate::build::build_configuration_edit_plan_command, - ), - CoreCommand::BuildDecodeConfigurationEdit => plugin_response( - id, - parsed.payload, - crate::build::build_decode_configuration_edit_command, - ), - CoreCommand::BuildConfigurationPlan => plugin_response( - id, - parsed.payload, - crate::build::build_configuration_plan_command, - ), - CoreCommand::BuildDecodeConfiguration => plugin_response( - id, - parsed.payload, - crate::build::build_decode_configuration_command, - ), - CoreCommand::BuildPrepareRunPlan => plugin_response( - id, - parsed.payload, - crate::build::build_prepare_run_plan_command, - ), - CoreCommand::BuildDecodeRunPreparation => plugin_response( - id, - parsed.payload, - crate::build::build_decode_run_preparation_command, - ), - CoreCommand::BuildTestPlan => { - plugin_response(id, parsed.payload, crate::build::build_test_plan_command) - } - CoreCommand::BuildTestDiscoveryPlan => plugin_response( - id, - parsed.payload, - crate::build::build_test_discovery_plan_command, - ), - CoreCommand::BuildDecodeTestDiscovery => plugin_response( - id, - parsed.payload, - crate::build::build_decode_test_discovery_command, - ), - CoreCommand::BuildDependencyPlan => plugin_response( - id, - parsed.payload, - crate::build::build_dependency_plan_command, - ), - CoreCommand::BuildDecodeModel => { - plugin_response(id, parsed.payload, crate::build::build_decode_model_command) - } - CoreCommand::BuildDecodeTestResults => plugin_response( - id, - parsed.payload, - crate::build::build_decode_test_results_command, - ), - CoreCommand::BuildDecodeDiagnostics => plugin_response( - id, - parsed.payload, - crate::build::build_decode_diagnostics_command, - ), - CoreCommand::BuildDecodeDependencies => plugin_response( - id, - parsed.payload, - crate::build::build_decode_dependencies_command, - ), - CoreCommand::BuildSyncFingerprint => plugin_response( - id, - parsed.payload, - crate::build::build_sync_fingerprint_command, - ), - CoreCommand::BuildResolveTestRunOutcome => reducer_response( - id, - parsed.payload, - "Invalid test run outcome request", - crate::build::resolve_test_run_outcome, - ), - CoreCommand::BuildMergeTestOutcomes => reducer_response( - id, - parsed.payload, - "Invalid test outcome merge request", - crate::build::merge_test_outcomes, - ), - CoreCommand::VcsDetectRoots => { - plugin_response(id, parsed.payload, crate::vcs::vcs_detect_roots_command) - } - CoreCommand::VcsOwnerOf => { - plugin_response(id, parsed.payload, crate::vcs::vcs_owner_of_command) - } - CoreCommand::VcsDecodeReply => { - plugin_response(id, parsed.payload, crate::vcs::vcs_decode_reply_command) - } - CoreCommand::ProjectGeneratorCatalog => plugin_response( - id, - parsed.payload, - crate::project_generators::project_generator_catalog_command, - ), - CoreCommand::ProjectGeneratorForm => plugin_response( - id, - parsed.payload, - crate::project_generators::project_generator_form_command, - ), - CoreCommand::ProjectGenerationPlan => plugin_response( - id, - parsed.payload, - crate::project_generators::project_generation_plan_command, - ), - CoreCommand::ProjectCheckName => plugin_response( - id, - parsed.payload, - crate::project_generators::project_check_name_command, - ), - CoreCommand::ProjectExtractArchive => plugin_response( - id, - parsed.payload, - crate::project_generators::project_extract_archive_command, - ), - CoreCommand::PluginsMarketplaceValidateIndex => plugin_response( - id, - parsed.payload, - crate::plugins::marketplace::validate_index_command, - ), - CoreCommand::PluginsMarketplaceResolveSource => plugin_response( - id, - parsed.payload, - crate::plugins::marketplace::resolve_source_command, - ), - CoreCommand::PluginsMarketplaceInstallReview => plugin_response( - id, - parsed.payload, - crate::plugins::marketplace::install_review_command, - ), - CoreCommand::WorkspaceReleaseIndexing => { - match serde_json::from_value::( - parsed.payload, - ) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid indexing release") - .with_details(error.to_string()) - }) - .and_then(project::indexing::release) - { - Ok(released) => { - CoreResponse::success(id, serde_json::json!({"released": released})) - } - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::WorkspaceConfigureIndexing => { - match serde_json::from_value::( - parsed.payload, - ) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid indexing policy") - .with_details(error.to_string()) - }) - .and_then(project::indexing::configure) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("indexing status should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::WorkspaceRefreshIndexing => { - match serde_json::from_value::( - parsed.payload, - ) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid indexing refresh") - .with_details(error.to_string()) - }) - .and_then(project::indexing::refresh_indexing) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("indexing status should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::WorkspaceSnapshot => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid workspace snapshot request", - ) - .with_details(error.to_string()) - }) - .and_then(project::snapshot) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("snapshot should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::WorkspaceFilterChanges => { - match serde_json::from_value::( - parsed.payload, - ) { - Ok(request) => CoreResponse::success( - id, - serde_json::json!(crate::project::filter_workspace_changes(request)), - ), - Err(error) => CoreResponse::failure( - id, - CoreError::new(ErrorCode::InvalidRequest, "Invalid workspace event paths") - .with_details(error.to_string()), - ), - } - } - CoreCommand::WorkspaceListDirectories => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid directory listing request", - ) - .with_details(error.to_string()) - }) - .and_then(project::list_directories) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("directory listing should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::WorkspaceOrderEntries => plugin_response( - id, - parsed.payload, - crate::project::file_name_order::order_entries_command, - ), - CoreCommand::WorkspaceRankNames => plugin_response( - id, - parsed.payload, - crate::project::name_ranking::rank_names_command, - ), - CoreCommand::WorkspacePlanDuplicate => plugin_response( - id, - parsed.payload, - crate::project::file_operations::plan_duplicate_command, - ), - CoreCommand::WorkspacePlanPaste => plugin_response( - id, - parsed.payload, - crate::project::file_operations::plan_paste_command, - ), - CoreCommand::WorkspaceReduceDirectoryAction => plugin_response( - id, - parsed.payload, - crate::project::directory_actions::reduce_command, - ), - CoreCommand::WorkspaceRelocateDirectoryRules => plugin_response( - id, - parsed.payload, - crate::project::directory_actions::relocate_command, - ), - CoreCommand::WorkspaceRepositories => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid workspace repositories request", - ) - .with_details(error.to_string()) - }) - .and_then(git::workspace_repositories) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("workspace repositories should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::WorkspaceRecentProjectsReduce => plugin_response( - id, - parsed.payload, - crate::project::recent_projects::reduce_command, - ), - CoreCommand::WorkspaceSearchIndexWarm => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid search index request") - .with_details(error.to_string()) - }) - .and_then(project::warm_search_index) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("search index status should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::WorkspaceSearchIndexUpdate => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid search index update") - .with_details(error.to_string()) - }) - .and_then(project::update_search_index) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("search index status should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::WorkspaceSearchIndexInvalidate => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid search index request") - .with_details(error.to_string()) - }) - .and_then(project::invalidate_search_index) - { - Ok(()) => CoreResponse::success(id, json!({})), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::SearchActions => rules_response(id, "search.actions", parsed.payload), - CoreCommand::WorkspaceSearch => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid search request") - .with_details(error.to_string()) - }) - .and_then(project::search) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("search should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::WorkspaceSearchEverywhere => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid Search Everywhere request", - ) - .with_details(error.to_string()) - }) - .and_then(project::search_page) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("search everywhere should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::WorkspaceReplacePreview => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid replacement preview request", - ) - .with_details(error.to_string()) - }) - .and_then(project::replace_preview) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("replacement preview should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::WorkspaceReplaceText => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid text replacement request", - ) - .with_details(error.to_string()) - }) - .and_then(project::replace_text) - { - Ok(data) => CoreResponse::success(id, data), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::WorkspaceSearchTextBatch => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid text search request") - .with_details(error.to_string()) - }) - .and_then(project::search_text_batch) - { - Ok(data) => CoreResponse::success(id, data), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::FileRead => match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid file read request") - .with_details(error.to_string()) - }) - .and_then(project::read_file) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("file response should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - }, - CoreCommand::FileWrite => match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid file write request") - .with_details(error.to_string()) - }) - .and_then(project::write_file) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("file response should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - }, - CoreCommand::TextDecode => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid text decode request") - .with_details(error.to_string()) - }) - .and_then(project::decode_text_bytes) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("text response should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::TextEncode => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid text encode request") - .with_details(error.to_string()) - }) - .and_then(project::encode_text) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("text response should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::DocumentClassifyText => { - match parsed - .payload - .get("text") - .and_then(serde_json::Value::as_str) - { - Some(text) => CoreResponse::success( - id, - serde_json::json!({ - "isPlainText": project::is_plain_text(text) - }), - ), - None => CoreResponse::failure( - id, - CoreError::new( - ErrorCode::InvalidRequest, - "Decoded document text is required", - ), - ), - } - } - CoreCommand::DocumentPlanSaveBatch => plugin_response( - id, - parsed.payload, - project::document_save_batch::plan_command, - ), - CoreCommand::DocumentReduceSaveBatch => plugin_response( - id, - parsed.payload, - project::document_save_batch::reduce_command, - ), - CoreCommand::DocumentLifecycle => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid document lifecycle request", - ) - .with_details(error.to_string()) - }) - .and_then(project::decide_document_lifecycle) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("document lifecycle decision should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::HistoryRecord => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid history record request") - .with_details(error.to_string()) - }) - .and_then(crate::project::record) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("history response should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::HistoryEntries => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid history entries request") - .with_details(error.to_string()) - }) - .and_then(crate::project::entries) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("history entries should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::HistoryContent => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid history content request") - .with_details(error.to_string()) - }) - .and_then(crate::project::content) - { - Ok(data) => CoreResponse::success(id, serde_json::json!({"text": data})), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::HistoryPlanEvent => { - plugin_response(id, parsed.payload, crate::project::plan_event_command) - } - CoreCommand::HistoryRelocate => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid history relocate request", - ) - .with_details(error.to_string()) - }) - .and_then(crate::project::relocate) - { - Ok(()) => CoreResponse::success(id, serde_json::json!({"relocated": true})), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::HistoryRename => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid history rename request") - .with_details(error.to_string()) - }) - .and_then(crate::project::rename) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("history entry should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::HistoryDelete => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid history delete request") - .with_details(error.to_string()) - }) - .and_then(crate::project::delete) - { - Ok(()) => CoreResponse::success(id, serde_json::json!({"deleted": true})), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::HistoryIgnorePreview => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid history ignore preview request", - ) - .with_details(error.to_string()) - }) - .and_then(crate::project::ignore_preview) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("history ignore preview should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::HistoryUsage => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid history usage request") - .with_details(error.to_string()) - }) - .and_then(crate::project::usage) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("history usage should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::HistoryClear => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid history clear request") - .with_details(error.to_string()) - }) - .and_then(crate::project::clear) - { - Ok(()) => CoreResponse::success(id, serde_json::json!({"cleared": true})), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::DiffCompareTexts => { - plugin_response(id, parsed.payload, lithe_diff::compare_texts_command) - } - CoreCommand::ExecutionResolveSearchPath => { - match serde_json::from_value::(parsed.payload) { - Ok(request) => CoreResponse::success( - id, - serde_json::json!(crate::execution::resolve_search_path(request)), - ), - Err(error) => CoreResponse::failure( - id, - CoreError::new(ErrorCode::InvalidRequest, "Invalid search path request") - .with_details(error.to_string()), - ), - } - } - CoreCommand::ExecutionResolveEnvironment => { - match serde_json::from_value::( - parsed.payload, - ) { - Ok(request) => CoreResponse::success( - id, - serde_json::to_value(crate::execution::resolve_launch_environment(request)) - .expect("environment encodes"), - ), - Err(error) => CoreResponse::failure( - id, - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid launch environment request", - ) - .with_details(error.to_string()), - ), - } - } - CoreCommand::ProcessLifecycleResolve => { - match serde_json::from_value::( - parsed.payload, - ) { - Ok(request) => CoreResponse::success( - id, - serde_json::to_value(lithe_process::lifecycle::resolve(&request)) - .expect("process lifecycle plan should encode"), - ), - Err(error) => CoreResponse::failure( - id, - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid process lifecycle request", - ) - .with_details(error.to_string()), - ), - } - } - CoreCommand::ProcessLifecycleInventory => { - match serde_json::from_value::( - parsed.payload, - ) { - Ok(request) => CoreResponse::success( - id, - serde_json::to_value(lithe_process::inventory::classify(&request)) - .expect("process inventory should encode"), - ), - Err(error) => CoreResponse::failure( - id, - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid process inventory request", - ) - .with_details(error.to_string()), - ), - } - } - CoreCommand::ProcessLifecycleSettle => { - match serde_json::from_value::(parsed.payload) - { - Ok(request) => CoreResponse::success( - id, - serde_json::to_value(lithe_process::lifecycle::settle(&request)) - .expect("process lifecycle settlement should encode"), - ), - Err(error) => CoreResponse::failure( - id, - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid process lifecycle settlement request", - ) - .with_details(error.to_string()), - ), - } - } - CoreCommand::KeymapParseContext => { - rules_response(id, "keymap.parseContext", parsed.payload) - } - CoreCommand::KeymapEvaluateContext => { - rules_response(id, "keymap.evaluateContext", parsed.payload) - } - CoreCommand::KeymapAnalyzeConflicts => { - rules_response(id, "keymap.analyzeConflicts", parsed.payload) - } - CoreCommand::KeymapModifierGestureStep => { - rules_response(id, "keymap.modifierGestureStep", parsed.payload) - } - CoreCommand::EditorWorkbenchStateReduce => { - rules_response(id, "editor.workbenchState.reduce", parsed.payload) - } - CoreCommand::WorkspaceClassifyVisibility => { - rules_response(id, "workspace.classifyVisibility", parsed.payload) - } - CoreCommand::WorkspaceVisibilityDefaults => { - rules_response(id, "workspace.visibilityDefaults", parsed.payload) - } - CoreCommand::WorkbenchChromeLayoutReduce => { - rules_response(id, "workbench.chromeLayout.reduce", parsed.payload) - } - CoreCommand::TerminalParseLinkTarget => { - let result = - serde_json::from_value::(parsed.payload) - .map_err(|_| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid terminal link request") - }) - .and_then(lithe_terminal::parse_link_targets); - match result { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("terminal link result should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::MarkdownRender => { - match serde_json::from_value::(parsed.payload).map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid Markdown render request") - .with_details(error.to_string()) - }) { - Ok(request) => CoreResponse::success( - id, - serde_json::to_value(crate::project::render(request)) - .expect("Markdown render response should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::DebugCreateSession => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid debug create-session request", - ) - .with_details(error.to_string()) - }) - .and_then(crate::debug::create_session) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Debug session update should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::DebugLaunch => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid debug launch request") - .with_details(error.to_string()) - }) - .and_then(crate::debug::launch) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Debug launch update should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::DebugSteppingFilters => { - match serde_json::from_value::( - parsed.payload, - ) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid debug stepping-filters request", - ) - .with_details(error.to_string()) - }) - .and_then(crate::debug::stepping_filters) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Debug stepping filters should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::DebugPlanVariablePage => reducer_response( - id, - parsed.payload, - "Invalid debug variable page plan request", - crate::debug::plan_variable_page, - ), - CoreCommand::DebugReduceVariablePage => reducer_response( - id, - parsed.payload, - "Invalid debug variable page reduction request", - crate::debug::reduce_variable_page, - ), - CoreCommand::DebugPlanDisassemblyWindow => reducer_response( - id, - parsed.payload, - "Invalid debug disassembly window request", - crate::debug::plan_disassembly_window, - ), - CoreCommand::DebugReduceDisassemblyPage => reducer_response( - id, - parsed.payload, - "Invalid debug disassembly page reduction request", - crate::debug::reduce_disassembly_page, - ), - CoreCommand::DebugPlanMemoryWindow => reducer_response( - id, - parsed.payload, - "Invalid debug memory window request", - crate::debug::plan_memory_window, - ), - CoreCommand::DebugLocateMemoryWindow => reducer_response( - id, - parsed.payload, - "Invalid debug memory window location request", - crate::debug::locate_memory_window, - ), - CoreCommand::DebugSetBreakpointsMuted => reducer_response( - id, - parsed.payload, - "Invalid debug set-breakpoints-muted request", - crate::debug::set_breakpoints_muted, - ), - CoreCommand::DebugRelocateBreakpoints => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid debug relocate-breakpoints request", - ) - .with_details(error.to_string()) - }) - .and_then(crate::debug::relocate_breakpoints) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Debug breakpoint relocation should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::DebugSetBreakpoints => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid debug set-breakpoints request", - ) - .with_details(error.to_string()) - }) - .and_then(crate::debug::set_breakpoints) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Debug breakpoint update should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::DebugSetExceptionBreakpoints => { - match serde_json::from_value::( - parsed.payload, - ) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid debug set-exception-breakpoints request", - ) - .with_details(error.to_string()) - }) - .and_then(crate::debug::set_exception_breakpoints) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data) - .expect("Debug exception breakpoint update should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::DebugSetFunctionBreakpoints => { - match serde_json::from_value::( - parsed.payload, - ) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid debug set-function-breakpoints request", - ) - .with_details(error.to_string()) - }) - .and_then(crate::debug::set_function_breakpoints) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data) - .expect("Debug function breakpoint update should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::DebugSetInstructionBreakpoints => { - match serde_json::from_value::( - parsed.payload, - ) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid debug set-instruction-breakpoints request", - ) - .with_details(error.to_string()) - }) - .and_then(crate::debug::set_instruction_breakpoints) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data) - .expect("Debug instruction breakpoint update should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::DebugDataBreakpointInfo => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid debug data-breakpoint-info request", - ) - .with_details(error.to_string()) - }) - .and_then(crate::debug::data_breakpoint_info) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data) - .expect("Debug data breakpoint info update should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::DebugSetDataBreakpoints => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid debug set-data-breakpoints request", - ) - .with_details(error.to_string()) - }) - .and_then(crate::debug::set_data_breakpoints) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Debug data breakpoint update should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::DebugSetVariable => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid debug set-variable request", - ) - .with_details(error.to_string()) - }) - .and_then(crate::debug::set_variable) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Debug variable update should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::DebugCancelOperation => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid debug cancel-operation request", - ) - .with_details(error.to_string()) - }) - .and_then(crate::debug::cancel_operation) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Debug cancellation update should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::DebugExecute => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid debug execute request") - .with_details(error.to_string()) - }) - .and_then(crate::debug::execute) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Debug execution update should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::DebugInspect => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid debug inspect request") - .with_details(error.to_string()) - }) - .and_then(crate::debug::inspect) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Debug inspection update should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::DebugReceive => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid debug receive request") - .with_details(error.to_string()) - }) - .and_then(crate::debug::receive) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Debug receive update should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::DebugRunInTerminalResponse => { - match serde_json::from_value::( - parsed.payload, - ) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid debug run-in-terminal response", - ) - .with_details(error.to_string()) - }) - .and_then(crate::debug::run_in_terminal_response) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data) - .expect("Debug run-in-terminal response update should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::DebugStartDebuggingResponse => { - match serde_json::from_value::( - parsed.payload, - ) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid debug start-debugging response", - ) - .with_details(error.to_string()) - }) - .and_then(crate::debug::start_debugging_response) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data) - .expect("Debug start-debugging response update should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::DebugDisconnect => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid debug disconnect request", - ) - .with_details(error.to_string()) - }) - .and_then(crate::debug::disconnect) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Debug disconnect update should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::DebugDestroySession => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid debug destroy-session request", - ) - .with_details(error.to_string()) - }) - .and_then(crate::debug::destroy_session) - { - Ok(destroyed_children) => CoreResponse::success( - id, - json!({"destroyed": true, "destroyedChildSessionIds": destroyed_children}), - ), - 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::LspDocumentIdentity => plugin_response( - id, - parsed.payload, - crate::lsp::document_identity::resolve_command, - ), - CoreCommand::LspPlanWorkspaceEdit => plugin_response( - id, - parsed.payload, - crate::lsp::workspace_edit_plan::plan_command, - ), - CoreCommand::LspClassifyNavigationTarget => plugin_response( - id, - parsed.payload, - crate::lsp::navigation_target::classify_command, - ), - CoreCommand::LspDeclarationOrUsages => plugin_response( - id, - parsed.payload, - crate::lsp::declaration_or_usages::decide_command, - ), - CoreCommand::EditorFind => match crate::editor::find::command(parsed.payload, document) { - Ok(data) => CoreResponse::success(id, data), - Err(error) => CoreResponse::failure(id, error), - }, - CoreCommand::EditorPreferencesResolve => plugin_response( - id, - parsed.payload, - crate::editor::preferences::resolve_command, - ), - CoreCommand::EditorRecentReduce => { - plugin_response(id, parsed.payload, crate::editor::recent::reduce_command) - } - CoreCommand::EditorRecoveryDecide => { - plugin_response(id, parsed.payload, crate::editor::recovery::decide_command) - } - CoreCommand::KeymapSchemesReduce => { - rules_response(id, "keymap.schemes.reduce", parsed.payload) - } - CoreCommand::EditorPlanTextEdits => plugin_response( - id, - parsed.payload, - crate::editor::text_edit_plan::plan_command, - ), - CoreCommand::EditorParseNavigationInput => plugin_response( - id, - parsed.payload, - crate::editor::navigation_input::parse_command, - ), - CoreCommand::EditorNavigationHistoryReduce => plugin_response( - id, - parsed.payload, - crate::editor::navigation_history::reduce_command, - ), - CoreCommand::EditorLineEdit => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid editor line edit request", - ) - .with_details(error.to_string()) - }) - .and_then(crate::editor::line_edit) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Editor line edit response should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::EditorLineCommentToken => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid editor line comment token request", - ) - .with_details(error.to_string()) - }) - .map(crate::editor::line_comment_token) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Comment token response should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::NetworkProxyPolicy => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid network proxy policy request", - ) - .with_details(error.to_string()) - }) - .map(lithe_network::proxy_policy) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Proxy policy 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::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::LspMergeCompletions => plugin_response( - id, - parsed.payload, - crate::lsp::host_policy::completion_merge::merge_command, - ), - CoreCommand::LspActivationPolicy => plugin_response( - id, - parsed.payload, - crate::lsp::host_policy::activation::policy_command, - ), - CoreCommand::LspProblemsQuery => plugin_response( - id, - parsed.payload, - crate::lsp::host_policy::problems::query_command, - ), - 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::LspUpdateWorkspaceConfiguration => { - match serde_json::from_value::( - parsed.payload, - ) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid workspace configuration update request", - ) - .with_details(error.to_string()) - }) - .and_then(crate::lsp::update_workspace_configuration) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data) - .expect("workspace configuration update response should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::LspSaveDocument => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid LSP save-document request", - ) - .with_details(error.to_string()) - }) - .and_then(crate::lsp::save_document) - { - Ok(sent) => CoreResponse::success(id, json!({"sent": sent})), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::LspDocumentUri => { - plugin_response(id, parsed.payload, crate::lsp::document_uri_command) - } - CoreCommand::LspSyncDocument => { - let uri = parsed - .payload - .get("uri") - .and_then(serde_json::Value::as_str) - .map(str::to_owned); - 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) => { - let mut encoded = serde_json::to_value(data) - .expect("LSP sync-document response should encode"); - encoded["uri"] = json!(uri); - CoreResponse::success(id, encoded) - } - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::LspWorkspaceFilesChanged => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid LSP workspace-files-changed request", - ) - .with_details(error.to_string()) - }) - .and_then(crate::lsp::workspace_files_changed) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data) - .expect("LSP workspace-files-changed 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::LspBuildRequest => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid build-provider request") - .with_details(error.to_string()) - }) - .and_then(crate::lsp::build_provider_request) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Build-provider response should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::LspToolchainRequest => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid toolchain-provider request", - ) - .with_details(error.to_string()) - }) - .and_then(crate::lsp::toolchain_provider_request) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Toolchain response should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::LspKeymapImportRequest => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid shortcut import request") - .with_details(error.to_string()) - }) - .and_then(crate::lsp::keymap_import_provider_request) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Shortcut import response should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::LspVcsRequest => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid VCS-provider request") - .with_details(error.to_string()) - }) - .and_then(crate::lsp::vcs_provider_request) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("VCS-provider response should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::LspFunctionProvider => { - plugin_response(id, parsed.payload, crate::lsp::function_provider_command) - } - CoreCommand::LspFunctionRequest => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid plugin-function request") - .with_details(error.to_string()) - }) - .and_then(crate::lsp::plugin_function_request) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Plugin-function response should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::LspConfigureFunctionRelay => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid function relay snapshot") - .with_details(error.to_string()) - }) - .and_then(crate::lsp::configure_function_relay) - { - Ok(()) => CoreResponse::success(id, json!({})), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::LspHostResponse => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid host-service response") - .with_details(error.to_string()) - }) - .and_then(crate::lsp::plugin_host_response) - { - Ok(()) => CoreResponse::success(id, json!({})), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::LspHostNotify => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid host notification") - .with_details(error.to_string()) - }) - .and_then(crate::lsp::plugin_host_notify) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Host notification response should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::LspPluginServerRequest => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid plugin language-server request", - ) - .with_details(error.to_string()) - }) - .and_then(crate::lsp::plugin_server_request) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data) - .expect("Plugin language-server response should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::LspPluginServerNotify => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid plugin language-server notification", - ) - .with_details(error.to_string()) - }) - .and_then(crate::lsp::plugin_server_notify) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data) - .expect("Plugin language-server notification 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::LspSetDetailedDiagnostics => { - match serde_json::from_value::( - parsed.payload, - ) - .map_err(|_| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid detailed diagnostics request", - ) - }) - .and_then(crate::lsp::set_detailed_diagnostics) - { - Ok(()) => CoreResponse::success(id, json!({})), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::LspWaitEvents => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid LSP wait-events request") - .with_details(error.to_string()) - }) - .and_then(crate::lsp::wait_events) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("LSP wait-events 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::RunConfigInspect => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid run configuration inspect request", - ) - .with_details(error.to_string()) - }) - .and_then(crate::execution::inspect) - { - Ok(data) => CoreResponse::success(id, data), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::RunConfigProjectToolchain => { - match serde_json::from_value::( - parsed.payload, - ) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid project toolchain request", - ) - .with_details(error.to_string()) - }) - .and_then(crate::execution::project_toolchain) - { - 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::execution::generate) - { - Ok(data) => CoreResponse::success(id, data), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::RunConfigArgumentText => { - match serde_json::from_value::(parsed.payload) { - Ok(request) => CoreResponse::success( - id, - serde_json::json!(crate::execution::format_argument_text(request)), - ), - Err(error) => CoreResponse::failure( - id, - CoreError::new(ErrorCode::InvalidRequest, "Invalid argument text request") - .with_details(error.to_string()), - ), - } - } - CoreCommand::RunConfigResolve => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid run configuration resolve request", - ) - .with_details(error.to_string()) - }) - .and_then(crate::execution::resolve) - { - Ok(data) => CoreResponse::success(id, data), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::RunConfigDecideTitleAction => { - match serde_json::from_value::(parsed.payload) { - Ok(request) => CoreResponse::success( - id, - serde_json::to_value(crate::execution::decide_title_action(request)) - .expect("Title action decisions always encode"), - ), - Err(error) => CoreResponse::failure( - id, - CoreError::new(ErrorCode::InvalidRequest, "Invalid title action request") - .with_details(error.to_string()), - ), - } - } - CoreCommand::RunConfigSelectContextConfiguration => { - match serde_json::from_value::( - parsed.payload, - ) { - Ok(request) => CoreResponse::success( - id, - serde_json::json!({"configurationId": crate::execution::select_context_configuration(request)}), - ), - Err(error) => CoreResponse::failure( - id, - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid context configuration request", - ) - .with_details(error.to_string()), - ), - } - } - 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::execution::update_options) - { - Ok(data) => CoreResponse::success(id, data), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::RunConfigUpdateToolPreferences => { - match serde_json::from_value::( - parsed.payload, - ) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid tool preferences request", - ) - .with_details(error.to_string()) - }) - .and_then(crate::execution::update_tool_preferences) - { - Ok(data) => CoreResponse::success(id, data), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::RunConfigSaveEditorChanges => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid run configuration editor request", - ) - .with_details(error.to_string()) - }) - .and_then(crate::execution::save_editor_changes) - { - Ok(data) => CoreResponse::success(id, data), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::RunConfigCreateUserConfiguration => { - match serde_json::from_value::( - parsed.payload, - ) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid user configuration request", - ) - .with_details(error.to_string()) - }) - .and_then(crate::execution::create_user_configuration) - { - Ok(data) => CoreResponse::success(id, data), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::RunConfigProviderLaunchInputs => { - match serde_json::from_value::( - parsed.payload, - ) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid provider launch input request", - ) - .with_details(error.to_string()) - }) - .and_then(crate::execution::provider_launch_inputs) - { - 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::execution::create_launch_plan) - { - Ok(data) => CoreResponse::success(id, data), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::GitWorkspaceCommitPrepare => { - let result = - serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid workspace commit request", - ) - .with_details(error.to_string()) - }) - .and_then(git::workspace_commit::prepare); - match result { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Workspace commit response should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::GitWorkspaceCommitStep => { - let result = - serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid workspace commit request", - ) - .with_details(error.to_string()) - }) - .and_then(git::workspace_commit::step); - match result { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Workspace commit response should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::GitCommitState => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid commit state request") - .with_details(error.to_string()) - }) - .and_then(git::commit_state) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Commit state should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::GitStatus => match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid Git status request") - .with_details(error.to_string()) - }) - .and_then(git::status) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Git response should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - }, - CoreCommand::GitUntrackedDirectory => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid Git untracked directory request", - ) - .with_details(error.to_string()) - }) - .and_then(git::untracked_directory) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Git untracked directory should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::GitClassifyMetadataChanges => { - plugin_response(id, parsed.payload, git::metadata_classification_command) - } - CoreCommand::GitWatchContext => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid Git watch context request", - ) - .with_details(error.to_string()) - }) - .and_then(git::watch_context) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Git watch context should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - - CoreCommand::GitWorktrees => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid Git worktrees request") - .with_details(error.to_string()) - }) - .and_then(git::worktrees) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Git worktrees should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - - CoreCommand::GitPullRequestContext => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid Git pull request context request", - ) - .with_details(error.to_string()) - }) - .and_then(git::pull_request_context) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Git pull request context should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - - CoreCommand::GitCommand => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid Git command request") - .with_details(error.to_string()) - }) - .and_then(git::command) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Git command response should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::GitRepositoryRoot => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid Git repository root request", - ) - .with_details(error.to_string()) - }) - .and_then(git::discover_repository_root) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Git repository root response should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::GitWorkspaceStatusMerge => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid workspace Git status request", - ) - .with_details(error.to_string()) - }) - .and_then(git::workspace_status::plan) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Git workspace status plan should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::GitResolveFileContext => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid Git file context request", - ) - .with_details(error.to_string()) - }) - .and_then(git::file_context::resolve) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Git file context should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::GitRemotes - | CoreCommand::GitTags - | CoreCommand::GitParseCheckoutMetadata - | CoreCommand::GitParseGitdirPointer => { - let operation = match command { - CoreCommand::GitRemotes => "remotes", - CoreCommand::GitTags => "tags", - CoreCommand::GitParseCheckoutMetadata => "checkout", - CoreCommand::GitParseGitdirPointer => "gitdir", - _ => unreachable!(), - }; - match git::catalog::command(operation, parsed.payload) { - Ok(data) => CoreResponse::success(id, data), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::GitRebasePreview => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid rebase preview request") - .with_details(error.to_string()) - }) - .and_then(git::rebase_preview) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Rebase preview should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::GitRebaseStart => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid rebase start request") - .with_details(error.to_string()) - }) - .and_then(git::rebase_start) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Rebase start should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::GitRebaseSession => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid rebase session request") - .with_details(error.to_string()) - }) - .and_then(git::rebase_session) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Rebase session should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::GitRebaseControl => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid rebase control request") - .with_details(error.to_string()) - }) - .and_then(git::rebase_control) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Rebase control should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::GitRepositorySetup => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid Git setup request") - .with_details(error.to_string()) - }) - .and_then(git::repository_setup) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Git setup should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::GitInitialize => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid Git setup request") - .with_details(error.to_string()) - }) - .and_then(git::initialize_repository) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Git setup should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::GitConfigureIdentity => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid Git setup request") - .with_details(error.to_string()) - }) - .and_then(git::configure_identity) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Git setup should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::GitPatchExport => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid patch export request") - .with_details(error.to_string()) - }) - .and_then(git::export_patch) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Patch export should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::GitPatchPreview => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid patch preview request") - .with_details(error.to_string()) - }) - .and_then(git::preview_patch) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Patch preview should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::GitPatchApply => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid patch apply request") - .with_details(error.to_string()) - }) - .and_then(git::apply_patch_reviewed) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Patch apply should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::GitHistoryRewritePreview => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid history rewrite preview request", - ) - .with_details(error.to_string()) - }) - .and_then(git::history_rewrite_preview) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("History rewrite preview should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::GitHistoryActionCapabilities => plugin_response( - id, - parsed.payload, - crate::git::history_action_capabilities_command, - ), - CoreCommand::GitRepositoryColorAssignments => plugin_response( - id, - parsed.payload, - crate::git::repository_color_assignments_command, - ), - CoreCommand::GitUpdatePlan => { - plugin_response(id, parsed.payload, crate::git::update_plan_command) - } - CoreCommand::GitHistoryWindow => { - plugin_response(id, parsed.payload, crate::git::history_window_command) - } - CoreCommand::GitGraphProjection => { - plugin_response(id, parsed.payload, crate::git::graph_projection_command) - } - CoreCommand::GitNormalizeChangeTargets => plugin_response( - id, - parsed.payload, - crate::git::normalize_change_targets_command, - ), - CoreCommand::GitRebasePlanPreview => { - plugin_response(id, parsed.payload, crate::git::rebase_plan_preview_command) - } - CoreCommand::GitReferenceTree => { - plugin_response(id, parsed.payload, crate::git::reference_tree_command) - } - CoreCommand::GitPrepareCommitSelection => plugin_response( - id, - parsed.payload, - crate::git::prepare_commit_selection_command, - ), - CoreCommand::GitShelf => plugin_response(id, parsed.payload, crate::git::shelf_command), - CoreCommand::GitCommitMessageContext => plugin_response( - id, - parsed.payload, - crate::git::commit_message_context_command, - ), - CoreCommand::GitChangelists => { - plugin_response(id, parsed.payload, crate::git::changelists_command) - } - CoreCommand::GitReconcileDeletedSelection => plugin_response( - id, - parsed.payload, - crate::git::reconcile_deleted_selection_command, - ), - CoreCommand::GitRollbackPrepare => { - plugin_response(id, parsed.payload, crate::git::rollback_prepare_command) - } - CoreCommand::GitParsePatch => { - plugin_response(id, parsed.payload, crate::git::parse_patch_command) - } - CoreCommand::GitHistoryQuery => { - plugin_response(id, parsed.payload, crate::git::history_query_command) - } - CoreCommand::GitRollbackPlan => { - plugin_response(id, parsed.payload, crate::git::rollback_plan_command) - } - CoreCommand::GitWrite => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid Git write request") - .with_details(error.to_string()) - }) - .and_then(git::write) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Git write response should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::GitAuthRespond => { - #[derive(serde::Deserialize)] - #[serde(rename_all = "camelCase", deny_unknown_fields)] - struct Reply { - request_id: String, - answer: Option, - } - match serde_json::from_value::(parsed.payload) { - Ok(reply) => CoreResponse::success( - id, - json!({ "accepted": lithe_git_host::authentication::respond(&reply.request_id, reply.answer) }), - ), - Err(_) => CoreResponse::failure( - id, - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid Git authentication response", - ), - ), - } - } - CoreCommand::GitExecutable => match git::configuration::locate_executable() { - Ok(data) => CoreResponse::success(id, data), - Err(error) => CoreResponse::failure(id, error), - }, - CoreCommand::RemoteProbe => { - match serde_json::from_value::(parsed.payload) { - Ok(request) => match crate::remote::probe(request) { - Ok(result) => CoreResponse::success( - id, - serde_json::to_value(result).expect("remote probe result should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - }, - Err(error) => CoreResponse::failure( - id, - CoreError::new(ErrorCode::InvalidRequest, "Invalid remote probe request") - .with_details(error.to_string()), - ), - } - } - CoreCommand::RemoteConnect => { - match serde_json::from_value::(parsed.payload) - { - Ok(request) => match crate::remote::sessions::connect(request) { - Ok(result) => CoreResponse::success( - id, - serde_json::to_value(result).expect("remote connect result should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - }, - Err(error) => CoreResponse::failure( - id, - CoreError::new(ErrorCode::InvalidRequest, "Invalid remote connect request") - .with_details(error.to_string()), - ), - } - } - CoreCommand::RemoteReadDir => { - match serde_json::from_value::(parsed.payload) - { - Ok(request) => match crate::remote::sessions::read_dir(request) { - Ok(result) => CoreResponse::success(id, result), - Err(error) => CoreResponse::failure(id, error), - }, - Err(error) => CoreResponse::failure( - id, - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid remote directory request", - ) - .with_details(error.to_string()), - ), - } - } - CoreCommand::RemoteReadFile => { - match serde_json::from_value::(parsed.payload) - { - Ok(request) => match crate::remote::sessions::read_file(request) { - Ok(result) => CoreResponse::success(id, result), - Err(error) => CoreResponse::failure(id, error), - }, - Err(error) => CoreResponse::failure( - id, - CoreError::new(ErrorCode::InvalidRequest, "Invalid remote file request") - .with_details(error.to_string()), - ), - } - } - CoreCommand::RemoteWriteFile => { - match serde_json::from_value::( - parsed.payload, - ) { - Ok(request) => match crate::remote::sessions::write_file(request) { - Ok(result) => CoreResponse::success(id, result), - Err(error) => CoreResponse::failure(id, error), - }, - Err(error) => CoreResponse::failure( - id, - CoreError::new(ErrorCode::InvalidRequest, "Invalid remote file request") - .with_details(error.to_string()), - ), - } - } - CoreCommand::RemoteCaptureProcess => { - match serde_json::from_value(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid host capture request") - .with_details(error.to_string()) - }) - .and_then(crate::remote::workspace_host::capture_process) - { - Ok(value) => CoreResponse::success(id, value), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::RemoteHostRequest - | CoreCommand::RemoteRunProcess - | CoreCommand::RemoteRunWatch - | CoreCommand::RemoteRunTerminal => { - let operation = match command { - CoreCommand::RemoteHostRequest => crate::remote::workspace_host::request, - CoreCommand::RemoteRunProcess => crate::remote::workspace_host::run_process, - CoreCommand::RemoteRunWatch => crate::remote::workspace_host::run_watch, - _ => crate::remote::workspace_host::run_terminal, - }; - match serde_json::from_value(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid workspace host request") - .with_details(error.to_string()) - }) - .and_then(operation) - { - Ok(value) => CoreResponse::success(id, value), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::RemoteUploadFile => { - match serde_json::from_value(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid upload request") - .with_details(error.to_string()) - }) - .and_then(crate::remote::upload::upload) - { - Ok(value) => CoreResponse::success(id, value), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::RemoteHostInfo => { - match parsed - .payload - .get("sessionId") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| CoreError::new(ErrorCode::InvalidRequest, "Missing host session")) - .and_then(crate::remote::upload::host_info) - { - Ok(value) => CoreResponse::success(id, value), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::RemoteDisconnect => { - match serde_json::from_value::( + CoreCommand::ProcessLifecycleResolve => { + match serde_json::from_value::( parsed.payload, ) { Ok(request) => CoreResponse::success( id, - json!({ "closed": crate::remote::sessions::disconnect(request) }), + serde_json::to_value(lithe_process::lifecycle::resolve(&request)) + .expect("process lifecycle plan should encode"), ), Err(error) => CoreResponse::failure( id, CoreError::new( ErrorCode::InvalidRequest, - "Invalid remote disconnect request", + "Invalid process lifecycle request", ) .with_details(error.to_string()), ), } } - CoreCommand::RemoteCloseAll => CoreResponse::success( - id, - json!({ "closed": crate::remote::sessions::close_all() }), - ), - CoreCommand::RemoteAuthRespond => { - #[derive(serde::Deserialize)] - #[serde(rename_all = "camelCase", deny_unknown_fields)] - struct Reply { - request_id: String, - answer: Option, - } - match serde_json::from_value::(parsed.payload) { - Ok(reply) => CoreResponse::success( - id, - json!({ "accepted": crate::remote::respond(&reply.request_id, reply.answer) }), - ), - Err(_) => CoreResponse::failure( - id, - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid remote authentication response", - ), - ), - } - } - CoreCommand::GitExecutionInspect | CoreCommand::GitExecutionConfigure => { - match git::configuration::dispatch( + CoreCommand::ProcessLifecycleInventory => { + match serde_json::from_value::( parsed.payload, - matches!(command, CoreCommand::GitExecutionConfigure), ) { - Ok(value) => CoreResponse::success(id, value), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::GitRemoteUrl => match git::configuration::remote_url(parsed.payload) { - Ok(value) => CoreResponse::success(id, value), - Err(error) => CoreResponse::failure(id, error), - }, - CoreCommand::GitConsolePresentation => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid Git console snapshot") - .with_details(error.to_string()) - }) - .and_then(git::console::present) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Console presentation should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::GitFetchPlan => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid Fetch plan request") - .with_details(error.to_string()) - }) - .and_then(git::fetch_plan) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Fetch plan should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::GitDiff => match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid Git diff request") - .with_details(error.to_string()) - }) - .and_then(git::diff) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Git diff response should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - }, - CoreCommand::GitPlanDiffBlocks => { - plugin_response(id, parsed.payload, crate::git::plan_diff_blocks_command) - } - CoreCommand::GitApplyDiffBlock => { - plugin_response(id, parsed.payload, crate::git::apply_diff_block_command) - } - CoreCommand::GitStageGutterChange => { - plugin_response(id, parsed.payload, crate::git::stage_gutter_change_command) - } - CoreCommand::GitApply => match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid Git apply request") - .with_details(error.to_string()) - }) - .and_then(git::apply) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Git apply response should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - }, - CoreCommand::GitHistory => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid Git history request") - .with_details(error.to_string()) - }) - .and_then(git::history) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Git history response should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::GitReferences => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid Git references request") - .with_details(error.to_string()) - }) - .and_then(git::references) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Git references response should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::GitHistoryPage => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid Git history page request", - ) - .with_details(error.to_string()) - }) - .and_then(git::history_page) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Git history page response should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::GitHistoryCursorClose => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid Git history cursor close request", - ) - .with_details(error.to_string()) - }) - .and_then(git::close_history_cursor) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data) - .expect("Git history cursor close response should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::GitHistoryLocate => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid Git history locate request", - ) - .with_details(error.to_string()) - }) - .and_then(git::history_locate) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Git history locate response should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::GitBranchNamePreview => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid Git branch name preview request", - ) - .with_details(error.to_string()) - }) - .and_then(git::branch_name_preview) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data) - .expect("Git branch name preview response should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::GitPushPreview => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid Git push preview request", - ) - .with_details(error.to_string()) - }) - .and_then(git::push_preview) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Git push preview response should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::GitCommit => match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid Git commit request") - .with_details(error.to_string()) - }) - .and_then(git::commit) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Git commit response should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - }, - CoreCommand::GitCommitFiles => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid Git commit files request", - ) - .with_details(error.to_string()) - }) - .and_then(git::commit_files) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Git commit files response should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::GitComparison => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid Git comparison request") - .with_details(error.to_string()) - }) - .and_then(git::comparison) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Git comparison response should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::GitStashes => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid Git stashes request") - .with_details(error.to_string()) - }) - .and_then(git::stashes) - { - Ok(data) => CoreResponse::success( + Ok(request) => CoreResponse::success( id, - serde_json::to_value(data).expect("Git stashes response should encode"), + serde_json::to_value(lithe_process::inventory::classify(&request)) + .expect("process inventory should encode"), ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::GitCheckoutPreflight => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { + Err(error) => CoreResponse::failure( + id, CoreError::new( ErrorCode::InvalidRequest, - "Invalid Git checkout preflight request", + "Invalid process inventory request", ) - .with_details(error.to_string()) - }) - .and_then(git::checkout_preflight) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data) - .expect("Git checkout preflight response should encode"), + .with_details(error.to_string()), ), - Err(error) => CoreResponse::failure(id, error), } } - CoreCommand::GitPullPreflight => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid Git pull preflight request", - ) - .with_details(error.to_string()) - }) - .and_then(git::pull_preflight) + CoreCommand::ProcessLifecycleSettle => { + match serde_json::from_value::(parsed.payload) { - Ok(data) => CoreResponse::success( + Ok(request) => CoreResponse::success( id, - serde_json::to_value(data).expect("Git pull preflight response should encode"), + serde_json::to_value(lithe_process::lifecycle::settle(&request)) + .expect("process lifecycle settlement should encode"), ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::GitConflictMarkers => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { + Err(error) => CoreResponse::failure( + id, CoreError::new( ErrorCode::InvalidRequest, - "Invalid Git conflict marker request", + "Invalid process lifecycle settlement request", ) - .with_details(error.to_string()) - }) - .and_then(git::conflict_marker_paths) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Git conflict marker response should encode"), + .with_details(error.to_string()), ), - Err(error) => CoreResponse::failure(id, error), } } - CoreCommand::GitIntegrationPreflight => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid Git integration preflight request", - ) - .with_details(error.to_string()) - }) - .and_then(git::integration_preflight) - { + CoreCommand::Editor(command) => editor::dispatch(command, id, parsed.payload, document), + CoreCommand::WorkbenchChromeLayoutReduce => { + rules_response(id, "workbench.chromeLayout.reduce", parsed.payload) + } + CoreCommand::TerminalParseLinkTarget => { + let result = + serde_json::from_value::(parsed.payload) + .map_err(|_| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid terminal link request") + }) + .and_then(lithe_terminal::parse_link_targets); + match result { Ok(data) => CoreResponse::success( id, - serde_json::to_value(data) - .expect("Git integration preflight response should encode"), + serde_json::to_value(data).expect("terminal link result should encode"), ), Err(error) => CoreResponse::failure(id, error), } } - CoreCommand::GitOperationState => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid Git operation state request", - ) + CoreCommand::MarkdownRender => { + match serde_json::from_value::(parsed.payload).map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid Markdown render request") .with_details(error.to_string()) - }) - .and_then(git::operation_state) - { - Ok(data) => CoreResponse::success( + }) { + Ok(request) => CoreResponse::success( id, - serde_json::to_value(data).expect("Git operation state response should encode"), + serde_json::to_value(crate::project::render(request)) + .expect("Markdown render response should encode"), ), Err(error) => CoreResponse::failure(id, error), } } - CoreCommand::GitBlame => match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid Git blame request") - .with_details(error.to_string()) - }) - .and_then(git::blame) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Git blame response should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - }, - CoreCommand::GitLineHistory => { - match serde_json::from_value::(parsed.payload) + CoreCommand::Lsp(command) => lsp::dispatch(command, id, parsed.payload), + CoreCommand::NetworkProxyPolicy => { + match serde_json::from_value::(parsed.payload) .map_err(|error| { CoreError::new( ErrorCode::InvalidRequest, - "Invalid Git line history request", + "Invalid network proxy policy request", ) .with_details(error.to_string()) }) - .and_then(git::line_history) + .map(lithe_network::proxy_policy) { Ok(data) => CoreResponse::success( id, - serde_json::to_value(data).expect("Git line history response should encode"), + serde_json::to_value(data).expect("Proxy policy response should encode"), ), Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::RunConfig(command) => run_config::dispatch(command, id, parsed.payload), + CoreCommand::Git(command) => git::dispatch(command, id, parsed.payload), + CoreCommand::Remote(command) => remote::dispatch(command, id, parsed.payload), CoreCommand::GitHubParseRemote => { match serde_json::from_value::(parsed.payload) .map_err(|error| { @@ -3583,40 +507,6 @@ fn execute_parsed(mut parsed: CoreRequest, document: Option<&str>) -> CoreRespon Err(error) => CoreResponse::failure(id, error), } } - CoreCommand::GitPullRequestCheckoutPreview => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid pull-request checkout preview", - ) - .with_details(error.to_string()) - }) - .and_then(git::checkout_pull_request_preview) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data) - .expect("Pull-request checkout preview should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::GitPullRequestCheckout => { - match serde_json::from_value::(parsed.payload) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid pull-request checkout") - .with_details(error.to_string()) - }) - .and_then(git::checkout_pull_request) - { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("Pull-request checkout should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } CoreCommand::GitHubPullRequestCheckoutPlan => { match serde_json::from_value::( parsed.payload, @@ -3669,239 +559,7 @@ fn execute_parsed(mut parsed: CoreRequest, document: Option<&str>) -> CoreRespon Err(error) => CoreResponse::failure(id, error), } } - CoreCommand::DiagnosticsPrepareLogEvent => { - match serde_json::from_value::( - parsed.payload, - ) { - Ok(request) => CoreResponse::success( - id, - serde_json::to_value(crate::diagnostics::logging::prepare_log_event(request)) - .expect("Log event should encode"), - ), - Err(_) => CoreResponse::failure( - id, - CoreError::new(ErrorCode::InvalidRequest, "Invalid log event request"), - ), - } - } - CoreCommand::DiagnosticsQueryLogs => { - match serde_json::from_value::( - parsed.payload, - ) { - Ok(request) => CoreResponse::success( - id, - serde_json::to_value(crate::diagnostics::logging::query_logs(request)) - .expect("Log query should encode"), - ), - Err(_) => CoreResponse::failure( - id, - CoreError::new(ErrorCode::InvalidRequest, "Invalid log query request"), - ), - } - } - CoreCommand::DiagnosticsRecordPluginOperation => CoreResponse::success( - id, - log_v2_result(crate::diagnostics::plugin_operations::record_request( - parsed.payload, - )), - ), - CoreCommand::DiagnosticsDrainCoreEvents => CoreResponse::success( - id, - serde_json::to_value(crate::diagnostics::core_events::drain()) - .expect("Core logs encode"), - ), - CoreCommand::DiagnosticsConfigureCoreEvents => { - match serde_json::from_value::(parsed.payload) { - Ok(configuration) => { - crate::diagnostics::core_events::configure( - configuration.debug, - configuration.roots, - ); - CoreResponse::success(id, json!({})) - } - Err(error) => CoreResponse::failure( - id, - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid Core event configuration", - ) - .with_details(error.to_string()), - ), - } - } - CoreCommand::DiagnosticsEncodeLogBatch => { - match serde_json::from_value::( - parsed.payload, - ) { - Ok(request) => CoreResponse::success( - id, - serde_json::to_value(crate::diagnostics::record::encode_log_batch(request)) - .expect("Log batch should encode"), - ), - Err(_) => CoreResponse::failure( - id, - CoreError::new(ErrorCode::InvalidRequest, "Invalid log batch request"), - ), - } - } - CoreCommand::DiagnosticsParseLogFilter => { - match serde_json::from_value::( - parsed.payload, - ) { - Ok(request) => CoreResponse::success( - id, - serde_json::to_value(crate::diagnostics::filter::parse_log_filter(request)) - .expect("Filter response serializes"), - ), - Err(_) => CoreResponse::failure( - id, - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid log parse_log_filter request", - ), - ), - } - } - CoreCommand::DiagnosticsComposeLogFilter => { - match serde_json::from_value::( - parsed.payload, - ) { - Ok(request) => CoreResponse::success( - id, - log_v2_result(crate::diagnostics::filter::compose_log_filter(request)), - ), - Err(_) => CoreResponse::failure( - id, - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid log compose_log_filter request", - ), - ), - } - } - CoreCommand::DiagnosticsFormatLogRecords => { - match serde_json::from_value::( - parsed.payload, - ) { - Ok(request) => CoreResponse::success( - id, - log_v2_result(crate::diagnostics::format::format_log_records(request)), - ), - Err(_) => CoreResponse::failure( - id, - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid log format_log_records request", - ), - ), - } - } - CoreCommand::DiagnosticsPlanLogCollection => { - match serde_json::from_value::( - parsed.payload, - ) { - Ok(request) => CoreResponse::success( - id, - log_v2_result(crate::diagnostics::collection::plan_log_collection(request)), - ), - Err(_) => CoreResponse::failure( - id, - CoreError::new(ErrorCode::InvalidRequest, "Invalid log collection request"), - ), - } - } - CoreCommand::DiagnosticsQueryLogsV2 => { - match serde_json::from_value::( - parsed.payload, - ) { - Ok(request) => CoreResponse::success( - id, - log_v2_result(crate::diagnostics::query_v2::query_logs_v2(request)), - ), - Err(_) => CoreResponse::failure( - id, - CoreError::new(ErrorCode::InvalidRequest, "Invalid structured log query"), - ), - } - } - CoreCommand::DiagnosticsPlanLogRetention => { - match serde_json::from_value::( - parsed.payload, - ) { - Ok(request) => CoreResponse::success( - id, - log_v2_result(crate::diagnostics::retention::plan_log_retention(request)), - ), - Err(_) => CoreResponse::failure( - id, - CoreError::new(ErrorCode::InvalidRequest, "Invalid log retention request"), - ), - } - } - CoreCommand::DiagnosticsResolveEngineLogSubject => { - match serde_json::from_value::( - parsed.payload, - ) { - Ok(request) => CoreResponse::success( - id, - serde_json::to_value(crate::lsp::resolve_engine_log_subject(request)) - .expect("Engine log subject should encode"), - ), - Err(_) => CoreResponse::failure( - id, - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid engine log subject request", - ), - ), - } - } - CoreCommand::DiagnosticsResolveLogChannel => { - match serde_json::from_value::( - parsed.payload, - ) { - Ok(request) => CoreResponse::success( - id, - log_v2_result(crate::diagnostics::channel::resolve_log_channel(request)), - ), - Err(_) => CoreResponse::failure( - id, - CoreError::new(ErrorCode::InvalidRequest, "Invalid log channel request"), - ), - } - } - CoreCommand::DiagnosticsRedactText => { - match serde_json::from_value::(parsed.payload).map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid diagnostics redact request", - ) - .with_details(error.to_string()) - }) { - Ok(request) => CoreResponse::success( - id, - serde_json::to_value(crate::diagnostics::redact_text(request)) - .expect("Diagnostics redact response should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } - CoreCommand::DiagnosticsBuildManifest => { - match serde_json::from_value::(parsed.payload).map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid diagnostics manifest request", - ) - .with_details(error.to_string()) - }) { - Ok(request) => CoreResponse::success( - id, - serde_json::to_value(crate::diagnostics::build_manifest(request)) - .expect("Diagnostics manifest response should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } - } + CoreCommand::Diagnostics(command) => diagnostics::dispatch(command, id, parsed.payload), }; // Workspace steps already reconcile cancelled writes under a bounded cleanup // deadline. Preserve that continuation so a retry cannot duplicate a commit. @@ -3915,6 +573,29 @@ fn execute_parsed(mut parsed: CoreRequest, document: Option<&str>) -> CoreRespon } } +/// Commands whose engines read and run through the request's workspace host; +/// every other command refuses a remote workspace instead of touching the +/// local placeholder folder. +fn has_remote_workspace_adapter(command: &str) -> bool { + command.starts_with("git.") + || command.starts_with("build.") + || matches!( + command, + "workspace.repositories" + | "workspace.snapshot" + | "workspace.listDirectories" + | "workspace.search" + | "workspace.searchEverywhere" + | "file.read" + | "file.write" + | "workspace.searchIndex.warm" + | "workspace.searchIndex.update" + | "workspace.searchIndex.invalidate" + | "workspace.replacePreview" + | "lsp.startServer" + ) +} + #[cfg(test)] mod tests { use super::execute_json; @@ -3922,8 +603,8 @@ mod tests { #[test] fn configure_core_events_sets_the_debug_gate_and_rejects_unknown_fields() { - use crate::diagnostics::core_events::{accepts, configure}; - use crate::diagnostics::record::LogLevel; + use lithe_diagnostics::core_events::{accepts, configure}; + use lithe_diagnostics::record::LogLevel; let send = |payload: Value| -> Value { serde_json::from_str(&execute_json( &json!({"command": "diagnostics.configureCoreEvents", "payload": payload}) diff --git a/rust/crates/core/src/runtime/dispatcher/ai.rs b/rust/crates/core/src/runtime/dispatcher/ai.rs new file mode 100644 index 00000000..0cd61867 --- /dev/null +++ b/rust/crates/core/src/runtime/dispatcher/ai.rs @@ -0,0 +1,27 @@ +//! `ai.*` commands, run by `lithe-ai`. + +use crate::protocol::{AiCommand, CoreResponse}; +use serde_json::Value; + +/// Runs one `ai.*` command. +pub(super) fn dispatch(command: AiCommand, id: Option, payload: Value) -> CoreResponse { + match command { + AiCommand::CommitInputBudget => ai_response(id, "ai.commitInputBudget", payload), + AiCommand::PlanCommit => ai_response(id, "ai.planCommit", payload), + AiCommand::DecodeCommitMessage => ai_response(id, "ai.decodeCommitMessage", payload), + AiCommand::ConfigurationSources => ai_response(id, "ai.configuration.sources", payload), + AiCommand::ConfigurationParse => ai_response(id, "ai.configuration.parse", payload), + AiCommand::PullRequestPlan => ai_response(id, "ai.pullRequest.plan", payload), + AiCommand::PullRequestDecode => ai_response(id, "ai.pullRequest.decode", payload), + AiCommand::InlineEditPlan => ai_response(id, "ai.inlineEdit.plan", payload), + AiCommand::InlineEditDecode => ai_response(id, "ai.inlineEdit.decode", payload), + } +} + +/// Runs one `ai.*` command in `lithe-ai`. +fn ai_response(id: Option, command: &str, payload: serde_json::Value) -> CoreResponse { + match lithe_ai::execute(command, payload) { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, crate::protocol::ai_error(error)), + } +} diff --git a/rust/crates/core/src/runtime/dispatcher/build.rs b/rust/crates/core/src/runtime/dispatcher/build.rs new file mode 100644 index 00000000..4d84150c --- /dev/null +++ b/rust/crates/core/src/runtime/dispatcher/build.rs @@ -0,0 +1,154 @@ +//! `build.*` commands: build detection, options, plans and decoded tool output. + +use super::{plugin_response, reducer_response}; +use crate::protocol::{BuildCommand, CoreResponse}; +/// The manifest type every domain command validates plugin manifests with. +use lithe_plugin_manifest::DeclarativePluginManifest as Manifest; +use serde_json::Value; + +/// Runs one `build.*` command. +pub(super) fn dispatch(command: BuildCommand, id: Option, payload: Value) -> CoreResponse { + match command { + BuildCommand::Detect => { + plugin_response(id, payload, crate::build::build_detect_command::) + } + BuildCommand::ProjectOptions => { + plugin_response(id, payload, crate::build::project_options_command) + } + BuildCommand::ResolveStoredOptions => { + plugin_response(id, payload, crate::build::resolve_stored_options_command) + } + BuildCommand::EditStoredOption => plugin_response( + id, + payload, + crate::build::build_edit_stored_option_command::, + ), + BuildCommand::InvocationPlan => plugin_response( + id, + payload, + crate::build::build_invocation_plan_command::, + ), + BuildCommand::CheckToolVersion => plugin_response( + id, + payload, + crate::build::build_check_tool_version_command::, + ), + BuildCommand::ResolveOptions => plugin_response( + id, + payload, + crate::build::build_resolve_options_command::, + ), + BuildCommand::ModelPlan => plugin_response( + id, + payload, + crate::build::build_model_plan_command::, + ), + BuildCommand::TaskPlan => plugin_response( + id, + payload, + crate::build::build_task_plan_command::, + ), + BuildCommand::PrepareDebugPlan => plugin_response( + id, + payload, + crate::build::build_prepare_debug_plan_command::, + ), + BuildCommand::DecodeDebugPreparation => plugin_response( + id, + payload, + crate::build::build_decode_debug_preparation_command::, + ), + BuildCommand::ResolveModelInputs => plugin_response( + id, + payload, + crate::build::build_resolve_model_inputs_command::, + ), + BuildCommand::ConfigurationEditPlan => plugin_response( + id, + payload, + crate::build::build_configuration_edit_plan_command::, + ), + BuildCommand::DecodeConfigurationEdit => plugin_response( + id, + payload, + crate::build::build_decode_configuration_edit_command::, + ), + BuildCommand::ConfigurationPlan => plugin_response( + id, + payload, + crate::build::build_configuration_plan_command::, + ), + BuildCommand::DecodeConfiguration => plugin_response( + id, + payload, + crate::build::build_decode_configuration_command::, + ), + BuildCommand::PrepareRunPlan => plugin_response( + id, + payload, + crate::build::build_prepare_run_plan_command::, + ), + BuildCommand::DecodeRunPreparation => plugin_response( + id, + payload, + crate::build::build_decode_run_preparation_command::, + ), + BuildCommand::TestPlan => plugin_response( + id, + payload, + crate::build::build_test_plan_command::, + ), + BuildCommand::TestDiscoveryPlan => plugin_response( + id, + payload, + crate::build::build_test_discovery_plan_command::, + ), + BuildCommand::DecodeTestDiscovery => plugin_response( + id, + payload, + crate::build::build_decode_test_discovery_command::, + ), + BuildCommand::DependencyPlan => plugin_response( + id, + payload, + crate::build::build_dependency_plan_command::, + ), + BuildCommand::DecodeModel => plugin_response( + id, + payload, + crate::build::build_decode_model_command::, + ), + BuildCommand::DecodeTestResults => plugin_response( + id, + payload, + crate::build::build_decode_test_results_command::, + ), + BuildCommand::DecodeDiagnostics => plugin_response( + id, + payload, + crate::build::build_decode_diagnostics_command::, + ), + BuildCommand::DecodeDependencies => plugin_response( + id, + payload, + crate::build::build_decode_dependencies_command::, + ), + BuildCommand::SyncFingerprint => plugin_response( + id, + payload, + crate::build::build_sync_fingerprint_command::, + ), + BuildCommand::ResolveTestRunOutcome => reducer_response( + id, + payload, + "Invalid test run outcome request", + crate::build::resolve_test_run_outcome, + ), + BuildCommand::MergeTestOutcomes => reducer_response( + id, + payload, + "Invalid test outcome merge request", + crate::build::merge_test_outcomes, + ), + } +} diff --git a/rust/crates/core/src/runtime/dispatcher/debug.rs b/rust/crates/core/src/runtime/dispatcher/debug.rs new file mode 100644 index 00000000..5a33aede --- /dev/null +++ b/rust/crates/core/src/runtime/dispatcher/debug.rs @@ -0,0 +1,430 @@ +//! `debug.*` commands: debug adapters, transports, sessions, breakpoints and inspection. + +use super::{plugin_response, reducer_response}; +use crate::protocol::{CoreError, CoreResponse, DebugCommand, ErrorCode}; +/// The manifest type every domain command validates plugin manifests with. +use lithe_plugin_manifest::DeclarativePluginManifest as Manifest; +use serde_json::{json, Value}; + +/// Runs one `debug.*` command. +pub(super) fn dispatch(command: DebugCommand, id: Option, payload: Value) -> CoreResponse { + match command { + DebugCommand::ValidateAdapterEndpoint => { + match crate::debug::validate_adapter_endpoint(payload) { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error), + } + } + DebugCommand::TransportPolicy => match crate::debug::transport_policy(payload) { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error), + }, + DebugCommand::TransportHandle => match crate::debug::handle_transport_event(payload) { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error), + }, + DebugCommand::TransportRelease => match crate::debug::release_transport(payload) { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error), + }, + DebugCommand::AdaptersStartArguments => plugin_response( + id, + payload, + lithe_plugin_manifest::debug_start_arguments_command, + ), + DebugCommand::AdaptersExecutablePlan => plugin_response( + id, + payload, + lithe_plugin_manifest::debug_executable_plan_command, + ), + DebugCommand::StartOrContinue => match crate::debug::start_or_continue(payload) { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error), + }, + DebugCommand::DecodeBackendConfiguration => plugin_response( + id, + payload, + crate::debug::decode_backend_configuration_command::, + ), + DebugCommand::CreateSession => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid debug create-session request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::debug::create_session) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Debug session update should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + DebugCommand::Launch => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid debug launch request") + .with_details(error.to_string()) + }) + .and_then(crate::debug::launch) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Debug launch update should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + DebugCommand::SteppingFilters => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid debug stepping-filters request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::debug::stepping_filters) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Debug stepping filters should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + DebugCommand::PlanVariablePage => reducer_response( + id, + payload, + "Invalid debug variable page plan request", + crate::debug::plan_variable_page, + ), + DebugCommand::ReduceVariablePage => reducer_response( + id, + payload, + "Invalid debug variable page reduction request", + crate::debug::reduce_variable_page, + ), + DebugCommand::PlanDisassemblyWindow => reducer_response( + id, + payload, + "Invalid debug disassembly window request", + crate::debug::plan_disassembly_window, + ), + DebugCommand::ReduceDisassemblyPage => reducer_response( + id, + payload, + "Invalid debug disassembly page reduction request", + crate::debug::reduce_disassembly_page, + ), + DebugCommand::PlanMemoryWindow => reducer_response( + id, + payload, + "Invalid debug memory window request", + crate::debug::plan_memory_window, + ), + DebugCommand::LocateMemoryWindow => reducer_response( + id, + payload, + "Invalid debug memory window location request", + crate::debug::locate_memory_window, + ), + DebugCommand::SetBreakpointsMuted => reducer_response( + id, + payload, + "Invalid debug set-breakpoints-muted request", + crate::debug::set_breakpoints_muted, + ), + DebugCommand::RelocateBreakpoints => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid debug relocate-breakpoints request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::debug::relocate_breakpoints) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Debug breakpoint relocation should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + DebugCommand::SetBreakpoints => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid debug set-breakpoints request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::debug::set_breakpoints) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Debug breakpoint update should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + DebugCommand::SetExceptionBreakpoints => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid debug set-exception-breakpoints request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::debug::set_exception_breakpoints) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data) + .expect("Debug exception breakpoint update should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + DebugCommand::SetFunctionBreakpoints => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid debug set-function-breakpoints request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::debug::set_function_breakpoints) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data) + .expect("Debug function breakpoint update should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + DebugCommand::SetInstructionBreakpoints => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid debug set-instruction-breakpoints request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::debug::set_instruction_breakpoints) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data) + .expect("Debug instruction breakpoint update should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + DebugCommand::DataBreakpointInfo => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid debug data-breakpoint-info request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::debug::data_breakpoint_info) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data) + .expect("Debug data breakpoint info update should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + DebugCommand::SetDataBreakpoints => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid debug set-data-breakpoints request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::debug::set_data_breakpoints) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Debug data breakpoint update should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + DebugCommand::SetVariable => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid debug set-variable request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::debug::set_variable) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Debug variable update should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + DebugCommand::CancelOperation => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid debug cancel-operation request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::debug::cancel_operation) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Debug cancellation update should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + DebugCommand::Execute => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid debug execute request") + .with_details(error.to_string()) + }) + .and_then(crate::debug::execute) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Debug execution update should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + DebugCommand::Inspect => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid debug inspect request") + .with_details(error.to_string()) + }) + .and_then(crate::debug::inspect) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Debug inspection update should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + DebugCommand::Receive => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid debug receive request") + .with_details(error.to_string()) + }) + .and_then(crate::debug::receive) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Debug receive update should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + DebugCommand::RunInTerminalResponse => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid debug run-in-terminal response", + ) + .with_details(error.to_string()) + }) + .and_then(crate::debug::run_in_terminal_response) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data) + .expect("Debug run-in-terminal response update should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + DebugCommand::StartDebuggingResponse => { + match serde_json::from_value::( + payload, + ) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid debug start-debugging response", + ) + .with_details(error.to_string()) + }) + .and_then(crate::debug::start_debugging_response) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data) + .expect("Debug start-debugging response update should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + DebugCommand::Disconnect => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid debug disconnect request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::debug::disconnect) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Debug disconnect update should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + DebugCommand::DestroySession => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid debug destroy-session request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::debug::destroy_session) + { + Ok(destroyed_children) => CoreResponse::success( + id, + json!({"destroyed": true, "destroyedChildSessionIds": destroyed_children}), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + } +} diff --git a/rust/crates/core/src/runtime/dispatcher/diagnostics.rs b/rust/crates/core/src/runtime/dispatcher/diagnostics.rs new file mode 100644 index 00000000..40c4f94e --- /dev/null +++ b/rust/crates/core/src/runtime/dispatcher/diagnostics.rs @@ -0,0 +1,262 @@ +//! `diagnostics.*` commands: log events and queries, log filters and retention, +//! redaction and diagnostic bundles. + +use crate::protocol::{CoreError, CoreResponse, DiagnosticsCommand, ErrorCode}; +use lithe_diagnostics::{manifest::BuildManifestRequest, redaction::RedactTextRequest}; +use serde_json::{json, Value}; + +/// Runs one `diagnostics.*` command. +pub(super) fn dispatch( + command: DiagnosticsCommand, + id: Option, + payload: Value, +) -> CoreResponse { + match command { + DiagnosticsCommand::PrepareLogEvent => { + match serde_json::from_value::(payload) { + Ok(request) => CoreResponse::success( + id, + serde_json::to_value(lithe_diagnostics::logging::prepare_log_event(request)) + .expect("Log event should encode"), + ), + Err(_) => CoreResponse::failure( + id, + CoreError::new(ErrorCode::InvalidRequest, "Invalid log event request"), + ), + } + } + DiagnosticsCommand::QueryLogs => { + match serde_json::from_value::(payload) { + Ok(request) => CoreResponse::success( + id, + serde_json::to_value(lithe_diagnostics::logging::query_logs(request)) + .expect("Log query should encode"), + ), + Err(_) => CoreResponse::failure( + id, + CoreError::new(ErrorCode::InvalidRequest, "Invalid log query request"), + ), + } + } + DiagnosticsCommand::RecordPluginOperation => CoreResponse::success( + id, + log_v2_result(lithe_diagnostics::plugin_operations::record_request( + payload, + )), + ), + DiagnosticsCommand::DrainCoreEvents => CoreResponse::success( + id, + serde_json::to_value(lithe_diagnostics::core_events::drain()) + .expect("Core logs encode"), + ), + DiagnosticsCommand::ConfigureCoreEvents => { + match serde_json::from_value::(payload) { + Ok(configuration) => { + lithe_diagnostics::core_events::configure( + configuration.debug, + configuration.roots, + ); + CoreResponse::success(id, json!({})) + } + Err(error) => CoreResponse::failure( + id, + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Core event configuration", + ) + .with_details(error.to_string()), + ), + } + } + DiagnosticsCommand::EncodeLogBatch => { + match serde_json::from_value::( + payload, + ) { + Ok(request) => CoreResponse::success( + id, + serde_json::to_value(lithe_diagnostics::record::encode_log_batch(request)) + .expect("Log batch should encode"), + ), + Err(_) => CoreResponse::failure( + id, + CoreError::new(ErrorCode::InvalidRequest, "Invalid log batch request"), + ), + } + } + DiagnosticsCommand::ParseLogFilter => { + match serde_json::from_value::( + payload, + ) { + Ok(request) => CoreResponse::success( + id, + serde_json::to_value(lithe_diagnostics::filter::parse_log_filter(request)) + .expect("Filter response serializes"), + ), + Err(_) => CoreResponse::failure( + id, + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid log parse_log_filter request", + ), + ), + } + } + DiagnosticsCommand::ComposeLogFilter => { + match serde_json::from_value::( + payload, + ) { + Ok(request) => CoreResponse::success( + id, + log_v2_result(lithe_diagnostics::filter::compose_log_filter(request)), + ), + Err(_) => CoreResponse::failure( + id, + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid log compose_log_filter request", + ), + ), + } + } + DiagnosticsCommand::FormatLogRecords => { + match serde_json::from_value::( + payload, + ) { + Ok(request) => CoreResponse::success( + id, + log_v2_result(lithe_diagnostics::format::format_log_records(request)), + ), + Err(_) => CoreResponse::failure( + id, + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid log format_log_records request", + ), + ), + } + } + DiagnosticsCommand::PlanLogCollection => { + match serde_json::from_value::( + payload, + ) { + Ok(request) => CoreResponse::success( + id, + log_v2_result(lithe_diagnostics::collection::plan_log_collection(request)), + ), + Err(_) => CoreResponse::failure( + id, + CoreError::new(ErrorCode::InvalidRequest, "Invalid log collection request"), + ), + } + } + DiagnosticsCommand::QueryLogsV2 => { + match serde_json::from_value::(payload) + { + Ok(request) => CoreResponse::success( + id, + log_v2_result(lithe_diagnostics::query_v2::query_logs_v2(request)), + ), + Err(_) => CoreResponse::failure( + id, + CoreError::new(ErrorCode::InvalidRequest, "Invalid structured log query"), + ), + } + } + DiagnosticsCommand::PlanLogRetention => { + match serde_json::from_value::( + payload, + ) { + Ok(request) => CoreResponse::success( + id, + log_v2_result(lithe_diagnostics::retention::plan_log_retention(request)), + ), + Err(_) => CoreResponse::failure( + id, + CoreError::new(ErrorCode::InvalidRequest, "Invalid log retention request"), + ), + } + } + DiagnosticsCommand::ResolveEngineLogSubject => { + match serde_json::from_value::( + payload, + ) { + Ok(request) => CoreResponse::success( + id, + serde_json::to_value(lithe_lsp_engine::resolve_engine_log_subject(request)) + .expect("Engine log subject should encode"), + ), + Err(_) => CoreResponse::failure( + id, + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid engine log subject request", + ), + ), + } + } + DiagnosticsCommand::ResolveLogChannel => { + match serde_json::from_value::( + payload, + ) { + Ok(request) => CoreResponse::success( + id, + log_v2_result(lithe_diagnostics::channel::resolve_log_channel(request)), + ), + Err(_) => CoreResponse::failure( + id, + CoreError::new(ErrorCode::InvalidRequest, "Invalid log channel request"), + ), + } + } + DiagnosticsCommand::RedactText => { + match serde_json::from_value::(payload).map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid diagnostics redact request", + ) + .with_details(error.to_string()) + }) { + Ok(request) => CoreResponse::success( + id, + serde_json::to_value(lithe_diagnostics::redaction::redact_text(request)) + .expect("Diagnostics redact response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + DiagnosticsCommand::BuildManifest => { + match serde_json::from_value::(payload).map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid diagnostics manifest request", + ) + .with_details(error.to_string()) + }) { + Ok(request) => CoreResponse::success( + id, + serde_json::to_value(lithe_diagnostics::manifest::build_manifest(request)) + .expect("Diagnostics manifest response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + } +} + +/// Host detailed-diagnostics state; `roots: None` enables DEBUG for every root. +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct CoreEventConfiguration { + debug: bool, + roots: Option>, +} + +// Domain failures retain their unique numeric logging codes inside the existing Core envelope. +fn log_v2_result( + result: Result, +) -> serde_json::Value { + match result { + Ok(value) => serde_json::json!({"result": value}), + Err(error) => serde_json::json!({"error": error}), + } +} diff --git a/rust/crates/core/src/runtime/dispatcher/editor.rs b/rust/crates/core/src/runtime/dispatcher/editor.rs new file mode 100644 index 00000000..293cf692 --- /dev/null +++ b/rust/crates/core/src/runtime/dispatcher/editor.rs @@ -0,0 +1,79 @@ +//! `editor.*` commands: workbench state, text edits, find, navigation and preferences. + +use super::{plugin_response, rules_response}; +use crate::protocol::{CoreError, CoreResponse, EditorCommand, ErrorCode}; +use serde_json::Value; + +/// Runs one `editor.*` command. +pub(super) fn dispatch( + command: EditorCommand, + id: Option, + payload: Value, + document: Option<&str>, +) -> CoreResponse { + match command { + EditorCommand::WorkbenchStateReduce => { + rules_response(id, "editor.workbenchState.reduce", payload) + } + EditorCommand::Find => match lithe_editor::find::command(payload, document) { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error), + }, + EditorCommand::PreferencesResolve => { + plugin_response(id, payload, lithe_editor::preferences::resolve_command) + } + EditorCommand::RecentReduce => { + plugin_response(id, payload, lithe_editor::recent::reduce_command) + } + EditorCommand::RecoveryDecide => { + plugin_response(id, payload, lithe_editor::recovery::decide_command) + } + EditorCommand::PlanTextEdits => { + plugin_response(id, payload, lithe_editor::text_edit_plan::plan_command) + } + EditorCommand::ParseNavigationInput => { + plugin_response(id, payload, lithe_editor::navigation_input::parse_command) + } + EditorCommand::NavigationHistoryReduce => plugin_response( + id, + payload, + lithe_editor::navigation_history::reduce_command, + ), + EditorCommand::LineEdit => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid editor line edit request", + ) + .with_details(error.to_string()) + }) + .and_then(lithe_editor::line_edit) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Editor line edit response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + EditorCommand::LineCommentToken => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid editor line comment token request", + ) + .with_details(error.to_string()) + }) + .map(lithe_editor::line_comment_token) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Comment token response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + } +} diff --git a/rust/crates/core/src/runtime/dispatcher/git.rs b/rust/crates/core/src/runtime/dispatcher/git.rs new file mode 100644 index 00000000..4c3090f5 --- /dev/null +++ b/rust/crates/core/src/runtime/dispatcher/git.rs @@ -0,0 +1,914 @@ +//! `git.*` commands: status, writes, history, rebase, patches, diffs, references and +//! pull requests. + +use super::plugin_response; +use crate::protocol::{CoreError, CoreResponse, ErrorCode, GitCommand}; +use lithe_git::{ + self as git, GitApplyRequest, GitBlameRequest, GitBranchNamePreviewRequest, + GitCheckoutPreflightRequest, GitCommandRequest, GitCommitFilesRequest, GitCommitRequest, + GitComparisonRequest, GitConflictMarkerRequest, GitDiffRequest, GitHistoryCursorCloseRequest, + GitHistoryLocateRequest, GitHistoryPageRequest, GitHistoryRequest, + GitHistoryRewritePreviewRequest, GitIntegrationPreflightRequest, GitLineHistoryRequest, + GitOperationStateRequest, GitPullPreflightRequest, GitPullRequestContextRequest, + GitPushPreviewRequest, GitRebaseControlRequest, GitRebasePreviewRequest, + GitRebaseSessionRequest, GitRebaseStartRequest, GitReferencesRequest, GitRepositoryRootRequest, + GitStashesRequest, GitStatusRequest, GitUntrackedDirectoryRequest, GitWatchContextRequest, + GitWorktreesRequest, GitWriteRequest, PatchApplyRequest, PatchExportRequest, + PatchPreviewRequest, +}; +use serde_json::{json, Value}; + +/// Runs one `git.*` command. +pub(super) fn dispatch(command: GitCommand, id: Option, payload: Value) -> CoreResponse { + match command { + GitCommand::WorkspaceCommitPrepare => { + let result = serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid workspace commit request", + ) + .with_details(error.to_string()) + }) + .and_then(git::workspace_commit::prepare); + match result { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Workspace commit response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + GitCommand::WorkspaceCommitStep => { + let result = serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid workspace commit request", + ) + .with_details(error.to_string()) + }) + .and_then(git::workspace_commit::step); + match result { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Workspace commit response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + GitCommand::CommitState => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid commit state request") + .with_details(error.to_string()) + }) + .and_then(git::commit_state) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Commit state should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + GitCommand::Status => match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid Git status request") + .with_details(error.to_string()) + }) + .and_then(git::status) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Git response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + }, + GitCommand::UntrackedDirectory => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git untracked directory request", + ) + .with_details(error.to_string()) + }) + .and_then(git::untracked_directory) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Git untracked directory should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + GitCommand::ClassifyMetadataChanges => { + plugin_response(id, payload, git::metadata_classification_command) + } + GitCommand::WatchContext => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git watch context request", + ) + .with_details(error.to_string()) + }) + .and_then(git::watch_context) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Git watch context should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + + GitCommand::Worktrees => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid Git worktrees request") + .with_details(error.to_string()) + }) + .and_then(git::worktrees) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Git worktrees should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + + GitCommand::PullRequestContext => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git pull request context request", + ) + .with_details(error.to_string()) + }) + .and_then(git::pull_request_context) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Git pull request context should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + + GitCommand::Command => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid Git command request") + .with_details(error.to_string()) + }) + .and_then(git::command) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Git command response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + GitCommand::RepositoryRoot => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git repository root request", + ) + .with_details(error.to_string()) + }) + .and_then(git::discover_repository_root) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Git repository root response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + GitCommand::WorkspaceStatusMerge => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid workspace Git status request", + ) + .with_details(error.to_string()) + }) + .and_then(git::workspace_status::plan) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Git workspace status plan should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + GitCommand::ResolveFileContext => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git file context request", + ) + .with_details(error.to_string()) + }) + .and_then(git::file_context::resolve) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Git file context should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + GitCommand::Remotes + | GitCommand::Tags + | GitCommand::ParseCheckoutMetadata + | GitCommand::ParseGitdirPointer => { + let operation = match command { + GitCommand::Remotes => "remotes", + GitCommand::Tags => "tags", + GitCommand::ParseCheckoutMetadata => "checkout", + GitCommand::ParseGitdirPointer => "gitdir", + _ => unreachable!(), + }; + match git::catalog::command(operation, payload) { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error), + } + } + GitCommand::RebasePreview => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid rebase preview request") + .with_details(error.to_string()) + }) + .and_then(git::rebase_preview) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Rebase preview should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + GitCommand::RebaseStart => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid rebase start request") + .with_details(error.to_string()) + }) + .and_then(git::rebase_start) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Rebase start should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + GitCommand::RebaseSession => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid rebase session request") + .with_details(error.to_string()) + }) + .and_then(git::rebase_session) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Rebase session should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + GitCommand::RebaseControl => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid rebase control request") + .with_details(error.to_string()) + }) + .and_then(git::rebase_control) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Rebase control should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + GitCommand::RepositorySetup => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid Git setup request") + .with_details(error.to_string()) + }) + .and_then(git::repository_setup) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Git setup should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + GitCommand::Initialize => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid Git setup request") + .with_details(error.to_string()) + }) + .and_then(git::initialize_repository) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Git setup should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + GitCommand::ConfigureIdentity => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid Git setup request") + .with_details(error.to_string()) + }) + .and_then(git::configure_identity) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Git setup should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + GitCommand::PatchExport => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid patch export request") + .with_details(error.to_string()) + }) + .and_then(git::export_patch) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Patch export should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + GitCommand::PatchPreview => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid patch preview request") + .with_details(error.to_string()) + }) + .and_then(git::preview_patch) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Patch preview should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + GitCommand::PatchApply => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid patch apply request") + .with_details(error.to_string()) + }) + .and_then(git::apply_patch_reviewed) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Patch apply should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + GitCommand::HistoryRewritePreview => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid history rewrite preview request", + ) + .with_details(error.to_string()) + }) + .and_then(git::history_rewrite_preview) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("History rewrite preview should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + GitCommand::HistoryActionCapabilities => { + plugin_response(id, payload, lithe_git::history_action_capabilities_command) + } + GitCommand::RepositoryColorAssignments => { + plugin_response(id, payload, lithe_git::repository_color_assignments_command) + } + GitCommand::UpdatePlan => plugin_response(id, payload, lithe_git::update_plan_command), + GitCommand::HistoryWindow => { + plugin_response(id, payload, lithe_git::history_window_command) + } + GitCommand::GraphProjection => { + plugin_response(id, payload, lithe_git::graph_projection_command) + } + GitCommand::NormalizeChangeTargets => { + plugin_response(id, payload, lithe_git::normalize_change_targets_command) + } + GitCommand::RebasePlanPreview => { + plugin_response(id, payload, lithe_git::rebase_plan_preview_command) + } + GitCommand::ReferenceTree => { + plugin_response(id, payload, lithe_git::reference_tree_command) + } + GitCommand::PrepareCommitSelection => { + plugin_response(id, payload, lithe_git::prepare_commit_selection_command) + } + GitCommand::Shelf => plugin_response(id, payload, lithe_git::shelf_command), + GitCommand::CommitMessageContext => { + plugin_response(id, payload, lithe_git::commit_message_context_command) + } + GitCommand::Changelists => plugin_response(id, payload, lithe_git::changelists_command), + GitCommand::ReconcileDeletedSelection => { + plugin_response(id, payload, lithe_git::reconcile_deleted_selection_command) + } + GitCommand::RollbackPrepare => { + plugin_response(id, payload, lithe_git::rollback_prepare_command) + } + GitCommand::ParsePatch => plugin_response(id, payload, lithe_git::parse_patch_command), + GitCommand::HistoryQuery => plugin_response(id, payload, lithe_git::history_query_command), + GitCommand::RollbackPlan => plugin_response(id, payload, lithe_git::rollback_plan_command), + GitCommand::Write => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid Git write request") + .with_details(error.to_string()) + }) + .and_then(git::write) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Git write response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + GitCommand::AuthRespond => { + #[derive(serde::Deserialize)] + #[serde(rename_all = "camelCase", deny_unknown_fields)] + struct Reply { + request_id: String, + answer: Option, + } + match serde_json::from_value::(payload) { + Ok(reply) => CoreResponse::success( + id, + json!({ "accepted": lithe_git_host::authentication::respond(&reply.request_id, reply.answer) }), + ), + Err(_) => CoreResponse::failure( + id, + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git authentication response", + ), + ), + } + } + GitCommand::Executable => match git::configuration::locate_executable() { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error), + }, + GitCommand::ExecutionInspect | GitCommand::ExecutionConfigure => { + match git::configuration::dispatch( + payload, + matches!(command, GitCommand::ExecutionConfigure), + ) { + Ok(value) => CoreResponse::success(id, value), + Err(error) => CoreResponse::failure(id, error), + } + } + GitCommand::RemoteUrl => match git::configuration::remote_url(payload) { + Ok(value) => CoreResponse::success(id, value), + Err(error) => CoreResponse::failure(id, error), + }, + GitCommand::ConsolePresentation => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid Git console snapshot") + .with_details(error.to_string()) + }) + .and_then(git::console::present) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Console presentation should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + GitCommand::FetchPlan => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid Fetch plan request") + .with_details(error.to_string()) + }) + .and_then(git::fetch_plan) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Fetch plan should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + GitCommand::Diff => match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid Git diff request") + .with_details(error.to_string()) + }) + .and_then(git::diff) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Git diff response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + }, + GitCommand::PlanDiffBlocks => { + plugin_response(id, payload, lithe_git::plan_diff_blocks_command) + } + GitCommand::ApplyDiffBlock => { + plugin_response(id, payload, lithe_git::apply_diff_block_command) + } + GitCommand::StageGutterChange => { + plugin_response(id, payload, lithe_git::stage_gutter_change_command) + } + GitCommand::Apply => match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid Git apply request") + .with_details(error.to_string()) + }) + .and_then(git::apply) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Git apply response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + }, + GitCommand::History => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid Git history request") + .with_details(error.to_string()) + }) + .and_then(git::history) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Git history response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + GitCommand::References => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid Git references request") + .with_details(error.to_string()) + }) + .and_then(git::references) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Git references response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + GitCommand::HistoryPage => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git history page request", + ) + .with_details(error.to_string()) + }) + .and_then(git::history_page) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Git history page response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + GitCommand::HistoryCursorClose => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git history cursor close request", + ) + .with_details(error.to_string()) + }) + .and_then(git::close_history_cursor) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data) + .expect("Git history cursor close response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + GitCommand::HistoryLocate => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git history locate request", + ) + .with_details(error.to_string()) + }) + .and_then(git::history_locate) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Git history locate response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + GitCommand::BranchNamePreview => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git branch name preview request", + ) + .with_details(error.to_string()) + }) + .and_then(git::branch_name_preview) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data) + .expect("Git branch name preview response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + GitCommand::PushPreview => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git push preview request", + ) + .with_details(error.to_string()) + }) + .and_then(git::push_preview) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Git push preview response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + GitCommand::Commit => match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid Git commit request") + .with_details(error.to_string()) + }) + .and_then(git::commit) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Git commit response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + }, + GitCommand::CommitFiles => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git commit files request", + ) + .with_details(error.to_string()) + }) + .and_then(git::commit_files) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Git commit files response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + GitCommand::Comparison => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid Git comparison request") + .with_details(error.to_string()) + }) + .and_then(git::comparison) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Git comparison response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + GitCommand::Stashes => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid Git stashes request") + .with_details(error.to_string()) + }) + .and_then(git::stashes) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Git stashes response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + GitCommand::CheckoutPreflight => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git checkout preflight request", + ) + .with_details(error.to_string()) + }) + .and_then(git::checkout_preflight) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data) + .expect("Git checkout preflight response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + GitCommand::PullPreflight => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git pull preflight request", + ) + .with_details(error.to_string()) + }) + .and_then(git::pull_preflight) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Git pull preflight response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + GitCommand::ConflictMarkers => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git conflict marker request", + ) + .with_details(error.to_string()) + }) + .and_then(git::conflict_marker_paths) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Git conflict marker response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + GitCommand::IntegrationPreflight => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git integration preflight request", + ) + .with_details(error.to_string()) + }) + .and_then(git::integration_preflight) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data) + .expect("Git integration preflight response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + GitCommand::OperationState => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git operation state request", + ) + .with_details(error.to_string()) + }) + .and_then(git::operation_state) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Git operation state response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + GitCommand::Blame => match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid Git blame request") + .with_details(error.to_string()) + }) + .and_then(git::blame) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Git blame response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + }, + GitCommand::LineHistory => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git line history request", + ) + .with_details(error.to_string()) + }) + .and_then(git::line_history) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Git line history response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + GitCommand::PullRequestCheckoutPreview => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid pull-request checkout preview", + ) + .with_details(error.to_string()) + }) + .and_then(git::checkout_pull_request_preview) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data) + .expect("Pull-request checkout preview should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + GitCommand::PullRequestCheckout => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid pull-request checkout") + .with_details(error.to_string()) + }) + .and_then(git::checkout_pull_request) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Pull-request checkout should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + } +} diff --git a/rust/crates/core/src/runtime/dispatcher/history.rs b/rust/crates/core/src/runtime/dispatcher/history.rs new file mode 100644 index 00000000..da58b993 --- /dev/null +++ b/rust/crates/core/src/runtime/dispatcher/history.rs @@ -0,0 +1,152 @@ +//! `history.*` commands: local file history. + +use super::plugin_response; +use crate::project::{ + HistoryContentRequest, HistoryDeleteRequest, HistoryEntriesRequest, + HistoryIgnorePreviewRequest, HistoryRecordRequest, HistoryRelocateRequest, + HistoryRenameRequest, HistoryStorageRequest, +}; +use crate::protocol::{CoreError, CoreResponse, ErrorCode, HistoryCommand}; +use serde_json::Value; + +/// Runs one `history.*` command. +pub(super) fn dispatch( + command: HistoryCommand, + id: Option, + payload: Value, +) -> CoreResponse { + match command { + HistoryCommand::Record => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid history record request") + .with_details(error.to_string()) + }) + .and_then(crate::project::record) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("history response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + HistoryCommand::Entries => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid history entries request") + .with_details(error.to_string()) + }) + .and_then(crate::project::entries) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("history entries should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + HistoryCommand::Content => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid history content request") + .with_details(error.to_string()) + }) + .and_then(crate::project::content) + { + Ok(data) => CoreResponse::success(id, serde_json::json!({"text": data})), + Err(error) => CoreResponse::failure(id, error), + } + } + HistoryCommand::PlanEvent => { + plugin_response(id, payload, crate::project::plan_event_command) + } + HistoryCommand::Relocate => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid history relocate request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::project::relocate) + { + Ok(()) => CoreResponse::success(id, serde_json::json!({"relocated": true})), + Err(error) => CoreResponse::failure(id, error), + } + } + HistoryCommand::Rename => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid history rename request") + .with_details(error.to_string()) + }) + .and_then(crate::project::rename) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("history entry should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + HistoryCommand::Delete => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid history delete request") + .with_details(error.to_string()) + }) + .and_then(crate::project::delete) + { + Ok(()) => CoreResponse::success(id, serde_json::json!({"deleted": true})), + Err(error) => CoreResponse::failure(id, error), + } + } + HistoryCommand::IgnorePreview => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid history ignore preview request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::project::ignore_preview) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("history ignore preview should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + HistoryCommand::Usage => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid history usage request") + .with_details(error.to_string()) + }) + .and_then(crate::project::usage) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("history usage should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + HistoryCommand::Clear => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid history clear request") + .with_details(error.to_string()) + }) + .and_then(crate::project::clear) + { + Ok(()) => CoreResponse::success(id, serde_json::json!({"cleared": true})), + Err(error) => CoreResponse::failure(id, error), + } + } + } +} diff --git a/rust/crates/core/src/runtime/dispatcher/keymap.rs b/rust/crates/core/src/runtime/dispatcher/keymap.rs new file mode 100644 index 00000000..cc70eb4a --- /dev/null +++ b/rust/crates/core/src/runtime/dispatcher/keymap.rs @@ -0,0 +1,33 @@ +//! `keymap.*` commands: keymap import, contexts, conflicts and schemes. + +use super::{plugin_response, rules_response}; +use crate::protocol::{CoreResponse, KeymapCommand}; +/// The manifest type every domain command validates plugin manifests with. +use lithe_plugin_manifest::DeclarativePluginManifest as Manifest; +use serde_json::Value; + +/// Runs one `keymap.*` command. +pub(super) fn dispatch(command: KeymapCommand, id: Option, payload: Value) -> CoreResponse { + match command { + KeymapCommand::DecodeImport => { + plugin_response(id, payload, lithe_plugin_keymap_import::decode_command) + } + KeymapCommand::PlanImportSources => plugin_response( + id, + payload, + lithe_plugin_keymap_import::plan_sources_command::, + ), + KeymapCommand::PlanImportBindings => plugin_response( + id, + payload, + lithe_plugin_keymap_import::plan_bindings_command, + ), + KeymapCommand::ParseContext => rules_response(id, "keymap.parseContext", payload), + KeymapCommand::EvaluateContext => rules_response(id, "keymap.evaluateContext", payload), + KeymapCommand::AnalyzeConflicts => rules_response(id, "keymap.analyzeConflicts", payload), + KeymapCommand::ModifierGestureStep => { + rules_response(id, "keymap.modifierGestureStep", payload) + } + KeymapCommand::SchemesReduce => rules_response(id, "keymap.schemes.reduce", payload), + } +} diff --git a/rust/crates/core/src/runtime/dispatcher/lsp.rs b/rust/crates/core/src/runtime/dispatcher/lsp.rs new file mode 100644 index 00000000..e89ebb65 --- /dev/null +++ b/rust/crates/core/src/runtime/dispatcher/lsp.rs @@ -0,0 +1,503 @@ +//! `lsp.*` commands: language server sessions, documents, edits, requests and events. + +use super::plugin_response; +use crate::protocol::{CoreError, CoreResponse, ErrorCode, LspCommand}; +use serde_json::{json, Value}; + +/// Runs one `lsp.*` command. +pub(super) fn dispatch(command: LspCommand, id: Option, payload: Value) -> CoreResponse { + match command { + LspCommand::ApplyTextEdits => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid LSP text edit request") + .with_details(error.to_string()) + }) + .and_then(lithe_text::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), + } + } + LspCommand::DocumentIdentity => plugin_response( + id, + payload, + lithe_lsp_policy::document_identity::resolve_command, + ), + LspCommand::PlanWorkspaceEdit => plugin_response( + id, + payload, + lithe_lsp_policy::workspace_edit_plan::plan_command, + ), + LspCommand::ClassifyNavigationTarget => plugin_response( + id, + payload, + lithe_lsp_policy::navigation_target::classify_command, + ), + LspCommand::DeclarationOrUsages => plugin_response( + id, + payload, + lithe_lsp_policy::declaration_or_usages::decide_command, + ), + LspCommand::PlainSnippet => { + match serde_json::from_value::(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(lithe_text::plain_snippet(request)) + .expect("LSP snippet response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + LspCommand::BuiltinCompletions => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid LSP completion request") + .with_details(error.to_string()) + }) + .and_then(lithe_text::builtin_completions) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP completion response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + LspCommand::BuiltinHover => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid LSP hover request") + .with_details(error.to_string()) + }) + .and_then(lithe_text::builtin_hover) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP hover response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + LspCommand::BuiltinNavigation => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid LSP navigation request") + .with_details(error.to_string()) + }) + .and_then(lithe_text::builtin_navigation) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP navigation response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + LspCommand::MergeCompletions => plugin_response( + id, + payload, + lithe_lsp_policy::completion_merge::merge_command, + ), + LspCommand::ActivationPolicy => { + plugin_response(id, payload, lithe_lsp_policy::activation::policy_command) + } + LspCommand::ProblemsQuery => { + plugin_response(id, payload, lithe_lsp_policy::problems::query_command) + } + LspCommand::StartServer => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid LSP start-server request", + ) + .with_details(error.to_string()) + }) + .and_then(lithe_lsp_engine::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), + } + } + LspCommand::StopServer => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid LSP stop-server request") + .with_details(error.to_string()) + }) + .and_then(lithe_lsp_engine::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), + } + } + LspCommand::UpdateWorkspaceConfiguration => { + match serde_json::from_value::( + payload, + ) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid workspace configuration update request", + ) + .with_details(error.to_string()) + }) + .and_then(lithe_lsp_engine::update_workspace_configuration) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data) + .expect("workspace configuration update response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + LspCommand::SaveDocument => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid LSP save-document request", + ) + .with_details(error.to_string()) + }) + .and_then(lithe_lsp_engine::save_document) + { + Ok(sent) => CoreResponse::success(id, json!({"sent": sent})), + Err(error) => CoreResponse::failure(id, error), + } + } + LspCommand::DocumentUri => { + plugin_response(id, payload, lithe_document_uri::document_uri_command) + } + LspCommand::SyncDocument => { + let uri = payload + .get("uri") + .and_then(serde_json::Value::as_str) + .map(str::to_owned); + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid LSP sync-document request", + ) + .with_details(error.to_string()) + }) + .and_then(lithe_lsp_engine::sync_document) + { + Ok(data) => { + let mut encoded = serde_json::to_value(data) + .expect("LSP sync-document response should encode"); + encoded["uri"] = json!(uri); + CoreResponse::success(id, encoded) + } + Err(error) => CoreResponse::failure(id, error), + } + } + LspCommand::WorkspaceFilesChanged => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid LSP workspace-files-changed request", + ) + .with_details(error.to_string()) + }) + .and_then(lithe_lsp_engine::workspace_files_changed) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data) + .expect("LSP workspace-files-changed response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + LspCommand::CloseDocument => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid LSP close-document request", + ) + .with_details(error.to_string()) + }) + .and_then(lithe_lsp_engine::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), + } + } + LspCommand::Request => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid semantic LSP request") + .with_details(error.to_string()) + }) + .and_then(lithe_lsp_engine::semantic_request) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Semantic LSP response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + LspCommand::BuildRequest => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid build-provider request") + .with_details(error.to_string()) + }) + .and_then(lithe_lsp_engine::build_provider_request) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Build-provider response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + LspCommand::ToolchainRequest => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid toolchain-provider request", + ) + .with_details(error.to_string()) + }) + .and_then(lithe_lsp_engine::toolchain_provider_request) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Toolchain response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + LspCommand::KeymapImportRequest => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid shortcut import request") + .with_details(error.to_string()) + }) + .and_then(lithe_lsp_engine::keymap_import_provider_request) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Shortcut import response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + LspCommand::VcsRequest => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid VCS-provider request") + .with_details(error.to_string()) + }) + .and_then(lithe_lsp_engine::vcs_provider_request) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("VCS-provider response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + LspCommand::FunctionProvider => { + plugin_response(id, payload, lithe_lsp_engine::function_provider_command) + } + LspCommand::FunctionRequest => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid plugin-function request") + .with_details(error.to_string()) + }) + .and_then(lithe_lsp_engine::plugin_function_request) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Plugin-function response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + LspCommand::ConfigureFunctionRelay => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid function relay snapshot") + .with_details(error.to_string()) + }) + .and_then(lithe_lsp_engine::configure_function_relay) + { + Ok(()) => CoreResponse::success(id, json!({})), + Err(error) => CoreResponse::failure(id, error), + } + } + LspCommand::HostResponse => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid host-service response") + .with_details(error.to_string()) + }) + .and_then(lithe_lsp_engine::plugin_host_response) + { + Ok(()) => CoreResponse::success(id, json!({})), + Err(error) => CoreResponse::failure(id, error), + } + } + LspCommand::HostNotify => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid host notification") + .with_details(error.to_string()) + }) + .and_then(lithe_lsp_engine::plugin_host_notify) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Host notification response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + LspCommand::PluginServerRequest => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid plugin language-server request", + ) + .with_details(error.to_string()) + }) + .and_then(lithe_lsp_engine::plugin_server_request) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data) + .expect("Plugin language-server response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + LspCommand::PluginServerNotify => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid plugin language-server notification", + ) + .with_details(error.to_string()) + }) + .and_then(lithe_lsp_engine::plugin_server_notify) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data) + .expect("Plugin language-server notification response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + LspCommand::CancelOperation => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid LSP cancellation request", + ) + .with_details(error.to_string()) + }) + .and_then(lithe_lsp_engine::cancel_operation) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP cancellation response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + LspCommand::PollEvents => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid LSP poll-events request") + .with_details(error.to_string()) + }) + .and_then(lithe_lsp_engine::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), + } + } + LspCommand::SetDetailedDiagnostics => { + match serde_json::from_value::(payload) + .map_err(|_| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid detailed diagnostics request", + ) + }) + .and_then(lithe_lsp_engine::set_detailed_diagnostics) + { + Ok(()) => CoreResponse::success(id, json!({})), + Err(error) => CoreResponse::failure(id, error), + } + } + LspCommand::WaitEvents => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid LSP wait-events request") + .with_details(error.to_string()) + }) + .and_then(lithe_lsp_engine::wait_events) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP wait-events response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + LspCommand::DestroyServer => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid LSP destroy-server request", + ) + .with_details(error.to_string()) + }) + .and_then(lithe_lsp_engine::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), + } + } + } +} diff --git a/rust/crates/core/src/runtime/dispatcher/plugins.rs b/rust/crates/core/src/runtime/dispatcher/plugins.rs new file mode 100644 index 00000000..87cdd3d9 --- /dev/null +++ b/rust/crates/core/src/runtime/dispatcher/plugins.rs @@ -0,0 +1,137 @@ +//! `plugins.*` commands: plugin manifests, native UI and web content, plugin settings, +//! installed plugins and the marketplace. + +use super::plugin_response; +use crate::protocol::{CoreResponse, PluginsCommand}; +use serde_json::Value; + +/// Runs one `plugins.*` command. +pub(super) fn dispatch( + command: PluginsCommand, + id: Option, + payload: Value, +) -> CoreResponse { + match command { + PluginsCommand::NativeUIValidateData => { + plugin_response(id, payload, lithe_plugin_native_ui::validate_data_command) + } + PluginsCommand::WebOpen => { + plugin_response(id, payload, lithe_plugin_web_content::open_command) + } + PluginsCommand::WebClose => { + plugin_response(id, payload, lithe_plugin_web_content::close_command) + } + PluginsCommand::WebResource => { + plugin_response(id, payload, lithe_plugin_web_content::resource_command) + } + PluginsCommand::WebDocument => { + plugin_response(id, payload, lithe_plugin_web_content::document_command) + } + PluginsCommand::WebRequest => { + plugin_response(id, payload, lithe_plugin_web_content::request_command) + } + PluginsCommand::WebReply => { + plugin_response(id, payload, lithe_plugin_web_content::reply_command) + } + PluginsCommand::WebEvent => { + plugin_response(id, payload, lithe_plugin_web_content::event_command) + } + PluginsCommand::DeclarativeValidate => { + plugin_response(id, payload, lithe_plugin_manifest::validate_command) + } + PluginsCommand::DeclarativeRunPlan => { + plugin_response(id, payload, lithe_plugin_manifest::run_plan_command) + } + PluginsCommand::DeclarativeDiscoverTests => { + plugin_response(id, payload, lithe_plugin_manifest::discover_tests_command) + } + PluginsCommand::DeclarativeTestPlan => { + plugin_response(id, payload, lithe_plugin_manifest::test_plan_command) + } + PluginsCommand::DeclarativeLanguageServerLaunchPlan => plugin_response( + id, + payload, + lithe_plugin_manifest::language_server_launch_plan_command, + ), + PluginsCommand::LanguageServersDecodeLaunch => plugin_response( + id, + payload, + lithe_plugin_manifest::decode_language_server_launch_command, + ), + PluginsCommand::LanguageServersStateRetention => plugin_response( + id, + payload, + lithe_plugin_language_servers::state_retention_command, + ), + PluginsCommand::ContentProvidersDecode => plugin_response( + id, + payload, + lithe_plugin_content_providers::decode_content_command, + ), + PluginsCommand::DeclarativeResolveSettings => { + plugin_response(id, payload, lithe_plugin_manifest::resolve_settings_command) + } + PluginsCommand::InstalledSelect => { + plugin_response(id, payload, lithe_plugin_model::installed::select_command) + } + PluginsCommand::SettingsCatalog => { + plugin_response(id, payload, lithe_plugin_manifest::settings_catalog_command) + } + PluginsCommand::SettingsNavigation => plugin_response( + id, + payload, + lithe_plugin_manifest::settings_navigation_command, + ), + PluginsCommand::SettingsDelivery => plugin_response( + id, + payload, + lithe_plugin_manifest::settings_delivery_command, + ), + PluginsCommand::DeclarativeSettingsState => { + plugin_response(id, payload, lithe_plugin_manifest::settings_state_command) + } + PluginsCommand::DeclarativeSettingsActionPlan => plugin_response( + id, + payload, + lithe_plugin_manifest::settings_action_plan_command, + ), + PluginsCommand::DeclarativeEditSettings => { + plugin_response(id, payload, lithe_plugin_manifest::edit_settings_command) + } + PluginsCommand::DeclarativeWorkspaceConfiguration => plugin_response( + id, + payload, + lithe_plugin_manifest::workspace_configuration_command, + ), + PluginsCommand::DeclarativeDecodeGrammar => { + plugin_response(id, payload, lithe_plugin_manifest::decode_grammar_command) + } + PluginsCommand::EditorActionsDecode => { + plugin_response(id, payload, lithe_plugin_editor::actions::decode_command) + } + PluginsCommand::HostApiDescribe => { + plugin_response(id, payload, lithe_plugin_rpc::host_api::describe_command) + } + PluginsCommand::HostApiAuthorize => { + plugin_response(id, payload, lithe_plugin_rpc::host_api::authorize_command) + } + PluginsCommand::EditorProvidersDecode => { + plugin_response(id, payload, lithe_plugin_editor::providers::decode_command) + } + PluginsCommand::MarketplaceValidateIndex => plugin_response( + id, + payload, + lithe_plugin_marketplace::validate_index_command, + ), + PluginsCommand::MarketplaceResolveSource => plugin_response( + id, + payload, + lithe_plugin_marketplace::resolve_source_command, + ), + PluginsCommand::MarketplaceInstallReview => plugin_response( + id, + payload, + lithe_plugin_marketplace::install_review_command, + ), + } +} diff --git a/rust/crates/core/src/runtime/dispatcher/remote.rs b/rust/crates/core/src/runtime/dispatcher/remote.rs new file mode 100644 index 00000000..dea8adff --- /dev/null +++ b/rust/crates/core/src/runtime/dispatcher/remote.rs @@ -0,0 +1,181 @@ +//! `remote.*` commands: probing, authentication, connections, files and processes. + +use crate::protocol::{CoreError, CoreResponse, ErrorCode, RemoteCommand}; +use serde_json::{json, Value}; + +/// Runs one `remote.*` command. +pub(super) fn dispatch(command: RemoteCommand, id: Option, payload: Value) -> CoreResponse { + match command { + RemoteCommand::Probe => { + match serde_json::from_value::(payload) { + Ok(request) => match lithe_workspace::probe::probe(request) { + Ok(result) => CoreResponse::success( + id, + serde_json::to_value(result).expect("remote probe result should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + }, + Err(error) => CoreResponse::failure( + id, + CoreError::new(ErrorCode::InvalidRequest, "Invalid remote probe request") + .with_details(error.to_string()), + ), + } + } + RemoteCommand::Connect => { + match serde_json::from_value::(payload) { + Ok(request) => match lithe_workspace::sessions::connect(request) { + Ok(result) => CoreResponse::success( + id, + serde_json::to_value(result).expect("remote connect result should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + }, + Err(error) => CoreResponse::failure( + id, + CoreError::new(ErrorCode::InvalidRequest, "Invalid remote connect request") + .with_details(error.to_string()), + ), + } + } + RemoteCommand::ReadDir => { + match serde_json::from_value::(payload) { + Ok(request) => match lithe_workspace::sessions::read_dir(request) { + Ok(result) => CoreResponse::success(id, result), + Err(error) => CoreResponse::failure(id, error), + }, + Err(error) => CoreResponse::failure( + id, + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid remote directory request", + ) + .with_details(error.to_string()), + ), + } + } + RemoteCommand::ReadFile => { + match serde_json::from_value::(payload) { + Ok(request) => match lithe_workspace::sessions::read_file(request) { + Ok(result) => CoreResponse::success(id, result), + Err(error) => CoreResponse::failure(id, error), + }, + Err(error) => CoreResponse::failure( + id, + CoreError::new(ErrorCode::InvalidRequest, "Invalid remote file request") + .with_details(error.to_string()), + ), + } + } + RemoteCommand::WriteFile => { + match serde_json::from_value::(payload) { + Ok(request) => match lithe_workspace::sessions::write_file(request) { + Ok(result) => CoreResponse::success(id, result), + Err(error) => CoreResponse::failure(id, error), + }, + Err(error) => CoreResponse::failure( + id, + CoreError::new(ErrorCode::InvalidRequest, "Invalid remote file request") + .with_details(error.to_string()), + ), + } + } + RemoteCommand::CaptureProcess => { + match serde_json::from_value(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid host capture request") + .with_details(error.to_string()) + }) + .and_then(lithe_workspace::host::capture_process) + { + Ok(value) => CoreResponse::success(id, value), + Err(error) => CoreResponse::failure(id, error), + } + } + RemoteCommand::HostRequest + | RemoteCommand::RunProcess + | RemoteCommand::RunWatch + | RemoteCommand::RunTerminal => { + let operation = match command { + RemoteCommand::HostRequest => lithe_workspace::host::request, + RemoteCommand::RunProcess => lithe_workspace::host::run_process, + RemoteCommand::RunWatch => lithe_workspace::host::run_watch, + _ => lithe_workspace::host::run_terminal, + }; + match serde_json::from_value(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid workspace host request") + .with_details(error.to_string()) + }) + .and_then(operation) + { + Ok(value) => CoreResponse::success(id, value), + Err(error) => CoreResponse::failure(id, error), + } + } + RemoteCommand::UploadFile => { + match serde_json::from_value(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid upload request") + .with_details(error.to_string()) + }) + .and_then(lithe_workspace::upload::upload) + { + Ok(value) => CoreResponse::success(id, value), + Err(error) => CoreResponse::failure(id, error), + } + } + RemoteCommand::HostInfo => { + match payload + .get("sessionId") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| CoreError::new(ErrorCode::InvalidRequest, "Missing host session")) + .and_then(lithe_workspace::upload::host_info) + { + Ok(value) => CoreResponse::success(id, value), + Err(error) => CoreResponse::failure(id, error), + } + } + RemoteCommand::Disconnect => { + match serde_json::from_value::(payload) { + Ok(request) => CoreResponse::success( + id, + json!({ "closed": lithe_workspace::sessions::disconnect(request) }), + ), + Err(error) => CoreResponse::failure( + id, + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid remote disconnect request", + ) + .with_details(error.to_string()), + ), + } + } + RemoteCommand::CloseAll => CoreResponse::success( + id, + json!({ "closed": lithe_workspace::sessions::close_all() }), + ), + RemoteCommand::AuthRespond => { + #[derive(serde::Deserialize)] + #[serde(rename_all = "camelCase", deny_unknown_fields)] + struct Reply { + request_id: String, + answer: Option, + } + match serde_json::from_value::(payload) { + Ok(reply) => CoreResponse::success( + id, + json!({ "accepted": lithe_workspace::probe::respond(&reply.request_id, reply.answer) }), + ), + Err(_) => CoreResponse::failure( + id, + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid remote authentication response", + ), + ), + } + } + } +} diff --git a/rust/crates/core/src/runtime/dispatcher/run_config.rs b/rust/crates/core/src/runtime/dispatcher/run_config.rs new file mode 100644 index 00000000..1429feb0 --- /dev/null +++ b/rust/crates/core/src/runtime/dispatcher/run_config.rs @@ -0,0 +1,203 @@ +//! `runConfig.*` commands: run configurations and their launch plans. + +use crate::protocol::{CoreError, CoreResponse, ErrorCode, RunConfigCommand}; +use serde_json::Value; + +/// Runs one `runConfig.*` command. +pub(super) fn dispatch( + command: RunConfigCommand, + id: Option, + payload: Value, +) -> CoreResponse { + match command { + RunConfigCommand::Inspect => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid run configuration inspect request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::execution::inspect) + { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error), + } + } + RunConfigCommand::ProjectToolchain => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid project toolchain request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::execution::project_toolchain) + { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error), + } + } + RunConfigCommand::Generate => { + match serde_json::from_value::(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), + } + } + RunConfigCommand::ArgumentText => { + match serde_json::from_value::(payload) { + Ok(request) => CoreResponse::success( + id, + serde_json::json!(crate::execution::format_argument_text(request)), + ), + Err(error) => CoreResponse::failure( + id, + CoreError::new(ErrorCode::InvalidRequest, "Invalid argument text request") + .with_details(error.to_string()), + ), + } + } + RunConfigCommand::Resolve => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid run configuration resolve request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::execution::resolve) + { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error), + } + } + RunConfigCommand::DecideTitleAction => { + match serde_json::from_value::(payload) { + Ok(request) => CoreResponse::success( + id, + serde_json::to_value(crate::execution::decide_title_action(request)) + .expect("Title action decisions always encode"), + ), + Err(error) => CoreResponse::failure( + id, + CoreError::new(ErrorCode::InvalidRequest, "Invalid title action request") + .with_details(error.to_string()), + ), + } + } + RunConfigCommand::SelectContextConfiguration => { + match serde_json::from_value::(payload) { + Ok(request) => CoreResponse::success( + id, + serde_json::json!({"configurationId": crate::execution::select_context_configuration(request)}), + ), + Err(error) => CoreResponse::failure( + id, + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid context configuration request", + ) + .with_details(error.to_string()), + ), + } + } + RunConfigCommand::UpdateOptions => { + match serde_json::from_value::(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), + } + } + RunConfigCommand::UpdateToolPreferences => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid tool preferences request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::execution::update_tool_preferences) + { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error), + } + } + RunConfigCommand::SaveEditorChanges => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid run configuration editor request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::execution::save_editor_changes) + { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error), + } + } + RunConfigCommand::CreateUserConfiguration => { + match serde_json::from_value::( + payload, + ) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid user configuration request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::execution::create_user_configuration) + { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error), + } + } + RunConfigCommand::ProviderLaunchInputs => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid provider launch input request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::execution::provider_launch_inputs) + { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error), + } + } + RunConfigCommand::CreateLaunchPlan => { + match serde_json::from_value::(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), + } + } + } +} diff --git a/rust/crates/core/src/runtime/dispatcher/toolchains.rs b/rust/crates/core/src/runtime/dispatcher/toolchains.rs new file mode 100644 index 00000000..d9baefd6 --- /dev/null +++ b/rust/crates/core/src/runtime/dispatcher/toolchains.rs @@ -0,0 +1,86 @@ +//! `toolchains.*` commands: the toolchain registry, pins, consumer preferences, +//! discovery and launch instantiation. + +use super::plugin_response; +use crate::protocol::{CoreResponse, ToolchainsCommand}; +use serde_json::Value; + +/// Runs one `toolchains.*` command. +pub(super) fn dispatch( + command: ToolchainsCommand, + id: Option, + payload: Value, +) -> CoreResponse { + match command { + ToolchainsCommand::Registry => plugin_response(id, payload, |payload| { + crate::toolchain::registry_command( + payload, + lithe_plugin_manifest::validated_manifest_json, + ) + }), + ToolchainsCommand::DecodeReply => plugin_response(id, payload, |payload| { + crate::toolchain::decode_reply_command( + payload, + lithe_plugin_manifest::validated_manifest_json, + ) + }), + ToolchainsCommand::FailureReply => { + plugin_response(id, payload, crate::toolchain::failure_reply_command) + } + ToolchainsCommand::ResolvePins => { + plugin_response(id, payload, crate::toolchain::resolve_pins_command) + } + ToolchainsCommand::EditPins => { + plugin_response(id, payload, crate::toolchain::edit_pins_command) + } + ToolchainsCommand::PinStoragePlan => { + plugin_response(id, payload, crate::toolchain::pin_storage_plan_command) + } + ToolchainsCommand::DecodePinDocument => { + plugin_response(id, payload, crate::toolchain::decode_pin_document_command) + } + ToolchainsCommand::EditPinSources => { + plugin_response(id, payload, crate::toolchain::edit_pin_sources_command) + } + ToolchainsCommand::ResolveConsumerPreferences => plugin_response( + id, + payload, + crate::toolchain::resolve_consumer_preferences_command, + ), + ToolchainsCommand::EditConsumerPreferences => plugin_response( + id, + payload, + crate::toolchain::edit_consumer_preferences_command, + ), + ToolchainsCommand::Resolve => plugin_response(id, payload, |payload| { + crate::toolchain::resolve_command( + payload, + lithe_plugin_manifest::validated_manifest_json, + ) + }), + ToolchainsCommand::CheckVersion => plugin_response(id, payload, |payload| { + crate::toolchain::check_version_command( + payload, + lithe_plugin_manifest::validated_manifest_json, + ) + }), + ToolchainsCommand::DiscoveryPlan => plugin_response(id, payload, |payload| { + crate::toolchain::discovery_plan_command( + payload, + lithe_plugin_manifest::validated_manifest_json, + ) + }), + ToolchainsCommand::Candidates => plugin_response(id, payload, |payload| { + crate::toolchain::candidates_command( + payload, + lithe_plugin_manifest::validated_manifest_json, + ) + }), + ToolchainsCommand::InstantiateLaunch => plugin_response(id, payload, |payload| { + crate::toolchain::instantiate_launch_command( + payload, + lithe_plugin_manifest::validated_manifest_json, + ) + }), + } +} diff --git a/rust/crates/core/src/runtime/dispatcher/workspace.rs b/rust/crates/core/src/runtime/dispatcher/workspace.rs new file mode 100644 index 00000000..9e0e6606 --- /dev/null +++ b/rust/crates/core/src/runtime/dispatcher/workspace.rs @@ -0,0 +1,293 @@ +//! `workspace.*` commands: snapshots, indexing, directory actions, repositories and search. + +use super::{plugin_response, rules_response}; +use crate::project::{ + self, DirectoryListingRequest, ReplacementPreviewRequest, SearchIndexRequest, + SearchIndexUpdateRequest, SearchRequest, WorkspaceSnapshotRequest, +}; +use crate::protocol::{CoreError, CoreResponse, ErrorCode, WorkspaceCommand}; +use lithe_git::{self as git, WorkspaceRepositoriesRequest}; +use serde_json::{json, Value}; + +/// Runs one `workspace.*` command. +pub(super) fn dispatch( + command: WorkspaceCommand, + id: Option, + payload: Value, +) -> CoreResponse { + match command { + WorkspaceCommand::ReleaseIndexing => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid indexing release") + .with_details(error.to_string()) + }) + .and_then(project::indexing::release) + { + Ok(released) => { + CoreResponse::success(id, serde_json::json!({"released": released})) + } + Err(error) => CoreResponse::failure(id, error), + } + } + WorkspaceCommand::ConfigureIndexing => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid indexing policy") + .with_details(error.to_string()) + }) + .and_then(|request| { + project::indexing::configure(request, lithe_lsp_engine::indexing_changed) + }) { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("indexing status should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + WorkspaceCommand::RefreshIndexing => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid indexing refresh") + .with_details(error.to_string()) + }) + .and_then(|request| { + project::indexing::refresh_indexing(request, lithe_lsp_engine::indexing_changed) + }) { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("indexing status should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + WorkspaceCommand::Snapshot => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid workspace snapshot request", + ) + .with_details(error.to_string()) + }) + .and_then(project::snapshot) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("snapshot should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + WorkspaceCommand::FilterChanges => { + match serde_json::from_value::(payload) { + Ok(request) => CoreResponse::success( + id, + serde_json::json!(crate::project::filter_workspace_changes(request)), + ), + Err(error) => CoreResponse::failure( + id, + CoreError::new(ErrorCode::InvalidRequest, "Invalid workspace event paths") + .with_details(error.to_string()), + ), + } + } + WorkspaceCommand::ListDirectories => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid directory listing request", + ) + .with_details(error.to_string()) + }) + .and_then(project::list_directories) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("directory listing should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + WorkspaceCommand::OrderEntries => plugin_response( + id, + payload, + crate::project::file_name_order::order_entries_command, + ), + WorkspaceCommand::RankNames => plugin_response( + id, + payload, + crate::project::name_ranking::rank_names_command, + ), + WorkspaceCommand::PlanDuplicate => plugin_response( + id, + payload, + crate::project::file_operations::plan_duplicate_command, + ), + WorkspaceCommand::PlanPaste => plugin_response( + id, + payload, + crate::project::file_operations::plan_paste_command, + ), + WorkspaceCommand::ReduceDirectoryAction => plugin_response( + id, + payload, + crate::project::directory_actions::reduce_command, + ), + WorkspaceCommand::RelocateDirectoryRules => plugin_response( + id, + payload, + crate::project::directory_actions::relocate_command, + ), + WorkspaceCommand::Repositories => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid workspace repositories request", + ) + .with_details(error.to_string()) + }) + .and_then(git::workspace_repositories) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("workspace repositories should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + WorkspaceCommand::RecentProjectsReduce => { + plugin_response(id, payload, crate::project::recent_projects::reduce_command) + } + WorkspaceCommand::SearchIndexWarm => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid search index request") + .with_details(error.to_string()) + }) + .and_then(project::warm_search_index) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("search index status should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + WorkspaceCommand::SearchIndexUpdate => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid search index update") + .with_details(error.to_string()) + }) + .and_then(|request| { + project::update_search_index(request, lithe_lsp_engine::indexing_changed) + }) { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("search index status should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + WorkspaceCommand::SearchIndexInvalidate => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid search index request") + .with_details(error.to_string()) + }) + .and_then(project::invalidate_search_index) + { + Ok(()) => CoreResponse::success(id, json!({})), + Err(error) => CoreResponse::failure(id, error), + } + } + WorkspaceCommand::Search => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid search request") + .with_details(error.to_string()) + }) + .and_then(project::search) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("search should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + WorkspaceCommand::SearchEverywhere => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Search Everywhere request", + ) + .with_details(error.to_string()) + }) + .and_then(project::search_page) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("search everywhere should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + WorkspaceCommand::ReplacePreview => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid replacement preview request", + ) + .with_details(error.to_string()) + }) + .and_then(project::replace_preview) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("replacement preview should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + WorkspaceCommand::ReplaceText => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid text replacement request", + ) + .with_details(error.to_string()) + }) + .and_then(project::replace_text) + { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error), + } + } + WorkspaceCommand::SearchTextBatch => { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid text search request") + .with_details(error.to_string()) + }) + .and_then(project::search_text_batch) + { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error), + } + } + WorkspaceCommand::ClassifyVisibility => { + rules_response(id, "workspace.classifyVisibility", payload) + } + WorkspaceCommand::VisibilityDefaults => { + rules_response(id, "workspace.visibilityDefaults", payload) + } + } +} diff --git a/rust/crates/core/src/runtime/ffi.rs b/rust/crates/core/src/runtime/ffi.rs index 7472e9d3..80496c40 100644 --- a/rust/crates/core/src/runtime/ffi.rs +++ b/rust/crates/core/src/runtime/ffi.rs @@ -178,7 +178,7 @@ pub unsafe extern "C" fn lithe_core_install_log_waker( context: *mut std::ffi::c_void, ) { if let Some(callback) = callback { - crate::diagnostics::core_events::install_waker(callback, context as usize); + lithe_diagnostics::core_events::install_waker(callback, context as usize); } } @@ -187,7 +187,7 @@ pub unsafe extern "C" fn lithe_core_install_log_waker( /// so the host may release it. #[no_mangle] pub extern "C" fn lithe_core_remove_log_waker(context: *mut std::ffi::c_void) -> i32 { - crate::diagnostics::core_events::remove_waker(context as usize) as i32 + lithe_diagnostics::core_events::remove_waker(context as usize) as i32 } /// Runs the native authentication helper without initializing the application. diff --git a/rust/crates/core/src/diagnostics/helper_failures.rs b/rust/crates/core/src/runtime/helper_failures.rs similarity index 92% rename from rust/crates/core/src/diagnostics/helper_failures.rs rename to rust/crates/core/src/runtime/helper_failures.rs index 35da603f..801d6d5e 100644 --- a/rust/crates/core/src/diagnostics/helper_failures.rs +++ b/rust/crates/core/src/runtime/helper_failures.rs @@ -1,6 +1,6 @@ //! One installation boundary for helper facts entering the bounded Core handoff. -use super::core_events; +use lithe_diagnostics::{channel::LogError, core_events}; use serde_json::Value; use std::collections::BTreeMap; use std::sync::Once; @@ -11,7 +11,6 @@ static INSTALL: Once = Once::new(); /// helpers or starting workers. Subsequent calls are cheap. pub(crate) fn install() { INSTALL.call_once(|| { - lithe_git_host::failure_log::install_failure_sink(sink); lithe_git_host::failure_log::install_failure_sink(sink); lithe_ide_host::failure_log::install_failure_sink(sink); lithe_remote::failure_log::install_failure_sink(sink); @@ -31,7 +30,7 @@ fn fields(input: &[(&'static str, Value)]) -> BTreeMap { fn dispatch( event: &str, input: &[(&'static str, Value)], - record: impl FnOnce(&str, BTreeMap) -> Result<(), super::channel::LogError>, + record: impl FnOnce(&str, BTreeMap) -> Result<(), LogError>, dropped: impl FnOnce(), ) { if record(event, fields(input)).is_err() { @@ -51,6 +50,7 @@ fn sink(event: &str, input: &[(&'static str, Value)]) { #[cfg(test)] mod tests { use super::*; + use lithe_diagnostics::catalog; use serde_json::json; use std::cell::{Cell, RefCell}; @@ -70,10 +70,7 @@ mod tests { (lithe_remote::failure_log::EVENTS, "remote"), ] { for id in events { - assert_eq!( - super::super::catalog::event_by_id(id).unwrap().owner, - "lithe-core" - ); + assert_eq!(catalog::event_by_id(id).unwrap().owner, "lithe-core"); // Exercise the exact sink conversion/validation into an owned // queue, so parallel host drains cannot steal this assertion. let queue = RefCell::new(Vec::new()); diff --git a/rust/crates/core/src/runtime/mod.rs b/rust/crates/core/src/runtime/mod.rs index a15e9e75..56e8d06d 100644 --- a/rust/crates/core/src/runtime/mod.rs +++ b/rust/crates/core/src/runtime/mod.rs @@ -2,5 +2,7 @@ mod dispatcher; mod ffi; +pub(crate) mod helper_failures; +pub(crate) mod request_trace; pub(crate) use dispatcher::execute_json; diff --git a/rust/crates/core/src/diagnostics/request_trace.rs b/rust/crates/core/src/runtime/request_trace.rs similarity index 91% rename from rust/crates/core/src/diagnostics/request_trace.rs rename to rust/crates/core/src/runtime/request_trace.rs index 6d9e2923..70b8c64e 100644 --- a/rust/crates/core/src/diagnostics/request_trace.rs +++ b/rust/crates/core/src/runtime/request_trace.rs @@ -6,12 +6,11 @@ //! or gives up. A host notice backed by these facts links to this record; the host //! adds its own terminal event only for facts Core does not have. -use super::core_events; -use super::record::LogLevel; -use crate::git::console::Source; use crate::protocol::{CoreRequest, CoreResponse, ErrorCode}; +use lithe_diagnostics::core_events; +use lithe_diagnostics::record::LogLevel; +use lithe_request_context::{trace, Source}; use serde_json::{json, Value}; -use std::cell::RefCell; use std::collections::BTreeMap; use std::time::Instant; @@ -26,23 +25,7 @@ const PATH_KEYS: [&str; 7] = [ "path", ]; -/// Correlation inherited by Git process traces started inside the request. -#[derive(Debug, Clone, Default)] -pub(crate) struct Context { - /// Operation shared by the user action, its requests and their Git processes. - pub(crate) op: Option, - /// Provenance resolved once from the request envelope. - pub(crate) source: Source, -} - -thread_local! { - static CURRENT: RefCell = RefCell::new(Context::default()); -} - -/// Snapshot for traces that finish on another thread, such as streamed history. -pub(crate) fn current() -> Context { - CURRENT.with(|current| current.borrow().clone()) -} +pub(crate) use lithe_request_context::source_token; /// Active request trace; dropping it restores the enclosing request's context. pub(crate) struct Request { @@ -55,7 +38,7 @@ pub(crate) struct Request { op: Option, hot: bool, started: Instant, - previous: Context, + previous: trace::Context, } impl Request { @@ -71,11 +54,9 @@ impl Request { .filter(|value| identity(value)) .or_else(|| request.operation_id.clone().filter(|value| identity(value))) .or_else(|| request.id.clone().filter(|value| identity(value))); - let previous = CURRENT.with(|current| { - current.replace(Context { - op: op.clone(), - source, - }) + let previous = trace::replace(trace::Context { + op: op.clone(), + source, }); let hot = is_hot(&request.command); Self { @@ -136,7 +117,7 @@ impl Request { let code = serde_json::to_value(&error.code).expect("Core error codes encode"); fields.insert( "error".into(), - json!(crate::git::execution_events::redact(&error.message)), + json!(lithe_request_context::events::redact(&error.message)), ); fields.insert("error_code".into(), code); fields.insert("error_domain".into(), json!("core")); @@ -154,8 +135,7 @@ impl Request { impl Drop for Request { fn drop(&mut self) { - let previous = std::mem::take(&mut self.previous); - CURRENT.with(|current| current.replace(previous)); + trace::replace(std::mem::take(&mut self.previous)); } } @@ -222,14 +202,6 @@ pub(crate) fn effective_source(origin: Option, git: Option) -> S } } -pub(crate) fn source_token(source: Source) -> &'static str { - match source { - Source::User => "user", - Source::Background => "background", - Source::Unknown => "unknown", - } -} - fn request_path(payload: &Value) -> Option { PATH_KEYS.iter().find_map(|key| { payload[*key] @@ -261,7 +233,8 @@ pub(crate) fn identity(value: &str) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::diagnostics::core_events::capture; + use lithe_diagnostics::core_events::capture; + use lithe_request_context::trace::current; fn request(value: Value) -> CoreRequest { serde_json::from_value(value).unwrap() diff --git a/rust/crates/core/src/tests/build_configuration.rs b/rust/crates/core/src/tests/build_configuration.rs index 7401a583..633237ec 100644 --- a/rust/crates/core/src/tests/build_configuration.rs +++ b/rust/crates/core/src/tests/build_configuration.rs @@ -4,7 +4,8 @@ use crate::build::{ build_configuration_edit_plan_command, build_configuration_plan_command, build_decode_configuration_command, build_decode_configuration_edit_command, }; -use crate::plugins::declarative::validate_command; +use lithe_plugin_manifest::validate_command; +use lithe_plugin_manifest::DeclarativePluginManifest as Manifest; use serde_json::{json, Value}; fn manifest() -> Value { @@ -49,7 +50,7 @@ fn native_configuration_needs_a_provider_host_and_preserves_option_arguments() { "/fixture/mvn" }; let plan = call( - build_configuration_plan_command, + build_configuration_plan_command::, json!({"root":".","options":{"offline":true},"tool":{"executable":executable}}), ); assert_eq!(plan["status"], "ok", "{plan}"); @@ -63,7 +64,7 @@ fn native_configuration_needs_a_provider_host_and_preserves_option_arguments() { ); assert_eq!( call( - build_configuration_plan_command, + build_configuration_plan_command::, json!({"root":".","tool":{"executable":"relative/mvn"}}) )["status"], "rejected" @@ -79,7 +80,7 @@ fn native_configuration_needs_a_provider_host_and_preserves_option_arguments() { fn native_configuration_rejects_sensitive_duplicate_and_unsafe_sources() { assert_eq!( call( - build_decode_configuration_command, + build_decode_configuration_command::, json!({"result":snapshot()}) )["status"], "ok" @@ -109,7 +110,10 @@ fn native_configuration_rejects_sensitive_duplicate_and_unsafe_sources() { _ => unreachable!(), } assert_eq!( - call(build_decode_configuration_command, json!({"result":value}))["status"], + call( + build_decode_configuration_command::, + json!({"result":value}) + )["status"], "rejected", "{variant}" ); @@ -125,12 +129,18 @@ fn native_configuration_allows_exact_read_only_aliases_but_no_editable_alias() { second["scope"] = json!("installation"); value["sources"].as_array_mut().unwrap().push(second); assert_eq!( - call(build_decode_configuration_command, json!({"result":value}))["status"], + call( + build_decode_configuration_command::, + json!({"result":value}) + )["status"], "ok" ); value["sources"][0]["writable"] = json!(true); assert_eq!( - call(build_decode_configuration_command, json!({"result":value}))["status"], + call( + build_decode_configuration_command::, + json!({"result":value}) + )["status"], "rejected" ); } @@ -141,7 +151,7 @@ fn native_editing_passes_opaque_fields_and_private_staging_without_document_byte request["snapshot"]["sources"][0]["uri"] = json!("file:///fixture/config.json"); request["snapshot"]["sources"][0]["fields"][0]["id"] = json!("cacheLocation"); request["edits"]["values"] = json!({"cacheLocation":"/new"}); - let result = call(build_configuration_edit_plan_command, request); + let result = call(build_configuration_edit_plan_command::, request); assert_eq!(result["status"], "ok", "{result}"); assert_eq!( result["plan"]["provider"]["method"], @@ -194,7 +204,7 @@ fn native_editing_rejects_stale_unknown_read_only_and_escaping_requests() { _ => unreachable!(), } assert_eq!( - call(build_configuration_edit_plan_command, request)["status"], + call(build_configuration_edit_plan_command::, request)["status"], "rejected", "{variant}" ); @@ -208,7 +218,7 @@ fn native_edit_receipt_is_bound_to_confirmed_source_and_contains_no_document() { let payload = |result: Value| json!({"snapshot":request["snapshot"],"edits":request["edits"],"result":result}); assert_eq!( call( - build_decode_configuration_edit_command, + build_decode_configuration_edit_command::, payload(receipt.clone()) )["status"], "ok" @@ -223,13 +233,19 @@ fn native_edit_receipt_is_bound_to_confirmed_source_and_contains_no_document() { let mut invalid = receipt.clone(); invalid[field] = value; assert_eq!( - call(build_decode_configuration_edit_command, payload(invalid))["status"], + call( + build_decode_configuration_edit_command::, + payload(invalid) + )["status"], "rejected" ); } let unchanged = json!({"format":"litheConfigurationEdit","version":0,"sourceID":"user","revision":request["edits"]["revision"],"changed":false}); assert_eq!( - call(build_decode_configuration_edit_command, payload(unchanged))["status"], + call( + build_decode_configuration_edit_command::, + payload(unchanged) + )["status"], "ok" ); } diff --git a/rust/crates/core/src/tests/build_project_options.rs b/rust/crates/core/src/tests/build_project_options.rs index 27d1b198..87bbb777 100644 --- a/rust/crates/core/src/tests/build_project_options.rs +++ b/rust/crates/core/src/tests/build_project_options.rs @@ -1,6 +1,7 @@ //! Native Build document storage syntax shared by macOS and Windows. use crate::build::project_options_command; +use lithe_plugin_manifest::DeclarativePluginManifest as Manifest; use serde_json::{json, Value}; #[test] @@ -17,7 +18,7 @@ fn local_option_edits_check_declarations_preserve_siblings_and_reset_to_team_val // fixture's instance-scoped scalars; the storage rules do not depend on the tool. manifest["buildSystems"][0]["options"][1]["scope"] = json!("workspace"); let edit = |local: &Value, root: &str, id: &str, value: Value, model: Value| { - crate::build::build_edit_stored_option_command(json!({"manifest":manifest,"buildSystemID":"cargo","request":{ + crate::build::build_edit_stored_option_command::(json!({"manifest":manifest,"buildSystemID":"cargo","request":{ "document":fixture["team"],"local":local,"root":root,"optionID":id,"value":value,"modelValues":model }})).unwrap() }; diff --git a/rust/crates/core/src/tests/build_runtime_versions.rs b/rust/crates/core/src/tests/build_runtime_versions.rs index 58c100a5..bad2afdf 100644 --- a/rust/crates/core/src/tests/build_runtime_versions.rs +++ b/rust/crates/core/src/tests/build_runtime_versions.rs @@ -2,6 +2,7 @@ //! definition with the same ID, which checks the version itself. use crate::build::{build_check_tool_version_command, build_invocation_plan_command}; +use lithe_plugin_manifest::DeclarativePluginManifest as Manifest; use serde_json::{json, Value}; fn manifest() -> Value { @@ -13,7 +14,7 @@ fn manifest() -> Value { } fn plan(manifest: Value, platform: &str) -> Value { - build_invocation_plan_command(json!({ + build_invocation_plan_command::(json!({ "manifest": manifest, "buildSystemID": "cargo", "request": { "root": ".", "platform": platform } @@ -22,7 +23,7 @@ fn plan(manifest: Value, platform: &str) -> Value { } fn check(manifest: Value, runtime: &str, output: &str) -> Value { - build_check_tool_version_command(json!({ + build_check_tool_version_command::(json!({ "manifest": manifest, "buildSystemID": "cargo", "request": { "runtime": runtime, "output": output } diff --git a/rust/crates/core/src/tests/build_systems.rs b/rust/crates/core/src/tests/build_systems.rs index c03b8d86..4b2f5c19 100644 --- a/rust/crates/core/src/tests/build_systems.rs +++ b/rust/crates/core/src/tests/build_systems.rs @@ -11,9 +11,10 @@ use crate::build::{ build_resolve_options_command, build_sync_fingerprint_command, build_task_plan_command, build_test_discovery_plan_command, build_test_plan_command, }; -use crate::plugins::declarative::{language_server_launch_plan_command, validate_command}; -use crate::plugins::marketplace::install_review_command; use crate::protocol::ErrorCode; +use lithe_plugin_manifest::DeclarativePluginManifest as Manifest; +use lithe_plugin_manifest::{language_server_launch_plan_command, validate_command}; +use lithe_plugin_marketplace::install_review_command; use serde_json::{json, Value}; use std::fs; use std::path::PathBuf; @@ -94,18 +95,18 @@ type Command = fn(Value) -> Result; fn operation(name: &str) -> Command { match name { - "invocationPlan" => build_invocation_plan_command, - "checkToolVersion" => build_check_tool_version_command, - "resolveOptions" => build_resolve_options_command, - "modelPlan" => build_model_plan_command, - "taskPlan" => build_task_plan_command, - "testPlan" => build_test_plan_command, - "dependencyPlan" => build_dependency_plan_command, - "decodeModel" => build_decode_model_command, - "decodeTestResults" => build_decode_test_results_command, - "decodeDiagnostics" => build_decode_diagnostics_command, - "decodeDependencies" => build_decode_dependencies_command, - "syncFingerprint" => build_sync_fingerprint_command, + "invocationPlan" => build_invocation_plan_command::, + "checkToolVersion" => build_check_tool_version_command::, + "resolveOptions" => build_resolve_options_command::, + "modelPlan" => build_model_plan_command::, + "taskPlan" => build_task_plan_command::, + "testPlan" => build_test_plan_command::, + "dependencyPlan" => build_dependency_plan_command::, + "decodeModel" => build_decode_model_command::, + "decodeTestResults" => build_decode_test_results_command::, + "decodeDiagnostics" => build_decode_diagnostics_command::, + "decodeDependencies" => build_decode_dependencies_command::, + "syncFingerprint" => build_sync_fingerprint_command::, other => panic!("unknown operation {other}"), } } @@ -179,7 +180,7 @@ fn detect(workspace: &TemporaryWorkspace, manifests: Vec, options: Value) .into_iter() .map(|manifest| json!({ "manifest": manifest })) .collect(); - let outcome = build_detect_command(json!({ + let outcome = build_detect_command::(json!({ "workspaceRoot": workspace.root(), "plugins": plugins, "options": options, @@ -299,7 +300,7 @@ fn instance_options_drive_static_run_entries_without_crossing_roots_or_scopes() .write(&format!("{root}/src/main.rs"), "fn main() {}\n"); } let manifest = fixture("build-instance-options.plugin.json"); - let result = build_detect_command(json!({ + let result = build_detect_command::(json!({ "workspaceRoot": workspace.root(), "plugins": [{"manifest":manifest}], "options":{"cargo":{"offline":true,"features":["gui"],"settingsFile":"/fixture/shared.xml"}}, "instanceOptions":{"cargo":{ @@ -765,7 +766,7 @@ fn detection_returns_empty_for_non_directory_and_missing_workspace_roots() { workspace.0.join("missing"), workspace.0.join("Cargo.toml/missing"), ] { - let outcome = build_detect_command(json!({ + let outcome = build_detect_command::(json!({ "workspaceRoot": root.to_string_lossy(), "plugins": [{"manifest": fixture("build-static-file.plugin.json")}], })) @@ -1917,7 +1918,7 @@ fn invalid_tool_path_values_reject_plans_while_the_settings_display_stays_tolera json!({"root":".","platform":"macos","settings":{"toolPath":false}}), ); assert_eq!(result["status"], "rejected", "{result}"); - let displayed = crate::plugins::declarative::resolve_settings_command( + let displayed = lithe_plugin_manifest::resolve_settings_command( json!({"manifest":manifest,"settings":{"toolPath":false}}), ) .unwrap(); @@ -1987,19 +1988,19 @@ fn provider_execution_tasks_are_typed_and_need_a_provider_host() { .remove("scopes"); run["buildSystems"][0]["run"] = json!([{"id":"run", "fromTasks":{"templates":["build"]}}]); assert_eq!(validate(run)["status"], "rejected"); - let escape = build_task_plan_command(json!({ + let escape = build_task_plan_command::(json!({ "manifest":manifest, "buildSystemID":"gradle", "request":{"root":"app","task":"build","project":{"name":"escape","path":"../escape"}} })) .unwrap(); assert_eq!(escape["status"], "rejected"); - let oversized = build_task_plan_command(json!({ + let oversized = build_task_plan_command::(json!({ "manifest":manifest, "buildSystemID":"gradle", "request":{"root":".","task":"custom","userArguments":vec!["arg";65]} })) .unwrap(); assert_eq!(oversized["status"], "rejected"); - let result = build_task_plan_command(json!({ + let result = build_task_plan_command::(json!({ "manifest": manifest, "buildSystemID":"gradle", "request": {"root":".", "task":"build", "project": {"name":"app","path":"app"}, "options":{"offline":true}} })) @@ -2098,7 +2099,7 @@ fn provider_tests_preserve_filters_and_report_ownership() { )) .unwrap(); assert_eq!(validate(manifest.clone())["status"], "ok"); - let plan = build_test_plan_command( + let plan = build_test_plan_command::( json!({"manifest": manifest, "buildSystemID":"gradle", "request":fixture["request"]}), ) .unwrap(); @@ -2119,9 +2120,10 @@ fn provider_tests_preserve_filters_and_report_ownership() { ); // A case-only filter is a typed value, not a CLI expression needing a suite. - let case_only = build_test_plan_command(json!({"manifest":manifest,"buildSystemID":"gradle", + let case_only = + build_test_plan_command::(json!({"manifest":manifest,"buildSystemID":"gradle", "request":{"root":".","case":"exact"}})) - .unwrap(); + .unwrap(); assert_eq!(case_only["status"], "ok", "{case_only}"); for request in [ json!({"root":"app","project":{"name":"escape","path":"../escape"}}), @@ -2129,7 +2131,7 @@ fn provider_tests_preserve_filters_and_report_ownership() { json!({"root":".","suite":"bad\u{0}filter"}), json!({"root":".","userArguments":vec!["arg";65]}), ] { - let rejected = build_test_plan_command( + let rejected = build_test_plan_command::( json!({"manifest":manifest,"buildSystemID":"gradle","request":request}), ) .unwrap(); @@ -2138,8 +2140,10 @@ fn provider_tests_preserve_filters_and_report_ownership() { let mut unsupported = manifest.clone(); unsupported["buildSystems"][0]["test"]["filters"] = json!([]); assert_eq!( - build_test_plan_command(json!({"manifest":unsupported,"buildSystemID":"gradle", - "request":fixture["request"]})) + build_test_plan_command::( + json!({"manifest":unsupported,"buildSystemID":"gradle", + "request":fixture["request"]}) + ) .unwrap()["status"], "rejected" ); @@ -2158,11 +2162,7 @@ fn provider_tests_require_a_provider_host_and_an_owned_process() { "buildSystems":[0],"settings":[0]}})) .unwrap(); assert_eq!(refused["error"]["field"], "requires.buildProviders"); - for (field, value) in [ - ("arguments", json!(["test"])), - ("source", json!("command")), - ("results", json!({"decoder":"hostIntegration"})), - ] { + for (field, value) in [("arguments", json!(["test"])), ("source", json!("command"))] { let mut invalid = manifest.clone(); invalid["buildSystems"][0]["test"][field] = value; assert_eq!(validate(invalid)["status"], "rejected"); @@ -2199,10 +2199,11 @@ fn provider_test_discovery_is_bounded_deterministic_and_runnable() { "fixtures/build-providers/build-test-discovery.json" )) .unwrap(); - let plan = - build_test_discovery_plan_command(json!({"manifest":manifest,"buildSystemID":"gradle", - "request":{"root":".","refresh":true}})) - .unwrap(); + let plan = build_test_discovery_plan_command::( + json!({"manifest":manifest,"buildSystemID":"gradle", + "request":{"root":".","refresh":true}}), + ) + .unwrap(); assert_eq!(plan["status"], "ok", "{plan}"); assert_eq!( plan["plan"]["provider"]["method"], @@ -2210,7 +2211,7 @@ fn provider_test_discovery_is_bounded_deterministic_and_runnable() { ); assert_eq!(plan["plan"]["provider"]["params"]["refresh"], true); let decode = |result: Value| { - build_decode_test_discovery_command(json!({"manifest":manifest, + build_decode_test_discovery_command::(json!({"manifest":manifest, "buildSystemID":"gradle","request":{"root":".","result":result}})) .unwrap() }; @@ -2230,7 +2231,7 @@ fn provider_test_discovery_is_bounded_deterministic_and_runnable() { let mut request = test.clone(); request.as_object_mut().unwrap().remove("id"); request["root"] = json!("."); - let runnable = build_test_plan_command( + let runnable = build_test_plan_command::( json!({"manifest":manifest,"buildSystemID":"gradle","request":request}), ) .unwrap(); @@ -2267,7 +2268,7 @@ fn provider_run_preparation_selects_targets_and_validates_launch_data() { .unwrap(); assert_eq!(validate(manifest.clone())["status"], "ok"); let plan = |request: Value| { - build_prepare_run_plan_command( + build_prepare_run_plan_command::( json!({"manifest":manifest,"buildSystemID":"gradle","request":request}), ) .unwrap() @@ -2315,8 +2316,10 @@ fn provider_run_preparation_selects_targets_and_validates_launch_data() { let workspace = TemporaryWorkspace::new("provider-run-launch"); workspace.write("app/Main.java", "class Main {}"); let decode = |result: Value| { - build_decode_run_preparation_command(json!({"manifest":manifest,"buildSystemID":"gradle", - "request":{"workspaceRoot":workspace.root(),"root":"app","result":result}})) + build_decode_run_preparation_command::( + json!({"manifest":manifest,"buildSystemID":"gradle", + "request":{"workspaceRoot":workspace.root(),"root":"app","result":result}}), + ) .unwrap() }; let valid = decode(fixture["result"].clone()); @@ -2355,7 +2358,7 @@ fn provider_run_working_directory_rejects_external_symlinks() { "fixtures/build-providers/build-provider-run-preparation.plugin.json" )) .unwrap(); - let result=build_decode_run_preparation_command(json!({"manifest":manifest,"buildSystemID":"gradle", + let result=build_decode_run_preparation_command::(json!({"manifest":manifest,"buildSystemID":"gradle", "request":{"workspaceRoot":workspace.root(),"root":".","result":{"executable":{"kind":"command","value":"java"}, "arguments":[],"workingDirectory":"escape","environment":{}}}})).unwrap(); assert_eq!(result["error"]["code"], "plugin.build.invalidOutput"); @@ -2377,7 +2380,7 @@ fn provider_debug_preparation_validates_native_target_and_same_plugin_adapter() )) .unwrap(); assert_eq!(validate(manifest.clone())["status"], "ok"); - let planned = build_prepare_debug_plan_command( + let planned = build_prepare_debug_plan_command::( json!({"manifest":manifest,"buildSystemID":"gradle","request":run["request"]}), ) .unwrap(); @@ -2403,7 +2406,7 @@ fn provider_debug_preparation_validates_native_target_and_same_plugin_adapter() let workspace = TemporaryWorkspace::new("provider-native-debug"); workspace.write("app/bin/sample", "sample"); let decode = |result: Value| { - build_decode_debug_preparation_command(json!({"manifest":manifest,"buildSystemID":"gradle", + build_decode_debug_preparation_command::(json!({"manifest":manifest,"buildSystemID":"gradle", "request":{"workspaceRoot":workspace.root(),"root":"app","run":run["request"]["run"],"result":result}})).unwrap() }; let valid = decode(fixture["result"].clone()); @@ -2443,7 +2446,7 @@ fn provider_debug_preparation_rejects_a_program_symlink_outside_the_instance() { workspace.0.join("app/program"), ) .unwrap(); - let result = build_decode_debug_preparation_command(json!({"manifest":manifest,"buildSystemID":"gradle","request":{ + let result = build_decode_debug_preparation_command::(json!({"manifest":manifest,"buildSystemID":"gradle","request":{ "workspaceRoot":workspace.root(),"root":"app","run":"application","result":{"program":"app/program","arguments":[],"workingDirectory":"app","environment":{}}}})).unwrap(); assert_eq!(result["status"], "rejected"); } @@ -2457,7 +2460,7 @@ fn provider_debug_target_bindings_keep_exact_arguments_and_require_bounded_facts let target = json!({"kind":"executable","path":"/workspace/app/main","workingDirectory":"/workspace/app", "arguments":["a b","x#y"],"environment":{"MODE":"debug"}}); let arguments = |target: Value| { - crate::plugins::declarative::debug_start_arguments_command(json!({"manifest":manifest, + lithe_plugin_manifest::debug_start_arguments_command(json!({"manifest":manifest, "adapterID":"native","request":"launch","facts":{"registration":{"generation":7,"currentGeneration":7},"target":target}})).unwrap() }; let result = arguments(target.clone()); @@ -2512,7 +2515,7 @@ fn model_inputs_project_public_facts_and_preserve_configuration_identity() { "tasks":[{"command":"private"}],"run":[{"command":"private"}],"optionValues":{},"settings":{"secret":"private"}} }]}]}); let resolve = |request: Value| { - build_resolve_model_inputs_command( + build_resolve_model_inputs_command::( json!({"manifest":consumer,"buildSystemID":"gradle","request":request}), ) .unwrap() @@ -2530,7 +2533,7 @@ fn model_inputs_project_public_facts_and_preserve_configuration_identity() { instance["model"]["targets"][0]["tags"], json!(["application"]) ); - let plan = build_task_plan_command(json!({"manifest":consumer,"buildSystemID":"gradle","request":{"root":"app","task":"build","modelInputs":snapshot}})).unwrap(); + let plan = build_task_plan_command::(json!({"manifest":consumer,"buildSystemID":"gradle","request":{"root":"app","task":"build","modelInputs":snapshot}})).unwrap(); assert_eq!(plan["status"], "ok", "{plan}"); assert_eq!(plan["plan"]["provider"]["params"]["modelInputs"], *snapshot); let mut no_sharing = request.clone(); @@ -2616,7 +2619,7 @@ fn model_inputs_reject_invalid_manifests_models_and_snapshot_smuggling() { } _ => model["truncated"] = json!(true), } - let value = build_resolve_model_inputs_command( + let value = build_resolve_model_inputs_command::( json!({"manifest":consumer,"buildSystemID":"gradle","request":request}), ) .unwrap(); @@ -2629,7 +2632,7 @@ fn model_inputs_reject_invalid_manifests_models_and_snapshot_smuggling() { assert_eq!(value["status"], "rejected", "{change}: {value}"); } } - let resolved = build_resolve_model_inputs_command( + let resolved = build_resolve_model_inputs_command::( json!({"manifest":consumer,"buildSystemID":"gradle","request":request}), ) .unwrap(); @@ -2653,7 +2656,7 @@ fn model_inputs_reject_invalid_manifests_models_and_snapshot_smuggling() { "unavailablePayload" => snapshot["inputs"][0]["state"] = json!("unavailable"), _ => snapshot["revision"] = json!(9007199254740992_u64), } - let plan = build_task_plan_command(json!({"manifest":consumer,"buildSystemID":"gradle","request":{"root":"app","task":"build","modelInputs":snapshot}})).unwrap(); + let plan = build_task_plan_command::(json!({"manifest":consumer,"buildSystemID":"gradle","request":{"root":"app","task":"build","modelInputs":snapshot}})).unwrap(); assert_eq!(plan["status"], "rejected", "{change}: {plan}"); } } @@ -2682,7 +2685,7 @@ fn provider_run_options_preserve_argv_and_validate_declared_execution_and_cwd() request["workspaceRoot"] = json!(workspace.root()); request["launchOptions"] = fixture["launchOptions"].clone(); let plan = |manifest: Value, request: Value| { - build_prepare_run_plan_command( + build_prepare_run_plan_command::( json!({"manifest":manifest,"buildSystemID":"gradle","request":request}), ) }; @@ -2725,7 +2728,7 @@ fn provider_run_options_preserve_argv_and_validate_declared_execution_and_cwd() let planned = plan(application_manifest, application).unwrap(); assert_eq!(planned["status"], "ok", "{planned}"); let decode = |options: Value, result: Value| { - build_decode_run_preparation_command(json!({"manifest":manifest,"buildSystemID":"gradle", + build_decode_run_preparation_command::(json!({"manifest":manifest,"buildSystemID":"gradle", "request":{"workspaceRoot":workspace.root(),"root":"app","launchOptions":options,"result":result}})).unwrap() }; let decoded = decode(fixture["launchOptions"].clone(), fixture["result"].clone()); @@ -2772,7 +2775,7 @@ fn provider_connection_debug_options_require_a_declared_registry_type() { request["workspaceRoot"] = json!(workspace.root()); request["launchOptions"] = fixture["launchOptions"].clone(); let plan = |manifest: &Value, request: &Value| { - build_prepare_run_plan_command( + build_prepare_run_plan_command::( json!({"manifest":manifest,"buildSystemID":"gradle","request":request}), ) }; @@ -2836,7 +2839,7 @@ fn provider_run_options_refuse_explicit_cwd_symlinks_outside_workspace() { .unwrap(); fixture["launchOptions"]["workingDirectory"] = json!("escape"); fixture["result"]["workingDirectory"] = json!("escape"); - let result = build_decode_run_preparation_command(json!({"manifest":manifest,"buildSystemID":"gradle", + let result = build_decode_run_preparation_command::(json!({"manifest":manifest,"buildSystemID":"gradle", "request":{"workspaceRoot":workspace.root(),"root":"app","launchOptions":fixture["launchOptions"],"result":fixture["result"]}})).unwrap(); assert_eq!(result["error"]["code"], "plugin.build.invalidOutput"); } diff --git a/rust/crates/core/src/tests/core_request_traces.rs b/rust/crates/core/src/tests/core_request_traces.rs index bbec0afb..699388c2 100644 --- a/rust/crates/core/src/tests/core_request_traces.rs +++ b/rust/crates/core/src/tests/core_request_traces.rs @@ -1,7 +1,7 @@ use super::support::temporary_root; -use crate::diagnostics::core_events::{capture, CoreEvent}; -use crate::diagnostics::record::LogLevel; use crate::execute_json; +use lithe_diagnostics::core_events::{capture, CoreEvent}; +use lithe_diagnostics::record::LogLevel; use serde_json::{json, Value}; use std::fs; use std::path::{Path, PathBuf}; diff --git a/rust/crates/core/src/tests/declarative_plugins.rs b/rust/crates/core/src/tests/declarative_plugins.rs index 78bf79a4..192a7d35 100644 --- a/rust/crates/core/src/tests/declarative_plugins.rs +++ b/rust/crates/core/src/tests/declarative_plugins.rs @@ -1,4 +1,4 @@ -use crate::plugins::declarative::{ +use lithe_plugin_manifest::{ discover_tests_command, language_server_launch_plan_command, resolve_settings_command, run_plan_command, test_plan_command, validate_command, workspace_configuration_command, }; @@ -162,9 +162,9 @@ fn bundled_publisher_trust_requires_an_explicit_host_capability() { bad["vendor"][field] = json!(value); assert_eq!(validate(bad)["status"], "rejected", "{field}: {value}"); } - let mut community = manifest.clone(); - community["id"] = json!("io.example.fixture"); - assert_eq!(validate(community)["status"], "rejected"); + let mut unofficial = manifest.clone(); + unofficial["id"] = json!("io.example.fixture"); + assert_eq!(validate(unofficial)["status"], "rejected"); let mut missing = manifest.clone(); missing["requires"] .as_object_mut() @@ -484,7 +484,7 @@ fn build_providers_require_their_own_host_capability() { /// through its initialization options. #[test] fn runtime_launch_and_initialization_options_are_reviewed_programs() { - let outcome = crate::plugins::marketplace::install_review_command(json!({ + let outcome = lithe_plugin_marketplace::install_review_command(json!({ "manifest": fixture("publisher-bundled.plugin.json"), })) .expect("payload is well formed"); @@ -499,7 +499,7 @@ fn runtime_launch_and_initialization_options_are_reviewed_programs() { assert_eq!(server["packagedRuntime"], "runtime/${arch}/bin/java"); assert!(programs.iter().all(|program| program["kind"] != "runtime")); - let outcome = crate::plugins::marketplace::install_review_command(json!({ + let outcome = lithe_plugin_marketplace::install_review_command(json!({ "manifest": fixture("node-runtime.plugin.json"), })) .expect("payload is well formed"); @@ -681,7 +681,7 @@ fn plugin_backend_is_planned_without_a_workspace_and_names_its_host_services() { })) .is_err()); - let review = crate::plugins::marketplace::install_review_command(json!({ + let review = lithe_plugin_marketplace::install_review_command(json!({ "manifest": fixture("backend.plugin.json"), })) .expect("payload is well formed"); @@ -1349,7 +1349,7 @@ fn debug_adapter_console_commands_are_sorted_for_the_host() { } fn debug_start(request: &str, target: Value) -> Value { - crate::plugins::declarative::debug_start_arguments_command(json!({ + lithe_plugin_manifest::debug_start_arguments_command(json!({ "manifest": fixture("debug-adapter.plugin.json"), "adapterID": "example", "request": request, @@ -1507,7 +1507,7 @@ fn debug_adapter_inside_a_backend_launched_language_server_plans_without_scratch #[test] fn debug_adapter_inside_the_language_server_is_a_reviewed_program() { - let review = crate::plugins::marketplace::install_review_command(json!({ + let review = lithe_plugin_marketplace::install_review_command(json!({ "manifest": fixture("debug-adapter-language-server.plugin.json"), })) .expect("payload is well formed"); @@ -1627,7 +1627,7 @@ fn debug_start_arguments_fill_bindings_without_overriding_defaults() { } fn remote_start_with(manifest: Value, adapter: &str, request: &str, target: Value) -> Value { - crate::plugins::declarative::debug_start_arguments_command(json!({ + lithe_plugin_manifest::debug_start_arguments_command(json!({ "manifest": manifest, "adapterID": adapter, "request": request, @@ -1984,7 +1984,7 @@ fn debug_adapter_launch_plan_says_which_adapters_attach_to_a_remote_endpoint() { } fn debug_start_native(target: Value) -> Value { - crate::plugins::declarative::debug_start_arguments_command(json!({ + lithe_plugin_manifest::debug_start_arguments_command(json!({ "manifest": fixture("debug-adapter.plugin.json"), "adapterID": "native", "request": "launch", @@ -2004,8 +2004,7 @@ fn debug_executable_plan(adapter: &str, extra: Value) -> Value { for (key, value) in extra.as_object().unwrap() { payload[key] = value.clone(); } - crate::plugins::declarative::debug_executable_plan_command(payload) - .expect("payload is well formed") + lithe_plugin_manifest::debug_executable_plan_command(payload).expect("payload is well formed") } #[test] @@ -2122,7 +2121,7 @@ fn debug_executable_plan_is_empty_for_an_adapter_without_a_lookup() { #[test] fn debug_start_arguments_put_a_backend_configuration_on_top() { let start = |configuration: Value| { - crate::plugins::declarative::debug_start_arguments_command(json!({ + lithe_plugin_manifest::debug_start_arguments_command(json!({ "manifest": fixture("debug-adapter.plugin.json"), "adapterID": "example", "request": "launch", @@ -2154,7 +2153,7 @@ fn debug_start_arguments_put_a_backend_configuration_on_top() { fn debug_start_arguments_refuse_stale_registrations_and_missing_target_facts() { let missing = debug_start("launch", json!({ "kind": "file" })); assert_eq!(missing["error"]["code"], "debug.target.invalid"); - let stale = crate::plugins::declarative::debug_start_arguments_command(json!({ + let stale = lithe_plugin_manifest::debug_start_arguments_command(json!({ "manifest": fixture("debug-adapter.plugin.json"), "adapterID": "example", "request": "launch", diff --git a/rust/crates/core/src/tests/diagnostics_dispatch.rs b/rust/crates/core/src/tests/diagnostics_dispatch.rs new file mode 100644 index 00000000..42df04eb --- /dev/null +++ b/rust/crates/core/src/tests/diagnostics_dispatch.rs @@ -0,0 +1,134 @@ +//! The `diagnostics.*` commands through the JSON dispatcher: request shapes, +//! the domain error envelope and the stable error codes the hosts rely on. +//! The logic itself is tested in `lithe-diagnostics`. + +use crate::execute_json; +use serde_json::{json, Value}; + +fn fixture() -> Value { + serde_json::from_str(include_str!( + "../../../../fixtures/diagnostics/logging-v2.json" + )) + .unwrap() +} + +#[test] +fn collection_plan_dispatches_with_the_domain_envelope() { + let request = json!({"version":1,"command":"diagnostics.planLogCollection","payload":{ + "files":[],"currentSession":"","session":"all"}}); + let response: Value = serde_json::from_str(&execute_json(&request.to_string())).unwrap(); + assert_eq!(response["data"]["result"]["reads"], json!([])); +} + +#[test] +fn diagnostics_v2_commands_dispatch_with_stable_domain_errors() { + let cases = [ + ("diagnostics.encodeLogBatch", json!({"events":[]})), + ("diagnostics.queryLogsV2", json!({"sources":[]})), + ("diagnostics.parseLogFilter", json!({"expression":""})), + ( + "diagnostics.composeLogFilter", + json!({"expression":"","term":{"kind":"text","value":"test"}}), + ), + ( + "diagnostics.formatLogRecords", + json!({"lines":[],"format":"json"}), + ), + ( + "diagnostics.planLogRetention", + json!({"currentSession":"", "now":"2026-11-03T00:00:00Z"}), + ), + ( + "diagnostics.resolveLogChannel", + json!({"channel":"vcs/git"}), + ), + ]; + for (command, payload) in cases { + let response: Value = serde_json::from_str(&execute_json( + &json!({"id":"fixture", "version":1, "command":command, "payload":payload}).to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], true, "{response}"); + assert!(response["data"].get("error").is_none(), "{response}"); + } + let response: Value = serde_json::from_str(&execute_json(&json!({"version":1,"command":"diagnostics.resolveLogChannel","payload":{"channel":"vcs/.."}}).to_string())).unwrap(); + assert_eq!(response["data"]["error"]["code"], -32033); +} + +#[test] +fn shared_window_commands_cover_dispatch_shapes_and_unique_errors() { + let fixture = fixture(); + let mut seen_codes = std::collections::BTreeSet::new(); + let mut seen_commands = std::collections::BTreeSet::new(); + for case in fixture["windowCommands"].as_array().unwrap() { + let mut payload = case["request"].clone(); + if let Some(count) = case["repeatCount"].as_u64() { + payload["lines"] = json!(vec![case["repeatLine"].as_str().unwrap(); count as usize]); + } + let response: Value = serde_json::from_str(&execute_json( + &json!({"version":1,"command":case["command"],"payload":payload}).to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], true, "{response}"); + if let Some(code) = case["code"].as_i64() { + assert_eq!( + response["data"]["error"]["code"], code, + "{case}: {response}" + ); + assert!(fixture["errorCodes"][code.to_string()].is_string()); + seen_codes.insert(code); + } else { + // parse returns diagnostics directly; other pure v2 commands use the domain envelope. + let actual = if case["command"] == "diagnostics.parseLogFilter" + || case["command"] == "diagnostics.buildManifest" + { + &response["data"] + } else { + &response["data"]["result"] + }; + assert_eq!(actual, &case["expected"], "{case}: {response}"); + } + seen_commands.insert(case["command"].as_str().unwrap()); + } + for command in [ + "diagnostics.parseLogFilter", + "diagnostics.composeLogFilter", + "diagnostics.formatLogRecords", + ] { + assert!(seen_commands.contains(command)); + } + for code in [-32060, -32061, -32062, -32063] { + assert!(seen_codes.contains(&code)); + } +} + +#[test] +fn engine_logging_commands_route_through_the_dispatcher() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../fixtures/diagnostics/engine-logging-v2.json" + )) + .unwrap(); + for name in ["trace", "drain", "resolveSubject"] { + assert!(crate::CoreCommand::parse(fixture[name]["command"].as_str().unwrap()).is_some()); + } + let response: Value = + serde_json::from_str(&execute_json(&fixture["resolveSubject"].to_string())).unwrap(); + assert_eq!(response["data"], fixture["resolveSubject"]["result"]); + let response: Value = + serde_json::from_str(&execute_json(&fixture["drain"].to_string())).unwrap(); + assert_eq!(response["ok"], true); + assert!(response["data"]["events"].is_array()); + assert!(response["data"]["dropped"].as_u64().is_some()); + for payload in [ + json!({}), + json!({"role":"unknown", "providerId":"id"}), + json!({"role":"language_server", "providerId": 5}), + ] { + let response: Value = serde_json::from_str(&execute_json( + &json!({"version":1, "command":"diagnostics.resolveEngineLogSubject", "payload":payload}) + .to_string(), + )) + .unwrap(); + assert_eq!(response["error"]["code"], "invalid_request"); + } +} diff --git a/rust/crates/core/src/tests/document_uri.rs b/rust/crates/core/src/tests/document_uri.rs new file mode 100644 index 00000000..4844d069 --- /dev/null +++ b/rust/crates/core/src/tests/document_uri.rs @@ -0,0 +1,41 @@ +use serde_json::{json, Value}; + +#[test] +fn document_uri_matches_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../fixtures/lsp/document-uri-v1.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let response: Value = serde_json::from_str(&crate::execute_json( + &json!({"command":"lsp.documentUri", "payload":{ + "path":case["path"], "platform":case["platform"] + }}) + .to_string(), + )) + .unwrap(); + if case["uri"].is_null() { + assert_eq!(response["ok"], false, "{}: {response}", case["name"]); + } else { + assert_eq!(response["ok"], true, "{}: {response}", case["name"]); + assert_eq!(response["data"]["uri"], case["uri"], "{}", case["name"]); + } + } +} + +#[test] +fn document_uri_accepts_non_ascii_windows_extended_length_paths() { + let path = format!("C:\\{}\\Main.txt", vec!["目".repeat(100); 120].join("\\")); + assert!(path.len() > 32_768); + assert!(path.encode_utf16().count() < 32_767); + let response: Value = serde_json::from_str(&crate::execute_json( + &json!({"command":"lsp.documentUri", "payload":{"path":path,"platform":"windows"}}) + .to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], true); + assert!(response["data"]["uri"] + .as_str() + .unwrap() + .starts_with("file:///C:/")); +} diff --git a/rust/crates/core/src/tests/editor_dispatch.rs b/rust/crates/core/src/tests/editor_dispatch.rs new file mode 100644 index 00000000..2da5cec5 --- /dev/null +++ b/rust/crates/core/src/tests/editor_dispatch.rs @@ -0,0 +1,137 @@ +//! The `editor.*` commands through the JSON dispatcher: shared fixtures for +//! navigation input, text edit plans and navigation history, and the request +//! envelope for malformed payloads. + +use serde_json::{json, Value}; + +#[test] +fn navigation_input_matches_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../fixtures/editor/navigation-input-v1.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let response: Value = serde_json::from_str(&crate::execute_json( + &json!({"command":"editor.parseNavigationInput", "payload":{"text":case["input"]}}) + .to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], true, "{}: {response}", case["name"]); + assert_eq!( + response["data"]["target"], case["target"], + "{}", + case["name"] + ); + } +} + +/// Plugin edit plans follow the shared fixture, and applying a plan's +/// document-separator text in reverse order yields the fixture's result. +#[test] +fn text_edit_plan_matches_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../fixtures/editor/text-edit-plan-v1.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let mut payload = json!({"text": case["text"], "edits": case["edits"]}); + if let Some(positions) = case.get("positions") { + payload["positions"] = positions.clone(); + } + let response: Value = serde_json::from_str(&crate::execute_json( + &json!({"command":"editor.planTextEdits", "payload": payload}).to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], true, "{}: {response}", case["name"]); + assert_eq!(response["data"], case["expected"], "{}", case["name"]); + if let Some(expected) = case["expectedText"].as_str() { + let mut units: Vec = case["text"].as_str().unwrap().encode_utf16().collect(); + for edit in response["data"]["edits"].as_array().unwrap().iter().rev() { + let offset = edit["utf16Offset"].as_u64().unwrap() as usize; + let length = edit["utf16Length"].as_u64().unwrap() as usize; + let replacement: Vec = edit["text"].as_str().unwrap().encode_utf16().collect(); + units.splice(offset..offset + length, replacement); + } + assert_eq!( + String::from_utf16(&units).unwrap(), + expected, + "{}", + case["name"] + ); + } + } +} + +#[test] +fn text_edit_plan_rejects_malformed_requests() { + for payload in [ + json!({"edits": []}), + json!({"text": "a", "edits": [{"range": {"start": {"line": -1, "column": 1}, "end": {"line": 1, "column": 1}}, "text": ""}]}), + json!({"text": "a", "unknown": true}), + ] { + let response: Value = serde_json::from_str(&crate::execute_json( + &json!({"command":"editor.planTextEdits", "payload": payload}).to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], false, "{payload}"); + assert_eq!(response["error"]["code"], "invalid_request"); + } +} + +fn navigation_history(payload: Value) -> Value { + serde_json::from_str(&crate::execute_json( + &json!({"command":"editor.navigationHistory.reduce", "payload":payload}).to_string(), + )) + .unwrap() +} + +#[test] +fn navigation_history_matches_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../fixtures/editor/navigation-history-v1.json" + )) + .unwrap(); + assert_eq!( + fixture["maxEntries"], + lithe_editor::navigation_history::MAX_HISTORY_ENTRIES + ); + for case in fixture["cases"].as_array().unwrap() { + let response = navigation_history(case["request"].clone()); + if let Some(code) = case.get("expectedError") { + assert_eq!(response["ok"], false, "{}: {response}", case["name"]); + assert_eq!(response["error"]["code"], *code, "{}", case["name"]); + continue; + } + assert_eq!(response["ok"], true, "{}: {response}", case["name"]); + assert_eq!(response["data"], case["expected"], "{}", case["name"]); + } +} + +// Both stacks are bounded so a long editing session cannot grow history (or +// the IPC payload that carries it) without limit; the oldest entries go first. +#[test] +fn navigation_history_keeps_only_the_newest_entries() { + let limit = lithe_editor::navigation_history::MAX_HISTORY_ENTRIES as u64; + let back: Vec = (0..limit) + .map(|line| json!({"document":"a", "line":line, "column":0})) + .collect(); + let response = navigation_history(json!({ + "state": {"back": back, "forward": []}, + "event": {"type":"record", "departure":{"document":"b", "line":0, "column":0}} + })); + let back = response["data"]["state"]["back"].as_array().unwrap(); + assert_eq!(back.len() as u64, limit); + assert_eq!(back[0]["line"], 1); + assert_eq!(back[back.len() - 1]["document"], "b"); + + let oversized: Vec = (0..limit + 5) + .map(|line| json!({"document":"a", "line":line, "column":0})) + .collect(); + let response = navigation_history(json!({ + "state": {"back": [], "forward": oversized}, + "event": {"type":"back", "current":null} + })); + let forward = response["data"]["state"]["forward"].as_array().unwrap(); + assert_eq!(forward.len() as u64, limit); + assert_eq!(forward[0]["line"], 5); +} diff --git a/rust/crates/core/src/tests/execution_commands.rs b/rust/crates/core/src/tests/execution_commands.rs new file mode 100644 index 00000000..f9ac168b --- /dev/null +++ b/rust/crates/core/src/tests/execution_commands.rs @@ -0,0 +1,73 @@ +//! Execution and run-configuration commands answered through the dispatcher. + +use serde_json::Value; + +#[test] +fn argument_text_command_formats_a_batch_from_the_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../fixtures/run-configuration/argument-text.json" + )) + .unwrap(); + let cases = fixture["cases"].as_array().unwrap(); + let argument_lists: Vec<_> = cases.iter().map(|case| case["values"].clone()).collect(); + let expected: Vec<_> = cases.iter().map(|case| case["text"].clone()).collect(); + let request = serde_json::json!({ + "id": "argument-text", "command": "runConfig.argumentText", + "payload": {"argumentLists": argument_lists} + }); + let response: Value = serde_json::from_str(&crate::execute_json(&request.to_string())).unwrap(); + assert_eq!(response["ok"], true, "{response}"); + assert_eq!(response["data"], Value::Array(expected)); +} + +#[test] +fn context_selection_command_matches_shared_provider_neutral_cases() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../fixtures/run-configuration/context-selection.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let request = serde_json::json!({ + "id": "context-selection", "command": "runConfig.selectContextConfiguration", + "payload": case["request"] + }); + let response: Value = + serde_json::from_str(&crate::execute_json(&request.to_string())).unwrap(); + assert_eq!(response["ok"], true, "{}: {response}", case["name"]); + assert_eq!( + response["data"]["configurationId"], case["expected"], + "{}", + case["name"] + ); + } +} + +#[test] +fn search_path_command_follows_the_shared_order_and_identity_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../fixtures/execution/search-path-v1.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let request = serde_json::json!({"id":"search-path", "command":"execution.resolveSearchPath", "payload":case["request"]}); + let response: Value = + serde_json::from_str(&crate::execute_json(&request.to_string())).unwrap(); + assert_eq!(response["ok"], true, "{}: {response}", case["name"]); + assert_eq!(response["data"], case["expected"], "{}", case["name"]); + } +} + +#[test] +fn environment_command_follows_the_shared_precedence_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../fixtures/execution/launch-environment-v1.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let request = serde_json::json!({"id":"environment", "command":"execution.resolveEnvironment", "payload":case["request"]}); + let response: Value = + serde_json::from_str(&crate::execute_json(&request.to_string())).unwrap(); + assert_eq!(response["ok"], true, "{}: {response}", case["name"]); + assert_eq!(response["data"], case["expected"], "{}", case["name"]); + } +} diff --git a/rust/crates/core/src/tests/file_name_order.rs b/rust/crates/core/src/tests/file_name_order.rs index 27cdec7e..1473e3c7 100644 --- a/rust/crates/core/src/tests/file_name_order.rs +++ b/rust/crates/core/src/tests/file_name_order.rs @@ -1,96 +1,8 @@ use super::support::temporary_root; use crate::execute_json; -use crate::project::file_name_order::compare_file_names; use serde_json::Value; -use std::cmp::Ordering; use std::fs; -fn fixture() -> Value { - serde_json::from_str(include_str!( - "../../../../fixtures/workspace/file-name-order-v1.json" - )) - .expect("file-name order fixture should be JSON") -} - -#[test] -fn shared_fixture_groups_sort_strictly_in_the_listed_order() { - let fixture = fixture(); - for group in fixture["orderedGroups"].as_array().unwrap() { - let names = group["names"] - .as_array() - .unwrap() - .iter() - .map(|name| name.as_str().unwrap()) - .collect::>(); - for (index, earlier) in names.iter().enumerate() { - for later in &names[index + 1..] { - assert_eq!( - compare_file_names(earlier, later), - Ordering::Less, - "{}: {earlier:?} should precede {later:?}", - group["id"] - ); - assert_eq!( - compare_file_names(later, earlier), - Ordering::Greater, - "{}: {later:?} should follow {earlier:?}", - group["id"] - ); - } - } - } -} - -// Rust's sort may panic on an inconsistent order, so the port must stay a total -// preorder even for the characters upstream treats specially: spaces, zeroes, -// digits, '#', '-', '_' and mixed-case letters. The generator is a fixed LCG, -// so a failure reproduces exactly. -#[test] -fn comparison_is_antisymmetric_and_transitive_for_special_characters() { - const ALPHABET: [char; 11] = [' ', '0', '1', '2', 'a', 'A', 'b', '-', '_', '#', '.']; - let mut state = 0x2545_F491_4F6C_DD1Du64; - let mut next = |bound: usize| { - state = state - .wrapping_mul(6_364_136_223_846_793_005) - .wrapping_add(1_442_695_040_888_963_407); - ((state >> 33) as usize) % bound - }; - let names = (0..72) - .map(|_| { - let length = next(6); - (0..length) - .map(|_| ALPHABET[next(ALPHABET.len())]) - .collect::() - }) - .collect::>(); - - for left in &names { - for right in &names { - assert_eq!( - compare_file_names(left, right), - compare_file_names(right, left).reverse(), - "{left:?} against {right:?}" - ); - } - } - for first in &names { - for second in &names { - if compare_file_names(first, second) == Ordering::Greater { - continue; - } - for third in &names { - if compare_file_names(second, third) != Ordering::Greater { - assert_ne!( - compare_file_names(first, third), - Ordering::Greater, - "{first:?} <= {second:?} <= {third:?}" - ); - } - } - } - } -} - #[test] fn list_directories_orders_versioned_names_by_number_with_directories_first() { let root = temporary_root("natural-directory-order"); diff --git a/rust/crates/core/src/tests/fixtures/community_build_plugins/gradle/app-dependencies.json b/rust/crates/core/src/tests/fixtures/published_build_plugins/gradle/app-dependencies.json similarity index 100% rename from rust/crates/core/src/tests/fixtures/community_build_plugins/gradle/app-dependencies.json rename to rust/crates/core/src/tests/fixtures/published_build_plugins/gradle/app-dependencies.json diff --git a/rust/crates/core/src/tests/fixtures/community_build_plugins/gradle/dependencies.json b/rust/crates/core/src/tests/fixtures/published_build_plugins/gradle/dependencies.json similarity index 100% rename from rust/crates/core/src/tests/fixtures/community_build_plugins/gradle/dependencies.json rename to rust/crates/core/src/tests/fixtures/published_build_plugins/gradle/dependencies.json diff --git a/rust/crates/core/src/tests/fixtures/community_build_plugins/gradle/model.json b/rust/crates/core/src/tests/fixtures/published_build_plugins/gradle/model.json similarity index 100% rename from rust/crates/core/src/tests/fixtures/community_build_plugins/gradle/model.json rename to rust/crates/core/src/tests/fixtures/published_build_plugins/gradle/model.json diff --git a/rust/crates/core/src/tests/fixtures/community_build_plugins/gradle/plugin.json b/rust/crates/core/src/tests/fixtures/published_build_plugins/gradle/plugin.json similarity index 100% rename from rust/crates/core/src/tests/fixtures/community_build_plugins/gradle/plugin.json rename to rust/crates/core/src/tests/fixtures/published_build_plugins/gradle/plugin.json diff --git a/rust/crates/core/src/tests/fixtures/community_build_plugins/maven/app-dependencies.json b/rust/crates/core/src/tests/fixtures/published_build_plugins/maven/app-dependencies.json similarity index 100% rename from rust/crates/core/src/tests/fixtures/community_build_plugins/maven/app-dependencies.json rename to rust/crates/core/src/tests/fixtures/published_build_plugins/maven/app-dependencies.json diff --git a/rust/crates/core/src/tests/fixtures/community_build_plugins/maven/dependencies.json b/rust/crates/core/src/tests/fixtures/published_build_plugins/maven/dependencies.json similarity index 100% rename from rust/crates/core/src/tests/fixtures/community_build_plugins/maven/dependencies.json rename to rust/crates/core/src/tests/fixtures/published_build_plugins/maven/dependencies.json diff --git a/rust/crates/core/src/tests/fixtures/community_build_plugins/maven/model.json b/rust/crates/core/src/tests/fixtures/published_build_plugins/maven/model.json similarity index 100% rename from rust/crates/core/src/tests/fixtures/community_build_plugins/maven/model.json rename to rust/crates/core/src/tests/fixtures/published_build_plugins/maven/model.json diff --git a/rust/crates/core/src/tests/fixtures/community_build_plugins/maven/plugin.json b/rust/crates/core/src/tests/fixtures/published_build_plugins/maven/plugin.json similarity index 100% rename from rust/crates/core/src/tests/fixtures/community_build_plugins/maven/plugin.json rename to rust/crates/core/src/tests/fixtures/published_build_plugins/maven/plugin.json diff --git a/rust/crates/core/src/tests/git.rs b/rust/crates/core/src/tests/git.rs index 6358abfe..2175bfe7 100644 --- a/rust/crates/core/src/tests/git.rs +++ b/rust/crates/core/src/tests/git.rs @@ -266,7 +266,7 @@ fn git_status_returns_contract_shape() { // Discovery reports canonical roots in plain native form: on Windows the // verbatim `\\?\` prefix from canonicalization must not leak to consumers. fn reported_repository_path(path: &Path) -> String { - crate::git::simplified_canonical_path( + lithe_git::simplified_canonical_path( path.canonicalize() .expect("discovered repository should exist"), ) @@ -1834,7 +1834,7 @@ fn git_worktrees_lists_primary_linked_and_locked_metadata() { assert_eq!(worktrees[0]["isCurrent"], true); assert_eq!( worktrees[1]["path"], - crate::git::simplified_canonical_path( + lithe_git::simplified_canonical_path( destination .canonicalize() .expect("linked worktree should canonicalize") @@ -5865,7 +5865,7 @@ charlie // cannot silently degrade to plain LF content. let crlf_snapshot = String::from("alpha\r\nbravo-two\r\ncharlie\r\n"); assert!( - crlf_snapshot.as_bytes().iter().any(|byte| *byte == b'\r'), + crlf_snapshot.as_bytes().contains(&b'\r'), "fixture must carry real carriage returns" ); let response = line_history_query(&root, "doc.txt", &crlf_snapshot, 2, 2); diff --git a/rust/crates/core/src/tests/git_change_targets.rs b/rust/crates/core/src/tests/git_change_targets.rs new file mode 100644 index 00000000..9479f4c0 --- /dev/null +++ b/rust/crates/core/src/tests/git_change_targets.rs @@ -0,0 +1,37 @@ +//! `git.normalizeChangeTargets` against the shared fixture. + +use serde_json::{json, Value}; + +fn call(payload: Value) -> Value { + serde_json::from_str(&crate::execute_json( + &json!({"id":"targets", "command":"git.normalizeChangeTargets", "payload":payload}) + .to_string(), + )) + .unwrap() +} + +#[test] +fn shared_fixture_cases_keep_only_operation_targets() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../fixtures/git/change-targets-v1.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let response = call(case["request"].clone()); + assert_eq!(response["ok"], true, "{}: {response}", case["name"]); + assert_eq!( + response["data"]["indices"], case["indices"], + "{}", + case["name"] + ); + } + for case in fixture["invalid"].as_array().unwrap() { + let response = call(case["request"].clone()); + assert_eq!(response["ok"], false, "{}", case["name"]); + assert_eq!( + response["error"]["code"], "invalid_request", + "{}", + case["name"] + ); + } +} diff --git a/rust/crates/core/src/tests/git_diff_blocks.rs b/rust/crates/core/src/tests/git_diff_blocks.rs index 8d33fba4..193ab730 100644 --- a/rust/crates/core/src/tests/git_diff_blocks.rs +++ b/rust/crates/core/src/tests/git_diff_blocks.rs @@ -2,7 +2,7 @@ use super::support::temporary_root; use crate::execute_json; -use crate::git::diff_blocks_fixture as fixture_support; +use lithe_git::test_support::diff_blocks as fixture_support; use serde_json::{json, Value}; use std::fs; use std::path::PathBuf; diff --git a/rust/crates/core/src/tests/git_fetch_plan.rs b/rust/crates/core/src/tests/git_fetch_plan.rs new file mode 100644 index 00000000..5182a7d0 --- /dev/null +++ b/rust/crates/core/src/tests/git_fetch_plan.rs @@ -0,0 +1,61 @@ +//! `git.fetchPlan` against the shared fixture, and option validation before any repository access. + +use serde_json::{json, Value}; + +fn fixture() -> Value { + serde_json::from_str(include_str!("../../../../fixtures/git/fetch-plan-v1.json")).unwrap() +} + +fn request(payload: Value) -> Value { + serde_json::from_str(&crate::execute_json( + &json!({ + "id": "fetch-plan-test", "command": "git.fetchPlan", "payload": payload + }) + .to_string(), + )) + .unwrap() +} + +#[test] +fn fetch_plans_match_shared_choices_without_accessing_a_repository() { + for case in fixture()["cases"].as_array().unwrap() { + let response = request(json!({"options": case["options"]})); + assert_eq!(response["ok"], true, "{response}"); + assert_eq!(response["data"]["options"], case["expectedOptions"]); + assert_eq!(response["data"]["arguments"], case["arguments"]); + } +} + +#[test] +fn fetch_rejects_sources_and_arguments_in_the_remote_name_field() { + for remote in fixture()["invalidRemotes"].as_array().unwrap() { + let response = request(json!({"options": {"remote": remote}})); + assert_eq!( + response["error"]["code"], "invalid_request", + "{remote}: {response}" + ); + } +} + +#[test] +fn fetch_rejects_unknown_settings_instead_of_silently_ignoring_them() { + for options in [ + json!({"prune": "false"}), + json!({"submodules": "sometimes"}), + json!({"arguments": ["--force"]}), + json!({"credentialHelper": ""}), + ] { + assert_eq!( + request(json!({"options": options}))["error"]["code"], + "invalid_request" + ); + } + // Misplaced options must fail before repository lookup or any mutation. + let response: Value = serde_json::from_str(&crate::execute_json(&json!({ + "command": "git.write", "payload": {"root": "", "operation": "commit", "fetchOptions": {}} + }).to_string())).unwrap(); + assert_eq!( + response["error"]["message"], + "Fetch options require a fetch operation" + ); +} diff --git a/rust/crates/core/src/tests/git_file_context.rs b/rust/crates/core/src/tests/git_file_context.rs new file mode 100644 index 00000000..6b07b4e1 --- /dev/null +++ b/rust/crates/core/src/tests/git_file_context.rs @@ -0,0 +1,57 @@ +//! `git.resolveFileContext` routes a path to the innermost repository that owns it. + +use super::support::temporary_root; +use crate::execute_json; +use serde_json::{json, Value}; +use std::fs; +use std::path::PathBuf; +use std::process::Command; + +struct Workspace(PathBuf); + +impl Workspace { + fn new() -> Self { + let workspace = Self(temporary_root("file-context")); + for child in ["first", "first/nested"] { + let directory = workspace.0.join(child); + fs::create_dir_all(&directory).unwrap(); + assert!(Command::new("git") + .args(["init", "-q"]) + .current_dir(directory) + .status() + .unwrap() + .success()); + } + workspace + } + + fn root(&self, name: &str) -> String { + lithe_git::simplified_canonical_path(self.0.join(name).canonicalize().unwrap()) + .unwrap() + .to_string_lossy() + .replace('\\', "/") + } +} + +impl Drop for Workspace { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +#[test] +fn routes_a_nested_path_to_the_nested_repository() { + let workspace = Workspace::new(); + let response: Value = serde_json::from_str(&execute_json( + &json!({ + "id": "resolve", "command": "git.resolveFileContext", + "payload": {"root": workspace.root("first"), "path": "nested/file.txt"} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], true, "{response}"); + assert_eq!(response["data"]["root"], workspace.root("first/nested")); + assert_eq!(response["data"]["path"], "file.txt"); + assert_eq!(response["data"]["ownership"], "git"); +} diff --git a/rust/crates/core/src/tests/git_graph_projection.rs b/rust/crates/core/src/tests/git_graph_projection.rs new file mode 100644 index 00000000..017f919c --- /dev/null +++ b/rust/crates/core/src/tests/git_graph_projection.rs @@ -0,0 +1,70 @@ +//! `git.graphProjection` against the shared fixture, including color stability across pages. + +use serde_json::{json, Value}; + +fn call(payload: Value) -> Value { + serde_json::from_str(&crate::execute_json( + &json!({"id":"graph", "command":"git.graphProjection", "payload":payload}).to_string(), + )) + .unwrap() +} + +fn shared_fixture() -> Value { + serde_json::from_str(include_str!( + "../../../../fixtures/git/graph-projection-v1.json" + )) + .unwrap() +} + +#[test] +fn shared_fixture_projects_the_exact_response() { + let fixture = shared_fixture(); + let cases = fixture["cases"].as_array().unwrap(); + assert!(cases.len() >= 20, "the fixture must keep its scenarios"); + for case in cases { + let response = call(case["request"].clone()); + assert_eq!(response["ok"], true, "{}: {response}", case["name"]); + assert_eq!(response["data"], case["response"], "{}", case["name"]); + } +} + +#[test] +fn shared_fixture_keeps_a_named_branch_color_across_pages() { + let fixture = shared_fixture(); + let cases = fixture["cases"].as_array().unwrap(); + for sequence in fixture["stability"].as_array().unwrap() { + let expected = &sequence["expectedColorId"]; + assert!(!expected.is_null()); + for step in sequence["steps"].as_array().unwrap() { + let case = cases + .iter() + .find(|case| case["name"] == step["case"]) + .unwrap(); + let response = call(case["request"].clone()); + let row = response["data"]["rows"] + .as_array() + .unwrap() + .iter() + .find(|row| row["commitHash"] == step["hash"]) + .unwrap(); + assert_eq!( + &row["colorId"], expected, + "{} / {}", + sequence["name"], step["case"] + ); + } + } +} + +#[test] +fn shared_fixture_rejects_invalid_requests() { + for case in shared_fixture()["invalid"].as_array().unwrap() { + let response = call(case["request"].clone()); + assert_eq!(response["ok"], false, "{}", case["name"]); + assert_eq!( + response["error"]["code"], "invalid_request", + "{}", + case["name"] + ); + } +} diff --git a/rust/crates/core/src/tests/git_history_rewrite.rs b/rust/crates/core/src/tests/git_history_rewrite.rs index 5bbd23e9..0021106f 100644 --- a/rust/crates/core/src/tests/git_history_rewrite.rs +++ b/rust/crates/core/src/tests/git_history_rewrite.rs @@ -924,7 +924,7 @@ fn native_rebase_rejects_escaped_manifest_overflow_before_replacing_session() { #[test] fn native_rebase_large_escaped_manifest_remains_readable_through_abort() { - use crate::git::{ + use lithe_git::{ rebase_control, rebase_session, rebase_start, GitRebaseControlRequest, GitRebaseSessionRequest, GitRebaseStartRequest, }; diff --git a/rust/crates/core/src/tests/git_history_window.rs b/rust/crates/core/src/tests/git_history_window.rs new file mode 100644 index 00000000..114d4930 --- /dev/null +++ b/rust/crates/core/src/tests/git_history_window.rs @@ -0,0 +1,41 @@ +//! `git.historyWindow` against the shared fixture and its window bound. + +use serde_json::{json, Value}; + +fn call(payload: Value) -> Value { + serde_json::from_str(&crate::execute_json( + &json!({"id":"window", "command":"git.historyWindow", "payload":payload}).to_string(), + )) + .unwrap() +} + +#[test] +fn shared_fixture_windows_keep_depth_and_selection() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../fixtures/git/history-window-v1.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let response = call(case["request"].clone()); + assert_eq!(response["ok"], true, "{}: {response}", case["name"]); + assert_eq!(response["data"], case["response"], "{}", case["name"]); + } + for case in fixture["invalid"].as_array().unwrap() { + assert_eq!( + call(case["request"].clone())["ok"], + false, + "{}", + case["name"] + ); + } +} + +#[test] +fn merge_never_grows_past_the_window_bound() { + let window = (0..4_999) + .map(|index| format!("w{index}")) + .collect::>(); + let response = call(json!({"operation":"mergePage", "window":window, + "page":["a","b","c"], "pageHasMore":true})); + assert_eq!(response["data"], json!({"append":[0], "hasMore":false})); +} diff --git a/rust/crates/core/src/tests/git_inherited_tags.rs b/rust/crates/core/src/tests/git_inherited_tags.rs new file mode 100644 index 00000000..52f2ac3f --- /dev/null +++ b/rust/crates/core/src/tests/git_inherited_tags.rs @@ -0,0 +1,257 @@ +//! Real Git integration: `git.executionInspect` normalizes inherited tag booleans. + +use serde_json::{json, Value}; +use std::fs; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; + +static SEQUENCE: AtomicU64 = AtomicU64::new(0); + +struct Repository(PathBuf); + +impl Repository { + fn request(&self, command: &str, mut payload: Value) -> Value { + payload["root"] = json!(self.0); + // Core owns subprocess deadlines; the fixture never waits on an + // unbounded native child, including repository initialization. + serde_json::from_str(&crate::execute_json( + &json!({ + "timeoutMilliseconds": 5000, + "command": command, + "payload": payload, + }) + .to_string(), + )) + .unwrap() + } +} + +impl Drop for Repository { + fn drop(&mut self) { + if let Err(error) = fs::remove_dir_all(&self.0) { + eprintln!("Could not clean tag boolean repository: {error}"); + } + } +} + +// Real Git integration protects the inspection-to-presentation contract. +#[test] +fn inspection_normalizes_inherited_tag_booleans_without_changing_edit_values() { + let repo = Repository(std::env::temp_dir().join(format!( + "lithe-tag-booleans-{}-{}", + std::process::id(), + SEQUENCE.fetch_add(1, Ordering::Relaxed) + ))); + fs::create_dir_all(&repo.0).unwrap(); + let initialized = repo.request( + "git.command", + json!({ "arguments": ["init", "--template=", "-q", "-b", "main"] }), + ); + assert_eq!(initialized["ok"], true, "{initialized}"); + assert_eq!(initialized["data"]["exitCode"], 0, "{initialized}"); + + // Exercise Git's parser rather than duplicating its accepted spellings. + // Valueless and empty assignments are deliberately distinct booleans. + let cases = [ + ("yes", "pruneTags = yes", "true"), + ("on", "pruneTags = on", "true"), + ("one", "pruneTags = 1", "true"), + ("mixed", "pruneTags = TrUe", "true"), + ("valueless", "pruneTags", "true"), + ("no", "pruneTags = no", "false"), + ("off", "pruneTags = off", "false"), + ("zero", "pruneTags = 0", "false"), + ("false", "pruneTags = FaLsE", "false"), + ("empty", "pruneTags =", "false"), + ("invalid", "pruneTags = invalid", "invalid"), + ]; + let path = repo.0.join(".git/config"); + let mut config = fs::read_to_string(&path).unwrap(); + config.push_str("\n[fetch]\npruneTags = yes\n"); + for (name, configuration, _) in cases { + config.push_str(&format!("\n[remote \"{name}\"]\n{configuration}\n")); + } + fs::write(path, config).unwrap(); + + let response = repo.request("git.executionInspect", json!({})); + assert_eq!(response["ok"], true, "{response}"); + let entries = response["data"]["entries"].as_array().unwrap(); + for (name, _, expected) in cases { + let key = format!("remote.{name}.prunetags"); + let entry = entries + .iter() + .find(|entry| entry["key"] == key && entry["effective"] == true) + .unwrap(); + assert_eq!(entry["value"], expected, "{key}"); + assert_eq!(entry["scope"], "local", "{key}"); + assert!(entry["origin"].as_str().unwrap().ends_with(".git/config")); + } + let fetch = entries + .iter() + .find(|entry| entry["key"] == "fetch.prunetags" && entry["effective"] == true) + .unwrap(); + assert_eq!(fetch["value"], "true"); + let field = response["data"]["fields"] + .as_array() + .unwrap() + .iter() + .find(|field| field["key"] == "fetch.prunetags") + .unwrap(); + assert_eq!(field["configuredValues"], json!(["yes"])); +} + +#[test] +fn inspection_normalizes_skip_fetch_all_booleans() { + let repo = Repository(std::env::temp_dir().join(format!( + "lithe-skip-fetch-booleans-{}-{}", + std::process::id(), + SEQUENCE.fetch_add(1, Ordering::Relaxed) + ))); + fs::create_dir_all(&repo.0).unwrap(); + let initialized = repo.request( + "git.command", + json!({ "arguments": ["init", "--template=", "-q", "-b", "main"] }), + ); + assert_eq!(initialized["ok"], true, "{initialized}"); + assert_eq!(initialized["data"]["exitCode"], 0, "{initialized}"); + + let path = repo.0.join(".git/config"); + let mut config = fs::read_to_string(&path).unwrap(); + config.push_str("\n[remote \"yes\"]\nurl = /tmp/yes\nskipFetchAll = yes\n"); + config.push_str("\n[remote \"valueless\"]\nurl = /tmp/valueless\nskipFetchAll\n"); + config.push_str("\n[remote \"empty\"]\nurl = /tmp/empty\nskipFetchAll =\n"); + config.push_str("\n[remote \"no\"]\nurl = /tmp/no\nskipFetchAll = no\n"); + fs::write(path, config).unwrap(); + + let response = repo.request("git.executionInspect", json!({})); + assert_eq!(response["ok"], true, "{response}"); + let entries = response["data"]["entries"].as_array().unwrap(); + for (name, expected) in [ + ("yes", "true"), + ("valueless", "true"), + ("empty", "false"), + ("no", "false"), + ] { + let key = format!("remote.{name}.skipfetchall"); + let entry = entries + .iter() + .find(|entry| entry["key"] == key && entry["effective"] == true) + .unwrap(); + assert_eq!(entry["value"], expected, "{key}"); + } +} + +#[test] +fn inspection_matches_git_when_tags_are_forced_or_configuration_is_invalid() { + let repo = Repository(std::env::temp_dir().join(format!( + "lithe-fetch-effective-{}-{}", + std::process::id(), + SEQUENCE.fetch_add(1, Ordering::Relaxed) + ))); + fs::create_dir_all(&repo.0).unwrap(); + let git = |arguments: &[&str]| { + let response = repo.request("git.command", json!({"arguments": arguments})); + assert_eq!(response["ok"], true, "{response}"); + response["data"].clone() + }; + let ok = |arguments: &[&str]| { + let response = git(arguments); + assert_eq!(response["exitCode"], 0, "{arguments:?}: {response}"); + }; + ok(&["init", "--template=", "-q", "-b", "main"]); + ok(&[ + "-c", + "user.name=Fixture", + "-c", + "user.email=test@example.invalid", + "commit", + "--allow-empty", + "--no-gpg-sign", + "-qm", + "base", + ]); + ok(&["-c", "tag.gpgSign=false", "tag", "reachable"]); + ok(&[ + "-c", + "user.name=Fixture", + "-c", + "user.email=test@example.invalid", + "commit", + "--allow-empty", + "--no-gpg-sign", + "-qm", + "other", + ]); + ok(&["-c", "tag.gpgSign=false", "tag", "extra"]); + ok(&["reset", "--hard", "HEAD~1"]); + let remote = repo.0.join("remote.git"); + ok(&[ + "init", + "--bare", + "--template=", + "-q", + remote.to_str().unwrap(), + ]); + ok(&[ + "--git-dir", + remote.to_str().unwrap(), + "symbolic-ref", + "HEAD", + "refs/heads/main", + ]); + ok(&["remote", "add", "origin", remote.to_str().unwrap()]); + ok(&[ + "push", + "-q", + "origin", + "HEAD:refs/heads/main", + "refs/tags/*:refs/tags/*", + ]); + ok(&["tag", "-d", "reachable", "extra"]); + ok(&["config", "lithe.fetch.tags", "none"]); + ok(&["config", "lithe.fetch.prune", "false"]); + ok(&["config", "fetch.pruneTags", "true"]); + let inspect = || { + let result = repo.request("git.executionInspect", json!({})); + assert_eq!(result["ok"], true, "{result}"); + result["data"]["resolvedFetchBehavior"]["tags"].clone() + }; + let fetch = || git(&["fetch", "--dry-run", "--no-tags", "--no-prune", "origin"]); + assert_eq!(inspect()["value"], "all"); + let actual = fetch(); + assert_eq!(actual["exitCode"], 0, "{actual}"); + assert!( + actual["stderr"].as_str().unwrap().contains("extra"), + "{actual}" + ); + + ok(&["config", "--unset", "fetch.pruneTags"]); + ok(&[ + "config", + "--add", + "remote.origin.fetch", + "+refs/tags/*:refs/tags/*", + ]); + assert_eq!(inspect()["value"], "all"); + assert!(fetch()["stderr"].as_str().unwrap().contains("extra")); + ok(&["config", "--add", "remote.origin.fetch", "^refs/tags/extra"]); + assert_eq!(inspect()["value"], "custom"); + assert!(!fetch()["stderr"].as_str().unwrap().contains("extra")); + + ok(&["config", "--add", "remote.origin.fetch", ""]); + assert_eq!(inspect()["value"], "none"); + assert!(!fetch()["stderr"].as_str().unwrap().contains("extra")); + + ok(&["config", "remote.origin.pruneTags", "invalid"]); + let unknown = inspect(); + assert_eq!(unknown["value"], "unavailable"); + assert_eq!(unknown["reasonCode"], "invalidConfiguration"); + assert_ne!(fetch()["exitCode"], 0); + ok(&["config", "--unset", "remote.origin.pruneTags"]); + for key in ["fetch.prune", "remote.origin.prune", "remote.other.prune"] { + ok(&["config", key, "invalid"]); + assert_eq!(inspect()["reasonCode"], "invalidConfiguration", "{key}"); + assert_ne!(fetch()["exitCode"], 0, "{key}"); + ok(&["config", "--unset", key]); + } +} diff --git a/rust/crates/core/src/tests/git_reference_tree.rs b/rust/crates/core/src/tests/git_reference_tree.rs new file mode 100644 index 00000000..5be2c5ea --- /dev/null +++ b/rust/crates/core/src/tests/git_reference_tree.rs @@ -0,0 +1,29 @@ +//! `git.referenceTree` against the shared fixture. + +use serde_json::{json, Value}; + +fn call(payload: Value) -> Value { + serde_json::from_str(&crate::execute_json( + &json!({"id":"tree", "command":"git.referenceTree", "payload":payload}).to_string(), + )) + .unwrap() +} + +#[test] +fn shared_fixture_trees_have_one_grouping_and_order() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../fixtures/git/reference-tree-v1.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let response = call(case["request"].clone()); + assert_eq!(response["ok"], true, "{}: {response}", case["name"]); + assert_eq!(response["data"]["nodes"], case["nodes"], "{}", case["name"]); + } +} + +#[test] +fn rejects_unknown_fields() { + let response = call(json!({"references":[{"name":"main","kind":"local"}]})); + assert_eq!(response["ok"], false); +} diff --git a/rust/crates/core/src/tests/git_repository_discovery.rs b/rust/crates/core/src/tests/git_repository_discovery.rs index dcd9d286..552c2983 100644 --- a/rust/crates/core/src/tests/git_repository_discovery.rs +++ b/rust/crates/core/src/tests/git_repository_discovery.rs @@ -60,7 +60,7 @@ fn discover(root: &Path) -> Vec { } fn canonical(root: &Path) -> String { - crate::git::simplified_canonical_path(root.canonicalize().unwrap()) + lithe_git::simplified_canonical_path(root.canonicalize().unwrap()) .unwrap() .to_string_lossy() .replace('\\', "/") diff --git a/rust/crates/core/src/tests/git_untracked_directories.rs b/rust/crates/core/src/tests/git_untracked_directories.rs index 7e9add46..239e066d 100644 --- a/rust/crates/core/src/tests/git_untracked_directories.rs +++ b/rust/crates/core/src/tests/git_untracked_directories.rs @@ -1,6 +1,6 @@ use super::support::temporary_root; use crate::execute_json; -use crate::git::UNTRACKED_DIRECTORY_SUMMARY_THRESHOLD; +use lithe_git::test_support::UNTRACKED_DIRECTORY_SUMMARY_THRESHOLD; use serde_json::{json, Value}; use std::{ fs, diff --git a/rust/crates/core/src/tests/git_update_plan.rs b/rust/crates/core/src/tests/git_update_plan.rs new file mode 100644 index 00000000..872e0c23 --- /dev/null +++ b/rust/crates/core/src/tests/git_update_plan.rs @@ -0,0 +1,25 @@ +//! `git.updatePlan` against the shared fixture. + +use serde_json::{json, Value}; + +fn call(payload: Value) -> Value { + serde_json::from_str(&crate::execute_json( + &json!({"id":"update", "command":"git.updatePlan", "payload":payload}).to_string(), + )) + .unwrap() +} + +#[test] +fn shared_fixture_steps_are_the_only_update_rules() { + let fixture: Value = + serde_json::from_str(include_str!("../../../../fixtures/git/update-plan-v1.json")).unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let response = call(case["request"].clone()); + assert_eq!(response["ok"], true, "{}: {response}", case["name"]); + assert_eq!(response["data"], case["plan"], "{}", case["name"]); + } + for case in fixture["invalid"].as_array().unwrap() { + let response = call(case["request"].clone()); + assert_eq!(response["ok"], false, "{}", case["name"]); + } +} diff --git a/rust/crates/core/src/tests/git_workspace_commit.rs b/rust/crates/core/src/tests/git_workspace_commit.rs index 24d008ce..333a311f 100644 --- a/rust/crates/core/src/tests/git_workspace_commit.rs +++ b/rust/crates/core/src/tests/git_workspace_commit.rs @@ -333,7 +333,7 @@ fn git_workspace_commit_state_matches_the_shared_fixture() { "../../../../fixtures/git/workspace-commit-v1.json" )) .unwrap(); - let state: crate::git::GitCommitState = + let state: lithe_git::GitCommitState = serde_json::from_value(fixture["commitState"].clone()).unwrap(); assert_eq!(serde_json::to_value(state).unwrap(), fixture["commitState"]); } diff --git a/rust/crates/core/src/tests/git_workspace_status.rs b/rust/crates/core/src/tests/git_workspace_status.rs new file mode 100644 index 00000000..e9c7572c --- /dev/null +++ b/rust/crates/core/src/tests/git_workspace_status.rs @@ -0,0 +1,36 @@ +//! `git.workspaceStatusMerge` keeps failed snapshots and confirms only successes. + +use serde_json::json; + +fn call(payload: serde_json::Value) -> serde_json::Value { + serde_json::from_str(&crate::execute_json( + &json!({"id":"status", "command":"git.workspaceStatusMerge", "payload":payload}) + .to_string(), + )) + .unwrap() +} + +#[test] +fn keeps_failed_existing_snapshots_and_only_confirms_successes() { + let response = call( + json!({"roots":["a","b","c"], "previousRoots":["a","b","removed"], "outcomes":[ + {"root":"a","success":true}, {"root":"b","success":false}, {"root":"c","success":false} + ]}), + ); + assert_eq!(response["ok"], true, "{response}"); + assert_eq!( + response["data"], + json!({"entries":[{"root":"a","source":"current"},{"root":"b","source":"previous"},{"root":"c","source":"missing"}], "confirmedRoots":["a"], "failedRoots":["b","c"]}) + ); +} + +#[test] +fn rejects_incomplete_duplicate_and_foreign_outcomes() { + for payload in [ + json!({"roots":["a","b"],"previousRoots":[],"outcomes":[{"root":"a","success":true}]}), + json!({"roots":["a","b"],"previousRoots":[],"outcomes":[{"root":"a","success":true},{"root":"a","success":false}]}), + json!({"roots":["a"],"previousRoots":[],"outcomes":[{"root":"b","success":true}]}), + ] { + assert_eq!(call(payload)["ok"], false); + } +} diff --git a/rust/crates/core/src/tests/installed_versions.rs b/rust/crates/core/src/tests/installed_versions.rs new file mode 100644 index 00000000..8e0ce180 --- /dev/null +++ b/rust/crates/core/src/tests/installed_versions.rs @@ -0,0 +1,30 @@ +//! `plugins.installed.select` against the shared installed-version fixture. + +use serde_json::{json, Value}; + +#[test] +fn shared_installed_version_fixture_uses_the_existing_numeric_comparator() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../fixtures/plugins/installed-versions.json" + ))) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let response: Value = serde_json::from_str(&crate::execute_json(&json!({ + "command":"plugins.installed.select", "payload":{ + "entries":case["entries"], "includeDisabled":case["includeDisabled"].as_bool().unwrap_or(false) + } + }).to_string())).unwrap(); + assert_eq!(response["ok"], true, "case {}: {response}", case["name"]); + assert_eq!( + response["data"]["selected"], case["selected"], + "case {}", + case["name"] + ); + assert_eq!( + response["data"]["rejected"], case["rejected"], + "case {}", + case["name"] + ); + } +} diff --git a/rust/crates/core/src/tests/keymap_import.rs b/rust/crates/core/src/tests/keymap_import.rs new file mode 100644 index 00000000..d75cad4b --- /dev/null +++ b/rust/crates/core/src/tests/keymap_import.rs @@ -0,0 +1,190 @@ +//! Shortcut import through the dispatcher and the host manifest: binding +//! plans, provider replies, provider declarations and source plans. + +use lithe_plugin_keymap_import::plan_sources_command; +use lithe_plugin_manifest::DeclarativePluginManifest as Manifest; +use serde_json::{json, Value}; + +#[test] +fn import_binding_plans_follow_shared_fixtures() { + let fixtures: Value = serde_json::from_str(include_str!( + "../../../../fixtures/keymap/import-plan-v1.json" + )) + .unwrap(); + for case in fixtures["cases"].as_array().unwrap() { + let request = json!({"id": "fixture", "command": "keymap.planImportBindings", "payload": case["request"]}); + let response: Value = + serde_json::from_str(&crate::execute_json(&request.to_string())).unwrap(); + if let Some(expected) = case.get("expected") { + assert_eq!(response["ok"], true, "{}: {response}", case["name"]); + assert_eq!(&response["data"], expected, "{}", case["name"]); + } else { + assert_eq!(response["ok"], false, "{}", case["name"]); + assert!(response.get("data").is_none()); + } + } +} + +#[test] +fn dispatcher_returns_the_validated_reply_without_partial_results() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../fixtures/keymap/import-provider-v1.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let request = + json!({"id": "fixture", "command": "keymap.decodeImport", "payload": case["request"]}); + let response: Value = + serde_json::from_str(&crate::execute_json(&request.to_string())).unwrap(); + assert_eq!(response["id"], "fixture"); + match case.get("expected") { + Some(expected) => { + assert_eq!(response["ok"], true, "{}", case["name"]); + assert_eq!(&response["data"], expected, "{}", case["name"]); + } + None => { + assert_eq!(response["ok"], false, "{}", case["name"]); + assert_eq!(response["error"]["code"], "invalid_request"); + assert!(response.get("data").is_none()); + } + } + } +} + +#[test] +fn import_provider_manifest_is_generic_bounded_and_permission_checked() { + let plugin: Value = serde_json::from_str(include_str!( + "../../../../../shared/plugins/contract/v1/fixtures/keymap-import/keymap-import-provider.plugin.json" + )) + .unwrap(); + let validate = lithe_plugin_manifest::validate_manifest_value; + assert!(validate(plugin.clone()).is_ok()); + let host_without_importers = json!({"backend": [0]}); + let host_with_importers = json!({"backend": [0], "keymapImportProviders": [0]}); + let command = lithe_plugin_manifest::validate_command; + let rejected = command(json!({ + "manifest": plugin, + "hostCapabilities": host_without_importers + })) + .unwrap(); + assert_eq!(rejected["status"], "rejected"); + assert_eq!(rejected["error"]["code"], "plugin.api.unsupported"); + let accepted = command(json!({ + "manifest": plugin, + "hostCapabilities": host_with_importers + })) + .unwrap(); + assert_eq!(accepted["status"], "ok"); + + let mut duplicate = plugin.clone(); + duplicate["keymapImportProviders"][1]["id"] = json!("first"); + assert!(validate(duplicate).is_err()); + let mut malformed = plugin.clone(); + malformed["keymapImportProviders"][0]["id"] = json!("../private"); + assert!(validate(malformed).is_err()); + let mut traversal = plugin.clone(); + traversal["keymapImportProviders"][0]["inputs"][0]["path"] = json!("../credentials"); + assert!(validate(traversal).is_err()); + let mut wildcard = plugin.clone(); + wildcard["keymapImportProviders"][1]["inputs"][1]["extension"] = json!("*"); + assert!(validate(wildcard).is_err()); + let mut duplicate_inputs = plugin.clone(); + duplicate_inputs["keymapImportProviders"][0]["inputs"] = json!([ + {"kind": "file", "path": "bindings.json"}, + {"kind": "file", "path": "bindings.json"} + ]); + assert!(validate(duplicate_inputs).is_err()); + let mut no_recognition = plugin.clone(); + no_recognition["keymapImportProviders"][0]["recognition"] = json!([]); + assert!(validate(no_recognition).is_err()); + let mut duplicate_recognition = plugin.clone(); + duplicate_recognition["keymapImportProviders"][0]["recognition"] = json!([ + {"kind": "file", "path": "bindings.json"}, {"kind": "file", "path": "bindings.json"} + ]); + assert!(validate(duplicate_recognition).is_err()); + let mut bad_root = plugin.clone(); + bad_root["keymapImportProviders"][0]["recognition"][1]["components"] = json!(["..", "User"]); + assert!(validate(bad_root).is_err()); + let mut bad_pattern = plugin.clone(); + bad_pattern["keymapImportProviders"][0]["recognition"][1]["components"] = + json!(["First*", "User"]); + assert!(validate(bad_pattern).is_err()); + let mut too_broad = plugin.clone(); + too_broad["keymapImportProviders"][0]["recognition"][1]["components"] = json!(["*", "*"]); + assert!(validate(too_broad).is_err()); + let mut unanchored = plugin.clone(); + unanchored["keymapImportProviders"][0]["recognition"][1]["components"] = json!(["*", "User"]); + assert!(validate(unanchored).is_err()); + let mut international = plugin.clone(); + international["keymapImportProviders"][0]["displayName"] = json!("编".repeat(64)); + assert!(validate(international.clone()).is_ok()); + international["keymapImportProviders"][0]["displayName"] = json!("编".repeat(65)); + assert!(validate(international).is_err()); + let mut no_grant = plugin.clone(); + no_grant["permissions"] = json!(["backend.launch"]); + assert_eq!( + validate(no_grant).unwrap_err().code, + "plugin.permission.undeclared" + ); + let mut no_source_grant = plugin.clone(); + no_source_grant["permissions"] = json!(["backend.launch", "keymaps.provide"]); + assert_eq!( + validate(no_source_grant).unwrap_err().code, + "plugin.permission.undeclared" + ); + let mut unknown_backend_version = plugin.clone(); + unknown_backend_version["requires"]["backend"] = json!(1); + assert_eq!( + validate(unknown_backend_version).unwrap_err().code, + "plugin.api.unsupported" + ); + let mut unsafe_launch = plugin.clone(); + unsafe_launch["backend"]["launch"] = + json!({"kind": "runtime", "tool": "node", "entry": "main.mjs"}); + assert!(validate(unsafe_launch).is_err()); + let mut too_many = plugin; + too_many["keymapImportProviders"] = json!((0..9) + .map(|i| json!({"id": format!("provider-{i}"), "displayName": "Provider"})) + .collect::>()); + assert!(validate(too_many).is_err()); +} + +#[test] +fn source_plans_only_expose_inputs_of_the_requested_provider() { + let plugin: Value = serde_json::from_str(include_str!( + "../../../../../shared/plugins/contract/v1/fixtures/keymap-import/keymap-import-provider.plugin.json" + )) + .unwrap(); + let fixture: Value = serde_json::from_str(include_str!( + "../../../../fixtures/keymap/source-plans-v1.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let actual = plan_sources_command::( + json!({"manifest": plugin, "providerID": case["providerID"]}), + ); + if case["expected"].is_null() { + assert!(actual.is_err()); + } else { + let actual = actual.unwrap(); + assert_eq!(actual["inputs"], case["expected"]["inputs"]); + assert_eq!(actual["recognition"], case["expected"]["recognition"]); + assert_eq!(actual["limits"], fixture["limits"]); + } + } + let mut invalid = plugin; + invalid["keymapImportProviders"][0]["inputs"][0]["path"] = json!("../credentials"); + assert!( + plan_sources_command::(json!({"manifest": invalid, "providerID": "second"})) + .is_err() + ); + let mut invalid: Value = serde_json::from_str(include_str!( + "../../../../../shared/plugins/contract/v1/fixtures/keymap-import/keymap-import-provider.plugin.json" + )) + .unwrap(); + invalid["keymapImportProviders"][1]["recognition"][0]["path"] = json!("../private"); + assert!( + plan_sources_command::(json!({"manifest": invalid, "providerID": "first"})) + .is_err() + ); +} diff --git a/rust/crates/core/src/tests/language_server_sessions.rs b/rust/crates/core/src/tests/language_server_sessions.rs index 85864d12..6b2fcd66 100644 --- a/rust/crates/core/src/tests/language_server_sessions.rs +++ b/rust/crates/core/src/tests/language_server_sessions.rs @@ -4,10 +4,10 @@ //! `shared/plugins/contract/v1/fixtures/` so the plugin documentation //! repository and other hosts run the same cases. -use crate::plugins::declarative::{ +use lithe_plugin_manifest::{ decode_language_server_launch_command, language_server_launch_plan_command, validate_command, }; -use crate::plugins::marketplace::install_review_command; +use lithe_plugin_marketplace::install_review_command; use serde_json::{json, Value}; macro_rules! contract_file { diff --git a/rust/crates/core/src/tests/lsp_policy.rs b/rust/crates/core/src/tests/lsp_policy.rs new file mode 100644 index 00000000..ff6c1d34 --- /dev/null +++ b/rust/crates/core/src/tests/lsp_policy.rs @@ -0,0 +1,142 @@ +//! Shared-fixture coverage for the language-tooling policy commands in +//! `lithe-lsp-policy`. Both platforms consume these fixtures, so a divergence +//! here is a cross-platform bug. + +use serde_json::{json, Value}; + +fn execute(command: &str, payload: &Value) -> Value { + serde_json::from_str(&crate::execute_json( + &json!({"command": command, "payload": payload}).to_string(), + )) + .expect("Core should return JSON") +} + +/// Every case yields its `expected` data, or fails with its `expectedError` code. +fn run_decision_fixture(command: &str, fixture: &str) { + let fixture: Value = serde_json::from_str(fixture).unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let response = execute(command, &case["request"]); + if let Some(code) = case.get("expectedError") { + assert_eq!(response["ok"], false, "{}: {response}", case["name"]); + assert_eq!(&response["error"]["code"], code, "{}", case["name"]); + } else { + assert_eq!(response["ok"], true, "{}: {response}", case["name"]); + assert_eq!(response["data"], case["expected"], "{}", case["name"]); + } + } +} + +/// Every case succeeds with its `response` data. +fn run_fixture(command: &str, fixture: &str) { + let fixture: Value = serde_json::from_str(fixture).expect("fixture should be JSON"); + let cases = fixture["cases"] + .as_array() + .expect("fixture should list cases"); + assert!(!cases.is_empty(), "{command} fixture has no cases"); + for case in cases { + let response = execute(command, &case["request"]); + assert_eq!(response["ok"], true, "{}: {response}", case["name"]); + assert_eq!(response["data"], case["response"], "{}", case["name"]); + } +} + +#[test] +fn declaration_or_usages_matches_shared_fixture() { + run_decision_fixture( + "lsp.declarationOrUsages", + include_str!("../../../../fixtures/lsp/declaration-or-usages-v1.json"), + ); +} + +#[test] +fn navigation_target_matches_shared_fixture() { + run_decision_fixture( + "lsp.classifyNavigationTarget", + include_str!("../../../../fixtures/lsp/navigation-target-v1.json"), + ); +} + +#[test] +fn workspace_edit_plan_matches_shared_fixture() { + run_decision_fixture( + "lsp.planWorkspaceEdit", + include_str!("../../../../fixtures/lsp/workspace-edit-plan-v1.json"), + ); +} + +#[test] +fn document_identity_matches_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../fixtures/lsp/document-identity-v1.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let response = execute("lsp.documentIdentity", &case["request"]); + if let Some(code) = case.get("error") { + assert_eq!(&response["error"]["code"], code, "{case}"); + } else { + assert_eq!(response["data"], case["expected"], "{case}: {response}"); + } + } +} + +#[test] +fn problems_query_matches_shared_fixture() { + run_fixture( + "lsp.problems.query", + include_str!("../../../../fixtures/lsp/problems-query-v1.json"), + ); +} + +#[test] +fn activation_policy_matches_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../fixtures/lsp/activation-policy-v1.json" + )) + .unwrap(); + // The fixture documents the budget; keep it pinned to the constants. + assert_eq!( + fixture["maxConsecutiveFailures"], + lithe_lsp_policy::activation::MAX_CONSECUTIVE_FAILURES + ); + assert_eq!( + fixture["failureCooldownMs"], + lithe_lsp_policy::activation::FAILURE_COOLDOWN_MS + ); + run_fixture( + "lsp.activationPolicy", + include_str!("../../../../fixtures/lsp/activation-policy-v1.json"), + ); +} + +#[test] +fn completion_merge_matches_shared_fixture() { + run_fixture( + "lsp.mergeCompletions", + include_str!("../../../../fixtures/lsp/completion-merge-v1.json"), + ); +} + +#[test] +fn malformed_requests_fail_with_invalid_request() { + for (command, payload) in [ + ( + "lsp.problems.query", + json!({"diagnostics": [{"fileKey": "a"}]}), + ), + ( + "lsp.problems.query", + json!({"diagnostics": [], "groupBy": "type"}), + ), + ("lsp.activationPolicy", json!({"event": "automaticStart"})), + ( + "lsp.activationPolicy", + json!({"event": "crashed", "nowMs": 1}), + ), + ("lsp.mergeCompletions", json!({"lists": [{"items": []}]})), + ] { + let response = execute(command, &payload); + assert_eq!(response["ok"], false, "{command}: {response}"); + assert_eq!(response["error"]["code"], "invalid_request", "{command}"); + } +} diff --git a/rust/crates/core/src/tests/marketplace_plugins.rs b/rust/crates/core/src/tests/marketplace_plugins.rs index 8e85fe7f..b2dc50a8 100644 --- a/rust/crates/core/src/tests/marketplace_plugins.rs +++ b/rust/crates/core/src/tests/marketplace_plugins.rs @@ -1,4 +1,4 @@ -use crate::plugins::marketplace::{ +use lithe_plugin_marketplace::{ install_review_command, resolve_source_command, validate_index_command, }; use serde_json::{json, Value}; diff --git a/rust/crates/core/src/tests/mod.rs b/rust/crates/core/src/tests/mod.rs index 83c3303d..f2192b26 100644 --- a/rust/crates/core/src/tests/mod.rs +++ b/rust/crates/core/src/tests/mod.rs @@ -2,39 +2,58 @@ mod build_configuration; mod build_project_options; mod build_runtime_versions; mod build_systems; -mod community_build_plugins; mod core_request_traces; mod declarative_plugins; mod detectors; +mod diagnostics_dispatch; mod directory_actions; +mod document_uri; +mod editor_dispatch; +mod execution_commands; mod file_name_order; mod file_operations; mod git; +mod git_change_targets; mod git_diff_blocks; mod git_fetch; +mod git_fetch_plan; +mod git_file_context; +mod git_graph_projection; mod git_history_rewrite; +mod git_history_window; +mod git_inherited_tags; mod git_patch_exchange; +mod git_reference_tree; mod git_remote_checkout; mod git_repository_discovery; mod git_repository_setup; mod git_untracked_directories; +mod git_update_plan; mod git_workspace_commit; +mod git_workspace_status; mod github; mod history_events; +mod installed_versions; +mod keymap_import; mod language_server_sessions; +mod lsp_policy; mod marketplace_plugins; +mod plugin_editor_resources; +mod plugin_run_debug; +mod plugin_settings_documents; mod project; mod project_generators; mod protocol; +mod published_build_plugins; mod rank_names; +#[cfg(unix)] +mod remote_sessions; +mod request_events; mod run_configuration; pub(crate) mod support; -mod vcs; - -mod plugin_editor_resources; -mod plugin_run_debug; mod toolchain_definitions; mod toolchain_pins; mod toolchain_queries; mod toolchain_resolution; mod toolchain_vectors; +mod vcs; diff --git a/rust/crates/core/src/tests/plugin_editor_resources.rs b/rust/crates/core/src/tests/plugin_editor_resources.rs index 1bada685..12060aec 100644 --- a/rust/crates/core/src/tests/plugin_editor_resources.rs +++ b/rust/crates/core/src/tests/plugin_editor_resources.rs @@ -1,6 +1,6 @@ //! Independent static editor resources, without language-server or Run providers. -use crate::plugins::declarative::{decode_grammar_command, validate_command}; +use lithe_plugin_manifest::{decode_grammar_command, validate_command}; use serde_json::{json, Value}; macro_rules! contract_file { diff --git a/rust/crates/core/src/tests/plugin_run_debug.rs b/rust/crates/core/src/tests/plugin_run_debug.rs index 4aab9a51..ed7405d2 100644 --- a/rust/crates/core/src/tests/plugin_run_debug.rs +++ b/rust/crates/core/src/tests/plugin_run_debug.rs @@ -7,9 +7,11 @@ use super::support::temporary_root; use crate::build::run_decode_start_command; use crate::debug::decode_backend_configuration_command; -use crate::plugins::declarative::{language_server_launch_plan_command, validate_manifest_value}; -use crate::plugins::output_links::{decode_resolution, match_lines}; use crate::toolchain::instantiate_launch_command; +use lithe_plugin_manifest::validated_manifest_json; +use lithe_plugin_manifest::DeclarativePluginManifest as Manifest; +use lithe_plugin_manifest::{language_server_launch_plan_command, validate_manifest_value}; +use lithe_plugin_output_links::{decode_resolution, match_lines}; use serde_json::{json, Value}; use std::fs; use std::path::PathBuf; @@ -85,7 +87,7 @@ fn start_run(workspace: &Workspace, params: Value, temporary_directory: Option<& if let Some(directory) = temporary_directory { payload["temporaryDirectory"] = json!(directory); } - run_decode_start_command(payload).expect("payload is well formed") + run_decode_start_command::(payload).expect("payload is well formed") } /// A start of the fixture's required tool with room to vary the rest. @@ -118,7 +120,7 @@ fn run_start_fixture_cases_decode_the_same_in_every_host() { let manifest = named_manifest(&fixture); for case in fixture["cases"].as_array().unwrap() { let name = case["name"].as_str().unwrap(); - let outcome = run_decode_start_command(json!({ + let outcome = run_decode_start_command::(json!({ "manifest": manifest, "workspaceRoot": workspace.root(), "temporaryDirectory": temporary.root(), @@ -361,7 +363,7 @@ fn debug_start_fixture_cases_decode_the_same_in_every_host() { let manifest = named_manifest(&fixture); for case in fixture["cases"].as_array().unwrap() { let name = case["name"].as_str().unwrap(); - let outcome = decode_backend_configuration_command(json!({ + let outcome = decode_backend_configuration_command::(json!({ "manifest": manifest, "params": case["params"], })) @@ -384,7 +386,7 @@ fn debug_start_fixture_cases_decode_the_same_in_every_host() { #[test] fn debug_start_configuration_is_limited_to_256_kib() { let start = |filler: usize| { - decode_backend_configuration_command(json!({ + decode_backend_configuration_command::(json!({ "manifest": parse(DEBUG_FEATURES_MANIFEST), "params": { "label": "Debug", @@ -458,7 +460,7 @@ fn instantiate(tool_definition: Value, parts: Value) -> Value { for (key, value) in parts.as_object().unwrap() { input[key] = value.clone(); } - instantiate_launch_command(input).expect("input is well formed") + instantiate_launch_command(input, validated_manifest_json).expect("input is well formed") } #[test] @@ -514,13 +516,16 @@ fn a_launch_the_definition_cannot_express_is_a_mismatch() { #[test] fn launching_a_tool_nobody_defined_is_refused() { - let outcome = instantiate_launch_command(json!({ - "registry": { "plugins": [{ - "manifest": parse(TOOL_REFERENCE_MANIFEST), - }] }, - "tool": "missing-tool", - "executable": "/opt/missing/bin/tool", - })) + let outcome = instantiate_launch_command( + json!({ + "registry": { "plugins": [{ + "manifest": parse(TOOL_REFERENCE_MANIFEST), + }] }, + "tool": "missing-tool", + "executable": "/opt/missing/bin/tool", + }), + validated_manifest_json, + ) .expect("input is well formed"); assert_rejected( diff --git a/rust/crates/core/src/plugins/settings_document_tests.rs b/rust/crates/core/src/tests/plugin_settings_documents.rs similarity index 100% rename from rust/crates/core/src/plugins/settings_document_tests.rs rename to rust/crates/core/src/tests/plugin_settings_documents.rs diff --git a/rust/crates/core/src/tests/project.rs b/rust/crates/core/src/tests/project.rs index 24ae4cb7..07e29713 100644 --- a/rust/crates/core/src/tests/project.rs +++ b/rust/crates/core/src/tests/project.rs @@ -71,6 +71,7 @@ fn search_exclusions_filter_names_content_and_replacement_and_keep_cache_variant "root": root, "paths": ["src/Name.swift"] })) .unwrap(), + lithe_lsp_engine::indexing_changed, ) .unwrap(); for excluded in [false, true] { @@ -92,7 +93,7 @@ fn search_everywhere_uses_git_ignore_rules_for_filename_foreground() { let _cleanup = Cleanup(root.clone()); fs::create_dir_all(root.join(".build")).unwrap(); let git = |arguments: &[&str]| { - let response = crate::git::execute_git( + let response = lithe_git::execute_git( root.to_str().unwrap(), &arguments .iter() @@ -324,7 +325,7 @@ fn search_everywhere_matches_names_before_limits_and_shares_utf16_fragments() { let update = |paths: &[&str]| { crate::project::files::update_search_index(serde_json::from_value(serde_json::json!({ "root": root, "paths": paths, "hiddenDirectoryNames": ["excluded", ".build", "target"] - })).unwrap()).unwrap() + })).unwrap(), lithe_lsp_engine::indexing_changed).unwrap() }; fs::write(root.join(".build/checkouts/NameGuide.swift"), "").unwrap(); update(&[".build/checkouts/NameGuide.swift"]); @@ -2191,3 +2192,114 @@ fn file_encoding_options_are_optional_and_validated() { assert_eq!(lossy["data"]["lossy"], true); fs::remove_dir_all(root).unwrap(); } + +#[test] +fn replacement_text_command_uses_search_boundaries_and_line_semantics() { + let call = |payload: serde_json::Value| -> serde_json::Value { + serde_json::from_str(&crate::execute_json( + &serde_json::json!({ + "id": "replace", "command": "workspace.replaceText", "payload": payload + }) + .to_string(), + )) + .unwrap() + }; + let whole = call(serde_json::json!({"text": "$foo foo\nfoo!", "query": "foo", + "replacement": "bar", "wholeWords": true})); + assert_eq!( + whole["data"], + serde_json::json!({"text": "$foo bar\nbar!", "count": 2}) + ); + let lines = call( + serde_json::json!({"text": "foo 1\nfoo 2", "query": "^foo (\\d)", + "replacement": "bar $1", "regularExpression": true}), + ); + assert_eq!( + lines["data"], + serde_json::json!({"text": "bar 1\nbar 2", "count": 2}) + ); + let single = call( + serde_json::json!({"text": "😀 foo foo\nfoo", "query": "foo", + "replacement": "bar", "from": {"line": 1, "column": 9}}), + ); + assert_eq!( + single["data"], + serde_json::json!({"text": "😀 foo bar\nfoo", "count": 1}) + ); + let adjacent = call(serde_json::json!({"text": "foofoo", "query": "foo", + "replacement": "bar", "from": {"line": 1, "column": 4}})); + assert_eq!( + adjacent["data"], + serde_json::json!({"text": "foobar", "count": 1}) + ); +} + +#[test] +fn provider_batch_search_uses_same_unicode_and_word_boundaries_as_replacement() { + let response: serde_json::Value = serde_json::from_str(&crate::execute_json( + &serde_json::json!({ + "id": "provider", "command": "workspace.searchTextBatch", "payload": { + "query": "foo", "wholeWords": true, "contextLines": 1, + "files": [{"path": "wsl://fixture/a", "text": "before\n😀 $foo foo\nafter"}] + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], true, "{response}"); + let found = &response["data"]["files"][0]["matches"][0]; + assert_eq!(found["line"], 2); + assert_eq!( + found["ranges"], + serde_json::json!([{"start": 8, "length": 3}]) + ); + assert_eq!(found["contextBefore"], serde_json::json!(["before"])); + assert_eq!(found["contextAfter"], serde_json::json!(["after"])); + let too_many: Vec<_> = (0..33) + .map(|index| serde_json::json!({"path": index.to_string(), "text": ""})) + .collect(); + let rejected: serde_json::Value = serde_json::from_str(&crate::execute_json(&serde_json::json!({ + "id": "provider-limit", "command": "workspace.searchTextBatch", "payload": {"query": "x", "files": too_many} + }).to_string())).unwrap(); + assert_eq!(rejected["ok"], false); +} + +#[test] +fn shared_text_snapshot_contract_fixture() { + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../../fixtures/search/text-snapshot-v1.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let request = serde_json::json!({ + "id": "fixture", "command": case["command"], "payload": case["payload"] + }); + let response: serde_json::Value = + serde_json::from_str(&crate::execute_json(&request.to_string())).unwrap(); + assert_eq!(response["ok"], true, "{}: {response}", case["name"]); + assert_eq!(response["data"], case["expected"], "{}", case["name"]); + } +} + +#[test] +fn shared_workspace_change_fixture() { + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../../fixtures/workspace/change-filter-v1.json" + )) + .unwrap(); + let paths: Vec = serde_json::from_value(fixture["paths"].clone()).unwrap(); + let retained: Vec<_> = paths + .iter() + .filter(|path| crate::project::is_workspace_event_relevant(path)) + .collect(); + assert_eq!(serde_json::to_value(retained).unwrap(), fixture["expected"]); + let response: serde_json::Value = serde_json::from_str(&crate::execute_json( + &serde_json::json!({ + "id": "filter", "command": "workspace.filterChanges", "payload": paths + }) + .to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], true, "{response}"); + assert_eq!(response["data"], fixture["expected"]); +} diff --git a/rust/crates/core/src/tests/project_generators.rs b/rust/crates/core/src/tests/project_generators.rs index ff51c855..e04fcc6e 100644 --- a/rust/crates/core/src/tests/project_generators.rs +++ b/rust/crates/core/src/tests/project_generators.rs @@ -1,9 +1,10 @@ -use crate::plugins::declarative::validate_command; -use crate::plugins::marketplace::install_review_command; use crate::project_generators::{ project_check_name_command, project_generation_plan_command, project_generator_catalog_command, project_generator_form_command, }; +use lithe_plugin_manifest::validate_command; +use lithe_plugin_manifest::DeclarativePluginManifest as Manifest; +use lithe_plugin_marketplace::install_review_command; use serde_json::{json, Value}; use std::path::PathBuf; @@ -95,12 +96,12 @@ fn golden_project_plans_match() { let name = case["name"].as_str().unwrap(); let manifest = case_manifest(case); let actual = match case["operation"].as_str().unwrap() { - "generatorForm" => project_generator_form_command(json!({ + "generatorForm" => project_generator_form_command::(json!({ "manifest": manifest, "generatorID": case["generatorID"], "form": case["form"], })), - "generationPlan" => project_generation_plan_command(json!({ + "generationPlan" => project_generation_plan_command::(json!({ "manifest": manifest, "generatorID": case["generatorID"], "packagePath": fixtures_path(), @@ -124,7 +125,7 @@ fn catalog_lists_generators_with_requirement_providers() { let mut build = json(BUILD_FIXTURE); build["buildSystems"][0]["id"] = json!("maven"); build["activationEvents"] = json!(["onBuildSystem:maven"]); - let result = project_generator_catalog_command(json!({ + let result = project_generator_catalog_command::(json!({ "plugins": [ {"manifest": json(FIXTURE), "enabled": true}, {"manifest": build, "enabled": false}, @@ -161,7 +162,7 @@ fn catalog_lists_generators_with_requirement_providers() { #[test] fn catalog_skips_plugins_the_host_cannot_run() { - let result = project_generator_catalog_command(json!({ + let result = project_generator_catalog_command::(json!({ "plugins": [{"manifest": json(FIXTURE), "enabled": true}], "hostCapabilities": {"languages": [0]}, })) @@ -193,7 +194,7 @@ fn catalog_names_the_plugins_that_define_each_tool() { let mut generators = json(TOOL_FIELD_FIXTURE); // Point the first field at the tool the definition fixture declares. generators["projectGenerators"][0]["fields"][0]["tool"] = json!(tool); - let result = project_generator_catalog_command(json!({ + let result = project_generator_catalog_command::(json!({ "plugins": [ {"manifest": generators, "enabled": true}, {"manifest": json(TOOL_DEFINITION_FIXTURE), "enabled": false}, @@ -227,7 +228,7 @@ fn catalog_names_the_plugins_that_define_each_tool() { ] { let mut generators = json(TOOL_FIELD_FIXTURE); generators["projectGenerators"][0]["fields"][0]["tool"] = json!(tool); - let qualified = project_generator_catalog_command(json!({ + let qualified = project_generator_catalog_command::(json!({ "plugins": [ {"manifest": generators, "enabled": true}, {"manifest": json(TOOL_DEFINITION_FIXTURE), "enabled": true}, @@ -244,7 +245,7 @@ fn catalog_names_the_plugins_that_define_each_tool() { } // A generator without tool fields lists no tools, and a tool nobody // defines has no provider. - let plain = project_generator_catalog_command(json!({ + let plain = project_generator_catalog_command::(json!({ "plugins": [{"manifest": json(FIXTURE), "enabled": true}], "hostCapabilities": host(), })) @@ -268,7 +269,7 @@ fn catalog_names_the_plugins_that_define_each_tool() { /// the plan: the plan is built from the newest listed installation instead. #[test] fn a_plan_never_uses_an_installation_the_host_did_not_list() { - let plan = project_generation_plan_command(json!({ + let plan = project_generation_plan_command::(json!({ "manifest": json(TOOL_FIELD_FIXTURE), "generatorID": "python-app", "packagePath": fixtures_path(), diff --git a/rust/crates/core/src/tests/community_build_plugins.rs b/rust/crates/core/src/tests/published_build_plugins.rs similarity index 91% rename from rust/crates/core/src/tests/community_build_plugins.rs rename to rust/crates/core/src/tests/published_build_plugins.rs index 7a09b4f2..ccdd6a5f 100644 --- a/rust/crates/core/src/tests/community_build_plugins.rs +++ b/rust/crates/core/src/tests/published_build_plugins.rs @@ -1,7 +1,7 @@ -//! The Maven and Gradle community build plugins, checked against what real +//! The published Maven and Gradle build plugins, checked against what real //! Maven and Gradle wrote for them. //! -//! `fixtures/community_build_plugins/` holds each plugin's `plugin.json` and +//! `fixtures/published_build_plugins/` holds each plugin's `plugin.json` and //! the documents its own integration test captured from a real build: //! //! - Maven: `github.com/Lithe-IDEA/lithe-plugin-maven`, `plugin.json` at @@ -26,8 +26,9 @@ use crate::build::{ build_decode_model_command, build_dependency_plan_command, build_detect_command, build_model_plan_command, build_task_plan_command, build_test_plan_command, }; -use crate::plugins::declarative::validate_command; -use crate::plugins::marketplace::install_review_command; +use lithe_plugin_manifest::validate_command; +use lithe_plugin_manifest::DeclarativePluginManifest as Manifest; +use lithe_plugin_marketplace::install_review_command; use serde_json::{json, Value}; use std::fs; use std::path::PathBuf; @@ -35,7 +36,7 @@ use std::path::PathBuf; macro_rules! plugin_file { ($tool:literal, $name:literal) => { include_str!(concat!( - "fixtures/community_build_plugins/", + "fixtures/published_build_plugins/", $tool, "/", $name @@ -108,7 +109,7 @@ fn capture_for_workspace(captured: &str, workspace_root: &str) -> String { } #[test] -fn community_captured_documents_escape_workspace_paths() { +fn published_captured_documents_escape_workspace_paths() { let captured = r#"{ "root": "${workspace}", "nested": [{ "path": "${workspace}/core" }], @@ -158,7 +159,7 @@ fn arguments(outcome: &Value) -> Vec<&str> { } fn detected_ids(workspace: &Workspace, manifest: Value) -> Vec { - let outcome = build_detect_command(json!({ + let outcome = build_detect_command::(json!({ "workspaceRoot": workspace.root(), "plugins": [{ "manifest": manifest }], "options": {}, @@ -185,7 +186,7 @@ fn strings(value: &Value, key: &str) -> Vec { /// Both packages pass the checks Lithe runs before loading and installing a /// plugin, and installing one shows the build tool it will run. #[test] -fn community_build_plugins_validate_and_review() { +fn published_build_plugins_validate_and_review() { for (manifest, build_system) in [(maven(), "maven"), (gradle(), "gradle")] { let outcome = validate_command(json!({ "manifest": manifest.clone() })).unwrap(); assert_eq!(outcome["status"], "ok", "{build_system}: {outcome}"); @@ -202,8 +203,8 @@ fn community_build_plugins_validate_and_review() { /// A multi-module build is one instance at its outermost build file. #[test] -fn community_build_plugins_detect_one_instance_per_build() { - let workspace = Workspace::new("community-maven-detect"); +fn published_build_plugins_detect_one_instance_per_build() { + let workspace = Workspace::new("published-maven-detect"); workspace .write( "pom.xml", @@ -212,7 +213,7 @@ fn community_build_plugins_detect_one_instance_per_build() { .write("app/pom.xml", ""); assert_eq!(detected_ids(&workspace, maven()), ["maven:."]); - let workspace = Workspace::new("community-gradle-detect"); + let workspace = Workspace::new("published-gradle-detect"); workspace .write("settings.gradle", "include 'app'\n") .write("build.gradle", "\n") @@ -223,8 +224,8 @@ fn community_build_plugins_detect_one_instance_per_build() { /// The model command loads the bundled extension or init script from the /// installed plugin and writes to the file Lithe chose. #[test] -fn community_model_plans_name_the_plugin_directory_and_output() { - let workspace = Workspace::new("community-model-plans"); +fn published_model_plans_name_the_plugin_directory_and_output() { + let workspace = Workspace::new("published-model-plans"); let plugin_directory = workspace.path("plugins/p"); let output_file = workspace.path("state/model.json"); let gradle_user_home = workspace.path("caches/gradle"); @@ -238,7 +239,7 @@ fn community_model_plans_name_the_plugin_directory_and_output() { }) }; let outcome = ok( - build_model_plan_command, + build_model_plan_command::, maven(), "maven", request(json!({ "profiles": ["release", "ci"], "offline": true })), @@ -260,7 +261,7 @@ fn community_model_plans_name_the_plugin_directory_and_output() { assert_eq!(outcome["plan"]["executesProjectCode"], true); let outcome = ok( - build_model_plan_command, + build_model_plan_command::, gradle(), "gradle", request(json!({ "offline": true, "gradleUserHome": gradle_user_home })), @@ -287,10 +288,10 @@ fn community_model_plans_name_the_plugin_directory_and_output() { /// and offers the profiles the POMs declare. #[test] fn maven_captured_model_decodes() { - let workspace = Workspace::new("community-maven-model"); + let workspace = Workspace::new("published-maven-model"); let output = workspace.capture("state/model.json", MAVEN_MODEL); let outcome = ok( - build_decode_model_command, + build_decode_model_command::, maven(), "maven", json!({ "workspaceRoot": workspace.root(), "root": ".", "outputFile": output }), @@ -329,10 +330,10 @@ fn maven_captured_model_decodes() { /// JavaExec task becomes a run entry that runs by its task path. #[test] fn gradle_captured_model_decodes() { - let workspace = Workspace::new("community-gradle-model"); + let workspace = Workspace::new("published-gradle-model"); let output = workspace.capture("state/model.json", GRADLE_MODEL); let outcome = ok( - build_decode_model_command, + build_decode_model_command::, gradle(), "gradle", json!({ "workspaceRoot": workspace.root(), "root": ".", "outputFile": output }), @@ -368,11 +369,11 @@ fn gradle_captured_model_decodes() { /// run by their full coordinates, and toolbar options apply to both. #[test] fn maven_task_plans() { - let workspace = Workspace::new("community-maven-task-plans"); + let workspace = Workspace::new("published-maven-task-plans"); let local_repository = workspace.path("m2"); let app = json!({ "path": "app", "name": "app" }); let outcome = ok( - build_task_plan_command, + build_task_plan_command::, maven(), "maven", json!({ @@ -396,7 +397,7 @@ fn maven_task_plans() { ); let outcome = ok( - build_task_plan_command, + build_task_plan_command::, maven(), "maven", json!({ @@ -419,7 +420,7 @@ fn maven_task_plans() { ); let outcome = ok( - build_task_plan_command, + build_task_plan_command::, maven(), "maven", json!({ @@ -441,7 +442,7 @@ fn maven_task_plans() { fn gradle_task_plans() { let task = |project: Value, path: &str| { let outcome = ok( - build_task_plan_command, + build_task_plan_command::, gradle(), "gradle", json!({ @@ -464,7 +465,7 @@ fn gradle_task_plans() { let custom = |project: Value| { let outcome = ok( - build_task_plan_command, + build_task_plan_command::, gradle(), "gradle", json!({ @@ -490,9 +491,9 @@ fn gradle_task_plans() { /// Test runs select a class or method and read the reports the build tool /// writes for the selected project. #[test] -fn community_test_plans() { +fn published_test_plans() { let outcome = ok( - build_test_plan_command, + build_test_plan_command::, maven(), "maven", json!({ @@ -519,7 +520,7 @@ fn community_test_plans() { let core = json!({ "path": "core", "name": "core", "properties": { "path": ":core", "taskPrefix": ":core:" } }); let outcome = ok( - build_test_plan_command, + build_test_plan_command::, gradle(), "gradle", json!({ "root": ".", "project": core, "suite": "demo.core.GreeterTest" }), @@ -539,7 +540,7 @@ fn community_test_plans() { ); let outcome = ok( - build_test_plan_command, + build_test_plan_command::, gradle(), "gradle", json!({ "root": ".", "projects": [".", "app", "core"] }), @@ -550,8 +551,8 @@ fn community_test_plans() { /// A project's dependency tree names the project in the build tool's own /// terms; the workspace tree asks for every project. #[test] -fn community_dependency_plans() { - let workspace = Workspace::new("community-dependency-plans"); +fn published_dependency_plans() { + let workspace = Workspace::new("published-dependency-plans"); let plugin_directory = workspace.path("plugins/p"); let output_file = workspace.path("state/deps.json"); let request = |project: Option| { @@ -567,7 +568,7 @@ fn community_dependency_plans() { }; let app = json!({ "path": "app", "name": "app", "properties": { "path": ":app", "taskPrefix": ":app:" } }); let outcome = ok( - build_dependency_plan_command, + build_dependency_plan_command::, maven(), "maven", request(Some(app.clone())), @@ -584,7 +585,7 @@ fn community_dependency_plans() { ] ); let outcome = ok( - build_dependency_plan_command, + build_dependency_plan_command::, maven(), "maven", request(None), @@ -597,7 +598,7 @@ fn community_dependency_plans() { ); let outcome = ok( - build_dependency_plan_command, + build_dependency_plan_command::, gradle(), "gradle", request(Some(app)), @@ -622,9 +623,9 @@ fn community_dependency_plans() { /// The captured trees decode with their scopes, project nodes and /// transitive children intact. #[test] -fn community_captured_dependencies_decode() { +fn published_captured_dependencies_decode() { let decode = |manifest: Value, build_system: &str, captured: &str, project: Option<&str>| { - let workspace = Workspace::new("community-dependencies"); + let workspace = Workspace::new("published-dependencies"); let output = workspace.capture("state/deps.json", captured); let mut request = json!({ "workspaceRoot": workspace.root(), "root": ".", "outputFile": output }); @@ -632,7 +633,7 @@ fn community_captured_dependencies_decode() { request["project"] = json!(project); } ok( - build_decode_dependencies_command, + build_decode_dependencies_command::, manifest, build_system, request, @@ -685,12 +686,12 @@ fn community_captured_dependencies_decode() { /// Compiler output in each tool's format becomes workspace-relative problems. #[test] -fn community_diagnostics_decode() { - let workspace = Workspace::new("community-diagnostics"); +fn published_diagnostics_decode() { + let workspace = Workspace::new("published-diagnostics"); let root = workspace.root(); let decode = |manifest: Value, build_system: &str, output: String| { ok( - build_decode_diagnostics_command, + build_decode_diagnostics_command::, manifest, build_system, json!({ "workspaceRoot": root, "root": ".", "output": output }), diff --git a/rust/crates/core/src/remote/sessions_integration_tests.rs b/rust/crates/core/src/tests/remote_sessions.rs similarity index 94% rename from rust/crates/core/src/remote/sessions_integration_tests.rs rename to rust/crates/core/src/tests/remote_sessions.rs index de6447fa..2c3c31d4 100644 --- a/rust/crates/core/src/remote/sessions_integration_tests.rs +++ b/rust/crates/core/src/tests/remote_sessions.rs @@ -1,9 +1,15 @@ //! Real framed-host integration coverage for remote workspace semantic engines. -use super::*; +use lithe_protocol::ErrorCode; +use lithe_remote::client::session::ConnectOptions; +use lithe_remote::client::RemoteSession; +use lithe_remote::protocol::methods; +use lithe_workspace::test_support; use serde_json::{json, Value}; use std::net::Shutdown; use std::os::unix::net::UnixStream; +use std::sync::mpsc; +use std::time::{Duration, Instant}; const STEP: Duration = Duration::from_secs(10); @@ -18,7 +24,7 @@ struct HostFixture { impl HostFixture { fn new() -> Self { - let root = crate::tests::support::temporary_root("remote-git-integration"); + let root = super::support::temporary_root("remote-git-integration"); std::fs::create_dir_all(root.join("host")).unwrap(); std::fs::create_dir_all(root.join("placeholder")).unwrap(); let root = root.canonicalize().unwrap(); @@ -42,15 +48,7 @@ impl HostFixture { ) .unwrap(); drop(events); - let id = nonce(); - registry().lock().unwrap().insert( - id.clone(), - Arc::new(Connection { - session, - git_tool: Mutex::new(None), - transport: ConnectionTransport::InProcess, - }), - ); + let id = test_support::register_in_process(session); Self { id, root, @@ -71,7 +69,7 @@ impl HostFixture { fn request_at(&self, remote_root: &std::path::Path, command: &str, payload: Value) -> Value { let response: Value = serde_json::from_str(&crate::execute_json(&json!({ - "id": nonce(), "command": command, "timeoutMilliseconds": 30_000, + "id": test_support::unique_id(), "command": command, "timeoutMilliseconds": 30_000, "remoteWorkspace": {"sessionId": self.id, "localRoot": self.root.join("placeholder"), "remoteRoot": remote_root}, "payload": payload, }).to_string())).unwrap(); @@ -200,13 +198,11 @@ fn remote_build_detects_a_root_opened_through_a_host_symbolic_link() { impl Drop for HostFixture { fn drop(&mut self) { let _ = crate::execute_json(&json!({ - "id": nonce(), "command": "workspace.searchIndex.invalidate", "timeoutMilliseconds": 1_000, + "id": test_support::unique_id(), "command": "workspace.searchIndex.invalidate", "timeoutMilliseconds": 1_000, "remoteWorkspace": {"sessionId": self.id, "localRoot": self.root.join("placeholder"), "remoteRoot": self.root.join("host")}, "payload": {"root": self.root.join("placeholder")} }).to_string()); - if let Some(connection) = registry().lock().unwrap().remove(&self.id) { - connection.close(); - } + test_support::unregister(&self.id); let _ = self.socket.shutdown(Shutdown::Both); if self.done.recv_timeout(STEP + STEP).is_ok() { if let Some(worker) = self.worker.take() { @@ -262,7 +258,7 @@ fn remote_directory_listing_uses_host_paths_visibility_and_natural_order() { ); // File type already arrives with readDir. Growing the number of regular // files must not add one network round trip per file. - let count = crate::remote::workspace_io::stat_requests_for_test; + let count = test_support::stat_requests; let before = count(); host.call("workspace.listDirectories", json!({"paths": [""]})); let baseline_stats = count() - before; @@ -297,7 +293,7 @@ fn remote_search_reads_host_content_and_applies_incremental_updates_without_loca // Warm the same URL locally first: host identity must separate its empty index. let local: Value = serde_json::from_str(&crate::execute_json( &json!({ - "id": nonce(), "command": "workspace.searchEverywhere", + "id": test_support::unique_id(), "command": "workspace.searchEverywhere", "payload": {"root": host.root.join("placeholder"), "query": "RemoteName"} }) .to_string(), @@ -350,7 +346,7 @@ fn remote_search_reads_host_content_and_applies_incremental_updates_without_loca ); let local: Value = serde_json::from_str(&crate::execute_json( &json!({ - "id": nonce(), "command": "workspace.searchIndex.invalidate", + "id": test_support::unique_id(), "command": "workspace.searchIndex.invalidate", "payload": {"root": host.root.join("placeholder")} }) .to_string(), @@ -463,7 +459,7 @@ fn discovered_repository_keeps_host_identity(symlink: bool) { let local_git = |arguments: &[&str]| { let response: Value = serde_json::from_str(&crate::execute_json( &json!({ - "id": nonce(), "command": "git.command", "timeoutMilliseconds": 10_000, + "id": test_support::unique_id(), "command": "git.command", "timeoutMilliseconds": 10_000, "payload": {"root": expected, "arguments": arguments} }) .to_string(), @@ -657,7 +653,7 @@ fn remote_git_configuration_reports_the_host_executable_and_sources() { host.git(&["config", "fetch.prune", "true"]); let request = |command| -> Value { serde_json::from_str(&crate::execute_json(&json!({ - "id": nonce(), "command": command, "timeoutMilliseconds": 10_000, + "id": test_support::unique_id(), "command": command, "timeoutMilliseconds": 10_000, "remoteWorkspace": {"sessionId": host.id, "localRoot": host.root.join("placeholder"), "remoteRoot": host.root.join("host")}, // This client override must not be used on the workspace host. "gitExecution": {"executable": "/fixture/client-only/git"}, @@ -683,22 +679,22 @@ fn remote_git_configuration_reports_the_host_executable_and_sources() { assert!(entry["origin"].as_str().unwrap().contains(".git/config")); // Disconnect after a cached resolution: errors must still propagate instead // of showing an empty setting or probing the client's selected executable. - lookup(&host.id).unwrap().close(); + test_support::drop_link(&host.id); let closed = request("git.executable"); assert_eq!(closed["ok"], false, "{closed}"); } #[test] fn remote_workspace_file_limits_preserve_host_content_and_report_disconnects() { - use crate::remote::{workspace, workspace_io as fs}; + use lithe_workspace::{self as workspace, fs}; use std::io::Read; let host = HostFixture::new(); - let _deadline = crate::protocol::cancellation::Scope::begin(None, Some(10_000)); + let _deadline = lithe_protocol::cancellation::Scope::begin(None, Some(10_000)); let workspace = serde_json::from_value(json!({"sessionId": host.id, "localRoot": host.root.join("placeholder"), "remoteRoot": host.root.join("host")})) .unwrap(); - let _workspace = workspace::Scope::begin(Some(workspace), "git.status").unwrap(); + let _workspace = workspace::Scope::begin(Some(workspace)).unwrap(); let local = host.root.join("placeholder"); let remote = host.root.join("host"); let content = vec![b'x'; 2 * 1024 * 1024 + 7]; @@ -731,7 +727,7 @@ fn remote_workspace_file_limits_preserve_host_content_and_report_disconnects() { assert!(!fs::try_exists(local.join("absent")).unwrap()); assert!(fs::is_dir(&local).unwrap()); - lookup(&host.id).unwrap().close(); + test_support::drop_link(&host.id); assert!(fs::try_exists(local.join("file")).is_err()); assert!(fs::is_dir(&local).is_err()); assert_eq!(std::fs::read_dir(local).unwrap().count(), 0); @@ -742,10 +738,10 @@ fn remote_workspace_file_limits_preserve_host_content_and_report_disconnects() { #[test] fn shared_run_scan_reads_the_host_tree_and_stops_when_the_link_drops() { use crate::execution::workspace_scan::{scan_run_workspace, scan_with_reader}; - use crate::remote::{workspace, workspace_io as fs}; + use lithe_workspace::{self as workspace, fs}; let host = HostFixture::new(); - let _deadline = crate::protocol::cancellation::Scope::begin(None, Some(10_000)); + let _deadline = lithe_protocol::cancellation::Scope::begin(None, Some(10_000)); let local = host.root.join("placeholder"); let remote = host.root.join("host"); std::fs::create_dir_all(remote.join("service/api")).unwrap(); @@ -755,7 +751,7 @@ fn shared_run_scan_reads_the_host_tree_and_stops_when_the_link_drops() { json!({"sessionId": host.id, "localRoot": local, "remoteRoot": remote}), ) .unwrap(); - let _workspace = workspace::Scope::begin(Some(workspace), "build.detect").unwrap(); + let _workspace = workspace::Scope::begin(Some(workspace)).unwrap(); let scan = scan_run_workspace(&local, Some(3)).unwrap(); let files: Vec<_> = scan @@ -810,7 +806,7 @@ fn shared_run_scan_reads_the_host_tree_and_stops_when_the_link_drops() { Ok((entry.path(), kind.is_dir(), kind.is_symlink())) }) .collect::>>(); - lookup(&host.id).unwrap().close(); + test_support::drop_link(&host.id); entries }); assert!( @@ -825,8 +821,8 @@ fn shared_run_scan_reads_the_host_tree_and_stops_when_the_link_drops() { fn captured_host_process_preserves_binary_streams_and_cleans_up_limit_failures() { let host = HostFixture::new(); let capture = |argv: &[&str], timeout: u64, limit: usize| { - let _scope = crate::protocol::cancellation::Scope::begin(None, Some(5_000)); - crate::remote::workspace_host::capture_process( + let _scope = lithe_protocol::cancellation::Scope::begin(None, Some(5_000)); + lithe_workspace::host::capture_process( serde_json::from_value(json!({ "sessionId": host.id, "params": {"processId": 0, "argv": argv}, "timeoutMilliseconds": timeout, "maxOutputBytes": limit @@ -865,7 +861,7 @@ fn captured_host_process_preserves_binary_streams_and_cleans_up_limit_failures() capture(&["/bin/sleep", "60"], 50, 128).unwrap_err().code, ErrorCode::TimedOut )); - let connection = lookup(&host.id).unwrap(); + let connection = test_support::connection(&host.id); let deadline = Instant::now() + STEP; loop { let state = connection @@ -897,7 +893,7 @@ fn native_plugin_upload_streams_beyond_one_frame_and_rejects_mismatch_without_pu let request = serde_json::from_value(json!({"sessionId": host.id, "sourcePath": source, "path": path, "sha256": expected, "mode": 0o500})) .unwrap(); - super::super::upload::upload(request) + lithe_workspace::upload::upload(request) }; let receipt = upload(&target, &digest).unwrap(); assert_eq!(receipt["sha256"], digest); @@ -928,15 +924,13 @@ fn native_plugin_upload_streams_beyond_one_frame_and_rejects_mismatch_without_pu #[test] fn plugin_stdio_uses_its_owned_host_and_reclaims_the_remote_process() { - use crate::lsp::interface::process::{ - LspProcessLauncher, LspProcessSpec, SystemProcessLauncher, - }; + use lithe_lsp_transport::process::{LspProcessLauncher, LspProcessSpec, SystemProcessLauncher}; use std::io::Read; let host = HostFixture::new(); let workspace = serde_json::from_value(json!({"sessionId": host.id, "localRoot": host.root.join("placeholder"), "remoteRoot": host.root.join("host")})) .unwrap(); - let scope = super::super::workspace::Scope::begin(Some(workspace), "lsp.startServer").unwrap(); + let scope = lithe_workspace::Scope::begin(Some(workspace)).unwrap(); let streams = SystemProcessLauncher .launch(LspProcessSpec { executable: "/bin/sh".into(), @@ -996,8 +990,8 @@ fn plugin_protocol_maps_owned_uris_without_rewriting_source_text() { let workspace = serde_json::from_value(json!({"sessionId": host.id, "localRoot": host.root.join("placeholder"), "remoteRoot": host.root.join("host")})) .unwrap(); - let _scope = super::super::workspace::Scope::begin(Some(workspace), "lsp.startServer").unwrap(); - let owner = super::super::workspace::current().unwrap(); + let _scope = lithe_workspace::Scope::begin(Some(workspace)).unwrap(); + let owner = lithe_workspace::current().unwrap(); let local = url::Url::from_file_path(host.root.join("placeholder/a space.txt")) .unwrap() .to_string(); @@ -1022,7 +1016,7 @@ fn plugin_protocol_maps_owned_uris_without_rewriting_source_text() { let value = json!({"method": "textDocument/didOpen", "params": {"textDocument": {"uri": local, "text": format!("// {local}"), "languageId": "plaintext", "version": 1}}}); let mapped: Value = serde_json::from_str( - &crate::lsp::interface::remote_process::map_message(&owner, &value.to_string(), true, None) + &lithe_lsp_transport::remote_process::map_message(&owner, &value.to_string(), true, None) .unwrap(), ) .unwrap(); @@ -1033,7 +1027,7 @@ fn plugin_protocol_maps_owned_uris_without_rewriting_source_text() { ); let edit = json!({"method": "workspace/applyEdit", "params": {"edit": {"changes": {remote.clone(): [{"newText": local}]}}}}); let mapped: Value = serde_json::from_str( - &crate::lsp::interface::remote_process::map_message(&owner, &edit.to_string(), false, None) + &lithe_lsp_transport::remote_process::map_message(&owner, &edit.to_string(), false, None) .unwrap(), ) .unwrap(); @@ -1043,7 +1037,7 @@ fn plugin_protocol_maps_owned_uris_without_rewriting_source_text() { ); let map = |value: &Value, outbound, method| -> Value { serde_json::from_str( - &crate::lsp::interface::remote_process::map_message( + &lithe_lsp_transport::remote_process::map_message( &owner, &value.to_string(), outbound, diff --git a/rust/crates/core/src/tests/request_events.rs b/rust/crates/core/src/tests/request_events.rs new file mode 100644 index 00000000..c0be5b19 --- /dev/null +++ b/rust/crates/core/src/tests/request_events.rs @@ -0,0 +1,81 @@ +//! Event channels of requests run through `execute_json_with_events`: a host +//! callback that answers through Core keeps the outer request's events and +//! cancellation, and a nested observed request gets its own channel. + +use std::sync::Arc; + +#[test] +fn callback_auth_reply_preserves_outer_events_and_cancellation() { + let events = Arc::new(std::sync::Mutex::new(Vec::::new())); + let captured = events.clone(); + let response = crate::execute_json_with_events( + r#"{"id":"callback-cancellation","command":"core.ping","timeoutMilliseconds":1000}"#, + Arc::new(move |event| { + let event: serde_json::Value = serde_json::from_str(event).unwrap(); + captured.lock().unwrap().push(event.clone()); + if event["type"] == "requestStarted" && event["operationId"] == "callback-cancellation" + { + // Like the C AskPass host, synchronously reply through Core + // before requesting cancellation of the observed operation. + let reply: serde_json::Value = serde_json::from_str(&crate::execute_json( + r#"{"id":"callback-auth-reply","command":"git.authRespond","payload":{"requestId":"expired-fixture","answer":null}}"#, + )).unwrap(); + assert_eq!(reply["ok"], true); + assert_eq!(reply["data"]["accepted"], false); + assert!(crate::cancel_operation("callback-cancellation")); + } + }), + ); + let response: serde_json::Value = serde_json::from_str(&response).unwrap(); + assert_eq!(response["error"]["code"], "cancelled", "{response}"); + let events = events.lock().unwrap(); + assert_eq!(events.len(), 2, "{events:?}"); + assert_eq!(events[0]["type"], "requestStarted"); + assert_eq!(events[1]["type"], "requestFinished"); + assert!(events + .iter() + .all(|event| event["operationId"] == "callback-cancellation")); + assert!(!crate::cancel_operation("callback-cancellation")); +} + +#[test] +fn callback_can_observe_a_nested_request_without_mixing_channels() { + let outer = Arc::new(std::sync::Mutex::new(Vec::::new())); + let inner = Arc::new(std::sync::Mutex::new(Vec::::new())); + let outer_capture = outer.clone(); + let inner_capture = inner.clone(); + let response = crate::execute_json_with_events( + r#"{"id":"outer-observer","command":"core.ping"}"#, + Arc::new(move |event| { + let event: serde_json::Value = serde_json::from_str(event).unwrap(); + outer_capture.lock().unwrap().push(event.clone()); + if event["type"] == "requestStarted" { + let captured = inner_capture.clone(); + let response = crate::execute_json_with_events( + r#"{"id":"inner-observer","command":"core.ping"}"#, + Arc::new(move |event| { + captured + .lock() + .unwrap() + .push(serde_json::from_str(event).unwrap()) + }), + ); + assert_eq!( + serde_json::from_str::(&response).unwrap()["ok"], + true + ); + } + }), + ); + assert_eq!( + serde_json::from_str::(&response).unwrap()["ok"], + true + ); + for (events, id) in [(outer, "outer-observer"), (inner, "inner-observer")] { + let events = events.lock().unwrap(); + assert_eq!(events.len(), 2); + assert_eq!(events[0]["type"], "requestStarted"); + assert_eq!(events[1]["type"], "requestFinished"); + assert!(events.iter().all(|event| event["operationId"] == id)); + } +} diff --git a/rust/crates/core/src/tests/toolchain_definitions.rs b/rust/crates/core/src/tests/toolchain_definitions.rs index 582ae279..4154252d 100644 --- a/rust/crates/core/src/tests/toolchain_definitions.rs +++ b/rust/crates/core/src/tests/toolchain_definitions.rs @@ -1,5 +1,5 @@ -use crate::plugins::declarative::{validate_command, validate_manifest_value}; use crate::toolchain::definitions::{registry, validate_declarations}; +use lithe_plugin_manifest::{validate_command, validate_manifest_value, validated_manifest_json}; use serde_json::{json, Value}; fn definition(id: &str) -> Value { @@ -34,7 +34,11 @@ fn tool_definitions_are_complete_contributions_without_languages_or_backend() { definition("dev.lithe.fixture.compiler:compiler"), ); assert!(validate_manifest_value(plugin.clone()).is_ok()); - let result = registry(&json!({"plugins":[{"manifest":plugin}]})).unwrap(); + let result = registry( + &json!({"plugins":[{"manifest":plugin}]}), + validated_manifest_json, + ) + .unwrap(); assert_eq!( result["definitions"][0]["definition"]["id"], "dev.lithe.fixture.compiler:compiler" @@ -56,12 +60,16 @@ fn tool_definitions_second_tool_needs_only_plugin_data() { } } -/// Every plugin is a community plugin, so a short ID needs no host-attested origin. +/// No plugin needs a host-attested origin for a short ID. #[test] fn registry_accepts_short_ids_from_any_enabled_plugin() { let plugin = manifest("dev.lithe.fixture.compiler", definition("compiler")); assert!(validate_manifest_value(plugin.clone()).is_ok()); - let result = registry(&json!({"plugins":[{"manifest":plugin}]})).unwrap(); + let result = registry( + &json!({"plugins":[{"manifest":plugin}]}), + validated_manifest_json, + ) + .unwrap(); assert_eq!(result["definitions"][0]["definition"]["id"], "compiler"); assert_eq!( result["definitions"][0]["pluginID"], @@ -69,7 +77,11 @@ fn registry_accepts_short_ids_from_any_enabled_plugin() { ); assert_eq!(result["conflicts"], json!([])); // The retired host-supplied origin flag is an unknown field, not silently ignored. - assert!(registry(&json!({"plugins":[{"manifest":plugin,"verifiedOfficial":true}]})).is_err()); + assert!(registry( + &json!({"plugins":[{"manifest":plugin,"verifiedOfficial":true}]}), + validated_manifest_json + ) + .is_err()); } /// Install order and plugin ID must not pick an owner, so a contested ID has none. @@ -86,7 +98,7 @@ fn registry_leaves_a_contested_tool_id_unowned_and_registers_the_rest() { json!([{"manifest":first},{"manifest":second},{"manifest":third}]), json!([{"manifest":third},{"manifest":second},{"manifest":first}]), ] { - let result = registry(&json!({"plugins":plugins})).unwrap(); + let result = registry(&json!({"plugins":plugins}), validated_manifest_json).unwrap(); let ids: Vec<_> = result["definitions"] .as_array() .unwrap() @@ -118,7 +130,7 @@ fn registry_keeps_providers_that_name_a_contested_tool() { "toolchainProviders":[{"id":"installs","displayName":"Installs","tools":["compiler"]}], "backend":{"launch":{"kind":"packaged","path":"bin/provider"}}}); let plugins = json!([{"manifest":first},{"manifest":second},{"manifest":provider}]); - let result = registry(&json!({"plugins":plugins})).unwrap(); + let result = registry(&json!({"plugins":plugins}), validated_manifest_json).unwrap(); assert_eq!(result["definitions"], json!([])); assert_eq!( result["providers"][0]["pluginID"], @@ -132,7 +144,11 @@ fn registry_keeps_providers_that_name_a_contested_tool() { fn registry_rejects_duplicate_plugin_ids() { let first = manifest("dev.lithe.fixture.compiler", definition("compiler")); let second = manifest("dev.lithe.fixture.compiler", definition("renderer")); - assert!(registry(&json!({"plugins":[{"manifest":first},{"manifest":second}]})).is_err()); + assert!(registry( + &json!({"plugins":[{"manifest":first},{"manifest":second}]}), + validated_manifest_json + ) + .is_err()); } #[test] @@ -201,7 +217,11 @@ fn definitions_reuse_shared_candidate_graph_ordering() { json!({"id":"alternative","kind":"command","command":"compiler-alt","insertAfter":"shell"}), ); let plugin = manifest("dev.lithe.fixture.compiler", value); - let result = registry(&json!({"plugins":[{"manifest":plugin}]})).unwrap(); + let result = registry( + &json!({"plugins":[{"manifest":plugin}]}), + validated_manifest_json, + ) + .unwrap(); assert_eq!( result["definitions"][0]["definition"]["executables"][4]["id"], "shell" @@ -268,7 +288,11 @@ fn a_definition_plugin_may_provide_installations_of_its_own_tool() { json!([{"id":"installs","displayName":"Installs","tools":[format!("{id}:compiler")]}]); plugin["backend"] = json!({"launch":{"kind":"packaged","path":"bin/provider"}}); assert!(validate_manifest_value(plugin.clone()).is_ok()); - let result = registry(&json!({"plugins":[{"manifest":plugin}]})).unwrap(); + let result = registry( + &json!({"plugins":[{"manifest":plugin}]}), + validated_manifest_json, + ) + .unwrap(); assert_eq!(result["definitions"][0]["pluginID"], id); assert_eq!(result["providers"][0]["pluginID"], id); } diff --git a/rust/crates/core/src/tests/toolchain_pins.rs b/rust/crates/core/src/tests/toolchain_pins.rs index 36f959bc..27275a7e 100644 --- a/rust/crates/core/src/tests/toolchain_pins.rs +++ b/rust/crates/core/src/tests/toolchain_pins.rs @@ -374,3 +374,26 @@ fn toolchain_pin_edits_enforce_final_collection_limits_and_validate_the_source() ); assert!(response["data"].get("edit").is_none()); } + +#[test] +fn source_edits_match_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../fixtures/toolchain-pin-storage.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let response: Value = serde_json::from_str(&crate::execute_json( + &json!({"command": case["command"], "payload": case["input"]}).to_string(), + )) + .unwrap(); + if let Some(code) = case.get("error") { + assert_eq!(response["data"]["status"], "rejected", "{case}: {response}"); + assert_eq!( + &response["data"]["error"]["code"], code, + "{case}: {response}" + ); + } else { + assert_eq!(response["data"], case["expected"], "{case}: {response}"); + } + } +} diff --git a/rust/crates/core/src/tests/toolchain_queries.rs b/rust/crates/core/src/tests/toolchain_queries.rs index bffea902..968854dd 100644 --- a/rust/crates/core/src/tests/toolchain_queries.rs +++ b/rust/crates/core/src/tests/toolchain_queries.rs @@ -2,6 +2,7 @@ use super::toolchain_vectors::{error_responses, query_request, query_response, HOME, WORKSPACE}; use crate::toolchain::{paths, query}; +use lithe_plugin_manifest::validated_manifest_json; use serde_json::{json, Value}; fn contract(name: &str) -> Value { @@ -35,7 +36,7 @@ fn input() -> Value { #[test] fn toolchain_queries_decode_installed_contract_reply_without_native_io() { - let outcome = query::decode(&input()).unwrap(); + let outcome = query::decode(&input(), validated_manifest_json).unwrap(); assert_eq!(outcome["installations"][0]["home"], HOME); assert_eq!( outcome["installations"][0]["executable"], @@ -61,7 +62,7 @@ fn toolchain_queries_preserve_bounded_string_and_numeric_wire_identities() { value["request"]["id"] = case["id"].clone(); value["response"]["id"] = case["id"].clone(); assert_eq!( - query::decode(&value).is_ok(), + query::decode(&value, validated_manifest_json).is_ok(), case["accepted"].as_bool().unwrap(), "{case}" ); @@ -75,7 +76,9 @@ fn toolchain_queries_preserve_bounded_string_and_numeric_wire_identities() { value["request"]["id"] = request; value["response"]["id"] = response; assert_eq!( - query::decode(&value).unwrap_err().code, + query::decode(&value, validated_manifest_json) + .unwrap_err() + .code, "plugin.toolchain.invalidReply" ); } @@ -87,7 +90,7 @@ fn toolchain_queries_uninstalled_request_is_structured_unmet_data() { value["response"]["result"]["installations"] = json!([]); value["response"]["result"]["statements"][0]["installation"] = Value::Null; value["response"]["result"]["statements"][0]["request"] = json!("99"); - let outcome = query::decode(&value).unwrap(); + let outcome = query::decode(&value, validated_manifest_json).unwrap(); assert_eq!(outcome["diagnostics"][0]["code"], "requestedNotInstalled"); assert_eq!(outcome["diagnostics"][0]["request"], "99"); assert!(outcome["statements"][0]["installation"].is_null()); @@ -102,7 +105,7 @@ fn toolchain_queries_unknown_definitions_retain_no_path_or_execution_authority() .remove(0); value["response"]["result"]["installations"][0]["home"] = json!("unusable-relative-home"); value["response"]["result"]["statements"][0]["installation"] = json!("unusable-relative-home"); - let outcome = query::decode(&value).unwrap(); + let outcome = query::decode(&value, validated_manifest_json).unwrap(); assert_eq!(outcome["installations"], json!([])); assert_eq!(outcome["statements"], json!([])); assert_eq!(outcome["diagnostics"][0]["code"], "definitionUnavailable"); @@ -119,7 +122,7 @@ fn toolchain_queries_filter_unsolicited_tools_before_native_path_validation() { .push(json!({"tool":"git","version":"1","home":"unusable-relative-home"})); value["response"]["result"]["statements"].as_array_mut().unwrap().push(json!({"tool":"git","scope":"project","source":"unusable-relative-source","request":"1","installation":null,"kind":"declaration","reader":"native"})); assert_eq!( - query::decode(&value).unwrap()["installations"] + query::decode(&value, validated_manifest_json).unwrap()["installations"] .as_array() .unwrap() .len(), @@ -133,14 +136,18 @@ fn toolchain_queries_refuse_workspace_homes_and_canonical_symlink_targets() { let mut value = input(); value["filesystem"][1]["canonicalPath"] = json!(target); assert_eq!( - query::decode(&value).unwrap_err().code(), + query::decode(&value, validated_manifest_json) + .unwrap_err() + .code(), "plugin.toolchain.unsafePath" ); } let mut value = input(); value["filesystem"][2]["canonicalPath"] = json!(format!("{WORKSPACE}/malicious-java")); assert_eq!( - query::decode(&value).unwrap_err().code(), + query::decode(&value, validated_manifest_json) + .unwrap_err() + .code(), "plugin.toolchain.unsafePath" ); } @@ -150,14 +157,18 @@ fn toolchain_queries_require_executable_and_same_tool_installation_reference() { let mut value = input(); value["filesystem"][2]["executable"] = json!(false); assert_eq!( - query::decode(&value).unwrap_err().code(), + query::decode(&value, validated_manifest_json) + .unwrap_err() + .code(), "plugin.toolchain.unsafePath" ); value = input(); value["response"]["result"]["statements"][0]["installation"] = json!("/opt/lithe-fixtures/unlisted"); assert_eq!( - query::decode(&value).unwrap_err().code(), + query::decode(&value, validated_manifest_json) + .unwrap_err() + .code(), "plugin.toolchain.invalidReply" ); } @@ -168,7 +179,7 @@ fn toolchain_queries_deduplicate_identical_source_facts_but_reject_conflicting_c let duplicate = value["response"]["result"]["statements"][0].clone(); value["response"]["result"]["statements"] = json!([duplicate.clone(), duplicate]); assert_eq!( - query::decode(&value).unwrap()["statements"] + query::decode(&value, validated_manifest_json).unwrap()["statements"] .as_array() .unwrap() .len(), @@ -178,7 +189,9 @@ fn toolchain_queries_deduplicate_identical_source_facts_but_reject_conflicting_c let mut conflict = value.clone(); conflict["response"]["result"]["statements"][1][field] = conflicting; assert_eq!( - query::decode(&conflict).unwrap_err().code(), + query::decode(&conflict, validated_manifest_json) + .unwrap_err() + .code(), "plugin.toolchain.invalidReply", "{field}" ); @@ -195,14 +208,18 @@ fn toolchain_queries_refuse_mismatched_rpc_and_operation_generations() { json!("other") }; assert_eq!( - query::decode(&value).unwrap_err().code(), + query::decode(&value, validated_manifest_json) + .unwrap_err() + .code(), "plugin.toolchain.operationExpired" ); } let mut value = input(); value["response"]["id"] = json!(2); assert_eq!( - query::decode(&value).unwrap_err().code(), + query::decode(&value, validated_manifest_json) + .unwrap_err() + .code(), "plugin.toolchain.invalidReply" ); value = input(); @@ -210,7 +227,9 @@ fn toolchain_queries_refuse_mismatched_rpc_and_operation_generations() { value["request"]["params"]["context"] = Value::Null; value["currentGeneration"] = json!(2); assert_eq!( - query::decode(&value).unwrap_err().code(), + query::decode(&value, validated_manifest_json) + .unwrap_err() + .code(), "plugin.toolchain.operationExpired" ); } @@ -224,7 +243,7 @@ fn toolchain_queries_keep_every_error_mapping_unique_and_validate_the_pair() { response["error"]["data"]["operationID"] = value["request"]["params"]["operationID"].clone(); value["response"] = response; - let error = query::decode(&value).unwrap_err(); + let error = query::decode(&value, validated_manifest_json).unwrap_err(); assert_eq!( Some(error.code()), value["response"]["error"]["data"]["code"].as_str() @@ -233,7 +252,9 @@ fn toolchain_queries_keep_every_error_mapping_unique_and_validate_the_pair() { assert!(numbers.insert(number)); value["response"]["error"]["code"] = json!(-1); assert_eq!( - query::decode(&value).unwrap_err().code(), + query::decode(&value, validated_manifest_json) + .unwrap_err() + .code(), "plugin.toolchain.invalidReply" ); } @@ -244,7 +265,9 @@ fn toolchain_queries_validate_declared_reader_and_project_ancestor() { let mut value = input(); value["response"]["result"]["statements"][0]["reader"] = json!("compatible"); assert_eq!( - query::decode(&value).unwrap_err().code(), + query::decode(&value, validated_manifest_json) + .unwrap_err() + .code(), "plugin.toolchain.invalidReply" ); value = input(); @@ -256,14 +279,18 @@ fn toolchain_queries_validate_declared_reader_and_project_ancestor() { value["response"]["result"]["statements"][0]["source"] = json!("/opt/lithe-fixtures/sibling/mise.toml"); assert_eq!( - query::decode(&value).unwrap_err().code(), + query::decode(&value, validated_manifest_json) + .unwrap_err() + .code(), "plugin.toolchain.unsafePath" ); value = input(); value["request"]["params"]["workspace"] = Value::Null; value["request"]["params"]["context"] = Value::Null; assert_eq!( - query::decode(&value).unwrap_err().code(), + query::decode(&value, validated_manifest_json) + .unwrap_err() + .code(), "plugin.toolchain.invalidReply" ); } @@ -277,7 +304,7 @@ fn toolchain_queries_nested_source_pattern_uses_declared_anchor_not_parent() { .unwrap() .push(fact(&source, "file", false)); value["response"]["result"]["statements"][0]["source"] = json!(source); - let result = query::decode(&value).unwrap(); + let result = query::decode(&value, validated_manifest_json).unwrap(); assert_eq!(result["statements"][0]["sourceDirectory"], WORKSPACE); } @@ -286,7 +313,9 @@ fn toolchain_queries_reject_unknown_fields_missing_null_and_duplicate_identities let mut value = input(); value["request"]["params"]["arbitrary"] = json!(true); assert_eq!( - query::decode(&value).unwrap_err().code(), + query::decode(&value, validated_manifest_json) + .unwrap_err() + .code(), "plugin.toolchain.invalidParams" ); value = input(); @@ -295,20 +324,26 @@ fn toolchain_queries_reject_unknown_fields_missing_null_and_duplicate_identities .unwrap() .remove("installation"); assert_eq!( - query::decode(&value).unwrap_err().code(), + query::decode(&value, validated_manifest_json) + .unwrap_err() + .code(), "plugin.toolchain.invalidReply" ); value = input(); value["request"]["params"]["tools"] = json!(["java", "java"]); assert_eq!( - query::decode(&value).unwrap_err().code(), + query::decode(&value, validated_manifest_json) + .unwrap_err() + .code(), "plugin.toolchain.invalidParams" ); value = input(); let duplicate = value["filesystem"][0].clone(); value["filesystem"].as_array_mut().unwrap().push(duplicate); assert_eq!( - query::decode(&value).unwrap_err().code(), + query::decode(&value, validated_manifest_json) + .unwrap_err() + .code(), "plugin.toolchain.invalidParams" ); } @@ -318,7 +353,9 @@ fn toolchain_queries_refuse_remote_authority_without_local_fallback() { let mut value = input(); value["request"]["params"]["authority"] = json!({"kind":"remote","id":"ssh-1"}); assert_eq!( - query::decode(&value).unwrap_err().code(), + query::decode(&value, validated_manifest_json) + .unwrap_err() + .code(), "plugin.toolchain.authorityUnsupported" ); } @@ -328,29 +365,36 @@ fn toolchain_queries_keep_missing_watch_inputs_and_enforce_reply_limits() { let mut value = input(); value["response"]["result"]["watch"] = json!(["/opt/lithe-fixtures/missing.toml"]); assert_eq!( - query::decode(&value).unwrap()["watch"], + query::decode(&value, validated_manifest_json).unwrap()["watch"], value["response"]["result"]["watch"] ); value["response"]["result"]["watch"] = json!(vec!["/opt/lithe-fixtures/missing.toml"; 257]); assert_eq!( - query::decode(&value).unwrap_err().code(), + query::decode(&value, validated_manifest_json) + .unwrap_err() + .code(), "plugin.toolchain.limitExceeded" ); } +/// Uses the default target-platform case rules for native containment. +fn path_within(path: &str, root: &str, platform: &str) -> bool { + paths::within(path, root, platform, platform != "windows") +} + #[test] fn toolchain_queries_apply_windows_drive_unc_and_case_rules_without_host_paths() { - assert!(paths::path_within( + assert!(path_within( r"C:\Workspace\bin\java.exe", "c:/workspace", "windows" )); - assert!(!paths::path_within( + assert!(!path_within( r"C:\WorkspaceOther\java.exe", "c:/workspace", "windows" )); - assert!(paths::path_within( + assert!(path_within( r"\\server\share\workspace\java.exe", "//SERVER/share/workspace", "windows" diff --git a/rust/crates/core/src/tests/toolchain_resolution.rs b/rust/crates/core/src/tests/toolchain_resolution.rs index 0fa78a27..2e8d5aba 100644 --- a/rust/crates/core/src/tests/toolchain_resolution.rs +++ b/rust/crates/core/src/tests/toolchain_resolution.rs @@ -7,6 +7,7 @@ use crate::toolchain::{ candidates_command, check_version_command, discovery_plan_command, failure_reply_command, resolve_command, }; +use lithe_plugin_manifest::validated_manifest_json; use serde_json::{json, Value}; const OLD: &str = "/opt/lithe-fixtures/installs/java/17.0.12"; @@ -57,7 +58,7 @@ fn installation(input: &mut Value, home: &str, version: &str) { /// Resolves through the exported command envelope, preserving rejection diagnostics. fn resolve(input: &Value) -> Value { - let result = resolve_command(input.clone()).unwrap(); + let result = resolve_command(input.clone(), validated_manifest_json).unwrap(); assert_eq!(result["status"], "ok", "{result}"); result["resolution"].clone() } @@ -93,7 +94,8 @@ fn consumer_fixed_executable_preserves_its_argv_identity_and_has_no_invented_hom .as_array_mut() .unwrap() .push(fact(executable, "file", true)); - let candidates = candidates_command(value_for_candidates(&value)).unwrap(); + let candidates = + candidates_command(value_for_candidates(&value), validated_manifest_json).unwrap(); assert_eq!(candidates["status"], "ok", "{candidates}"); assert!(candidates["candidates"]["skipped"] .as_array() @@ -157,7 +159,8 @@ fn consumer_fixed_project_files_require_the_current_action_before_any_version_pr value["filesystem"].as_array_mut().unwrap().push( json!({"path":executable,"canonicalPath":inside,"kind":"file","executable":true}), ); - let candidates = candidates_command(value_for_candidates(&value)).unwrap(); + let candidates = + candidates_command(value_for_candidates(&value), validated_manifest_json).unwrap(); assert_eq!(candidates["status"], "ok", "{candidates}"); assert!(!candidates["candidates"]["skipped"] .as_array() @@ -189,12 +192,13 @@ fn consumer_fixed_choices_reject_ambiguous_relative_and_authority_smuggling_fiel let mut value = input(); value["fixed"] = fixed.clone(); assert_eq!( - resolve_command(value.clone()).unwrap()["status"], + resolve_command(value.clone(), validated_manifest_json).unwrap()["status"], "rejected", "{fixed}" ); assert_eq!( - candidates_command(value_for_candidates(&value)).unwrap()["status"], + candidates_command(value_for_candidates(&value), validated_manifest_json).unwrap() + ["status"], "rejected", "{fixed}" ); @@ -349,7 +353,7 @@ fn toolchain_resolution_provider_reply_goes_through_real_decoder() { "dev.lithe.fixture.mise:mise" ); value["queries"][0]["response"]["result"]["operationID"] = json!("expired-query"); - let rejected = resolve_command(value).unwrap(); + let rejected = resolve_command(value, validated_manifest_json).unwrap(); assert_eq!(rejected["status"], "rejected"); assert_eq!( rejected["error"]["code"], @@ -368,12 +372,12 @@ fn toolchain_resolution_query_cannot_replace_outer_generation_or_context_policy( )); value["queries"] = json!([query(&value)]); value["generation"] = json!(2); - let rejected = resolve_command(value.clone()).unwrap(); + let rejected = resolve_command(value.clone(), validated_manifest_json).unwrap(); assert_eq!(rejected["status"], "rejected"); assert_eq!(rejected["error"]["code"], "plugin.toolchain.invalidParams"); value["generation"] = json!(1); value["context"]["contextID"] = json!("replacement-context"); - let rejected = resolve_command(value).unwrap(); + let rejected = resolve_command(value, validated_manifest_json).unwrap(); assert_eq!(rejected["status"], "rejected"); assert_eq!(rejected["error"]["code"], "plugin.toolchain.invalidParams"); } @@ -492,7 +496,7 @@ fn toolchain_resolution_equal_versions_are_ambiguous_and_manual_facts_not_automa #[test] fn toolchain_resolution_version_capture_is_definition_driven_for_java8_and_custom_tool() { let value = input(); - let checked=check_version_command(json!({"registry":value["registry"],"tool":"java","output":"java version \"1.8.0_422\"","minimumVersion":"8"})).unwrap(); + let checked=check_version_command(json!({"registry":value["registry"],"tool":"java","output":"java version \"1.8.0_422\"","minimumVersion":"8"}), validated_manifest_json).unwrap(); assert_eq!(checked["status"], "ok", "{checked}"); assert_eq!(checked["version"]["version"], "8.0"); assert_eq!(checked["version"]["satisfied"], true); @@ -501,7 +505,7 @@ fn toolchain_resolution_version_capture_is_definition_driven_for_java8_and_custo let definition = &mut registry["plugins"][0]["manifest"]["toolDefinitions"][0]; definition["id"] = json!("dev.lithe.fixture.java-support:renderer"); definition["version"]["pattern"] = json!("release=([0-9]+(?:\\.[0-9]+)*)"); - let checked=check_version_command(json!({"registry":registry,"tool":"dev.lithe.fixture.java-support:renderer","output":"render release=10.2","minimumVersion":"9.99"})).unwrap(); + let checked=check_version_command(json!({"registry":registry,"tool":"dev.lithe.fixture.java-support:renderer","output":"render release=10.2","minimumVersion":"9.99"}), validated_manifest_json).unwrap(); assert_eq!(checked["status"], "ok", "{checked}"); assert_eq!(checked["version"]["version"], "10.2"); assert_eq!(checked["version"]["satisfied"], true); @@ -512,8 +516,11 @@ fn toolchain_resolution_discovery_comes_only_from_enabled_definition_data() { let mut value = input(); let scans = json!([{"root":{"kind":"home"},"pattern":"runtimes/*.fixture","platforms":["macos"]},{"root":{"kind":"home"},"pattern":"other/*.fixture","platforms":["windows"]}]); value["registry"]["plugins"][0]["manifest"]["toolDefinitions"][0]["discovery"] = scans.clone(); - let result = - discovery_plan_command(json!({"registry":value["registry"],"platform":"macos"})).unwrap(); + let result = discovery_plan_command( + json!({"registry":value["registry"],"platform":"macos"}), + validated_manifest_json, + ) + .unwrap(); assert_eq!(result["status"], "ok", "{result}"); assert_eq!(result["plan"]["tools"][0]["scans"], json!([scans[0]])); assert_eq!( @@ -524,24 +531,32 @@ fn toolchain_resolution_discovery_comes_only_from_enabled_definition_data() { .as_array_mut() .unwrap() .remove(0); - let result = - discovery_plan_command(json!({"registry":value["registry"],"platform":"macos"})).unwrap(); + let result = discovery_plan_command( + json!({"registry":value["registry"],"platform":"macos"}), + validated_manifest_json, + ) + .unwrap(); assert_eq!(result["plan"]["tools"], json!([])); } #[test] fn toolchain_discovery_plan_carries_path_entries_and_platform_home_variables() { - let result = - discovery_plan_command(json!({"registry":input()["registry"],"platform":"macos"})).unwrap(); + let result = discovery_plan_command( + json!({"registry":input()["registry"],"platform":"macos"}), + validated_manifest_json, + ) + .unwrap(); let tool = &result["plan"]["tools"][0]; assert_eq!(tool["pathPrepend"], json!(["${home}/bin"])); assert_eq!( tool["environment"], json!([{"variable":"JAVA_HOME","path":"bin/java"}]) ); - let result = - discovery_plan_command(json!({"registry":input()["registry"],"platform":"windows"})) - .unwrap(); + let result = discovery_plan_command( + json!({"registry":input()["registry"],"platform":"windows"}), + validated_manifest_json, + ) + .unwrap(); assert_eq!( result["plan"]["tools"][0]["environment"], json!([{"variable":"JAVA_HOME","path":"bin/java.exe"}]) @@ -569,7 +584,7 @@ fn listing(input: &Value) -> Value { } fn candidates(input: &Value) -> Value { - let result = candidates_command(listing(input)).unwrap(); + let result = candidates_command(listing(input), validated_manifest_json).unwrap(); assert_eq!(result["status"], "ok", "{result}"); result["candidates"].clone() } @@ -661,7 +676,7 @@ fn toolchain_candidates_refuse_a_provider_outside_the_registry() { self_provided(&mut value); for provider in ["dev.example.other:installs", "installs", "host:system"] { value["providerInstallations"] = json!([{"tool":"java","home":HOME,"provider":provider}]); - let result = candidates_command(listing(&value)).unwrap(); + let result = candidates_command(listing(&value), validated_manifest_json).unwrap(); assert_eq!(result["status"], "rejected", "{provider}"); } } @@ -678,10 +693,16 @@ fn toolchain_candidates_report_equal_versions_as_a_conflict() { assert_eq!(listed["installations"].as_array().unwrap().len(), 2); let mut unknown = listing(&value); unknown["tool"] = json!("dev.lithe.fixture.java-support:missing"); - assert_eq!(candidates_command(unknown).unwrap()["status"], "rejected"); + assert_eq!( + candidates_command(unknown, validated_manifest_json).unwrap()["status"], + "rejected" + ); let mut directory = listing(&value); directory["directory"] = json!("."); - assert_eq!(candidates_command(directory).unwrap()["status"], "rejected"); + assert_eq!( + candidates_command(directory, validated_manifest_json).unwrap()["status"], + "rejected" + ); } /// Turns the definition plugin into its own installation-only provider, without scans. @@ -748,8 +769,11 @@ fn toolchain_resolution_definition_plugin_can_be_its_own_installation_provider() let mut value = input(); self_provided(&mut value); installation(&mut value, HOME, "21.0.4"); - let plan = - discovery_plan_command(json!({"registry":value["registry"],"platform":"macos"})).unwrap(); + let plan = discovery_plan_command( + json!({"registry":value["registry"],"platform":"macos"}), + validated_manifest_json, + ) + .unwrap(); assert_eq!(plan["plan"]["tools"][0]["scans"], json!([])); assert_eq!( plan["plan"]["tools"][0]["providers"], @@ -772,7 +796,7 @@ fn toolchain_resolution_definition_plugin_can_be_its_own_installation_provider() value["queries"][0]["filesystem"] = value["filesystem"].clone(); value["queries"][0]["response"]["result"]["statements"] = query(&value)["response"]["result"]["statements"].clone(); - let rejected = resolve_command(value).unwrap(); + let rejected = resolve_command(value, validated_manifest_json).unwrap(); assert_eq!(rejected["status"], "rejected"); assert_eq!(rejected["error"]["code"], "plugin.toolchain.invalidReply"); assert_eq!( @@ -844,7 +868,10 @@ fn toolchain_resolution_linked_provider_home_resolves_against_the_same_snapshot( .unwrap() .push(fact(HOME, "file", false)); value["queries"][0]["filesystem"] = value["filesystem"].clone(); - assert_eq!(resolve_command(value).unwrap()["status"], "rejected"); + assert_eq!( + resolve_command(value, validated_manifest_json).unwrap()["status"], + "rejected" + ); } #[test] @@ -904,12 +931,15 @@ fn toolchain_resolution_contested_tool_id_is_unowned_for_every_consumer() { .as_array_mut() .unwrap() .push(json!({"manifest":rival})); - let plan = - discovery_plan_command(json!({"registry":value["registry"],"platform":"macos"})).unwrap(); + let plan = discovery_plan_command( + json!({"registry":value["registry"],"platform":"macos"}), + validated_manifest_json, + ) + .unwrap(); assert_eq!(plan["status"], "ok", "{plan}"); assert_eq!(plan["plan"]["tools"], json!([])); let request = json!({"registry":value["registry"],"tool":tool,"output":"21.0.4"}); - let checked = check_version_command(request).unwrap(); + let checked = check_version_command(request, validated_manifest_json).unwrap(); assert_eq!(checked["status"], "rejected", "{checked}"); let result = resolve(&value); assert_eq!(result["outcome"]["kind"], "skip"); @@ -1028,3 +1058,19 @@ fn toolchain_resolution_project_environment_home_cannot_symlink_out_of_workspace .iter() .any(|row| row["reason"]["code"] == "unsafePath")); } + +#[test] +fn consumer_preferences_json_dispatcher_preserves_the_value_boundary() { + let payload = json!({"document":{"version":1,"workspace":{"tools":{"dev.example.runtime:sdk":{"home":"/fixture/sdk-one"}}, + "consumers":{"dev.example.build/engine":{"dev.example.runtime:sdk":{"inherit":"workspace"}}}}, + "directories":{"nested":{"tools":{"dev.example.runtime:sdk":{"home":"/fixture/directory-sdk"}}}}}, + "tool":"dev.example.runtime:sdk","directory":"nested/child","consumer":"dev.example.build/engine"}); + let response: Value = serde_json::from_str(&crate::execute_json(&json!({ + "id":"consumer-preferences","command":"toolchains.resolveConsumerPreferences","payload":payload + }).to_string())).unwrap(); + assert_eq!(response["ok"], true, "{response}"); + assert_eq!( + response["data"]["preferences"]["fixed"], + json!({"home":"/fixture/sdk-one"}) + ); +} diff --git a/rust/crates/core/src/tests/vcs.rs b/rust/crates/core/src/tests/vcs.rs index 0e14fe43..6d88030e 100644 --- a/rust/crates/core/src/tests/vcs.rs +++ b/rust/crates/core/src/tests/vcs.rs @@ -4,13 +4,14 @@ //! the Subversion provider manifest fixture. use super::support::temporary_root; -use crate::plugins::declarative::{language_server_launch_plan_command, validate_command}; -use crate::plugins::marketplace::install_review_command; use crate::protocol::ErrorCode; use crate::vcs::{ negotiated_capabilities, vcs_decode_reply_command, vcs_detect_roots_command, vcs_owner_of_command, }; +use lithe_plugin_manifest::DeclarativePluginManifest as Manifest; +use lithe_plugin_manifest::{language_server_launch_plan_command, validate_command}; +use lithe_plugin_marketplace::install_review_command; use serde_json::{json, Value}; use std::fs; use std::path::{Path, PathBuf}; @@ -215,7 +216,7 @@ fn root_detection_matches_the_contract_cases() { } } let workspace = sandbox.directory(case["workspace"].as_str().unwrap_or("")); - let outcome = vcs_detect_roots_command(json!({ + let outcome = vcs_detect_roots_command::(json!({ "workspaceRoot": workspace.to_string_lossy(), "plugins": [{ "manifest": svn_provider() }], "mappings": case.get("mappings").cloned().unwrap_or(json!([])), @@ -288,15 +289,15 @@ fn detection_lists_providers_and_reports_conflicts_and_rejections() { let sandbox = Sandbox::new("vcs-providers"); // `com.` sorts before `dev.`, so only the namespace rule lets the // official fixture win. - let mut community = svn_provider(); - community["id"] = json!("com.example.svn"); + let mut unofficial = svn_provider(); + unofficial["id"] = json!("com.example.svn"); let mut broken = svn_provider(); broken["vcsProviders"][0]["id"] = json!("SVN"); for plugins in [ - vec![community.clone(), broken.clone(), svn_provider()], - vec![svn_provider(), broken.clone(), community.clone()], + vec![unofficial.clone(), broken.clone(), svn_provider()], + vec![svn_provider(), broken.clone(), unofficial.clone()], ] { - let outcome = vcs_detect_roots_command(json!({ + let outcome = vcs_detect_roots_command::(json!({ "workspaceRoot": sandbox.0.to_string_lossy(), "plugins": plugins .into_iter() @@ -330,7 +331,7 @@ fn detection_lists_providers_and_reports_conflicts_and_rejections() { assert_eq!(rejected[0]["index"], 1); } - let error = vcs_detect_roots_command(json!({ "workspaceRoot": "relative/path" })) + let error = vcs_detect_roots_command::(json!({ "workspaceRoot": "relative/path" })) .expect_err("a relative workspace is a malformed call"); assert!(matches!(error.code, ErrorCode::InvalidRequest), "{error:?}"); } diff --git a/rust/crates/core/src/toolchain/resolution.rs b/rust/crates/core/src/toolchain/resolution.rs deleted file mode 100644 index 435a7563..00000000 --- a/rust/crates/core/src/toolchain/resolution.rs +++ /dev/null @@ -1,1606 +0,0 @@ -//! Resolves captured toolchain candidates without native I/O or manager semantics. - -use super::{definitions, fixed::FixedToolchainSelection, paths, pins, query}; -use crate::plugins::declarative::{PluginRejection, Validation}; -use regex::RegexBuilder; -use serde::Deserialize; -use serde_json::{json, Value}; -use std::collections::{BTreeMap, BTreeSet}; - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct Probe { - executable: String, - output: String, - exit_code: i32, -} - -fn fields(input: &Value, allowed: &[&str]) -> Validation<()> { - if !input - .as_object() - .is_some_and(|object| object.keys().all(|key| allowed.contains(&key.as_str()))) - { - return Err(reject("input")); - } - Ok(()) -} - -/// Numeric releases are tool-neutral; display aliases never enter comparison. -fn components(value: &str) -> Option> { - if value.is_empty() || value.len() > 64 { - return None; - } - let parts: Vec<_> = value.split('.').collect(); - if parts.len() > 8 { - return None; - } - let mut numbers = parts - .iter() - .map(|part| { - (!part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit())) - .then(|| part.parse().ok()) - .flatten() - }) - .collect::>>()?; - while numbers.len() > 1 && numbers.last() == Some(&0) { - numbers.pop(); - } - Some(numbers) -} - -fn reject(field: &str) -> PluginRejection { - PluginRejection::at( - "plugin.toolchain.invalidParams", - field, - "Invalid toolchain snapshot or native evidence.", - ) -} - -fn bounded(value: &str, max: usize) -> bool { - !value.is_empty() && value.len() <= max && !value.chars().any(char::is_control) -} - -fn array<'a>(input: &'a Value, key: &str, max: usize) -> Validation<&'a [Value]> { - match input.get(key) { - None => Ok(&[]), - Some(value) => value - .as_array() - .filter(|items| items.len() <= max) - .map(Vec::as_slice) - .ok_or_else(|| reject(key)), - } -} - -fn enabled<'a>(registry: &'a Value, tool: &str) -> Option<&'a Value> { - registry["definitions"] - .as_array()? - .iter() - .find(|entry| entry["definition"]["id"] == tool) - .map(|entry| &entry["definition"]) -} - -fn capture(definition: &Value, output: &str, minimum: Option<&str>) -> Validation { - if output.len() > 128 * 1024 { - return Err(reject("output")); - } - let minimum = minimum - .map(|value| components(value).ok_or_else(|| reject("minimumVersion"))) - .transpose()?; - let expression = RegexBuilder::new(definition["version"]["pattern"].as_str().unwrap_or("")) - .size_limit(1024 * 1024) - .build() - .map_err(|_| reject("version.pattern"))?; - let captured = expression - .captures(output) - .and_then(|captures| captures.get(1)) - .map(|value| value.as_str()); - let version = captured.filter(|value| components(value).is_some()); - let satisfied = version.is_some_and(|version| { - minimum - .as_ref() - .is_none_or(|minimum| components(version).as_ref() >= Some(minimum)) - }); - Ok(json!({"satisfied":satisfied,"version":version})) -} - -/// Uses only the installed definition's regular expression, never a tool-name branch. -pub(crate) fn check_version(input: &Value) -> Validation { - fields(input, &["registry", "tool", "output", "minimumVersion"])?; - let registry = definitions::registry(&input["registry"])?; - let tool = input["tool"].as_str().ok_or_else(|| reject("tool"))?; - let definition = enabled(®istry, tool).ok_or_else(|| reject("tool"))?; - let output = input["output"].as_str().ok_or_else(|| reject("output"))?; - let minimum = input - .get("minimumVersion") - .map(|value| value.as_str().ok_or_else(|| reject("minimumVersion"))) - .transpose()?; - capture(definition, output, minimum) -} - -/// Longest argument list or string the launch expansion accepts, in items and bytes. -const MAX_LAUNCH_ITEMS: usize = 1024; -const MAX_LAUNCH_ITEM_BYTES: usize = 65536; - -/// Reads an optional bounded string argument that never carries a NUL. -fn launch_text<'a>(input: &'a Value, key: &str) -> Validation> { - match input.get(key) { - None | Some(Value::Null) => Ok(None), - Some(value) => value - .as_str() - .filter(|text| !text.is_empty() && text.len() <= 4096 && !text.contains('\0')) - .map(Some) - .ok_or_else(|| reject(key)), - } -} - -fn launch_list(input: &Value, key: &str) -> Validation> { - let items = array(input, key, MAX_LAUNCH_ITEMS)?; - items - .iter() - .map(|item| { - item.as_str() - .filter(|text| text.len() <= MAX_LAUNCH_ITEM_BYTES && !text.contains('\0')) - .map(str::to_owned) - .ok_or_else(|| reject(key)) - }) - .collect() -} - -fn launch_mismatch(message: &str) -> PluginRejection { - PluginRejection::new("plugin.toolchain.launchMismatch", message) -} - -/// Expands an installed definition's `launch` template for a selected executable. -/// -/// The definition alone says how its tool starts an entry or takes arguments, so a plugin and a -/// host never branch on a tool name. A part the caller supplies that the template cannot carry, -/// or a variable the caller leaves out, is `plugin.toolchain.launchMismatch`. -pub(crate) fn instantiate_launch(input: &Value) -> Validation { - fields( - input, - &[ - "registry", - "tool", - "executable", - "home", - "entry", - "runtimeArguments", - "arguments", - ], - )?; - let registry = definitions::registry(&input["registry"])?; - let tool = input["tool"].as_str().ok_or_else(|| reject("tool"))?; - let definition = enabled(®istry, tool).ok_or_else(|| reject("tool"))?; - let executable = launch_text(input, "executable")?.ok_or_else(|| reject("executable"))?; - let home = launch_text(input, "home")?; - let entry = launch_text(input, "entry")?; - let runtime_arguments = launch_list(input, "runtimeArguments")?; - let arguments = launch_list(input, "arguments")?; - let mut consumed = BTreeSet::new(); - let mut expanded = Vec::new(); - for token in definition["launch"].as_array().into_iter().flatten() { - let token = token.as_str().ok_or_else(|| reject("launch"))?; - match token { - "${executable}" => expanded.push(executable.to_owned()), - "${home}" => expanded.push( - home.ok_or_else(|| launch_mismatch("The tool's launch needs its home."))? - .to_owned(), - ), - "${entry}" => { - consumed.insert("entry"); - expanded.push( - entry - .ok_or_else(|| launch_mismatch("The tool's launch needs an entry."))? - .to_owned(), - ); - } - "${runtimeArguments}" => { - consumed.insert("runtimeArguments"); - expanded.extend(runtime_arguments.iter().cloned()); - } - "${arguments}" => { - consumed.insert("arguments"); - expanded.extend(arguments.iter().cloned()); - } - literal => expanded.push(literal.to_owned()), - } - } - if entry.is_some() && !consumed.contains("entry") { - return Err(launch_mismatch("The tool does not launch an entry.")); - } - if !runtime_arguments.is_empty() && !consumed.contains("runtimeArguments") { - return Err(launch_mismatch("The tool takes no runtime arguments.")); - } - if !arguments.is_empty() && !consumed.contains("arguments") { - return Err(launch_mismatch("The tool takes no program arguments.")); - } - let rest = expanded.split_off(1); - Ok(json!({ - "executable": expanded.into_iter().next().unwrap_or_default(), - "arguments": rest, - "versionArguments": definition["version"]["arguments"], - })) -} - -/// Exposes both installation routes per tool: definition-owned scans the host expands, -/// and enabled providers the host queries. Native expansion and checks stay in hosts. -pub(crate) fn discovery_plan(input: &Value) -> Validation { - fields(input, &["registry", "platform"])?; - let registry = definitions::registry(&input["registry"])?; - let platform = input["platform"] - .as_str() - .ok_or_else(|| reject("platform"))?; - if !matches!(platform, "macos" | "windows" | "linux") { - return Err(reject("platform")); - } - let tools = registry["definitions"] - .as_array() - .into_iter() - .flatten() - .map(|entry| { - let definition = &entry["definition"]; - let scans: Vec<_> = definition["discovery"] - .as_array() - .into_iter() - .flatten() - .filter(|scan| applicable(scan, platform)) - .cloned() - .collect(); - let providers: Vec<_> = registry["providers"] - .as_array() - .into_iter() - .flatten() - .filter(|provider| { - provider["provider"]["tools"] - .as_array() - .is_some_and(|tools| tools.contains(&definition["id"])) - }) - .map(|provider| { - format!( - "{}:{}", - provider["pluginID"].as_str().unwrap_or(""), - provider["provider"]["id"].as_str().unwrap_or("") - ) - }) - .collect(); - // Login-shell home variables the host reads, with the layout each one selects. - let environment: Vec<_> = definition["executables"] - .as_array() - .into_iter() - .flatten() - .filter(|value| value["kind"] == "environment" && applicable(value, platform)) - .map(|value| json!({"variable":value["variable"],"path":value.get("path")})) - .collect(); - json!({"tool":definition["id"],"pluginID":entry["pluginID"],"scans":scans,"providers":providers, - "homeLayouts":definition.get("homeLayouts").cloned().unwrap_or(json!([])), - "executableLayouts":definition["executableLayouts"],"version":definition["version"], - "pathPrepend":definition.get("pathPrepend").cloned().unwrap_or(json!([])), - "environment":environment}) - }) - .collect::>(); - Ok(json!({"tools":tools,"limits":{"roots":256,"entries":4096,"ancestors":64}})) -} - -/// Provenance of homes found by expanding definition-owned `discovery` scans. -const SYSTEM_PROVIDER: &str = "host:system"; - -/// One canonical home reported by several routes stays one installation. A provider -/// answer outranks the host scan, then qualified provider IDs give a stable label; -/// the label never decides between different homes. -fn attribution(selection: &Value) -> (bool, &str) { - let provider = selection["provider"].as_str().unwrap_or(""); - (provider == SYSTEM_PROVIDER, provider) -} - -fn applicable(value: &Value, platform: &str) -> bool { - value.get("platforms").is_none_or(|values| { - values - .as_array() - .is_some_and(|values| values.iter().any(|value| value == platform)) - }) -} - -/// A host-side value snapshot is not a provider reply or a persisted launch permit. -struct Snapshot<'a> { - input: &'a Value, - definition: &'a Value, - platform: &'a str, - case_sensitive: bool, - roots: Vec, - facts: &'a [Value], - /// Resolved targets implied by link facts; canonical selections are looked up here. - targets: Vec, - probes: BTreeMap, - minimum: Option<&'a str>, -} - -impl Snapshot<'_> { - /// An explicit consumer choice precedes directory pins, but has exactly the - /// same native version and project-code boundary as other checked candidates. - fn fixed_selection(&self, fixed: &FixedToolchainSelection) -> Result { - match fixed { - FixedToolchainSelection::Installation { home } => { - self.selection(home, "host:manual", "pinned") - } - FixedToolchainSelection::Executable { executable } => { - let fact = self.fact(executable, "file").map_err(|_| "notFound")?; - if fact["executable"] != true { - return Err("notExecutable"); - } - let canonical = fact["canonicalPath"].as_str().ok_or("unsafePath")?; - let project = self.roots.iter().any(|root| { - paths::within(executable, root, self.platform, self.case_sensitive) - || paths::within(canonical, root, self.platform, self.case_sensitive) - }); - if project && self.input["projectCodeAllowed"] != true { - return Err("projectCodeRequiresAction"); - } - if self.definition["launch"] - .as_array() - .into_iter() - .flatten() - .any(|token| token == "${home}") - { - return Err("homeUnavailable"); - } - let version = self.version(executable)?; - self.environment_selection( - None, - executable, - Some(&version), - "host:manual", - "pinned", - project, - ) - } - } - } - - fn key(&self, path: &str) -> Validation { - paths::normalize(path, self.platform, self.case_sensitive).ok_or_else(|| reject("path")) - } - - fn fact(&self, path: &str, kind: &str) -> Validation<&Value> { - let key = self.key(path)?; - self.facts - .iter() - .chain(&self.targets) - .find(|fact| { - fact["path"] - .as_str() - .and_then(|path| self.key(path).ok()) - .as_ref() - == Some(&key) - && fact["kind"] == kind - }) - .ok_or_else(|| reject("filesystem")) - } - - fn validate_facts(&self) -> Validation<()> { - let mut identities = BTreeSet::new(); - for fact in self.facts { - fields(fact, &["path", "canonicalPath", "kind", "executable"])?; - let path = fact["path"] - .as_str() - .ok_or_else(|| reject("filesystem.path"))?; - let canonical = fact["canonicalPath"] - .as_str() - .ok_or_else(|| reject("filesystem.canonicalPath"))?; - self.key(canonical)?; - if !identities.insert(self.key(path)?) - || !matches!( - fact["kind"].as_str(), - Some("file" | "directory" | "missing") - ) - || fact - .get("executable") - .is_some_and(|value| !value.is_boolean()) - { - return Err(reject("filesystem")); - } - } - for root in &self.roots { - self.fact(root, "directory")?; - } - Ok(()) - } - - fn checked(&self, home: &str, layout: Option<&str>) -> Validation { - query::validate_installation( - home, - self.definition, - self.facts, - &self.roots, - self.platform, - self.case_sensitive, - layout, - ) - } - - fn version(&self, executable: &str) -> Result { - let key = self.key(executable).map_err(|_| "unsafePath")?; - let probe = self.probes.get(&key).ok_or("probeFailed")?; - if probe.exit_code != 0 { - return Err("probeFailed"); - } - let checked = - capture(self.definition, &probe.output, self.minimum).map_err(|_| "probeFailed")?; - if checked["satisfied"] != true { - return Err("versionUnsatisfied"); - } - checked["version"] - .as_str() - .map(str::to_owned) - .ok_or("probeFailed") - } - - fn selection(&self, home: &str, provider: &str, layer: &str) -> Result { - self.layout_selection(home, None, provider, layer) - } - - /// Selects `home` through one declared executable layout, or the first present one. - fn layout_selection( - &self, - home: &str, - layout: Option<&str>, - provider: &str, - layer: &str, - ) -> Result { - let checked = self.checked(home, layout).map_err(|_| "unsafePath")?; - let executable = checked["executable"].as_str().ok_or("unsafePath")?; - let version = self.version(executable)?; - self.environment_selection( - checked["home"].as_str(), - executable, - Some(&version), - provider, - layer, - false, - ) - } - - fn environment_selection( - &self, - home: Option<&str>, - executable: &str, - version: Option<&str>, - provider: &str, - layer: &str, - project: bool, - ) -> Result { - let mut environment = serde_json::Map::new(); - for export in self.definition["exports"].as_array().into_iter().flatten() { - let value = match export["value"].as_str() { - Some("${home}") => home, - Some("${executable}") => Some(executable), - _ => None, - }; - if let Some(value) = value { - environment.insert( - export["name"].as_str().ok_or("unsafePath")?.to_owned(), - json!(value), - ); - } - } - let mut prepend = Vec::new(); - if let Some(home) = home { - for template in self.definition["pathPrepend"] - .as_array() - .into_iter() - .flatten() - { - let suffix = template - .as_str() - .and_then(|template| template.strip_prefix("${home}")) - .ok_or("unsafePath")?; - let path = format!("{home}{suffix}"); - let fact = self.fact(&path, "directory").map_err(|_| "unsafePath")?; - let canonical = fact["canonicalPath"].as_str().ok_or("unsafePath")?; - if !paths::within(canonical, home, self.platform, self.case_sensitive) { - return Err("unsafePath"); - } - prepend.push(canonical.to_owned()); - } - } - Ok( - json!({"home":home,"executable":executable,"version":version,"provider":provider, - "layer":layer,"projectCode":project,"environment":environment,"pathPrepend":prepend}), - ) - } -} - -fn reason(code: &str, tool: &str) -> Value { - json!({"code":code,"tool":tool}) -} - -fn trace(trace: &mut Vec, id: &str, layer: &str, state: &str, reason: Option) { - let mut row = json!({"candidateID":id,"layer":layer,"state":state}); - if let Some(reason) = reason { - row["reason"] = reason; - } - trace.push(row); -} - -fn output( - input: &Value, - outcome: Value, - trace: Vec, - unmet: Vec, - conflicts: Vec, -) -> Value { - json!({"operationID":input["operationID"],"tool":input["tool"],"directory":input["directory"], - "outcome":outcome,"trace":trace,"unmet":unmet,"conflicts":conflicts}) -} - -/// Project sources are merged before attempting a candidate; distance wins over kind. -struct ProjectChoice { - anchor: String, - category: u8, - native: bool, - value: Value, - candidate: bool, -} - -fn ancestors(directory: &str) -> Vec<&str> { - let mut result = vec![directory]; - let mut current = directory; - while current != "." && result.len() < 64 { - current = current.rsplit_once('/').map_or(".", |(parent, _)| parent); - result.push(current); - } - result -} - -fn project_selection( - snapshot: &Snapshot<'_>, - choice: &ProjectChoice, -) -> Result { - if !choice.candidate { - let home = choice.value["installation"] - .as_str() - .ok_or("requestedNotInstalled")?; - return snapshot.selection( - home, - choice.value["provider"].as_str().unwrap_or(""), - "project", - ); - } - if snapshot.input["projectCodeAllowed"] != true { - return Err("projectCodeRequiresAction"); - } - let path = choice.value["nativePath"].as_str().ok_or("unsafePath")?; - let file = snapshot.fact(path, "file").map_err(|_| "notFound")?; - if file["executable"] != true { - return Err("notExecutable"); - } - let canonical = file["canonicalPath"].as_str().ok_or("unsafePath")?; - if snapshot.key(canonical).map_err(|_| "unsafePath")? - != snapshot.key(path).map_err(|_| "unsafePath")? - { - // Native link-chain identities are not available in this Core-only slice. - return Err("unsafePath"); - } - let home = choice.value["nativeHome"].as_str(); - if let Some(home) = home { - let fact = snapshot.fact(home, "directory").map_err(|_| "unsafePath")?; - let canonical_home = fact["canonicalPath"].as_str().ok_or("unsafePath")?; - if snapshot.key(canonical_home).map_err(|_| "unsafePath")? - != snapshot.key(home).map_err(|_| "unsafePath")? - || !snapshot.roots.iter().any(|root| { - paths::within( - canonical_home, - root, - snapshot.platform, - snapshot.case_sensitive, - ) - }) - { - return Err("unsafePath"); - } - } - // A lexical project path must never turn into a provider-owned installation. - if !snapshot - .roots - .iter() - .any(|root| paths::within(path, root, snapshot.platform, snapshot.case_sensitive)) - { - return Err("unsafePath"); - } - if home.is_none() - && snapshot.definition["launch"] - .as_array() - .into_iter() - .flatten() - .any(|token| token == "${home}") - { - return Err("homeUnavailable"); - } - // Preserve the lexical interpreter path for project environments (including venv). - let version = match snapshot.version(path) { - Ok(version) => Some(version), - Err(code) if snapshot.minimum.is_some() => return Err(code), - Err(_) => None, - }; - snapshot.environment_selection( - home, - path, - version.as_deref(), - "host:project", - "project", - true, - ) -} - -/// Validates the native evidence shared by resolution and candidate listing: platform -/// and case policy, workspace roots, filesystem facts and bounded probe outputs. -fn prepare<'a>(input: &'a Value, definition: &'a Value) -> Validation<(Snapshot<'a>, String)> { - let platform = input["platform"] - .as_str() - .filter(|platform| matches!(*platform, "macos" | "windows" | "linux")) - .ok_or_else(|| reject("platform"))?; - let case_sensitive = input - .get("filesystemCaseSensitive") - .map(|value| { - value - .as_bool() - .ok_or_else(|| reject("filesystemCaseSensitive")) - }) - .transpose()? - .unwrap_or(platform != "windows"); - let root = input["workspaceRoot"] - .as_str() - .and_then(|root| paths::normalize(root, platform, true)) - .ok_or_else(|| reject("workspaceRoot"))?; - let mut roots = vec![root.clone()]; - for value in array(input, "workspaceRoots", 64)? { - roots.push( - value - .as_str() - .ok_or_else(|| reject("workspaceRoots"))? - .to_owned(), - ); - } - let facts = array(input, "filesystem", 16384)?; - let minimum = input - .get("minimumVersion") - .map(|value| { - value - .as_str() - .filter(|value| components(value).is_some()) - .ok_or_else(|| reject("minimumVersion")) - }) - .transpose()?; - let mut probes = BTreeMap::new(); - for value in array(input, "probes", 1024)? { - let probe: Probe = serde_json::from_value(value.clone()).map_err(|_| reject("probes"))?; - if probe.output.len() > 128 * 1024 { - return Err(reject("probes.output")); - } - let key = paths::normalize(&probe.executable, platform, case_sensitive) - .ok_or_else(|| reject("probes.executable"))?; - if probes.insert(key, probe).is_some() { - return Err(reject("probes")); - } - } - let mut snapshot = Snapshot { - input, - definition, - platform, - case_sensitive, - roots, - facts, - targets: Vec::new(), - probes, - minimum, - }; - snapshot.validate_facts()?; - snapshot.targets = query::implied_targets(facts, platform, case_sensitive) - .map_err(|_| reject("filesystem"))?; - if snapshot.key( - snapshot.fact(&root, "directory")?["canonicalPath"] - .as_str() - .ok_or_else(|| reject("workspaceRoot"))?, - )? != snapshot.key(&root)? - { - return Err(reject("workspaceRoot")); - } - Ok((snapshot, root)) -} - -/// Host-observed string maps (login-shell variables, command homes, explicit choices). -fn string_maps(input: &Value, keys: &[&str]) -> Validation<()> { - for key in keys { - if let Some(value) = input.get(*key) { - let map = value - .as_object() - .filter(|map| map.len() <= 256) - .ok_or_else(|| reject(key))?; - if map.values().any(|value| { - !value - .as_str() - .is_some_and(|value| value.is_empty() || bounded(value, 4096)) - }) { - return Err(reject(key)); - } - } - } - Ok(()) -} - -/// Homes the host found by expanding this tool's `discovery` scans. -fn system_installations(input: &Value, tool: &str) -> Validation> { - let mut installations = Vec::new(); - for value in array(input, "systemInstallations", 512)? { - fields(value, &["tool", "home"])?; - if value["tool"] == tool { - let home = value["home"] - .as_str() - .ok_or_else(|| reject("systemInstallations"))?; - installations.push(json!({"tool":tool,"home":home,"provider":SYSTEM_PROVIDER})); - } - } - Ok(installations) -} - -/// Homes `toolchains.decodeReply` accepted from a provider declaring `tool`, kept -/// under that provider's identity; one the registry does not list is refused. -fn provider_installations(input: &Value, tool: &str, registry: &Value) -> Validation> { - let mut installations = Vec::new(); - for value in array(input, "providerInstallations", 512)? { - fields(value, &["tool", "home", "provider"])?; - if value["tool"] != tool { - continue; - } - let home = value["home"] - .as_str() - .ok_or_else(|| reject("providerInstallations"))?; - let provider = value["provider"] - .as_str() - .and_then(|provider| provider.rsplit_once(':')) - .filter(|(plugin, provider)| { - registry["providers"].as_array().is_some_and(|entries| { - entries.iter().any(|entry| { - entry["pluginID"] == *plugin - && entry["provider"]["id"] == *provider - && entry["provider"]["tools"] - .as_array() - .is_some_and(|tools| tools.iter().any(|known| known == tool)) - }) - }) - }) - .ok_or_else(|| reject("providerInstallations"))?; - installations.push( - json!({"tool":tool,"home":home,"provider":format!("{}:{}", provider.0, provider.1)}), - ); - } - Ok(installations) -} - -/// The automatic layer's candidate set, keyed by canonical home. -struct Automatic { - eligible: BTreeMap, - /// Reported installations that failed a check, with the executable to probe when - /// the layout itself was valid. - skipped: BTreeMap, - failure: Option<&'static str>, -} - -fn automatic_set(snapshot: &Snapshot<'_>, installations: &[Value]) -> Validation { - let mut set = Automatic { - eligible: BTreeMap::new(), - skipped: BTreeMap::new(), - failure: None, - }; - for installation in installations { - let Some(home) = installation["home"].as_str() else { - continue; - }; - let provider = installation["provider"].as_str().unwrap_or(""); - match snapshot.selection(home, provider, "automatic") { - Ok(value) => { - let key = snapshot.key(value["home"].as_str().unwrap_or(""))?; - if set - .eligible - .get(&key) - .is_none_or(|kept: &Value| attribution(&value) < attribution(kept)) - { - set.eligible.insert(key, value); - } - } - Err(code) => { - set.failure = Some(code); - let checked = snapshot.checked(home, None).ok(); - let canonical = checked - .as_ref() - .and_then(|value| value["home"].as_str()) - .unwrap_or(home); - let mut row = json!({"home":canonical,"provider":provider,"reason":code}); - if let Some(executable) = checked.as_ref().map(|value| &value["executable"]) { - row["executable"] = executable.clone(); - } - let key = snapshot - .key(canonical) - .unwrap_or_else(|_| canonical.to_owned()); - if set - .skipped - .get(&key) - .is_none_or(|kept: &Value| attribution(&row) < attribution(kept)) - { - set.skipped.insert(key, row); - } - } - } - } - set.skipped.retain(|key, _| !set.eligible.contains_key(key)); - Ok(set) -} - -/// Picks the highest version; equal highest versions in different homes need `chosen`, -/// otherwise they become an `automatic` conflict naming every route. -fn automatic_choice( - snapshot: &Snapshot<'_>, - eligible: BTreeMap, - chosen: Option<&str>, -) -> Validation<(Option, Option)> { - let newest = eligible - .values() - .filter_map(|value| components(value["version"].as_str()?)) - .max(); - let mut best: Vec<_> = eligible - .into_values() - .filter(|value| value["version"].as_str().and_then(components) == newest) - .collect(); - if best.len() <= 1 { - return Ok((best.pop(), None)); - } - if let Some(chosen) = chosen { - let identity = snapshot.key(chosen)?; - let selected = best - .iter() - .find(|value| { - value["home"] - .as_str() - .and_then(|home| snapshot.key(home).ok()) - .as_ref() - == Some(&identity) - }) - .cloned(); - if selected.is_some() { - return Ok((selected, None)); - } - } - let providers: BTreeSet<_> = best - .iter() - .filter_map(|value| value["provider"].as_str()) - .collect(); - let installations: Vec<_> = best.iter().map(|value| value["home"].clone()).collect(); - Ok(( - None, - Some( - json!({"scope":"automatic","providers":providers,"installations":installations, - "action":"chooseInstallation"}), - ), - )) -} - -/// Lists what a settings page shows for one tool's global and automatic layers without -/// resolving a directory: login-shell homes and the discovered installations with their -/// checked executables, versions or skip reasons, and Core's automatic choice. Hosts -/// probe only the executables returned here, then call again with `probes`. -pub(crate) fn candidates(input: &Value) -> Validation { - fields( - input, - &[ - "registry", - "tool", - "platform", - "filesystemCaseSensitive", - "workspaceRoot", - "workspaceRoots", - "filesystem", - "minimumVersion", - "probes", - "systemInstallations", - "providerInstallations", - "fixed", - "projectCodeAllowed", - "environment", - ], - )?; - let registry = definitions::registry(&input["registry"])?; - let tool = input["tool"] - .as_str() - .filter(|tool| definitions::tool_id(tool)) - .ok_or_else(|| reject("tool"))?; - let definition = enabled(®istry, tool).ok_or_else(|| reject("tool"))?; - let (snapshot, _) = prepare(input, definition)?; - if input - .get("projectCodeAllowed") - .is_some_and(|value| !value.is_boolean()) - { - return Err(reject("projectCodeAllowed")); - } - string_maps(input, &["environment"])?; - let mut shell = Vec::new(); - for candidate in definition["executables"] - .as_array() - .into_iter() - .flatten() - .filter(|value| value["kind"] == "environment" && applicable(value, snapshot.platform)) - { - let Some(variable) = candidate["variable"].as_str() else { - continue; - }; - let Some(home) = input["environment"][variable] - .as_str() - .filter(|value| !value.is_empty()) - else { - continue; - }; - let layout = candidate["path"].as_str(); - let mut row = json!({"candidateID":candidate["id"],"variable":variable,"home":home}); - if let Ok(checked) = snapshot.checked(home, layout) { - row["home"] = checked["home"].clone(); - row["executable"] = checked["executable"].clone(); - } - match snapshot.layout_selection(home, layout, "host:shell", "global") { - Ok(value) => row["version"] = value["version"].clone(), - Err(code) => row["reason"] = json!(code), - } - shell.push(row); - } - let mut reported = provider_installations(input, tool, ®istry)?; - reported.extend(system_installations(input, tool)?); - let set = automatic_set(&snapshot, &reported)?; - let mut installations: Vec<_> = set - .eligible - .values() - .map(|value| { - json!({"home":value["home"],"executable":value["executable"], - "version":value["version"],"provider":value["provider"]}) - }) - .collect(); - installations.sort_by(|left, right| { - let version = |value: &Value| value["version"].as_str().and_then(components); - version(right) - .cmp(&version(left)) - .then_with(|| left["home"].as_str().cmp(&right["home"].as_str())) - }); - let mut skipped: Vec<_> = set.skipped.into_values().collect(); - if let Some(value) = input.get("fixed") { - let fixed = FixedToolchainSelection::parse(value)?; - let executable = match &fixed { - FixedToolchainSelection::Installation { home } => snapshot - .checked(home, None) - .ok() - .and_then(|value| value["executable"].as_str().map(str::to_owned)), - FixedToolchainSelection::Executable { executable } => snapshot - .fact(executable, "file") - .ok() - .filter(|fact| fact["executable"] == true) - .map(|_| executable.clone()), - }; - if let Err(code) = snapshot.fixed_selection(&fixed) { - let mut row = json!({"reason":code}); - // A project program is not a probe target without the current action. - if code == "probeFailed" { - if let Some(executable) = executable { - row["executable"] = json!(executable); - } - } - skipped.push(row); - } - } - let (selected, conflict) = automatic_choice(&snapshot, set.eligible, None)?; - Ok( - json!({"tool":tool,"shell":shell,"installations":installations,"skipped":skipped, - "selected":selected.map(|value| value["home"].clone()), - "conflicts":conflict.into_iter().collect::>()}), - ) -} - -/// Resolves definitions, provider replies, pins and trusted native observations once. -pub(crate) fn resolve(input: &Value) -> Validation { - fields( - input, - &[ - "registry", - "tool", - "directory", - "operationID", - "local", - "platform", - "filesystemCaseSensitive", - "workspaceRoot", - "workspaceRoots", - "filesystem", - "minimumVersion", - "probes", - "queries", - "systemInstallations", - "globalDefault", - "environment", - "commandHomes", - "projectCodeAllowed", - "providerChoices", - "generation", - "context", - "fixed", - ], - )?; - let registry = definitions::registry(&input["registry"])?; - let tool = input["tool"] - .as_str() - .filter(|tool| definitions::tool_id(tool)) - .ok_or_else(|| reject("tool"))?; - let directory = input["directory"] - .as_str() - .ok_or_else(|| reject("directory"))?; - input["operationID"] - .as_str() - .filter(|value| bounded(value, 128)) - .ok_or_else(|| reject("operationID"))?; - let document = input - .get("local") - .cloned() - .unwrap_or(json!({"version":1,"toolchains":{}})); - let pin = pins::resolve(&json!({"document":document,"directory":directory,"tool":tool}))?; - let fixed = input - .get("fixed") - .map(FixedToolchainSelection::parse) - .transpose()?; - let Some(definition) = enabled(®istry, tool) else { - return Ok(output( - input, - json!({"kind":"skip","reason":reason("definitionUnavailable",tool)}), - vec![], - vec![], - vec![], - )); - }; - let generation = input["generation"] - .as_u64() - .filter(|value| *value > 0) - .ok_or_else(|| reject("generation"))?; - let (snapshot, root) = prepare(input, definition)?; - let platform = snapshot.platform; - let case_sensitive = snapshot.case_sensitive; - if input - .get("projectCodeAllowed") - .is_some_and(|value| !value.is_boolean()) - { - return Err(reject("projectCodeAllowed")); - } - string_maps(input, &["environment", "commandHomes", "providerChoices"])?; - if let Some(choices) = input.get("providerChoices") { - fields(choices, &["project", "global", "automatic"])?; - } - if let Some(fixed) = fixed { - return Ok(match snapshot.fixed_selection(&fixed) { - Ok(selection) => output( - input, - json!({"kind":"resolved","selection":selection}), - vec![json!({"candidateID":"consumerFixed","layer":"pinned","state":"resolved"})], - vec![], - vec![], - ), - Err(code) => output( - input, - json!({"kind":"terminalFailure","reason":reason(code,tool)}), - vec![ - json!({"candidateID":"consumerFixed","layer":"pinned","state":"terminalFailure","reason":reason(code,tool)}), - ], - vec![], - vec![], - ), - }); - } - // A failed optional provider must never revoke a usable explicit fixed value. - if let Some(home) = pin["pin"]["home"].as_str() { - let result = snapshot.selection( - home, - pin["pin"]["provider"].as_str().unwrap_or(""), - "pinned", - ); - return Ok(match result { - Ok(selection) => output( - input, - json!({"kind":"resolved","selection":selection}), - vec![json!({"candidateID":"pinned","layer":"pinned","state":"resolved"})], - vec![], - vec![], - ), - Err(code) => output( - input, - json!({"kind":"terminalFailure","reason":reason(code,tool)}), - vec![ - json!({"candidateID":"pinned","layer":"pinned","state":"terminalFailure","reason":reason(code,tool)}), - ], - vec![], - vec![], - ), - }); - } - let mut installations = Vec::new(); - let mut statements = Vec::new(); - let mut unmet = Vec::new(); - let mut query_providers = BTreeSet::new(); - for value in array(input, "queries", 256)? { - if value["registry"] != input["registry"] { - return Err(reject("queries.registry")); - } - if value["platform"] != platform { - return Err(reject("queries.platform")); - } - if value["request"]["params"]["workspace"]["root"] - .as_str() - .and_then(|path| snapshot.key(path).ok()) - != Some(snapshot.key(&root)?) - { - return Err(reject("queries.workspace")); - } - let requested_directory = value["request"]["params"]["workspace"]["directory"] - .as_str() - .unwrap_or(""); - if requested_directory != directory { - return Err(reject("queries.directory")); - } - if value["filesystem"] != input["filesystem"] { - return Err(reject("queries.filesystem")); - } - let query_case = value - .get("filesystemCaseSensitive") - .and_then(Value::as_bool) - .unwrap_or(platform != "windows"); - if query_case != case_sensitive - || value.get("workspaceRoots") != input.get("workspaceRoots") - { - return Err(reject("queries.filesystemPolicy")); - } - if value["currentGeneration"].as_u64() != Some(generation) { - return Err(reject("queries.generation")); - } - if value["request"]["params"]["context"] != input["context"] { - return Err(reject("queries.context")); - } - if !query_providers.insert(( - value["pluginID"].to_string(), - value["request"]["params"]["providerID"].to_string(), - )) { - return Err(reject("queries.providerID")); - } - let decoded = match query::decode(value) { - Ok(decoded) => decoded, - Err(error) - if matches!( - error.code, - "plugin.toolchain.timeout" | "plugin.toolchain.providerUnavailable" - ) => - { - // Only a provider asked about this tool leaves it without an answer; `cause` - // keeps a missed deadline apart from a backend that could not answer at all. - if value["request"]["params"]["tools"] - .as_array() - .is_some_and(|tools| tools.iter().any(|asked| asked == tool)) - { - unmet.push(json!({"code":"providerUnavailable","tool":tool, - "provider":format!("{}:{}",value["pluginID"].as_str().unwrap_or(""),value["request"]["params"]["providerID"].as_str().unwrap_or("")), - "cause":error.code})); - } - continue; - } - Err(error) => return Err(error), - }; - installations.extend( - decoded["installations"] - .as_array() - .into_iter() - .flatten() - .filter(|value| value["tool"] == tool) - .cloned(), - ); - statements.extend( - decoded["statements"] - .as_array() - .into_iter() - .flatten() - .filter(|value| value["tool"] == tool) - .cloned(), - ); - unmet.extend( - decoded["diagnostics"] - .as_array() - .into_iter() - .flatten() - .filter(|value| value["tool"] == tool && value["code"] != "requestedNotInstalled") - .cloned(), - ); - } - installations.extend(system_installations(input, tool)?); - let candidates: Vec<_> = definition["executables"] - .as_array() - .into_iter() - .flatten() - .filter(|value| applicable(value, platform)) - .collect(); - let mut trace_rows = Vec::new(); - let mut conflicts = Vec::new(); - let mut outcome = None; - let mut project_done = false; - if let Some(default) = input.get("globalDefault").filter(|value| !value.is_null()) { - fields(default, &["home"])?; - default["home"] - .as_str() - .filter(|value| bounded(value, 4096)) - .ok_or_else(|| reject("globalDefault.home"))?; - } - // Explicit application defaults obey the same terminal policy without a plugin slot. - let default_preflight = input - .get("globalDefault") - .filter(|value| !value.is_null()) - .is_some() - && !candidates - .iter() - .any(|value| value["kind"] == "setting" && value["layer"] == "global"); - let mut default_checked = !default_preflight; - for candidate in &candidates { - let kind = candidate["kind"].as_str().unwrap_or(""); - let id = candidate["id"].as_str().unwrap_or(kind); - let layer = match kind { - "setting" if candidate["layer"] == "project" => "pinned", - "projectFile" => "project", - "toolchain" if candidate["scope"] == "project" => "project", - "toolchain" if candidate["scope"] == "installed" => "automatic", - _ => "global", - }; - if !default_checked && matches!(layer, "global" | "automatic") && outcome.is_none() { - default_checked = true; - let home = input["globalDefault"]["home"] - .as_str() - .ok_or_else(|| reject("globalDefault"))?; - let result = snapshot.selection(home, "host:default", "global"); - outcome = Some(match result { - Ok(selection) => json!({"kind":"resolved","selection":selection}), - Err(code) => json!({"kind":"terminalFailure","reason":reason(code,tool)}), - }); - } - if outcome.is_some() { - trace(&mut trace_rows, id, layer, "shadowed", None); - continue; - } - let mut selected = None; - let mut failure = None; - let mut terminal = false; - match (kind, layer) { - ("setting", "pinned") => { - if let Some(home) = pin["pin"]["home"].as_str() { - match snapshot.selection( - home, - pin["pin"]["provider"].as_str().unwrap_or(""), - layer, - ) { - Ok(value) => selected = Some(value), - Err(code) => { - failure = Some(reason(code, tool)); - terminal = true; - } - } - } - } - ("setting", _) => { - if let Some(value) = input.get("globalDefault").filter(|value| !value.is_null()) { - let home = value["home"] - .as_str() - .ok_or_else(|| reject("globalDefault"))?; - match snapshot.selection(home, "host:default", layer) { - Ok(value) => selected = Some(value), - Err(code) => { - failure = Some(reason(code, tool)); - terminal = true; - } - } - } - } - (_, "project") if !project_done => { - project_done = true; - let mut choices = Vec::new(); - for source in &candidates { - if source["kind"] != "projectFile" { - continue; - } - for ancestor in ancestors(directory) { - let anchor = if ancestor == "." { - root.clone() - } else { - paths::join(&root, ancestor, platform) - .ok_or_else(|| reject("directory"))? - }; - let native = - paths::join(&anchor, source["path"].as_str().unwrap_or(""), platform) - .ok_or_else(|| reject("projectFile"))?; - if snapshot.fact(&native, "file").is_err() { - continue; - } - let markers = - source["requires"] - .as_array() - .into_iter() - .flatten() - .all(|marker| { - marker - .as_str() - .and_then(|marker| paths::join(&anchor, marker, platform)) - .is_some_and(|path| snapshot.fact(&path, "file").is_ok()) - }); - if !markers { - continue; - } - let mut value = (*source).clone(); - value["nativePath"] = json!(native); - if let Some(home) = source["home"].as_str() { - value["nativeHome"] = json!(if home == "." { - anchor.clone() - } else { - paths::join(&anchor, home, platform) - .ok_or_else(|| reject("projectFile.home"))? - }); - } - choices.push(ProjectChoice { - anchor, - category: if source["projectKind"] == "environment" { - 1 - } else { - 0 - }, - native: true, - value, - candidate: true, - }); - } - } - if candidates - .iter() - .any(|value| value["kind"] == "toolchain" && value["scope"] == "project") - { - for statement in statements - .iter() - .filter(|value| value["scope"] == "project") - { - let anchor = statement["sourceDirectory"] - .as_str() - .ok_or_else(|| reject("statements.sourceDirectory"))?; - choices.push(ProjectChoice { - anchor: anchor.to_owned(), - category: if statement["kind"] == "environment" { - 1 - } else { - 2 - }, - native: statement["reader"] == "native", - value: statement.clone(), - candidate: false, - }); - } - } - choices.sort_by_key(|value| { - ( - std::cmp::Reverse(value.anchor.split('/').count()), - value.category, - ) - }); - // The nearest declaration masks inherited declarations even when unmet. - if let Some(nearest) = choices.first().map(|value| value.anchor.split('/').count()) - { - choices.retain(|value| value.anchor.split('/').count() == nearest); - } - while let Some(first) = choices.first() { - let depth = first.anchor.split('/').count(); - let category = first.category; - let count = choices - .iter() - .take_while(|value| { - value.anchor.split('/').count() == depth && value.category == category - }) - .count(); - let mut group: Vec<_> = choices.drain(..count).collect(); - let native_sources: BTreeSet<_> = group - .iter() - .filter(|value| value.native && !value.candidate) - .map(|value| value.value["source"].clone().to_string()) - .collect(); - group.retain(|value| { - value.native || !native_sources.contains(&value.value["source"].to_string()) - }); - let homes: BTreeSet<_> = group - .iter() - .filter_map(|value| value.value["installation"].as_str().map(str::to_owned)) - .collect(); - let requests: BTreeSet<_> = group - .iter() - .filter(|value| !value.candidate) - .map(|value| { - ( - value.value["request"].to_string(), - value.value["installation"].to_string(), - ) - }) - .collect(); - let choice = input["providerChoices"]["project"].as_str(); - if let Some(chosen) = choice { - group.retain(|value| value.candidate || value.value["provider"] == chosen); - } - let retained: BTreeSet<_> = group - .iter() - .filter(|value| !value.candidate) - .map(|value| { - ( - value.value["request"].to_string(), - value.value["installation"].to_string(), - ) - }) - .collect(); - if (choice.is_none() && (homes.len() > 1 || requests.len() > 1)) - || retained.len() > 1 - { - let providers: BTreeSet<_> = group - .iter() - .filter_map(|value| value.value["provider"].as_str()) - .collect(); - conflicts.push(json!({"scope":"project","providers":providers,"action":"chooseProvider"})); - failure = Some(reason("providerConflict", tool)); - break; - } - for choice in &group { - match project_selection(&snapshot, choice) { - Ok(value) => { - selected = Some(value); - break; - } - Err(code) => { - let mut why = reason(code, tool); - for key in ["provider", "source", "request"] { - if let Some(value) = choice.value.get(key) { - why[key] = value.clone(); - } - } - if code == "requestedNotInstalled" && !unmet.contains(&why) { - unmet.push(why.clone()); - } - failure = Some(why); - } - } - } - if selected.is_some() { - break; - } - } - } - (_, "project") => continue, - ("toolchain", "global") => { - let globals: Vec<_> = statements - .iter() - .filter(|value| value["scope"] == "global") - .collect(); - let chosen = input["providerChoices"]["global"].as_str(); - let globals: Vec<_> = globals - .into_iter() - .filter(|value| chosen.is_none_or(|chosen| value["provider"] == chosen)) - .collect(); - let homes: BTreeSet<_> = globals - .iter() - .filter_map(|value| value["installation"].as_str()) - .collect(); - let requests: BTreeSet<_> = globals - .iter() - .map(|value| { - ( - value["request"].to_string(), - value["installation"].to_string(), - ) - }) - .collect(); - if homes.len() > 1 || requests.len() > 1 { - let providers: BTreeSet<_> = globals - .iter() - .filter_map(|value| value["provider"].as_str()) - .collect(); - conflicts.push( - json!({"scope":"global","providers":providers,"action":"chooseProvider"}), - ); - failure = Some(reason("providerConflict", tool)); - } else if let Some(value) = globals.first() { - if let Some(home) = value["installation"].as_str() { - match snapshot.selection( - home, - value["provider"].as_str().unwrap_or(""), - layer, - ) { - Ok(value) => selected = Some(value), - Err(code) => failure = Some(reason(code, tool)), - } - } else { - let why = json!({"code":"requestedNotInstalled","tool":tool,"provider":value["provider"],"source":value["source"],"request":value["request"]}); - unmet.push(why.clone()); - failure = Some(why); - } - } - } - ("toolchain", "automatic") => { - let set = automatic_set(&snapshot, &installations)?; - failure = set.failure.map(|code| reason(code, tool)); - let (choice, conflict) = automatic_choice( - &snapshot, - set.eligible, - input["providerChoices"]["automatic"].as_str(), - )?; - selected = choice; - if let Some(conflict) = conflict { - conflicts.push(conflict); - failure = Some(reason("ambiguousInstallation", tool)); - } - } - ("environment", _) => { - if let Some(home) = candidate["variable"] - .as_str() - .and_then(|name| input["environment"][name].as_str()) - .filter(|value| !value.is_empty()) - { - match snapshot.layout_selection( - home, - candidate["path"].as_str(), - "host:shell", - layer, - ) { - Ok(value) => selected = Some(value), - Err(code) => failure = Some(reason(code, tool)), - } - } - } - ("command", _) => { - if let Some(home) = candidate["command"] - .as_str() - .and_then(|name| input["commandHomes"][name].as_str()) - { - match snapshot.selection(home, "host:shell", layer) { - Ok(value) => selected = Some(value), - Err(code) => failure = Some(reason(code, tool)), - } - } - } - _ => return Err(reject("executables.kind")), - } - if let Some(selection) = selected { - trace(&mut trace_rows, id, layer, "resolved", None); - outcome = Some(json!({"kind":"resolved","selection":selection})); - } else if let Some(why) = failure { - trace( - &mut trace_rows, - id, - layer, - if terminal { "terminalFailure" } else { "skip" }, - Some(why.clone()), - ); - if terminal { - outcome = Some(json!({"kind":"terminalFailure","reason":why})); - } - } else { - trace(&mut trace_rows, id, layer, "empty", None); - } - } - if outcome.is_none() && !default_checked { - let home = input["globalDefault"]["home"] - .as_str() - .ok_or_else(|| reject("globalDefault"))?; - outcome = Some(match snapshot.selection(home, "host:default", "global") { - Ok(selection) => json!({"kind":"resolved","selection":selection}), - Err(code) => json!({"kind":"terminalFailure","reason":reason(code,tool)}), - }); - } - Ok(output( - input, - outcome.unwrap_or_else(|| json!({"kind":"skip","reason":reason("notFound",tool)})), - trace_rows, - unmet, - conflicts, - )) -} diff --git a/rust/crates/core/tests/core_log_waker.rs b/rust/crates/core/tests/core_log_waker.rs index e2592e60..626892d5 100644 --- a/rust/crates/core/tests/core_log_waker.rs +++ b/rust/crates/core/tests/core_log_waker.rs @@ -3,7 +3,9 @@ //! The queue and waker are process globals, so this file is its own test binary //! and keeps every assertion in one test: no other test can record or drain. -use lithe_core::diagnostics::core_events::{drain, record}; +// Links the C ABI under test. +use lithe_core as _; +use lithe_diagnostics::core_events::{drain, record}; use serde_json::json; use std::collections::BTreeMap; use std::ffi::c_void; diff --git a/rust/crates/debug/Cargo.toml b/rust/crates/debug/Cargo.toml new file mode 100644 index 00000000..96502b9d --- /dev/null +++ b/rust/crates/debug/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "lithe-debug" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Debug Adapter Protocol sessions for Lithe: adapter declarations, transport lifecycle and normalized debugger models" + +[dependencies] +base64 = "0.22" +lithe-diagnostics = { path = "../diagnostics" } +lithe-plugin-model = { path = "../plugin-model" } +lithe-protocol = { path = "../protocol" } +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0", features = ["preserve_order"] } +sha2 = "0.10" diff --git a/rust/crates/debug/src/adapters.rs b/rust/crates/debug/src/adapters.rs new file mode 100644 index 00000000..b663ba67 --- /dev/null +++ b/rust/crates/debug/src/adapters.rs @@ -0,0 +1,215 @@ +//! `debugAdapters[]` of an API level 4 plugin manifest: what a host must know +//! about a debug adapter before starting any process for it. +//! +//! A declaration names the adapter, how its process is started and reached +//! (stdio, a loopback TCP endpoint, or a port the plugin's own language server +//! opens on request), and optional matching, default DAP arguments and +//! capability suppression. It carries no behavior: there is no +//! resolver, workflow or host integration, so a plugin can only choose among +//! facts Core already understands. DAP capabilities belong to the running +//! adapter and are negotiated in `initialize`; `capabilities.disabled` can +//! only switch standard ones off. +//! +//! Validation has three layers. `validate_declarations` checks the manifest +//! on its own. `check_package_resources` checks the declaration against the +//! files of the installed package. `plan` checks one debug start against the +//! facts of that moment (target, registration and runtime generation). +//! +//! Note: the decision is recorded in .agents/notes/proposed/architecture/2026-10-25-unified-debug-engine-and-adapter-registry.md + +use super::types::{DebugAdapterDefaults, DebugAdapterFeatures, DeclaredAdapterBehavior}; +use lithe_plugin_model::process::{validate_process_arguments, ServerProcess, ServerRole}; +use lithe_plugin_model::settings::DeclaredSettings; +use lithe_plugin_model::{invalid, is_safe_relative_path, PluginRejection, Validation}; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::{json, Map, Value}; +use std::collections::{BTreeSet, HashSet}; +use std::net::IpAddr; + +mod checks; +mod declaration; +mod plan; +mod process; +mod resources; +mod start; +mod validate_adapter; +mod validate_configuration; +mod validate_launch; + +pub(crate) use checks::*; +pub use declaration::*; +pub use plan::*; +pub use process::*; +pub use resources::*; +pub use start::*; +pub use validate_adapter::*; + +/// Most adapters one plugin may declare. +const MAX_ADAPTERS: usize = 16; + +/// Longest display name or DAP adapter ID, in characters. +const MAX_NAME: usize = 128; + +/// Longest adapter-local ID, in characters. +const MAX_LOCAL_ID: usize = 64; + +/// Most selectors one adapter may declare. +const MAX_SELECTORS: usize = 16; + +/// Most imported `launch.json` type aliases one adapter may declare. +const MAX_CONFIGURATION_TYPES: usize = 16; + +/// Longest announced-endpoint prefix, in bytes. +const MAX_ENDPOINT_PREFIX: usize = 256; + +/// Longest language server command ID, in characters. +const MAX_ENDPOINT_COMMAND: usize = 128; + +/// Most console commands one adapter may declare. +const MAX_CONSOLE_COMMANDS: usize = 1024; + +/// Longest console command name, in characters. +const MAX_CONSOLE_COMMAND_NAME: usize = 128; + +/// Longest console command description, in characters. +const MAX_CONSOLE_COMMAND_DESCRIPTION: usize = 240; + +/// Longest console command prefix, in characters. +const MAX_CONSOLE_COMMAND_PREFIX: usize = 8; + +/// Most places an executable lookup may list. +const MAX_EXECUTABLE_CANDIDATES: usize = 8; + +/// Longest executable candidate, in characters. +const MAX_EXECUTABLE_CANDIDATE: usize = 1024; + +/// The `targetBindings.program` source that reads the executable the host +/// resolved, instead of the target's own path. +const TARGET_EXECUTABLE: &str = "target.executable"; + +/// The target kind of a remote `host:port` the debuggee listens on. +const TARGET_CONNECTION: &str = "connection"; + +/// Most names an argument path joins, for example `connect.host`. +const MAX_ARGUMENT_PATH_DEPTH: usize = 4; + +/// Longest single name in an argument path, in characters. +const MAX_ARGUMENT_NAME: usize = 64; + +/// Most path mappings one remote target may carry. +const MAX_PATH_MAPPINGS: usize = 16; + +/// Longest local or remote root of a path mapping, in characters. +const MAX_PATH_MAPPING_ROOT: usize = 1024; + +/// Longest host name, as DNS limits it. +const MAX_HOST: usize = 253; + +/// Longest label of a host name, as DNS limits it. +const MAX_HOST_LABEL: usize = 63; + +/// Variables an executable candidate may name, and the only ones the host +/// can answer: the workspace root and the open source file's location. +const EXECUTABLE_VARIABLES: &[&str] = &[ + "workspaceFolder", + "fileDirectory", + "fileBasenameNoExtension", +]; + +/// What a candidate may start with, so a lookup stays beside the user's work. +const EXECUTABLE_ROOTS: &[&str] = &["${workspaceFolder}/", "${fileDirectory}/"]; + +/// Target kinds a selector may match. +const TARGET_KINDS: &[&str] = &["connection", "executable", "file", "process", "runTarget"]; + +/// `process` keys of a debug adapter launch. `initializationOptions` is an +/// LSP field and has no meaning for a DAP transport. +const PROCESS_KEYS: &[&str] = &[ + "launch", + "arguments", + "validationArguments", + "runtimeDependencies", +]; + +/// Standard DAP `Capabilities` booleans plus the exception filter list; the +/// only names `capabilities.disabled` accepts. +const DAP_CAPABILITIES: &[&str] = &[ + "exceptionBreakpointFilters", + "supportSuspendDebuggee", + "supportTerminateDebuggee", + "supportsANSIStyling", + "supportsBreakpointLocationsRequest", + "supportsCancelRequest", + "supportsClipboardContext", + "supportsCompletionsRequest", + "supportsConditionalBreakpoints", + "supportsConfigurationDoneRequest", + "supportsDataBreakpointBytes", + "supportsDataBreakpoints", + "supportsDelayedStackTraceLoading", + "supportsDisassembleRequest", + "supportsEvaluateForHovers", + "supportsExceptionFilterOptions", + "supportsExceptionInfoRequest", + "supportsExceptionOptions", + "supportsFunctionBreakpoints", + "supportsGotoTargetsRequest", + "supportsHitConditionalBreakpoints", + "supportsInstructionBreakpoints", + "supportsLoadedSourcesRequest", + "supportsLogPoints", + "supportsModulesRequest", + "supportsReadMemoryRequest", + "supportsRestartFrame", + "supportsRestartRequest", + "supportsSetExpression", + "supportsSetVariable", + "supportsSingleThreadExecutionRequests", + "supportsStepBack", + "supportsStepInTargetsRequest", + "supportsSteppingGranularity", + "supportsTerminateRequest", + "supportsTerminateThreadsRequest", + "supportsValueFormattingOptions", + "supportsWriteMemoryRequest", +]; + +/// What validation needs from the manifest around the declarations. +pub struct DeclarationContext<'a> { + /// Settings the manifest declares, which process arguments and the executable + /// lookup may reference. + pub declared_settings: &'a DeclaredSettings, + /// Language IDs of the plugin's `languageServers[]`. + pub language_servers: &'a BTreeSet, + /// Whether tool names are host runtimes or tool-definition IDs. + pub runtime_tools: lithe_plugin_model::process::RuntimeTools<'a>, +} + +/// Validates every declaration and sorts them by ID. +pub fn validate_declarations( + adapters: &mut [DebugAdapterDeclaration], + context: &DeclarationContext<'_>, +) -> Validation<()> { + if adapters.is_empty() { + return Ok(()); + } + if adapters.len() > MAX_ADAPTERS { + return Err(invalid( + "debugAdapters", + format!("A plugin declares at most {MAX_ADAPTERS} debug adapters."), + )); + } + let mut ids = BTreeSet::new(); + for (index, adapter) in adapters.iter_mut().enumerate() { + let field = format!("debugAdapters[{index}]"); + adapter.validate(&field, context)?; + if !ids.insert(adapter.id.clone()) { + return Err(invalid( + format!("{field}.id"), + "A debug adapter is declared more than once.", + )); + } + } + adapters.sort_by(|left, right| left.id.cmp(&right.id)); + Ok(()) +} diff --git a/rust/crates/debug/src/adapters/checks.rs b/rust/crates/debug/src/adapters/checks.rs new file mode 100644 index 00000000..bdbb9dfa --- /dev/null +++ b/rust/crates/debug/src/adapters/checks.rs @@ -0,0 +1,102 @@ +//! Shared field checks of adapter declarations. + +use super::*; + +/// A name in an argument path: a DAP argument or a key of a nested object. +pub(crate) fn is_argument_name(name: &str) -> bool { + let mut characters = name.chars(); + name.len() <= MAX_ARGUMENT_NAME + && characters + .next() + .is_some_and(|first| first.is_ascii_alphabetic() || first == '_') + && characters.all(|character| character.is_ascii_alphanumeric() || "_-".contains(character)) +} + +/// A dotted path to an argument, such as `hostName` or `connect.host`. +pub(crate) fn is_argument_path(path: &str) -> bool { + let mut depth = 0; + path.split('.').all(|name| { + depth += 1; + depth <= MAX_ARGUMENT_PATH_DEPTH && is_argument_name(name) + }) +} + +/// Whether two argument paths are the same or one is inside the other. +pub(crate) fn paths_overlap(left: &str, right: &str) -> bool { + left.split('.') + .zip(right.split('.')) + .all(|(left, right)| left == right) +} + +/// Whether the arguments already hold something at the path, or a value that +/// is not an object on the way to it. +pub(crate) fn default_occupies(defaults: &Map, path: &str) -> bool { + let mut current = defaults; + let mut names = path.split('.').peekable(); + while let Some(name) = names.next() { + let Some(value) = current.get(name) else { + return false; + }; + match value { + Value::Object(next) if names.peek().is_some() => current = next, + _ => return true, + } + } + false +} + +pub(crate) fn validate_name(value: &str, field: &str) -> Validation<()> { + let trimmed = value.trim(); + if trimmed.is_empty() + || trimmed.chars().count() > MAX_NAME + || trimmed.chars().any(char::is_control) + { + return Err(invalid(field, "A name is one line of 1-128 characters.")); + } + Ok(()) +} + +pub(crate) fn is_local_id(value: &str) -> bool { + let mut characters = value.chars(); + value.len() <= MAX_LOCAL_ID + && characters + .next() + .is_some_and(|first| first.is_ascii_lowercase()) + && characters.all(|character| { + character.is_ascii_lowercase() + || character.is_ascii_digit() + || "._-".contains(character) + }) +} + +/// A `workspace/executeCommand` command ID, as language servers register them. +pub(crate) fn is_server_command(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_ENDPOINT_COMMAND + && value + .chars() + .all(|character| character.is_ascii_alphanumeric() || "._:-".contains(character)) +} + +pub(crate) fn is_language_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_LOCAL_ID + && value.chars().all(|character| { + character.is_ascii_lowercase() + || character.is_ascii_digit() + || "+_-".contains(character) + }) +} + +/// Sorts a list and rejects a repeated entry, so equal declarations compare +/// equal whatever order the author wrote. +pub(crate) fn sort_unique(values: &mut Vec, field: &str) -> Validation<()> { + values.sort(); + let before = values.len(); + values.dedup(); + if values.len() == before { + Ok(()) + } else { + Err(invalid(field, "An entry is listed more than once.")) + } +} diff --git a/rust/crates/debug/src/adapters/declaration.rs b/rust/crates/debug/src/adapters/declaration.rs new file mode 100644 index 00000000..fb237942 --- /dev/null +++ b/rust/crates/debug/src/adapters/declaration.rs @@ -0,0 +1,370 @@ +//! `debugAdapters` declarations: consoles, requests, configurations and their bindings. + +use super::*; + +/// One debug adapter a plugin implements. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DebugAdapterDeclaration { + /// Plugin-local ID. The Registry identity is qualified by the plugin ID. + pub id: String, + pub(crate) display_name: String, + /// DAP `initialize.adapterID`; the local ID when absent. + #[serde(default, rename = "adapterID", skip_serializing_if = "Option::is_none")] + pub(crate) adapter_id: Option, + /// Requests the adapter serves; `launch` only when absent. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub requests: Vec, + /// Automatic matching; absent means explicit selection only. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub selectors: Vec, + /// Imported `launch.json` `type` values this adapter answers to. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) configuration_types: Vec, + /// DAP argument defaults and target bindings per request; absent, a start + /// begins from empty arguments. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub configurations: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) capabilities: Option, + /// What the Debug Console offers while typing a debugger command. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) console: Option, + /// DAP extensions the adapter implements; `debugAdapters` 3. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) features: Option, + /// Values the adapter starts with when the user set none; `debugAdapters` 3. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) defaults: Option, + /// How the host starts and reaches the adapter. + pub launch: DebugLaunch, +} + +/// Debugger commands the adapter's console understands. Adapters that answer +/// the DAP `completions` request complete live; this list is for the ones +/// that do not, and a host shows both together. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct DebugConsole { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) commands: Vec, + /// Text the adapter's console reads as "what follows is a debugger + /// command, not an expression". A host puts it before the typed line when + /// it asks the adapter to complete, so the adapter proposes commands + /// instead of expression keywords it would offer for a bare word. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) command_prefix: Option, +} + +/// One completable command word or phrase, for example `breakpoint set`. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct DebugConsoleCommand { + /// The text inserted when the command is chosen. + pub(crate) name: String, + /// One line shown beside the name. Adapter-owned text, shown as written. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) description: Option, +} + +/// The DAP request that starts a session. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum DebugRequest { + /// The adapter starts the program it debugs. + Launch, + /// The adapter connects to a program that is already running. + Attach, +} + +/// Optional automatic matching: OR between selectors, AND between fields. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DebugSelector { + /// Target kinds that select this adapter, matched exactly. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub target_kinds: Vec, + #[serde(default, rename = "languageIDs", skip_serializing_if = "Vec::is_empty")] + pub(crate) language_ids: Vec, +} + +/// Argument defaults and bindings for each request the adapter serves. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DebugConfigurations { + /// Defaults for a `launch` request. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub launch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) attach: Option, +} + +/// Defaults for the DAP arguments of one request. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DebugConfiguration { + /// Adapter-owned default DAP arguments, ordinary JSON data. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub arguments: Option>, + /// Packaged JSON schema of the final merged arguments. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) parameters_schema: Option, + /// Which target fact fills which missing DAP argument. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target_bindings: Option, + /// Where the program to debug comes from when the target is a source + /// file. Launch only, and exactly when `program` binds to + /// `target.executable`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub executable: Option, +} + +/// How a host finds the program a source file builds into. The user's own +/// choice in a `file` setting always wins; the candidates are what a host +/// looks for when the user chose nothing, so the setting can show it. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DebugExecutable { + /// ID of a `file` setting of the same plugin. + pub setting: String, + /// Paths to try in order. Each starts with `${workspaceFolder}/` or + /// `${fileDirectory}/` and may use `${fileBasenameNoExtension}`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) candidates: Vec, +} + +/// Typed projections from the target into known DAP fields. Each field has +/// exactly one legal source, so a binding is a fact, not an expression. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DebugTargetBindings { + /// Source of the `args` argument: only `target.arguments`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub args: Option, + /// Source of the `env` argument: only `target.environment`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub env: Option, + /// Source of the `program` argument: `target.path` or `target.executable`. Launch only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub program: Option, + /// Source of the `processId` argument: only `target.processID`. Attach only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub process_id: Option, + /// Source of the `cwd` argument: only `target.workingDirectory`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option, + /// Where a remote `host:port` goes. Attach only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) connection: Option, +} + +/// Where each fact of a remote target lands in the adapter's attach +/// arguments. Adapters name and nest these differently (`hostName` and `port`, +/// `connect.host`, a single `host:port` string), so unlike the fixed names +/// above each value here is the destination path, not the source fact: `host` +/// reads `target.host`, `port` reads `target.port`, `address` reads both. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct DebugConnectionBinding { + /// Argument that receives the host name or IP literal. Pairs with `port`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) host: Option, + /// Argument that receives the port, as an integer. Pairs with `host`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) port: Option, + /// Argument that receives `host:port` as one string, with an IPv6 host in + /// brackets. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) address: Option, + /// How local and remote source roots reach the adapter. Absent when the + /// adapter has no such argument; the host then does not offer the choice. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) path_mappings: Option, +} + +/// The ways adapters take source roots: where they go and in what form. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(tag = "shape", rename_all = "camelCase", deny_unknown_fields)] +pub(crate) enum DebugPathMappingBinding { + /// `[{ : …, : … }, …]` at `argument`. + #[serde(rename_all = "camelCase")] + Objects { + argument: String, + local_key: String, + remote_key: String, + }, + /// The first mapping only, as two strings: `local` and `remote` are the + /// destination arguments. + First { local: String, remote: String }, + /// `{ : , … }` at `argument`, keyed by one side. + #[serde(rename_all = "camelCase")] + Dictionary { + argument: String, + keyed_by: MappingRoot, + }, + /// `[[, ], …]` at `argument`, in the declared order. + Pairs { + argument: String, + order: MappingOrder, + }, +} + +/// Which side of a mapping a dictionary is keyed by. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum MappingRoot { + Local, + Remote, +} + +/// Which root comes first in a pair. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum MappingOrder { + RemoteLocal, + LocalRemote, +} + +impl DebugPathMappingBinding { + /// Destination paths the binding writes, with the field each belongs to. + fn destinations(&self) -> Vec<(&'static str, &str)> { + match self { + Self::Objects { argument, .. } + | Self::Dictionary { argument, .. } + | Self::Pairs { argument, .. } => vec![("pathMappings.argument", argument)], + Self::First { local, remote } => { + vec![ + ("pathMappings.local", local), + ("pathMappings.remote", remote), + ] + } + } + } + + fn key_fields(&self) -> Vec<(&'static str, &str)> { + match self { + Self::Objects { + local_key, + remote_key, + .. + } => vec![ + ("pathMappings.localKey", local_key), + ("pathMappings.remoteKey", remote_key), + ], + Self::First { .. } | Self::Dictionary { .. } | Self::Pairs { .. } => Vec::new(), + } + } + + /// How many mappings the adapter can take. A shape that holds one mapping + /// refuses more rather than keeping the first and dropping the rest. + pub(crate) fn limit(&self) -> usize { + match self { + Self::First { .. } => 1, + Self::Objects { .. } | Self::Dictionary { .. } | Self::Pairs { .. } => { + MAX_PATH_MAPPINGS + } + } + } +} + +impl DebugConnectionBinding { + /// Every destination argument path with the binding field it comes from. + fn destinations(&self) -> Vec<(&'static str, &str)> { + let mut destinations = Vec::new(); + for (name, path) in [ + ("host", &self.host), + ("port", &self.port), + ("address", &self.address), + ] { + if let Some(path) = path { + destinations.push((name, path.as_str())); + } + } + if let Some(mappings) = &self.path_mappings { + destinations.extend(mappings.destinations()); + } + destinations + } + + pub(crate) fn validate( + &self, + field: &str, + defaults: Option<&Map>, + fixed_names: &[&str], + ) -> Validation<()> { + if self.host.is_some() != self.port.is_some() { + return Err(invalid( + format!("{field}.host"), + "host and port are declared together.", + )); + } + if self.host.is_none() && self.address.is_none() { + return Err(invalid( + field, + "A connection binding receives the target through host and port, or address.", + )); + } + let destinations = self.destinations(); + for (name, path) in &destinations { + if !is_argument_path(path) { + return Err(invalid( + format!("{field}.{name}"), + format!( + "An argument path is up to {MAX_ARGUMENT_PATH_DEPTH} names joined by dots, each a letter or underscore followed by letters, digits, underscores, or hyphens." + ), + )); + } + } + if let Some(mappings) = &self.path_mappings { + for (name, key) in mappings.key_fields() { + if !is_argument_name(key) { + return Err(invalid( + format!("{field}.{name}"), + "A key is a letter or underscore followed by letters, digits, underscores, or hyphens.", + )); + } + } + if let DebugPathMappingBinding::Objects { + local_key, + remote_key, + .. + } = mappings + { + if local_key == remote_key { + return Err(invalid( + format!("{field}.pathMappings.remoteKey"), + "The local and remote keys differ.", + )); + } + } + } + // Two facts can never share a destination, and a destination the + // plugin's own defaults already set would make the default win over + // the host the user confirmed: the adapter would connect somewhere the + // confirmation did not name. + for (index, (name, path)) in destinations.iter().enumerate() { + if let Some((other, _)) = destinations[..index] + .iter() + .find(|(_, other)| paths_overlap(path, other)) + { + return Err(invalid( + format!("{field}.{name}"), + format!("The argument path overlaps {other}."), + )); + } + if fixed_names.iter().any(|fixed| paths_overlap(path, fixed)) { + return Err(invalid( + format!("{field}.{name}"), + "The argument path overlaps another binding.", + )); + } + if defaults.is_some_and(|defaults| default_occupies(defaults, path)) { + return Err(invalid( + format!("{field}.{name}"), + "The argument path is already set by the configuration's own arguments.", + )); + } + } + Ok(()) + } +} diff --git a/rust/crates/debug/src/adapters/plan.rs b/rust/crates/debug/src/adapters/plan.rs new file mode 100644 index 00000000..7d23efeb --- /dev/null +++ b/rust/crates/debug/src/adapters/plan.rs @@ -0,0 +1,170 @@ +//! The launch plan of a debug session. + +use super::*; + +impl DebugAdapterDeclaration { + /// What the host needs besides the process: how to reach the adapter and + /// what to show. The process itself is the launch plan's own part. + pub fn plan_descriptor(&self, plugin_id: &str) -> Value { + let mut descriptor = json!({ + "id": self.id, + "providerID": format!("{plugin_id}/{}", self.id), + "displayName": self.display_name, + "adapterID": self.adapter_id.as_deref().unwrap_or(&self.id), + "requests": self.requests, + "selectors": self.selectors, + "configurationTypes": self.configuration_types, + "transport": self.launch.transport, + "endpoint": self.launch.endpoint, + "disabledCapabilities": self.capabilities.as_ref().map(|c| c.disabled.clone()).unwrap_or_default(), + // Present exactly when the adapter attaches to a remote host and + // port; says whether the host may offer path mappings and how many + // the adapter can take (0 when it takes none). + "remoteAttach": self.connection_binding().map(|binding| json!({ + "pathMappings": binding.path_mappings.is_some(), + "maxPathMappings": binding.path_mappings.as_ref().map_or(0, DebugPathMappingBinding::limit), + })), + }); + // The host hands these to the debug engine with the session, which then follows the + // declaration instead of recognizing the adapter by its ID. + if let Some(features) = &self.features { + descriptor["features"] = json!(features); + } + if let Some(defaults) = &self.defaults { + descriptor["defaults"] = json!(defaults); + } + descriptor + } +} + +/// Checks one debug start against the registration, the selected runtime and +/// the target facts the request's bindings read. +pub fn plan( + adapter: &DebugAdapterDeclaration, + request: DebugRequest, + facts: &StartFacts, +) -> Validation<()> { + if facts.registration.is_stale() { + return Err(PluginRejection::at( + "debug.provider.stale", + "registration", + "The adapter registration changed after this start was prepared.", + )); + } + if !adapter.requests.contains(&request) { + return Err(PluginRejection::at( + "debug.request.unsupported", + "request", + "The adapter does not serve this request.", + )); + } + if uses_selected_runtime(std::slice::from_ref(adapter)) + && facts.runtime_selection.is_none_or(Generation::is_stale) + { + return Err(PluginRejection::at( + "debug.runtime.stale", + "runtimeSelection", + "The selected runtime changed after this start was prepared.", + )); + } + // A remote target is only ever attached to, through an adapter that says + // where its host and port go. + if facts.target.kind == TARGET_CONNECTION + && (request != DebugRequest::Attach || adapter.connection_binding().is_none()) + { + return Err(PluginRejection::at( + "debug.target.invalid", + "target.kind", + "The adapter does not attach to a remote host and port.", + )); + } + let bindings = adapter + .configurations + .as_ref() + .and_then(|configurations| match request { + DebugRequest::Launch => configurations.launch.as_ref(), + DebugRequest::Attach => configurations.attach.as_ref(), + }) + .and_then(|configuration| configuration.target_bindings.as_ref()); + let Some(bindings) = bindings else { + return Ok(()); + }; + let missing = |field: &'static str, fact: &str| { + PluginRejection::at( + "debug.target.invalid", + field, + format!("The target has no {fact}, which the adapter's configuration reads."), + ) + }; + let target = &facts.target; + match bindings.program.as_deref() { + Some(TARGET_EXECUTABLE) + if !target + .executable + .as_deref() + .is_some_and(|path| std::path::Path::new(path).is_absolute()) => + { + return Err(missing("target.executable", "executable")); + } + Some(source) + if source != TARGET_EXECUTABLE && target.path.as_deref().is_none_or(str::is_empty) => + { + return Err(missing("target.path", "path")); + } + _ => {} + } + if bindings.args.is_some() + && target.arguments.as_ref().is_none_or(|args| { + args.len() > 1024 + || args + .iter() + .any(|arg| arg.len() > 65536 || arg.contains('\0')) + }) + { + return Err(missing("target.arguments", "bounded arguments")); + } + if bindings.env.is_some() + && target.environment.as_ref().is_none_or(|env| { + env.len() > 64 + || env.iter().any(|(key, value)| { + key.is_empty() + || key.len() > 128 + || !key.bytes().enumerate().all(|(index, byte)| { + byte == b'_' + || byte.is_ascii_alphabetic() + || index > 0 && byte.is_ascii_digit() + }) + || value.len() > 65536 + || value.contains('\0') + }) + }) + { + return Err(missing("target.environment", "bounded environment")); + } + if bindings.process_id.is_some() && target.process_id.is_none() { + return Err(missing("target.processID", "process ID")); + } + if bindings.cwd.is_some() + && target + .working_directory + .as_deref() + .is_none_or(str::is_empty) + { + return Err(missing("target.workingDirectory", "working directory")); + } + if let Some(connection) = &bindings.connection { + connection_endpoint(target)?; + if let Some(shape) = &connection.path_mappings { + check_path_mappings(&target.path_mappings, shape)?; + } else if !target.path_mappings.is_empty() { + // Dropping a mapping the user typed would let the session start + // with source lines that silently do not match. + return Err(PluginRejection::at( + "debug.target.invalid", + "target.pathMappings", + "The adapter takes no path mappings.", + )); + } + } + Ok(()) +} diff --git a/rust/crates/debug/src/adapters/process.rs b/rust/crates/debug/src/adapters/process.rs new file mode 100644 index 00000000..d7f84c2c --- /dev/null +++ b/rust/crates/debug/src/adapters/process.rs @@ -0,0 +1,169 @@ +//! Adapter capabilities, launch, transport, endpoint and process declarations. + +use super::*; + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct DebugCapabilities { + /// Standard DAP capability names the host treats as absent. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) disabled: Vec, +} + +/// How the adapter process is started and reached. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DebugLaunch { + /// Absent exactly for `tcpLanguageServer`, whose adapter lives inside a + /// language server the plugin already declares and has no process of its + /// own. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub process: Option, + pub(crate) transport: DebugTransport, + /// Required by, and only allowed for, the TCP transports. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub endpoint: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum DebugTransport { + Stdio, + TcpAnnounced, + TcpPort, + /// The adapter runs inside the plugin's language server, which opens a + /// loopback port when asked by a command. + TcpLanguageServer, +} + +/// Where a TCP adapter listens. The fields of the other transports must stay +/// absent, which `validate` checks. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DebugEndpoint { + /// `tcpLanguageServer`: language ID of a language server of this plugin. + #[serde( + default, + rename = "languageID", + skip_serializing_if = "Option::is_none" + )] + pub language_id: Option, + /// `tcpLanguageServer`: the `workspace/executeCommand` command that starts + /// the adapter and answers with its loopback port. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) command: Option, + /// `tcpAnnounced`: the stream the adapter prints its endpoint on. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) stream: Option, + /// `tcpAnnounced`: the text before the endpoint on that line. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) prefix: Option, + /// `tcpAnnounced`: `port` or `hostPort`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) value: Option, + /// `tcpPort`: a fixed loopback port of the adapter process. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) port: Option, +} + +/// The adapter process: the launch fields every plugin server process shares, +/// or the runtime Run selected for the target. +#[derive(Debug, Clone, Serialize)] +#[serde(untagged)] +pub enum DebugProcess { + /// One of the plugin's own server processes. + Server(ServerProcess), + /// The runtime the selected Run uses for the target. + SelectedRuntime(SelectedRuntimeProcess), +} + +/// Start the adapter on the runtime Run selected for the target, not on a +/// runtime the plugin names a version of. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SelectedRuntimeProcess { + pub(crate) launch: SelectedRuntimeLaunch, + #[serde(default)] + pub(crate) arguments: Vec, + #[serde(default)] + pub(crate) validation_arguments: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct SelectedRuntimeLaunch { + kind: SelectedRuntimeKind, + pub(crate) tool: String, +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize)] +enum SelectedRuntimeKind { + #[serde(rename = "runSelectedRuntime")] + RunSelectedRuntime, +} + +impl<'de> Deserialize<'de> for DebugProcess { + /// Chooses the branch by `launch.kind` and refuses fields a DAP adapter + /// cannot use, so `ServerProcess` keeps one shape for every owner. + fn deserialize>(deserializer: D) -> Result { + use serde::de::Error; + let value = Value::deserialize(deserializer)?; + let object = value + .as_object() + .ok_or_else(|| D::Error::custom("a debug adapter process is an object"))?; + let selected = object + .get("launch") + .and_then(|launch| launch.get("kind")) + .and_then(Value::as_str) + == Some("runSelectedRuntime"); + if selected { + return serde_json::from_value(value) + .map(Self::SelectedRuntime) + .map_err(D::Error::custom); + } + if let Some(key) = object + .keys() + .find(|key| !PROCESS_KEYS.contains(&key.as_str())) + { + return Err(D::Error::custom(format!( + "unknown field `{key}` in a debug adapter process" + ))); + } + serde_json::from_value(value) + .map(Self::Server) + .map_err(D::Error::custom) + } +} + +impl DebugProcess { + pub(crate) fn validate(&self, field: &str, context: &DeclarationContext<'_>) -> Validation<()> { + match self { + Self::Server(process) => process.validate( + field, + context.declared_settings, + ServerRole::DebugAdapter, + context.runtime_tools, + ), + Self::SelectedRuntime(process) => { + let tool = process.launch.tool.as_str(); + context + .runtime_tools + .check(tool, format!("{field}.launch.tool"))?; + validate_process_arguments( + &process.arguments, + &process.validation_arguments, + context.declared_settings, + field, + ) + } + } + } + + /// The runtime tool this adapter needs Run to have selected. + pub(crate) fn selected_runtime_tool(&self) -> Option<&str> { + match self { + Self::SelectedRuntime(process) => Some(process.launch.tool.as_str()), + Self::Server(_) => None, + } + } +} diff --git a/rust/crates/debug/src/adapters/resources.rs b/rust/crates/debug/src/adapters/resources.rs new file mode 100644 index 00000000..72fda1d4 --- /dev/null +++ b/rust/crates/debug/src/adapters/resources.rs @@ -0,0 +1,141 @@ +//! Package resources an adapter declares and their link checks. + +use super::*; + +/// A file of the installed package, as the installer found it. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[cfg_attr(not(test), allow(dead_code))] +pub struct PackageResource { + pub(crate) path: String, + pub(crate) kind: PackageResourceKind, + /// Link target of a symlink, as stored in the package. + #[serde(default)] + pub(crate) target: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(not(test), allow(dead_code))] +pub(crate) enum PackageResourceKind { + File, + Directory, + Symlink, +} + +/// Checks that every packaged file an adapter refers to (its packaged +/// executable and parameter schemas) is a regular file or a link that stays +/// inside the package. Only the installer can see the files, so this is +/// separate from the manifest checks. +#[cfg_attr(not(test), allow(dead_code))] +pub fn check_package_resources( + adapters: &[DebugAdapterDeclaration], + resources: &[PackageResource], +) -> Validation<()> { + for (index, adapter) in adapters.iter().enumerate() { + if let Some(DebugProcess::Server(process)) = &adapter.launch.process { + for (key, path) in process.packaged_files() { + check_resource( + resources, + path, + format!("debugAdapters[{index}].launch.process.launch.{key}"), + "The adapter file", + )?; + } + } + let Some(configurations) = &adapter.configurations else { + continue; + }; + for (name, configuration) in [ + ("launch", &configurations.launch), + ("attach", &configurations.attach), + ] { + let Some(path) = configuration + .as_ref() + .and_then(|configuration| configuration.parameters_schema.as_ref()) + else { + continue; + }; + check_resource( + resources, + path, + format!("debugAdapters[{index}].configurations.{name}.parametersSchema"), + "The parameters schema", + )?; + } + } + Ok(()) +} + +/// Links followed before a chain counts as a loop. +const MAX_LINK_HOPS: usize = 8; + +/// One packaged path: present, and a regular file once its links are +/// followed. Every link on the way must stay inside the package. `what` +/// starts the message. +#[cfg_attr(not(test), allow(dead_code))] +fn check_resource( + resources: &[PackageResource], + path: &str, + field: String, + what: &str, +) -> Validation<()> { + let mut current = normalize(path); + for _ in 0..=MAX_LINK_HOPS { + let Some(resource) = resources.iter().find(|resource| resource.path == current) else { + return Err(invalid(field, format!("{what} is not in the package."))); + }; + match resource.kind { + PackageResourceKind::File => return Ok(()), + PackageResourceKind::Directory => { + return Err(invalid(field, format!("{what} is a directory."))); + } + PackageResourceKind::Symlink => { + let Some(next) = resolve_link(&resource.path, resource.target.as_deref()) else { + return Err(invalid( + field, + format!("{what} links outside the plugin package."), + )); + }; + current = next; + } + } + } + Err(invalid(field, format!("{what} is behind too many links."))) +} + +/// Drops empty and `.` components, so `./bin//x` names the file `bin/x`. +#[cfg_attr(not(test), allow(dead_code))] +fn normalize(path: &str) -> String { + path.split('/') + .filter(|component| !component.is_empty() && *component != ".") + .collect::>() + .join("/") +} + +/// Resolves a symlink target lexically from the link's directory to a +/// package-relative path. Only the final component is followed: a link in a +/// middle directory is the installer's to flatten into the resource list. A target that climbs above the package root, is +/// absolute, or resolves to the root itself has no such path. +#[cfg_attr(not(test), allow(dead_code))] +fn resolve_link(link: &str, target: Option<&str>) -> Option { + let target = target?; + if target.is_empty() || target.starts_with('/') || target.contains(['\\', '\0']) { + return None; + } + let mut parts: Vec<&str> = link.split('/').collect(); + parts.pop(); + for component in target.split('/') { + match component { + "" | "." => {} + ".." => { + parts.pop()?; + } + name => parts.push(name), + } + } + if parts.is_empty() { + return None; + } + Some(parts.join("/")) +} diff --git a/rust/crates/debug/src/adapters/start.rs b/rust/crates/debug/src/adapters/start.rs new file mode 100644 index 00000000..5c08d909 --- /dev/null +++ b/rust/crates/debug/src/adapters/start.rs @@ -0,0 +1,440 @@ +//! Start facts, arguments, connections and path mappings of a debug session. + +use super::*; + +/// Facts of one debug start that the manifest cannot know. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct StartFacts { + pub(crate) registration: Generation, + #[serde(default)] + pub(crate) runtime_selection: Option, + pub(crate) target: TargetFacts, +} + +/// A generation the plan was made against and the one current at start. +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct Generation { + pub(crate) generation: u64, + pub(crate) current_generation: u64, +} + +impl Generation { + pub(crate) fn is_stale(self) -> bool { + self.generation != self.current_generation + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct TargetFacts { + /// Host-validated process arguments and environment; absent differs from empty. + #[serde(default)] + pub(crate) arguments: Option>, + #[serde(default)] + pub(crate) environment: Option>, + // Selection by kind happens before a start; the host still states it so a + // start names what it was prepared for. A `connection` target is also + // checked against the adapter, see `plan`. + pub(crate) kind: String, + #[serde(default)] + pub(crate) path: Option, + #[serde(default, rename = "processID")] + pub(crate) process_id: Option, + #[serde(default)] + pub(crate) working_directory: Option, + /// The program the host found for a source file, an absolute path. + #[serde(default)] + pub(crate) executable: Option, + /// Where a remote debuggee listens: a host name or IP literal without + /// brackets, port, or scheme. + #[serde(default)] + pub(crate) host: Option, + #[serde(default)] + pub(crate) port: Option, + /// Local source roots and where the remote machine has them. + #[serde(default)] + pub(crate) path_mappings: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PathMapping { + pub(crate) local: String, + pub(crate) remote: String, +} + +/// What a start asks of the adapter: the request and the facts around it. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct StartRequest { + pub(crate) request: DebugRequest, + pub(crate) facts: StartFacts, + /// A plugin backend's own DAP arguments from `lithe/host/debug/start`, as + /// `debug.decodeBackendConfiguration` accepted them. They go on top of the + /// adapter's arguments, so the host sends the result unchanged. + #[serde(default)] + pub(crate) configuration: Option>, +} + +/// The remote endpoint a start connects to, as the adapter will receive it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StartConnection { + pub(crate) host: String, + pub(crate) port: u16, + /// False only when `host` is `localhost` or a loopback IP literal. A name + /// that resolves to loopback still counts as remote, so a host asks the + /// user about every endpoint Core cannot prove stays on this machine. + pub(crate) remote: bool, +} + +/// What a start gives the host: the DAP arguments and, for a connection +/// target, the endpoint they carry. +#[derive(Debug, Clone)] +pub struct StartArguments { + /// DAP arguments to send unchanged with the request. + pub arguments: Map, + /// The remote endpoint, present only for a connection target. + pub connection: Option, +} + +/// The default DAP arguments of a request with each binding filling its +/// argument from the target, unless the defaults already set it, and then a +/// backend's configuration replacing any argument it names. +pub fn start_arguments( + adapter: &DebugAdapterDeclaration, + start: &StartRequest, +) -> Validation { + let mut started = bound_arguments(adapter, start)?; + if let Some(configuration) = &start.configuration { + crate::backend_start::reject_host_keys(configuration)?; + for (key, value) in configuration { + started.arguments.insert(key.clone(), value.clone()); + } + } + Ok(started) +} + +/// The adapter's own arguments for a start: its defaults and target bindings. +fn bound_arguments( + adapter: &DebugAdapterDeclaration, + start: &StartRequest, +) -> Validation { + plan(adapter, start.request, &start.facts)?; + let configuration = + adapter + .configurations + .as_ref() + .and_then(|configurations| match start.request { + DebugRequest::Launch => configurations.launch.as_ref(), + DebugRequest::Attach => configurations.attach.as_ref(), + }); + let mut arguments = configuration + .and_then(|configuration| configuration.arguments.clone()) + .unwrap_or_default(); + let Some(bindings) = + configuration.and_then(|configuration| configuration.target_bindings.as_ref()) + else { + return Ok(StartArguments { + arguments, + connection: None, + }); + }; + let target = &start.facts.target; + // A binding is the fact path that fills the argument of the same name. + let mut bind = |name: &str, binding: &Option, value: Option| { + if let (Some(_), Some(value)) = (binding, value) { + arguments.entry(name.to_string()).or_insert(value); + } + }; + let program = match bindings.program.as_deref() { + Some(TARGET_EXECUTABLE) => target.executable.clone(), + _ => target.path.clone(), + }; + bind("program", &bindings.program, program.map(Value::from)); + bind( + "processId", + &bindings.process_id, + target.process_id.map(Value::from), + ); + bind( + "cwd", + &bindings.cwd, + target.working_directory.clone().map(Value::from), + ); + bind( + "args", + &bindings.args, + target.arguments.as_ref().map(|args| json!(args)), + ); + bind( + "env", + &bindings.env, + target.environment.as_ref().map(|env| json!(env)), + ); + let connection = match &bindings.connection { + Some(binding) => Some(bind_connection(binding, target, &mut arguments)?), + None => None, + }; + Ok(StartArguments { + arguments, + connection, + }) +} + +/// Writes the target's endpoint, and its path mappings when the adapter takes +/// them, to the destinations the binding names. +fn bind_connection( + binding: &DebugConnectionBinding, + target: &TargetFacts, + arguments: &mut Map, +) -> Validation { + let endpoint = connection_endpoint(target)?; + if let (Some(host), Some(port)) = (&binding.host, &binding.port) { + set_argument(arguments, host, Value::from(endpoint.host.clone()))?; + set_argument(arguments, port, Value::from(endpoint.port))?; + } + if let Some(address) = &binding.address { + let joined = if endpoint.host.contains(':') { + format!("[{}]:{}", endpoint.host, endpoint.port) + } else { + format!("{}:{}", endpoint.host, endpoint.port) + }; + set_argument(arguments, address, Value::from(joined))?; + } + // `plan` has already checked the mappings against this shape: their number + // fits it, and a dictionary has no key twice. + if let (Some(mappings), false) = (&binding.path_mappings, target.path_mappings.is_empty()) { + let roots = |mapping: &PathMapping, order: MappingOrder| match order { + MappingOrder::RemoteLocal => (mapping.remote.clone(), mapping.local.clone()), + MappingOrder::LocalRemote => (mapping.local.clone(), mapping.remote.clone()), + }; + match mappings { + DebugPathMappingBinding::Objects { + argument, + local_key, + remote_key, + } => { + let entries = target + .path_mappings + .iter() + .map(|mapping| { + let mut entry = Map::new(); + entry.insert(local_key.clone(), Value::from(mapping.local.clone())); + entry.insert(remote_key.clone(), Value::from(mapping.remote.clone())); + Value::Object(entry) + }) + .collect(); + set_argument(arguments, argument, Value::Array(entries))?; + } + DebugPathMappingBinding::First { local, remote } => { + for first in target.path_mappings.iter().take(1) { + set_argument(arguments, local, Value::from(first.local.clone()))?; + set_argument(arguments, remote, Value::from(first.remote.clone()))?; + } + } + DebugPathMappingBinding::Dictionary { argument, keyed_by } => { + let order = match keyed_by { + MappingRoot::Remote => MappingOrder::RemoteLocal, + MappingRoot::Local => MappingOrder::LocalRemote, + }; + let entries = target + .path_mappings + .iter() + .map(|mapping| { + let (key, value) = roots(mapping, order); + (key, Value::from(value)) + }) + .collect(); + set_argument(arguments, argument, Value::Object(entries))?; + } + DebugPathMappingBinding::Pairs { argument, order } => { + let entries = target + .path_mappings + .iter() + .map(|mapping| { + let (first, second) = roots(mapping, *order); + Value::Array(vec![Value::from(first), Value::from(second)]) + }) + .collect(); + set_argument(arguments, argument, Value::Array(entries))?; + } + } + } + Ok(endpoint) +} + +/// Sets the argument at a dotted path, creating the objects on the way. A +/// declaration cannot leave anything at the path (see +/// `DebugConnectionBinding::validate`), so an occupied path is a bug in the +/// declaration that slipped past it and is refused rather than overwritten. +fn set_argument(arguments: &mut Map, path: &str, value: Value) -> Validation<()> { + let occupied = || { + PluginRejection::at( + "debug.arguments.invalid", + path, + "The adapter's arguments already hold something at this path.", + ) + }; + let mut current = arguments; + let mut names = path.split('.').peekable(); + while let Some(name) = names.next() { + if names.peek().is_none() { + return match current.entry(name.to_string()) { + serde_json::map::Entry::Vacant(slot) => { + slot.insert(value); + Ok(()) + } + serde_json::map::Entry::Occupied(_) => Err(occupied()), + }; + } + let next = current + .entry(name.to_string()) + .or_insert_with(|| Value::Object(Map::new())); + let Value::Object(next) = next else { + return Err(occupied()); + }; + current = next; + } + Ok(()) +} + +/// The endpoint a connection target names, checked. +pub(crate) fn connection_endpoint(target: &TargetFacts) -> Validation { + let invalid_target = |field: &'static str, message: &str| { + PluginRejection::at("debug.target.invalid", field, message) + }; + let Some(host) = target.host.as_deref() else { + return Err(invalid_target( + "target.host", + "The target has no host, which the adapter's configuration reads.", + )); + }; + if let Some(problem) = host_problem(host) { + return Err(invalid_target("target.host", problem)); + } + let port = target + .port + .ok_or_else(|| { + invalid_target( + "target.port", + "The target has no port, which the adapter's configuration reads.", + ) + }) + .and_then(|port| { + u16::try_from(port) + .ok() + .filter(|port| *port != 0) + .ok_or_else(|| invalid_target("target.port", "A port is 1-65535.")) + })?; + Ok(StartConnection { + host: host.to_string(), + port, + remote: !is_loopback_host(host), + }) +} + +/// Why a string is not a host to connect to, or `None` when it is one: a DNS +/// name or an IP literal, without scheme, credentials, port, path, brackets, +/// or whitespace. Non-ASCII names are written in punycode; an IPv6 zone is not +/// supported. +pub(crate) fn host_problem(host: &str) -> Option<&'static str> { + if host.is_empty() || host.len() > MAX_HOST { + return Some("A host is 1-253 characters."); + } + if host.parse::().is_ok() { + return None; + } + if host.contains(':') { + return Some("A host is a name or an IP address, without a scheme, port, or brackets."); + } + if host + .bytes() + .all(|byte| byte.is_ascii_digit() || byte == b'.') + { + return Some("A numeric host is a dotted IPv4 address."); + } + let labels_valid = host.split('.').all(|label| { + !label.is_empty() + && label.len() <= MAX_HOST_LABEL + && !label.starts_with('-') + && !label.ends_with('-') + && label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + }); + if labels_valid { + None + } else { + Some("A host name is dot-separated labels of letters, digits, and hyphens.") + } +} + +/// Whether Core can tell from the text alone that the host is this machine. +pub(crate) fn is_loopback_host(host: &str) -> bool { + host.eq_ignore_ascii_case("localhost") + || host + .parse::() + .is_ok_and(|address| address.is_loopback()) +} + +/// Checks the path mappings the host sends along with a remote target against +/// the shape the adapter takes them in. +pub(crate) fn check_path_mappings( + mappings: &[PathMapping], + shape: &DebugPathMappingBinding, +) -> Validation<()> { + let invalid_mapping = |message: String| { + PluginRejection::at("debug.target.invalid", "target.pathMappings", message) + }; + // A shape that holds fewer mappings than the user gave would start a + // session with some source lines silently unmapped. + if mappings.len() > shape.limit() { + return Err(invalid_mapping(match shape.limit() { + 1 => "The adapter takes one path mapping.".to_string(), + limit => format!("A target has up to {limit} path mappings."), + })); + } + for mapping in mappings { + let sound = |root: &str| { + !root.is_empty() + && root.chars().count() <= MAX_PATH_MAPPING_ROOT + && !root.chars().any(char::is_control) + }; + if !sound(&mapping.local) || !sound(&mapping.remote) { + return Err(invalid_mapping(format!( + "A path mapping root is one line of 1-{MAX_PATH_MAPPING_ROOT} characters." + ))); + } + // The local root is on this machine, so it must be a full path; the + // remote root is the other machine's and may follow other conventions. + if !std::path::Path::new(&mapping.local).is_absolute() { + return Err(invalid_mapping( + "A path mapping's local root is an absolute path.".to_string(), + )); + } + } + // A dictionary holds one value per key; two mappings from the same root + // cannot both be sent, and keeping either would drop the other unseen. + if let DebugPathMappingBinding::Dictionary { keyed_by, .. } = shape { + let mut keys = HashSet::new(); + for mapping in mappings { + let key = match keyed_by { + MappingRoot::Local => &mapping.local, + MappingRoot::Remote => &mapping.remote, + }; + if !keys.insert(key.as_str()) { + return Err(invalid_mapping(format!( + "The adapter takes one path mapping per {} root.", + match keyed_by { + MappingRoot::Local => "local", + MappingRoot::Remote => "remote", + } + ))); + } + } + } + Ok(()) +} diff --git a/rust/crates/debug/src/adapters/validate_adapter.rs b/rust/crates/debug/src/adapters/validate_adapter.rs new file mode 100644 index 00000000..9dbfcf77 --- /dev/null +++ b/rust/crates/debug/src/adapters/validate_adapter.rs @@ -0,0 +1,210 @@ +//! Validation of one adapter declaration. + +use super::*; + +/// Whether any adapter starts on the runtime Run selected. +pub(crate) fn uses_selected_runtime(adapters: &[DebugAdapterDeclaration]) -> bool { + adapters.iter().any(|adapter| { + adapter + .launch + .process + .as_ref() + .and_then(DebugProcess::selected_runtime_tool) + .is_some() + }) +} + +/// Whether any adapter serves `attach`, which needs its own permission. +pub fn serves_attach(adapters: &[DebugAdapterDeclaration]) -> bool { + adapters + .iter() + .any(|adapter| adapter.requests.contains(&DebugRequest::Attach)) +} + +impl DebugAdapterDeclaration { + pub(crate) fn validate( + &mut self, + field: &str, + context: &DeclarationContext<'_>, + ) -> Validation<()> { + if !is_local_id(&self.id) { + return Err(invalid( + format!("{field}.id"), + "A debug adapter ID is a lowercase letter followed by up to 63 lowercase letters, digits, dots, underscores, or hyphens.", + )); + } + validate_name(&self.display_name, &format!("{field}.displayName"))?; + if let Some(adapter_id) = &self.adapter_id { + validate_name(adapter_id, &format!("{field}.adapterID"))?; + } + + // `launch` is the default; an explicit empty list would serve nothing. + if self.requests.is_empty() { + self.requests = vec![DebugRequest::Launch]; + } + self.requests.sort(); + let before = self.requests.len(); + self.requests.dedup(); + if self.requests.len() != before { + return Err(invalid( + format!("{field}.requests"), + "A request is listed more than once.", + )); + } + + self.validate_selectors(field)?; + self.validate_configuration_types(field)?; + if let Some(configurations) = &self.configurations { + // A configuration for a request the adapter does not serve could + // never apply. + for (name, request, configuration) in [ + ("launch", DebugRequest::Launch, &configurations.launch), + ("attach", DebugRequest::Attach, &configurations.attach), + ] { + let Some(configuration) = configuration else { + continue; + }; + let configuration_field = format!("{field}.configurations.{name}"); + if !self.requests.contains(&request) { + return Err(invalid( + configuration_field, + "The configuration is for a request the adapter does not declare.", + )); + } + configuration.validate(&configuration_field, request, context)?; + } + } + // A connection target needs somewhere to put `host:port`; without it + // the selector would offer the adapter for a target it cannot start. + if self.selectors.iter().any(|selector| { + selector + .target_kinds + .iter() + .any(|kind| kind == TARGET_CONNECTION) + }) && self.connection_binding().is_none() + { + return Err(invalid( + format!("{field}.selectors"), + "A connection selector needs an attach configuration with a connection binding.", + )); + } + if let Some(capabilities) = &mut self.capabilities { + capabilities.validate(&format!("{field}.capabilities"))?; + } + if let Some(console) = &mut self.console { + console.validate(&format!("{field}.console"))?; + } + let declared = DeclaredAdapterBehavior { + features: self.features.clone(), + defaults: self.defaults.clone(), + }; + declared.check().map_err(|(declared_field, message)| { + invalid(format!("{field}.{declared_field}"), message) + })?; + self.launch.validate(&format!("{field}.launch"), context) + } + + /// The binding that receives a remote target, when the adapter attaches + /// to one. + pub(crate) fn connection_binding(&self) -> Option<&DebugConnectionBinding> { + self.configurations + .as_ref() + .and_then(|configurations| configurations.attach.as_ref()) + .and_then(|configuration| configuration.target_bindings.as_ref()) + .and_then(|bindings| bindings.connection.as_ref()) + } + + fn validate_selectors(&mut self, field: &str) -> Result<(), PluginRejection> { + if self.selectors.len() > MAX_SELECTORS { + return Err(invalid( + format!("{field}.selectors"), + format!("An adapter declares at most {MAX_SELECTORS} selectors."), + )); + } + for (index, selector) in self.selectors.iter_mut().enumerate() { + let selector_field = format!("{field}.selectors[{index}]"); + if selector.target_kinds.is_empty() && selector.language_ids.is_empty() { + return Err(invalid( + selector_field, + "A selector matches on at least one field.", + )); + } + if let Some(kind) = selector + .target_kinds + .iter() + .find(|kind| !TARGET_KINDS.contains(&kind.as_str())) + { + return Err(invalid( + format!("{selector_field}.targetKinds"), + format!("{kind} is not a target kind."), + )); + } + if selector.language_ids.iter().any(|id| !is_language_id(id)) { + return Err(invalid( + format!("{selector_field}.languageIDs"), + "A language ID is lowercase letters, digits, and + _ -.", + )); + } + sort_unique( + &mut selector.target_kinds, + &format!("{selector_field}.targetKinds"), + )?; + sort_unique( + &mut selector.language_ids, + &format!("{selector_field}.languageIDs"), + )?; + } + Ok(()) + } + + fn validate_configuration_types(&mut self, field: &str) -> Validation<()> { + let types_field = format!("{field}.configurationTypes"); + if self.configuration_types.len() > MAX_CONFIGURATION_TYPES + || self + .configuration_types + .iter() + .any(|name| name.trim().is_empty() || name.chars().any(char::is_control)) + { + return Err(invalid( + types_field, + format!("Configuration types are up to {MAX_CONFIGURATION_TYPES} non-empty names."), + )); + } + sort_unique(&mut self.configuration_types, &types_field) + } +} + +/// Event that activates the plugin when a debug session needs the adapter. +pub fn activation_event(adapter: &DebugAdapterDeclaration) -> String { + format!("onDebug:{}", adapter.id) +} + +/// Install-review entries: the adapter process with the runtimes it needs. +/// Pointing an adapter at another executable or transport is a new program +/// the user must approve. A selected-runtime adapter adds no runtime entry: +/// the runtime is the one the user already chose for Run. +pub fn programs(adapter: &DebugAdapterDeclaration) -> Vec { + let mut program = match &adapter.launch.process { + Some(DebugProcess::Server(process)) => process.program(ServerRole::DebugAdapter), + Some(DebugProcess::SelectedRuntime(process)) => json!({ + "kind": "selectedRuntimeDebugAdapter", + "tool": process.launch.tool, + "arguments": process.arguments, + "validationArguments": process.validation_arguments, + }), + // The server is reviewed as the plugin's language server; what is new + // here is the command that makes it serve debugging. + None => json!({ "kind": "languageServerDebugAdapter" }), + }; + program["debugAdapter"] = json!(adapter.id); + program["transport"] = json!(adapter.launch.transport); + program["endpoint"] = json!(adapter.launch.endpoint); + let mut programs = vec![program]; + if let Some(DebugProcess::Server(process)) = &adapter.launch.process { + programs.extend(process.runtime_programs()); + } + programs +} + +// The install check has no caller until the plugin installer reads it; tests +// run it now. diff --git a/rust/crates/debug/src/adapters/validate_configuration.rs b/rust/crates/debug/src/adapters/validate_configuration.rs new file mode 100644 index 00000000..75f9bd48 --- /dev/null +++ b/rust/crates/debug/src/adapters/validate_configuration.rs @@ -0,0 +1,231 @@ +//! Validation of debug configurations and executables. + +use super::*; + +impl DebugConfiguration { + pub(crate) fn validate( + &self, + field: &str, + request: DebugRequest, + context: &DeclarationContext<'_>, + ) -> Validation<()> { + if self.arguments.is_none() + && self.parameters_schema.is_none() + && self.target_bindings.is_none() + && self.executable.is_none() + { + return Err(invalid( + field, + "A configuration declares at least one field.", + )); + } + if let Some(path) = &self.parameters_schema { + if !is_safe_relative_path(path) || path.contains(':') { + return Err(invalid( + format!("{field}.parametersSchema"), + "The parameters schema must be a file inside the plugin package.", + )); + } + } + let reads_executable = self + .target_bindings + .as_ref() + .is_some_and(|bindings| bindings.program.as_deref() == Some(TARGET_EXECUTABLE)); + match (&self.executable, reads_executable) { + (Some(executable), true) => { + executable.validate(&format!("{field}.executable"), request, context)? + } + (Some(_), false) => { + return Err(invalid( + format!("{field}.executable"), + "An executable lookup needs program bound to target.executable.", + )) + } + (None, true) => { + return Err(invalid( + format!("{field}.executable"), + "A program bound to target.executable needs an executable lookup.", + )) + } + (None, false) => {} + } + let Some(bindings) = &self.target_bindings else { + return Ok(()); + }; + let binding_field = format!("{field}.targetBindings"); + if bindings.program.is_none() + && bindings.process_id.is_none() + && bindings.cwd.is_none() + && bindings.args.is_none() + && bindings.env.is_none() + && bindings.connection.is_none() + { + return Err(invalid( + &binding_field, + "A binding list declares a binding.", + )); + } + for (name, source, allowed) in [ + ( + "program", + &bindings.program, + &["target.path", TARGET_EXECUTABLE][..], + ), + ("processId", &bindings.process_id, &["target.processID"][..]), + ("cwd", &bindings.cwd, &["target.workingDirectory"][..]), + ("args", &bindings.args, &["target.arguments"][..]), + ("env", &bindings.env, &["target.environment"][..]), + ] { + if source + .as_deref() + .is_some_and(|source| !allowed.contains(&source)) + { + return Err(invalid( + format!("{binding_field}.{name}"), + format!("{name} can only be bound to {}.", allowed.join(" or ")), + )); + } + } + // `processId` names a running process and `connection` a remote + // endpoint, which only attach has. + if ((bindings.process_id.is_some() || bindings.connection.is_some()) + && request != DebugRequest::Attach) + || (bindings.program.is_some() && request != DebugRequest::Launch) + { + return Err(invalid( + binding_field, + "The binding does not apply to this request.", + )); + } + if let Some(connection) = &bindings.connection { + let fixed_names: Vec<&str> = [ + ("processId", bindings.process_id.is_some()), + ("cwd", bindings.cwd.is_some()), + ] + .into_iter() + .filter_map(|(name, declared)| declared.then_some(name)) + .collect(); + connection.validate( + &format!("{binding_field}.connection"), + self.arguments.as_ref(), + &fixed_names, + )?; + } + Ok(()) + } +} + +impl DebugExecutable { + fn validate( + &self, + field: &str, + request: DebugRequest, + context: &DeclarationContext<'_>, + ) -> Validation<()> { + if request != DebugRequest::Launch { + return Err(invalid(field, "Only a launch looks for an executable.")); + } + if !context.declared_settings.is_file(&self.setting) { + return Err(invalid( + format!("{field}.setting"), + "The setting must be a declared file setting of this plugin.", + )); + } + if self.candidates.len() > MAX_EXECUTABLE_CANDIDATES { + return Err(invalid( + format!("{field}.candidates"), + format!("A lookup lists at most {MAX_EXECUTABLE_CANDIDATES} candidates."), + )); + } + for (index, candidate) in self.candidates.iter().enumerate() { + if !is_executable_candidate(candidate) { + return Err(invalid( + format!("{field}.candidates[{index}]"), + "A candidate starts with ${workspaceFolder}/ or ${fileDirectory}/, \ + names only known variables, and has no .. or control characters.", + )); + } + } + let mut seen = BTreeSet::new(); + if !self + .candidates + .iter() + .all(|candidate| seen.insert(candidate)) + { + return Err(invalid( + format!("{field}.candidates"), + "A candidate is listed more than once.", + )); + } + Ok(()) + } + + /// The candidates a host can look for with what it knows now, in order. + /// One that needs the source file is skipped when there is none, which is + /// how the settings page asks without a file open. + pub fn expanded_candidates( + &self, + workspace: Option<&str>, + target_path: Option<&str>, + ) -> Vec { + let file = target_path.map(std::path::Path::new); + let directory = file + .and_then(std::path::Path::parent) + .and_then(std::path::Path::to_str); + let stem = file + .and_then(std::path::Path::file_stem) + .and_then(std::ffi::OsStr::to_str); + let mut expanded = Vec::new(); + for candidate in &self.candidates { + let mut path = candidate.clone(); + for (name, value) in [ + ("workspaceFolder", workspace), + ("fileDirectory", directory), + ("fileBasenameNoExtension", stem), + ] { + let variable = format!("${{{name}}}"); + if !path.contains(&variable) { + continue; + } + let Some(value) = value.filter(|value| !value.is_empty()) else { + path.clear(); + break; + }; + path = path.replace(&variable, value.trim_end_matches('/')); + } + if !path.is_empty() && !expanded.contains(&path) { + expanded.push(path); + } + } + expanded + } +} + +/// Whether a candidate is a path template a host may look at. +fn is_executable_candidate(candidate: &str) -> bool { + if candidate.chars().count() > MAX_EXECUTABLE_CANDIDATE + || candidate + .chars() + .any(|character| character.is_control() || character == '\\') + || !EXECUTABLE_ROOTS + .iter() + .any(|root| candidate.starts_with(root)) + || candidate.split('/').any(|part| part == "..") + { + return false; + } + // Every `${` must close on a known variable; a stray one would reach the + // file system as part of a name. + let mut rest = candidate; + while let Some(start) = rest.find("${") { + let after = &rest[start + 2..]; + let Some(end) = after.find('}') else { + return false; + }; + if !EXECUTABLE_VARIABLES.contains(&&after[..end]) { + return false; + } + rest = &after[end + 1..]; + } + true +} diff --git a/rust/crates/debug/src/adapters/validate_launch.rs b/rust/crates/debug/src/adapters/validate_launch.rs new file mode 100644 index 00000000..45c36243 --- /dev/null +++ b/rust/crates/debug/src/adapters/validate_launch.rs @@ -0,0 +1,198 @@ +//! Validation of capabilities, consoles, launches and endpoints. + +use super::*; + +impl DebugCapabilities { + pub(crate) fn validate(&mut self, field: &str) -> Validation<()> { + let disabled_field = format!("{field}.disabled"); + if self.disabled.is_empty() { + return Err(invalid( + disabled_field, + "Disabled capabilities list at least one name.", + )); + } + if let Some(index) = self + .disabled + .iter() + .position(|name| !DAP_CAPABILITIES.contains(&name.as_str())) + { + return Err(invalid( + format!("{disabled_field}[{index}]"), + "Only standard DAP capability names can be disabled.", + )); + } + sort_unique(&mut self.disabled, &disabled_field) + } +} + +impl DebugConsole { + pub(crate) fn validate(&mut self, field: &str) -> Validation<()> { + let commands_field = format!("{field}.commands"); + if let Some(prefix) = &self.command_prefix { + // The host concatenates the prefix and the line as written, so a + // space or control character would shift what the adapter reads. + if prefix.is_empty() + || prefix.chars().count() > MAX_CONSOLE_COMMAND_PREFIX + || prefix + .chars() + .any(|character| character.is_control() || character.is_whitespace()) + { + return Err(invalid( + format!("{field}.commandPrefix"), + "A command prefix is 1-8 characters without spaces.", + )); + } + } + if self.commands.is_empty() && self.command_prefix.is_none() { + return Err(invalid( + commands_field, + "A console declares at least one command or a command prefix.", + )); + } + if self.commands.len() > MAX_CONSOLE_COMMANDS { + return Err(invalid( + commands_field, + format!("An adapter declares at most {MAX_CONSOLE_COMMANDS} console commands."), + )); + } + for (index, command) in self.commands.iter().enumerate() { + let command_field = format!("{commands_field}[{index}]"); + // The name is inserted as written, so surrounding spaces would + // change what the user's line means. + if command.name.is_empty() + || command.name.trim() != command.name + || command.name.chars().count() > MAX_CONSOLE_COMMAND_NAME + || command.name.chars().any(char::is_control) + { + return Err(invalid( + format!("{command_field}.name"), + "A command name is one line of 1-128 characters without surrounding spaces.", + )); + } + if let Some(description) = &command.description { + if description.trim().is_empty() + || description.chars().count() > MAX_CONSOLE_COMMAND_DESCRIPTION + || description.chars().any(char::is_control) + { + return Err(invalid( + format!("{command_field}.description"), + "A description is one line of 1-240 characters.", + )); + } + } + } + self.commands + .sort_by(|left, right| left.name.cmp(&right.name)); + if let Some(pair) = self + .commands + .windows(2) + .find(|pair| pair[0].name == pair[1].name) + { + return Err(invalid( + commands_field, + format!("The command {} is listed more than once.", pair[0].name), + )); + } + Ok(()) + } +} + +impl DebugLaunch { + pub(crate) fn validate(&self, field: &str, context: &DeclarationContext<'_>) -> Validation<()> { + let endpoint_field = format!("{field}.endpoint"); + let hosted = self.transport == DebugTransport::TcpLanguageServer; + match (&self.process, hosted) { + (Some(process), false) => process.validate(&format!("{field}.process"), context)?, + (None, true) => {} + (None, false) => { + return Err(invalid( + format!("{field}.process"), + "An adapter process is required unless the transport is tcpLanguageServer.", + )); + } + (Some(_), true) => { + return Err(invalid( + format!("{field}.process"), + "A tcpLanguageServer adapter runs inside the language server and has no process.", + )); + } + } + match (self.transport, &self.endpoint) { + (DebugTransport::Stdio, None) => Ok(()), + (DebugTransport::Stdio, Some(_)) => Err(invalid( + endpoint_field, + "The stdio transport has no endpoint.", + )), + (_, None) => Err(invalid( + endpoint_field, + "A TCP transport needs an endpoint.", + )), + (DebugTransport::TcpAnnounced, Some(endpoint)) => { + let valid = endpoint.port.is_none() + && !endpoint.has_language_server_fields() + && matches!(endpoint.stream.as_deref(), Some("stdout" | "stderr")) + && matches!(endpoint.value.as_deref(), Some("port" | "hostPort")) + && endpoint.prefix.as_deref().is_some_and(|prefix| { + !prefix.is_empty() + && prefix.len() <= MAX_ENDPOINT_PREFIX + && !prefix.contains(['\r', '\n', '\0']) + }); + if valid { + Ok(()) + } else { + Err(invalid( + endpoint_field, + "An announced endpoint names a stream, a one-line prefix, and port or hostPort.", + )) + } + } + (DebugTransport::TcpPort, Some(endpoint)) => { + let valid = endpoint.stream.is_none() + && endpoint.prefix.is_none() + && endpoint.value.is_none() + && !endpoint.has_language_server_fields() + && endpoint.port.is_some_and(|port| port >= 1); + if valid { + Ok(()) + } else { + Err(invalid( + endpoint_field, + "A fixed endpoint is a loopback port from 1 to 65535.", + )) + } + } + (DebugTransport::TcpLanguageServer, Some(endpoint)) => { + if endpoint.stream.is_some() + || endpoint.prefix.is_some() + || endpoint.value.is_some() + || endpoint.port.is_some() + { + return Err(invalid( + endpoint_field, + "A language server endpoint names only a language and a command.", + )); + } + let language = endpoint.language_id.as_deref().unwrap_or_default(); + if !context.language_servers.contains(language) { + return Err(invalid( + format!("{endpoint_field}.languageID"), + "The language server is not declared in languageServers.", + )); + } + if !endpoint.command.as_deref().is_some_and(is_server_command) { + return Err(invalid( + format!("{endpoint_field}.command"), + "A command is 1-128 letters, digits, and . _ : -.", + )); + } + Ok(()) + } + } + } +} + +impl DebugEndpoint { + fn has_language_server_fields(&self) -> bool { + self.language_id.is_some() || self.command.is_some() + } +} diff --git a/rust/crates/core/src/debug/backend_start.rs b/rust/crates/debug/src/backend_start.rs similarity index 88% rename from rust/crates/core/src/debug/backend_start.rs rename to rust/crates/debug/src/backend_start.rs index f8174e12..86c718c7 100644 --- a/rust/crates/core/src/debug/backend_start.rs +++ b/rust/crates/debug/src/backend_start.rs @@ -6,11 +6,9 @@ //! and the session are the generic debug engine's job; nothing here is specific to a language. use super::adapters::DebugRequest; -use crate::plugins::declarative::{ - decode_payload, is_identifier, outcome, validate_manifest_value, DeclarativePluginManifest, - PluginRejection, Validation, -}; -use crate::protocol::CoreError; +use crate::DebugManifest; +use lithe_plugin_model::{decode_payload, is_identifier, outcome, PluginRejection, Validation}; +use lithe_protocol::CoreError; use serde::Deserialize; use serde_json::{json, Map, Value}; @@ -48,16 +46,21 @@ fn invalid(field: &str, message: impl Into) -> PluginRejection { /// `debug.decodeBackendConfiguration`: the qualified adapter, request, label and checked /// arguments of one backend-initiated debug start. -pub(crate) fn decode_backend_configuration_command(payload: Value) -> Result { +/// +/// The plugin host owns the manifest schema (`M::validate`), so a start is checked +/// against the same declaration the plugin installed with. +pub fn decode_backend_configuration_command( + payload: Value, +) -> Result { let payload: DecodePayload = decode_payload(payload, "debug.decodeBackendConfiguration")?; Ok(outcome( - validate_manifest_value(payload.manifest) + M::validate(payload.manifest) .and_then(|manifest| decode(&manifest, payload.params)) .map(|start| ("start", start)), )) } -fn decode(manifest: &DeclarativePluginManifest, params: Value) -> Validation { +fn decode(manifest: &impl DebugManifest, params: Value) -> Validation { let params: StartParams = serde_json::from_value(params) .map_err(|_| invalid("params", "The debug start parameters are malformed."))?; let label = ¶ms.label; @@ -122,7 +125,7 @@ pub(crate) fn reject_host_keys(configuration: &Map) -> Validation } /// The plugin-local adapter ID of `adapter`, which is either local or qualified by this plugin. -fn own_adapter<'a>(manifest: &DeclarativePluginManifest, adapter: &'a str) -> Validation<&'a str> { +fn own_adapter<'a>(manifest: &impl DebugManifest, adapter: &'a str) -> Validation<&'a str> { let local = match adapter.split_once('/') { Some((plugin, _)) if plugin != manifest.id() => { return Err(PluginRejection::at( diff --git a/rust/crates/core/src/debug/breakpoint_relocation.rs b/rust/crates/debug/src/breakpoint_relocation.rs similarity index 97% rename from rust/crates/core/src/debug/breakpoint_relocation.rs rename to rust/crates/debug/src/breakpoint_relocation.rs index cb75bd74..f6e0f3dd 100644 --- a/rust/crates/core/src/debug/breakpoint_relocation.rs +++ b/rust/crates/debug/src/breakpoint_relocation.rs @@ -5,20 +5,20 @@ use std::sync::{Mutex, OnceLock}; use serde::{Deserialize, Serialize}; -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; -use super::SourceBreakpoint; +use super::types::SourceBreakpoint; #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] /// One native-editor replacement expressed in document-relative UTF-16 offsets. pub struct DebugSourceEdit { /// Inclusive start offset in the source text before the edit. - pub start_utf16_offset: usize, + pub(crate) start_utf16_offset: usize, /// Exclusive end offset in the source text before the edit. - pub end_utf16_offset: usize, + pub(crate) end_utf16_offset: usize, /// Text inserted in place of the edited range. - pub replacement: String, + pub(crate) replacement: String, } #[derive(Debug, Clone, Deserialize)] @@ -27,28 +27,28 @@ pub struct DebugSourceEdit { pub struct RelocateBreakpointsRequest { /// Complete source text before the mutation. #[serde(default)] - pub source: String, + pub(crate) source: String, #[serde(default)] - pub edit: Option, + pub(crate) edit: Option, /// Original-source edits, validated and applied together before projecting lines. #[serde(default)] - pub edits: Vec, + pub(crate) edits: Vec, /// Further editor mutations, each expressed relative to the previous mutation's result. #[serde(default)] - pub edit_batches: Vec>, + pub(crate) edit_batches: Vec>, /// Optional ephemeral snapshot identity; version one supplies the complete source. #[serde(default)] - pub document: Option, + pub(crate) document: Option, #[serde(default)] - pub breakpoints: Vec, + pub(crate) breakpoints: Vec, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] /// Versioned, process-local source snapshot, isolated from installed resources. pub struct DebugSourceDocument { - pub id: String, - pub version: u64, + pub(crate) id: String, + pub(crate) version: u64, } struct SourceSnapshot { @@ -70,9 +70,9 @@ static SNAPSHOTS: OnceLock> = OnceLock::new(); /// Stable breakpoint set after applying one source edit. pub struct RelocateBreakpointsResult { /// Relocated, deduplicated breakpoints in deterministic position order. - pub breakpoints: Vec, + pub(crate) breakpoints: Vec, /// Input index of each surviving breakpoint, aligned with `breakpoints`. - pub original_indices: Vec, + pub(crate) original_indices: Vec, } /// Moves breakpoint anchors with inserted or removed source text. diff --git a/rust/crates/core/src/debug/capabilities.rs b/rust/crates/debug/src/capabilities.rs similarity index 99% rename from rust/crates/core/src/debug/capabilities.rs rename to rust/crates/debug/src/capabilities.rs index 7e545dc6..00ec71fc 100644 --- a/rust/crates/core/src/debug/capabilities.rs +++ b/rust/crates/debug/src/capabilities.rs @@ -5,7 +5,7 @@ //! read the resulting snapshot instead of inspecting adapter or plugin names. use super::projection_v2::{is_capability_name, CapabilityState}; -use crate::protocol::CoreError; +use lithe_protocol::CoreError; use serde_json::{json, Map, Value}; use std::collections::{BTreeMap, BTreeSet}; diff --git a/rust/crates/core/src/debug/disassembly_paging.rs b/rust/crates/debug/src/disassembly_paging.rs similarity index 97% rename from rust/crates/core/src/debug/disassembly_paging.rs rename to rust/crates/debug/src/disassembly_paging.rs index 17d7f1d8..439b830f 100644 --- a/rust/crates/core/src/debug/disassembly_paging.rs +++ b/rust/crates/debug/src/disassembly_paging.rs @@ -15,7 +15,7 @@ //! the same address and never does arithmetic on them or guesses an //! instruction length. -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use std::collections::BTreeSet; @@ -64,7 +64,7 @@ pub(crate) struct DisassembleRequestArguments { /// The cursor of one fetch, owned by Core and echoed by the host with each reply. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct DisassemblyFetch { +pub struct DisassemblyFetch { pub(crate) direction: DisassemblyDirection, /// The instruction pointer (`around`) or the listed edge (`before`, `after`). pub(crate) reference: String, @@ -83,7 +83,7 @@ pub(crate) struct DisassemblyFetch { /// Input of `debug.planDisassemblyWindow`. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanDisassemblyWindowRequest { +pub struct PlanDisassemblyWindowRequest { pub(crate) direction: DisassemblyDirection, pub(crate) reference: String, pub(crate) count: u32, @@ -123,7 +123,7 @@ pub(crate) enum DisassemblyReply { /// Input of `debug.reduceDisassemblyPage`. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct ReduceDisassemblyPageRequest { +pub struct ReduceDisassemblyPageRequest { pub(crate) fetch: DisassemblyFetch, pub(crate) reply: DisassemblyReply, /// Addresses already listed on the side being extended; the page drops them. @@ -138,7 +138,7 @@ pub(crate) struct ReduceDisassemblyPageRequest { rename_all = "camelCase", rename_all_fields = "camelCase" )] -pub(crate) enum DisassemblyStep { +pub enum DisassemblyStep { /// Send `fetch.request` next and reduce its reply with this fetch. Request { fetch: DisassemblyFetch }, /// The fetch finished. `keep` indexes the reply's instructions to list, in @@ -149,7 +149,7 @@ pub(crate) enum DisassemblyStep { } /// Plans the first request of a fetch. -pub(crate) fn plan_disassembly_window( +pub fn plan_disassembly_window( request: PlanDisassemblyWindowRequest, ) -> Result { validate_address(&request.reference)?; @@ -180,7 +180,7 @@ pub(crate) fn plan_disassembly_window( } /// Reduces one adapter reply to the next step of the fetch. -pub(crate) fn reduce_disassembly_page( +pub fn reduce_disassembly_page( request: ReduceDisassemblyPageRequest, ) -> Result { let ReduceDisassemblyPageRequest { @@ -376,7 +376,7 @@ mod tests { fn fixture() -> Value { serde_json::from_str(include_str!( - "../../../../fixtures/debug/disassembly-paging-v1.json" + "../../../fixtures/debug/disassembly-paging-v1.json" )) .unwrap() } diff --git a/rust/crates/core/src/debug/endpoint.rs b/rust/crates/debug/src/endpoint.rs similarity index 94% rename from rust/crates/core/src/debug/endpoint.rs rename to rust/crates/debug/src/endpoint.rs index e520c3d8..95732110 100644 --- a/rust/crates/core/src/debug/endpoint.rs +++ b/rust/crates/debug/src/endpoint.rs @@ -3,11 +3,11 @@ use serde::Deserialize; use serde_json::{json, Value}; -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; /// Checks a user-selected DAP endpoint without DNS, sockets or implied consent. /// Unknown names are remote even if they later resolve to a loopback address. -pub(crate) fn validate_adapter_endpoint(payload: Value) -> Result { +pub fn validate_adapter_endpoint(payload: Value) -> Result { #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct Endpoint { diff --git a/rust/crates/debug/src/engine.rs b/rust/crates/debug/src/engine.rs new file mode 100644 index 00000000..ca47bdca --- /dev/null +++ b/rust/crates/debug/src/engine.rs @@ -0,0 +1,457 @@ +//! Stateful DAP reducer whose byte transport and process lifecycle remain platform owned. + +use super::capabilities::{ + capability_states, client_capabilities, effective_body, validate_disabled, HostFeatures, +}; +use super::projection_v2::{ + capabilities_projection, scopes_projection, stack_trace_projection, CapabilityState, +}; +use super::protocol::{frame_message, parse_messages}; +use super::types::*; +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine as _; +use lithe_diagnostics::channel::identity_segment; +use lithe_protocol::{CoreError, ErrorCode}; +use serde_json::{json, Map, Value}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::sync::{Mutex, OnceLock}; + +mod breakpoints; +mod execution; +mod fields; +mod inspection; +mod messages; +mod state; +mod step_filters; + +pub use breakpoints::*; +pub use execution::*; +pub(crate) use fields::*; +pub use inspection::*; +use messages::*; +use state::*; +use step_filters::*; + +static SESSIONS: OnceLock>> = OnceLock::new(); + +/// Creates a session and returns the framed DAP `initialize` request to send. +pub fn create_session(request: CreateSessionRequest) -> Result { + validate_identifier(&request.session_id, "sessionId")?; + validate_identifier(&request.adapter_id, "adapterId")?; + validate_path(&request.root_path, "rootPath")?; + let disabled_capabilities = validate_disabled(&request.disabled_capabilities)?; + request + .declared + .check() + .map_err(|(field, message)| invalid_request(message).with_details(field.to_string()))?; + let DeclaredAdapterBehavior { features, defaults } = request.declared; + let mut sessions = sessions_lock()?; + if sessions.contains_key(&request.session_id) { + return Err(invalid_request( + "A debug session with this sessionId already exists.", + )); + } + let mut breakpoints_muted = false; + if let Some(parent_id) = &request.parent_session_id { + validate_identifier(parent_id, "parentSessionId")?; + let parent = sessions + .get(parent_id) + .ok_or_else(|| session_not_found(parent_id))?; + if matches!( + parent.state, + DebugSessionState::Terminating | DebugSessionState::Terminated + ) { + return Err(invalid_request( + "A child debug session needs a running parent session.", + )); + } + breakpoints_muted = parent.breakpoints_muted; + } + let mut session = DebugSession { + id: request.session_id.clone(), + // Same mapping as plugin facts, so one adapter id names one channel. + log_adapter: identity_segment(&request.adapter_id), + logged_terminal_failure: false, + adapter_id: request.adapter_id, + root_path: request.root_path, + state: DebugSessionState::Idle, + next_request_sequence: 1, + next_event_sequence: 1, + read_buffer: Vec::new(), + pending_requests: BTreeMap::new(), + request_stop_generations: BTreeMap::new(), + stop_generation: 0, + parent_id: request.parent_session_id.clone(), + child_ids: Vec::new(), + stopping_children: BTreeSet::new(), + breakpoints: BTreeMap::new(), + breakpoint_generations: BTreeMap::new(), + resolved_source_breakpoints: BTreeMap::new(), + exception_breakpoints: Vec::new(), + did_configure_exception_breakpoints: false, + function_breakpoints: Vec::new(), + instruction_breakpoints: Vec::new(), + data_breakpoints: Vec::new(), + breakpoints_muted, + supports_run_in_terminal_request: request.supports_run_in_terminal_request, + pending_run_in_terminal_requests: BTreeMap::new(), + supports_start_debugging_request: request.supports_start_debugging_request, + pending_start_debugging_requests: BTreeMap::new(), + did_receive_initialized: false, + supports_configuration_done: false, + capabilities: DebugCapabilities::default(), + adapter_capabilities: Map::new(), + disabled_capabilities, + capability_states: BTreeMap::new(), + capability_revision: 0, + stepping_filters: DebugSteppingFilters::unfiltered(), + features: features.unwrap_or_default(), + default_stepping_filters: defaults + .and_then(|defaults| defaults.step_filters) + .map(|filters| DebugSteppingFilters::from(&filters)), + debug_request_kind: None, + pending_launch: None, + outbound_frames: Vec::new(), + events: Vec::new(), + }; + session.log_fact( + "debug.process.started", + BTreeMap::new(), + Some(session.id.clone()), + ); + session.transition(DebugSessionState::Initializing); + let mut initialize = json!({ + "clientID": "lithe", + "clientName": "Lithe", + "adapterID": session.adapter_id, + "linesStartAt1": true, + "columnsStartAt1": true, + "pathFormat": "path", + "supportsVariableType": true, + "supportsVariablePaging": true + }); + if let (Some(arguments), Value::Object(client)) = ( + initialize.as_object_mut(), + client_capabilities(HostFeatures { + run_in_terminal: session.supports_run_in_terminal_request, + start_debugging: session.supports_start_debugging_request, + }), + ) { + arguments.extend(client); + } + session.send_request("initialize", initialize, PendingRequest::Initialize)?; + let update = session.take_update(); + if let Some(parent) = session + .parent_id + .as_ref() + .and_then(|parent_id| sessions.get_mut(parent_id)) + { + // The host created the child, so it needs no attach event. + parent.child_ids.push(session.id.clone()); + } + sessions.insert(request.session_id, session); + Ok(update) +} + +/// Queues launch or attach now, or stores it until initialize completes. +pub fn launch(request: LaunchRequest) -> Result { + validate_identifier(&request.operation_id, "operationId")?; + with_session(&request.session_id, |session| { + match session.state { + DebugSessionState::Initializing => { + session.pending_launch = Some((request.operation_id, request.configuration)); + } + DebugSessionState::Ready => { + session.perform_launch(request.operation_id, request.configuration)?; + } + _ => { + return Err(invalid_request( + "The debug session is not ready to launch or attach.", + )) + } + } + Ok(session.take_update()) + }) +} + +/// Returns deterministic adapter defaults or a normalized native-client override. +pub fn stepping_filters( + request: DebugSteppingFiltersRequest, +) -> Result { + validate_identifier(&request.adapter_id, "adapterId")?; + normalize_stepping_filters( + request + .filters + .unwrap_or_else(DebugSteppingFilters::unfiltered), + ) +} + +/// Ends one caller-owned operation and ignores any later adapter response. +pub fn cancel_operation(request: CancelOperationRequest) -> Result { + validate_identifier(&request.operation_id, "operationId")?; + with_session(&request.session_id, |session| { + let pending_sequence = session + .pending_requests + .iter() + .find_map(|(sequence, pending)| { + (pending.operation_id() == Some(request.operation_id.as_str())).then_some(*sequence) + }); + let Some(pending_sequence) = pending_sequence else { + return Ok(session.take_update()); + }; + let pending = session + .pending_requests + .remove(&pending_sequence) + .expect("located pending debug operation should still exist"); + let command = pending.command().to_string(); + let message = match request.reason { + DebugCancellationReason::Cancelled => "Debug operation was cancelled.", + DebugCancellationReason::TimedOut => "Debug operation timed out.", + }; + session.emit(DebugEventBody::OperationFailed { + operation_id: request.operation_id, + command, + code: match request.reason { + DebugCancellationReason::Cancelled => DebugOperationFailureCode::Cancelled, + DebugCancellationReason::TimedOut => DebugOperationFailureCode::TimedOut, + }, + message: message.to_string(), + }); + if matches!(pending, PendingRequest::Launch { .. }) { + session.transition(DebugSessionState::Failed); + } + if session.capabilities.supports_cancel_request { + session.send_request( + "cancel", + json!({"requestId": pending_sequence}), + PendingRequest::Cancel, + )?; + } + Ok(session.take_update()) + }) +} + +/// Reduces one transport byte chunk and returns ordered writes and events. +pub fn receive(request: ReceiveRequest) -> Result { + let bytes = BASE64.decode(request.data_base64).map_err(|error| { + invalid_request("Debug transport dataBase64 was invalid.").with_details(error.to_string()) + })?; + with_session(&request.session_id, |session| { + let messages = match parse_messages(&mut session.read_buffer, &bytes) { + Ok(messages) => messages, + Err(error) => { + session.log_session_failure("protocol"); + session.transition(DebugSessionState::Failed); + return Err(error); + } + }; + for message in messages { + if let Err(error) = session.handle_message(message) { + session.log_session_failure("protocol"); + session.transition(DebugSessionState::Failed); + return Err(error); + } + } + Ok(session.take_update()) + }) +} + +/// Completes one adapter-originated terminal launch and emits its DAP response. +pub fn run_in_terminal_response( + request: DebugRunInTerminalResponseRequest, +) -> Result { + validate_identifier(&request.request_id, "requestId")?; + with_session(&request.session_id, |session| { + let Some(&request_sequence) = session + .pending_run_in_terminal_requests + .get(&request.request_id) + else { + // Native terminal creation is asynchronous. A response that races + // session shutdown, timeout, or a previous completion must not be + // applied to a later adapter request. + return Ok(session.take_update()); + }; + + if request.success { + validate_dap_process_id(request.process_id, "processId")?; + validate_dap_process_id(request.shell_process_id, "shellProcessId")?; + } + session + .pending_run_in_terminal_requests + .remove(&request.request_id); + + if request.success { + let mut body = Map::new(); + insert_option(&mut body, "processId", request.process_id); + insert_option(&mut body, "shellProcessId", request.shell_process_id); + session.send_response_with_body( + request_sequence, + "runInTerminal", + true, + None, + Some(Value::Object(body)), + )?; + } else { + let message = normalize_optional_text(request.message) + .unwrap_or_else(|| "Lithe could not start the debuggee terminal.".to_string()); + session.send_response_with_body( + request_sequence, + "runInTerminal", + false, + Some(&message), + None, + )?; + } + Ok(session.take_update()) + }) +} + +/// Completes one adapter-originated child session start and emits its DAP response. +pub fn start_debugging_response( + request: DebugStartDebuggingResponseRequest, +) -> Result { + validate_identifier(&request.request_id, "requestId")?; + with_session(&request.session_id, |session| { + let Some(request_sequence) = session + .pending_start_debugging_requests + .remove(&request.request_id) + else { + // The child start is asynchronous, so a reply can race shutdown or a + // previous completion and must not answer a later adapter request. + return Ok(session.take_update()); + }; + if request.success { + session.send_response(request_sequence, "startDebugging", true, None)?; + } else { + let message = normalize_optional_text(request.message) + .unwrap_or_else(|| "Lithe could not start the child debug session.".to_string()); + session.send_response(request_sequence, "startDebugging", false, Some(&message))?; + } + Ok(session.take_update()) + }) +} + +/// Begins a graceful DAP disconnect while the host keeps transport ownership. +pub fn disconnect(request: DisconnectRequest) -> Result { + with_session(&request.session_id, |session| { + if matches!( + session.state, + DebugSessionState::Terminating | DebugSessionState::Terminated + ) { + return Ok(session.take_update()); + } + session.fail_pending_run_in_terminal_requests( + "The debug session stopped before the terminal could start.", + )?; + session.fail_pending_start_debugging_requests( + "The debug session stopped before the child session could start.", + )?; + session.request_children_stop(); + // An attach session does not own the remote process, so disconnect must + // never terminate it. A launch session owns the local debuggee and + // follows the user's explicit choice, defaulting to termination. + let terminate_debuggee = match session.debug_request_kind { + Some(DebugRequestKind::Launch) => request.terminate_debuggee.unwrap_or(true), + Some(DebugRequestKind::Attach) | None => false, + }; + session.send_request( + "disconnect", + json!({"restart": false, "terminateDebuggee": terminate_debuggee}), + PendingRequest::Disconnect, + )?; + session.transition(DebugSessionState::Terminating); + Ok(session.take_update()) + }) +} + +/// Removes a session and its whole child tree, returning the removed descendant IDs. +/// +/// A child never outlives its parent, so destroying a parent also removes every +/// descendant (leaves first). The host owns the transports and must close the transport of +/// each returned ID; the list is empty for a session without children. +pub fn destroy_session(request: SessionRequest) -> Result, CoreError> { + let mut sessions = sessions_lock()?; + let Some(mut removed) = sessions.remove(&request.session_id) else { + return Err(session_not_found(&request.session_id)); + }; + removed.transition(DebugSessionState::Terminated); + if let Some(parent) = removed + .parent_id + .as_ref() + .and_then(|parent_id| sessions.get_mut(parent_id)) + { + parent.child_ids.retain(|id| id != &removed.id); + parent.stopping_children.remove(&removed.id); + } + let mut descendants = Vec::new(); + let mut pending = removed.child_ids; + while let Some(child_id) = pending.pop() { + if let Some(mut child) = sessions.remove(&child_id) { + child.transition(DebugSessionState::Terminated); + pending.extend(child.child_ids); + descendants.push(child_id); + } + } + descendants.reverse(); + Ok(descendants) +} + +impl DebugSession { + fn perform_launch( + &mut self, + operation_id: String, + configuration: DebugLaunchConfiguration, + ) -> Result<(), CoreError> { + let request_kind = configuration.request; + let mut arguments = configuration.arguments; + let filters = normalize_stepping_filters( + configuration + .stepping_filters + .or_else(|| self.default_stepping_filters.clone()) + .unwrap_or_else(DebugSteppingFilters::unfiltered), + )?; + // Only an adapter that declared the argument receives the filters; the + // engine never invents launch arguments for an undeclared adapter. + if let Some(feature) = &self.features.step_filters { + if !arguments.contains_key(&feature.argument) { + arguments.insert(feature.argument.clone(), step_filter_argument(&filters)); + } + } + self.stepping_filters = filters; + arguments + .entry("name".to_string()) + .or_insert(Value::String(configuration.name)); + arguments + .entry("cwd".to_string()) + .or_insert(Value::String(self.root_path.clone())); + self.debug_request_kind = Some(request_kind); + self.transition(DebugSessionState::Launching); + self.send_request( + request_kind.command(), + Value::Object(arguments), + PendingRequest::Launch { operation_id }, + ) + } +} + +fn with_session( + session_id: &str, + operation: impl FnOnce(&mut DebugSession) -> Result, +) -> Result { + let mut sessions = sessions_lock()?; + let session = sessions + .get_mut(session_id) + .ok_or_else(|| session_not_found(session_id))?; + operation(session) +} + +fn sessions_lock( +) -> Result>, CoreError> { + SESSIONS + .get_or_init(|| Mutex::new(HashMap::new())) + .lock() + .map_err(|_| CoreError::new(ErrorCode::Unknown, "Debug session state is unavailable.")) +} + +#[cfg(test)] +mod tests; diff --git a/rust/crates/debug/src/engine/breakpoints.rs b/rust/crates/debug/src/engine/breakpoints.rs new file mode 100644 index 00000000..49442502 --- /dev/null +++ b/rust/crates/debug/src/engine/breakpoints.rs @@ -0,0 +1,641 @@ +//! Breakpoints of every kind: host requests, the sets sent to the adapter, muting and the +//! verified results. + +use super::*; + +/// Stores a deterministic breakpoint set and sends it after DAP initialization. +pub fn set_breakpoints( + mut request: SetBreakpointsRequest, +) -> Result { + validate_path(&request.source_path, "sourcePath")?; + for breakpoint in &request.breakpoints { + if breakpoint.line < 1 || breakpoint.column.is_some_and(|column| column < 1) { + return Err(invalid_request( + "Debug breakpoint line and column values must be one-based.", + )); + } + } + request.breakpoints.sort_by_key(|breakpoint| { + ( + breakpoint.line, + breakpoint.column.unwrap_or(0), + breakpoint.enabled, + breakpoint.condition.clone().unwrap_or_default(), + breakpoint.hit_condition.clone().unwrap_or_default(), + breakpoint.log_message.clone().unwrap_or_default(), + ) + }); + request.breakpoints.dedup(); + with_session(&request.session_id, |session| { + session + .resolved_source_breakpoints + .retain(|_, point| point.source_path.as_deref() != Some(request.source_path.as_str())); + session + .breakpoints + .insert(request.source_path.clone(), request.breakpoints); + if session.did_receive_initialized { + session.send_breakpoints(&request.source_path)?; + } + Ok(session.take_update()) + }) +} + +/// Stores deterministic exception filters and sends them after DAP initialization. +pub fn set_exception_breakpoints( + mut request: SetExceptionBreakpointsRequest, +) -> Result { + for breakpoint in &mut request.breakpoints { + breakpoint.filter = breakpoint.filter.trim().to_string(); + if breakpoint.filter.is_empty() { + return Err(invalid_request( + "Debug exception breakpoint filters cannot be empty.", + )); + } + breakpoint.condition = breakpoint + .condition + .take() + .map(|condition| condition.trim().to_string()) + .filter(|condition| !condition.is_empty()); + } + request.breakpoints.sort_by(|left, right| { + (&left.filter, left.enabled, &left.condition).cmp(&( + &right.filter, + right.enabled, + &right.condition, + )) + }); + request + .breakpoints + .dedup_by(|left, right| left.filter == right.filter); + with_session(&request.session_id, |session| { + session.exception_breakpoints = request.breakpoints; + session.did_configure_exception_breakpoints = true; + if session.did_receive_initialized { + session.send_exception_breakpoints()?; + } + Ok(session.take_update()) + }) +} + +/// Stores deterministic function breakpoints and sends them when supported. +pub fn set_function_breakpoints( + mut request: SetFunctionBreakpointsRequest, +) -> Result { + for breakpoint in &mut request.breakpoints { + breakpoint.name = breakpoint.name.trim().to_string(); + if breakpoint.name.is_empty() { + return Err(invalid_request( + "Debug function breakpoint names cannot be empty.", + )); + } + breakpoint.condition = normalize_optional_text(breakpoint.condition.take()); + breakpoint.hit_condition = normalize_optional_text(breakpoint.hit_condition.take()); + } + request.breakpoints.sort_by(|left, right| { + ( + &left.name, + left.enabled, + &left.condition, + &left.hit_condition, + ) + .cmp(&( + &right.name, + right.enabled, + &right.condition, + &right.hit_condition, + )) + }); + request + .breakpoints + .dedup_by(|left, right| left.name == right.name); + with_session(&request.session_id, |session| { + session.function_breakpoints = request.breakpoints; + if session.did_receive_initialized && session.capabilities.supports_function_breakpoints { + session.send_function_breakpoints()?; + } + Ok(session.take_update()) + }) +} + +/// Stores deterministic instruction breakpoints and sends them when supported. +/// +/// References are opaque address strings: they are only trimmed and bounded, never parsed, +/// so a 64-bit address survives untouched and no CPU-specific logic enters the Core. +pub fn set_instruction_breakpoints( + mut request: SetInstructionBreakpointsRequest, +) -> Result { + for breakpoint in &mut request.breakpoints { + breakpoint.instruction_reference = + validated_memory_reference(&breakpoint.instruction_reference)?; + breakpoint.condition = normalize_optional_text(breakpoint.condition.take()); + breakpoint.hit_condition = normalize_optional_text(breakpoint.hit_condition.take()); + } + request.breakpoints.sort_by(|left, right| { + ( + &left.instruction_reference, + left.offset, + left.enabled, + &left.condition, + &left.hit_condition, + ) + .cmp(&( + &right.instruction_reference, + right.offset, + right.enabled, + &right.condition, + &right.hit_condition, + )) + }); + request.breakpoints.dedup_by(|left, right| { + left.instruction_reference == right.instruction_reference && left.offset == right.offset + }); + with_session(&request.session_id, |session| { + session.instruction_breakpoints = request.breakpoints; + if session.did_receive_initialized && session.capabilities.supports_instruction_breakpoints + { + session.send_instruction_breakpoints()?; + } + Ok(session.take_update()) + }) +} + +/// Resolves one adapter-owned data breakpoint identity for the selected variable. +pub fn data_breakpoint_info( + mut request: DataBreakpointInfoRequest, +) -> Result { + validate_identifier(&request.operation_id, "operationId")?; + request.name = request.name.trim().to_string(); + if request.name.is_empty() { + return Err(invalid_request( + "Debug data breakpoint info requires a variable name.", + )); + } + if request + .variables_reference + .is_some_and(|reference| reference < 1) + { + return Err(invalid_request( + "Debug variablesReference must be positive.", + )); + } + if request.frame_id.is_some_and(|frame_id| frame_id < 0) { + return Err(invalid_request("Debug frameId cannot be negative.")); + } + if request.variables_reference.is_none() && request.frame_id.is_none() { + return Err(invalid_request( + "Debug data breakpoint info requires a variable reference or frame.", + )); + } + with_session(&request.session_id, |session| { + if !session.capabilities.supports_data_breakpoints { + return Err(invalid_request( + "The debug adapter does not support data breakpoints.", + )); + } + let mut arguments = Map::new(); + arguments.insert("name".to_string(), json!(request.name)); + insert_option( + &mut arguments, + "variablesReference", + request.variables_reference, + ); + insert_option(&mut arguments, "frameId", request.frame_id); + session.send_request( + "dataBreakpointInfo", + Value::Object(arguments), + PendingRequest::DataBreakpointInfo { + operation_id: request.operation_id, + }, + )?; + Ok(session.take_update()) + }) +} + +/// Stores deterministic adapter-resolved data breakpoints and sends them when supported. +pub fn set_data_breakpoints( + mut request: SetDataBreakpointsRequest, +) -> Result { + for breakpoint in &mut request.breakpoints { + breakpoint.data_id = breakpoint.data_id.trim().to_string(); + if breakpoint.data_id.is_empty() { + return Err(invalid_request( + "Debug data breakpoint identifiers cannot be empty.", + )); + } + breakpoint.label = normalize_optional_text(breakpoint.label.take()); + breakpoint.access_type = normalize_optional_text(breakpoint.access_type.take()); + breakpoint.condition = normalize_optional_text(breakpoint.condition.take()); + breakpoint.hit_condition = normalize_optional_text(breakpoint.hit_condition.take()); + } + request.breakpoints.sort_by(|left, right| { + ( + &left.data_id, + &left.access_type, + left.enabled, + &left.condition, + &left.hit_condition, + ) + .cmp(&( + &right.data_id, + &right.access_type, + right.enabled, + &right.condition, + &right.hit_condition, + )) + }); + request.breakpoints.dedup_by(|left, right| { + left.data_id == right.data_id && left.access_type == right.access_type + }); + with_session(&request.session_id, |session| { + session.data_breakpoints = request.breakpoints; + if session.did_receive_initialized && session.capabilities.supports_data_breakpoints { + session.send_data_breakpoints()?; + } + Ok(session.take_update()) + }) +} + +/// Mutes or unmutes every breakpoint of one session (`debug.setBreakpointsMuted`). +/// +/// Muting keeps the stored source, exception, function, instruction and data breakpoints +/// and sends each set empty; unmuting sends them as stored again. Only a set holding an +/// enabled breakpoint is resent, since muting does not change what any other set sends. +/// Before DAP initialization the state is only stored and applies to the first sets sent. +pub fn set_breakpoints_muted( + request: SetBreakpointsMutedRequest, +) -> Result { + with_session(&request.session_id, |session| { + if session.breakpoints_muted != request.muted { + session.breakpoints_muted = request.muted; + if session.did_receive_initialized { + session.resend_enabled_breakpoints()?; + } + } + Ok(session.take_update()) + }) +} + +impl DebugSession { + /// Whether a stored breakpoint with this enabled state goes to the adapter. + fn sends(&self, enabled: bool) -> bool { + enabled && !self.breakpoints_muted + } + + /// Sends again every set holding an enabled breakpoint, after muting changed what + /// those sets send. An adapter is never sent a kind it did not declare. + fn resend_enabled_breakpoints(&mut self) -> Result<(), CoreError> { + if self + .exception_breakpoints + .iter() + .any(|breakpoint| breakpoint.enabled) + { + self.send_exception_breakpoints()?; + } + if self.capabilities.supports_function_breakpoints + && self + .function_breakpoints + .iter() + .any(|breakpoint| breakpoint.enabled) + { + self.send_function_breakpoints()?; + } + if self.capabilities.supports_instruction_breakpoints + && self + .instruction_breakpoints + .iter() + .any(|breakpoint| breakpoint.enabled) + { + self.send_instruction_breakpoints()?; + } + if self.capabilities.supports_data_breakpoints + && self + .data_breakpoints + .iter() + .any(|breakpoint| breakpoint.enabled) + { + self.send_data_breakpoints()?; + } + let sources: Vec = self + .breakpoints + .iter() + .filter(|(_, points)| points.iter().any(|breakpoint| breakpoint.enabled)) + .map(|(path, _)| path.clone()) + .collect(); + for source in sources { + self.send_breakpoints(&source)?; + } + Ok(()) + } + + pub(crate) fn send_breakpoints(&mut self, source_path: &str) -> Result<(), CoreError> { + let generation = self + .breakpoint_generations + .entry(source_path.to_string()) + .or_default(); + *generation += 1; + let generation = *generation; + let requested = self + .breakpoints + .get(source_path) + .cloned() + .unwrap_or_default(); + let active: Vec = requested + .iter() + .filter(|breakpoint| self.sends(breakpoint.enabled)) + .cloned() + .collect(); + let breakpoints: Vec = active + .iter() + .map(|breakpoint| { + let mut value = Map::new(); + value.insert("line".to_string(), json!(breakpoint.line)); + insert_option(&mut value, "column", breakpoint.column); + insert_nonempty(&mut value, "condition", breakpoint.condition.as_deref()); + insert_nonempty( + &mut value, + "hitCondition", + breakpoint.hit_condition.as_deref(), + ); + insert_nonempty(&mut value, "logMessage", breakpoint.log_message.as_deref()); + Value::Object(value) + }) + .collect(); + let source_name = source_path + .rsplit(['/', '\\']) + .next() + .unwrap_or(source_path); + self.send_request( + "setBreakpoints", + json!({ + "source": {"name": source_name, "path": source_path}, + "breakpoints": breakpoints, + "sourceModified": false + }), + PendingRequest::SetBreakpoints { + source_path: source_path.to_string(), + requested: active, + generation, + }, + ) + } + + pub(crate) fn send_exception_breakpoints(&mut self) -> Result<(), CoreError> { + let active: Vec<&ExceptionBreakpoint> = self + .exception_breakpoints + .iter() + .filter(|breakpoint| self.sends(breakpoint.enabled)) + .collect(); + let filters: Vec<&str> = active + .iter() + .map(|breakpoint| breakpoint.filter.as_str()) + .collect(); + let filter_options: Vec = if self.capabilities.supports_exception_filter_options { + active + .iter() + .filter_map(|breakpoint| { + breakpoint.condition.as_ref().map(|condition| { + json!({ + "filterId": breakpoint.filter, + "condition": condition + }) + }) + }) + .collect() + } else { + Vec::new() + }; + let mut arguments = Map::new(); + arguments.insert("filters".to_string(), json!(filters)); + if !filter_options.is_empty() { + arguments.insert("filterOptions".to_string(), Value::Array(filter_options)); + } + self.send_request( + "setExceptionBreakpoints", + Value::Object(arguments), + PendingRequest::SetExceptionBreakpoints, + ) + } + + pub(crate) fn send_function_breakpoints(&mut self) -> Result<(), CoreError> { + let requested: Vec = self + .function_breakpoints + .iter() + .filter(|breakpoint| self.sends(breakpoint.enabled)) + .cloned() + .collect(); + let breakpoints: Vec = requested + .iter() + .map(|breakpoint| { + let mut value = Map::new(); + value.insert("name".to_string(), json!(breakpoint.name)); + insert_nonempty(&mut value, "condition", breakpoint.condition.as_deref()); + insert_nonempty( + &mut value, + "hitCondition", + breakpoint.hit_condition.as_deref(), + ); + Value::Object(value) + }) + .collect(); + self.send_request( + "setFunctionBreakpoints", + json!({"breakpoints": breakpoints}), + PendingRequest::SetFunctionBreakpoints { requested }, + ) + } + + pub(crate) fn send_instruction_breakpoints(&mut self) -> Result<(), CoreError> { + let requested: Vec = self + .instruction_breakpoints + .iter() + .filter(|breakpoint| self.sends(breakpoint.enabled)) + .cloned() + .collect(); + let breakpoints: Vec = requested + .iter() + .map(|breakpoint| { + let mut value = Map::new(); + value.insert( + "instructionReference".to_string(), + json!(breakpoint.instruction_reference), + ); + insert_option(&mut value, "offset", breakpoint.offset); + insert_nonempty(&mut value, "condition", breakpoint.condition.as_deref()); + insert_nonempty( + &mut value, + "hitCondition", + breakpoint.hit_condition.as_deref(), + ); + Value::Object(value) + }) + .collect(); + self.send_request( + "setInstructionBreakpoints", + json!({"breakpoints": breakpoints}), + PendingRequest::SetInstructionBreakpoints { requested }, + ) + } + + pub(crate) fn send_data_breakpoints(&mut self) -> Result<(), CoreError> { + let requested: Vec = self + .data_breakpoints + .iter() + .filter(|breakpoint| self.sends(breakpoint.enabled)) + .cloned() + .collect(); + let breakpoints: Vec = requested + .iter() + .map(|breakpoint| { + let mut value = Map::new(); + value.insert("dataId".to_string(), json!(breakpoint.data_id)); + insert_nonempty(&mut value, "accessType", breakpoint.access_type.as_deref()); + insert_nonempty(&mut value, "condition", breakpoint.condition.as_deref()); + insert_nonempty( + &mut value, + "hitCondition", + breakpoint.hit_condition.as_deref(), + ); + Value::Object(value) + }) + .collect(); + self.send_request( + "setDataBreakpoints", + json!({"breakpoints": breakpoints}), + PendingRequest::SetDataBreakpoints { requested }, + ) + } +} + +impl DebugSession { + pub(crate) fn emit_breakpoint_results( + &mut self, + body: &Value, + source_path: &str, + requested: &[SourceBreakpoint], + ) { + let values = body + .get("breakpoints") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + for (index, value) in values.iter().enumerate() { + let fallback = requested.get(index).map(|breakpoint| breakpoint.line); + let mut breakpoint = + parse_breakpoint(value, None, Some(source_path), fallback.unwrap_or(0)); + breakpoint.requested_line = fallback; + if value.get("id").and_then(Value::as_i64).is_some() { + self.resolved_source_breakpoints + .insert(breakpoint.id, breakpoint.clone()); + } + self.emit(DebugEventBody::Breakpoint { breakpoint }); + } + } + + pub(crate) fn emit_function_breakpoint_results( + &mut self, + body: &Value, + requested: &[FunctionBreakpoint], + ) { + let values = body + .get("breakpoints") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + for (index, value) in values.iter().enumerate() { + let function_name = requested + .get(index) + .map(|breakpoint| breakpoint.name.as_str()); + self.emit(DebugEventBody::Breakpoint { + breakpoint: parse_breakpoint(value, function_name, None, 0), + }); + } + } + + pub(crate) fn emit_instruction_breakpoint_results( + &mut self, + body: &Value, + requested: &[InstructionBreakpoint], + ) { + let values = body + .get("breakpoints") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + for (index, value) in values.iter().enumerate() { + let mut breakpoint = parse_breakpoint(value, None, None, 0); + // Adapters may omit the reference; the request at the same index owns it. + if breakpoint.instruction_reference.is_none() { + breakpoint.instruction_reference = requested + .get(index) + .map(|item| item.instruction_reference.clone()); + } + self.emit(DebugEventBody::Breakpoint { breakpoint }); + } + } + + pub(crate) fn emit_data_breakpoint_results( + &mut self, + body: &Value, + requested: &[DataBreakpoint], + ) { + let values = body + .get("breakpoints") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + for (index, value) in values.iter().enumerate() { + let mut breakpoint = parse_breakpoint(value, None, None, 0); + breakpoint.data_id = requested.get(index).map(|item| item.data_id.clone()); + self.emit(DebugEventBody::Breakpoint { breakpoint }); + } + } +} + +pub(crate) fn parse_breakpoint( + value: &Value, + function_name: Option<&str>, + source_path: Option<&str>, + fallback_line: i64, +) -> DebugBreakpoint { + DebugBreakpoint { + id: value.get("id").and_then(Value::as_i64).unwrap_or(0), + verified: value + .get("verified") + .and_then(Value::as_bool) + .unwrap_or(false), + message: string_field(value, "message"), + function_name: function_name.map(str::to_string), + data_id: None, + instruction_reference: string_field(value, "instructionReference"), + requested_line: None, + source_path: value + .get("source") + .and_then(|source| source.get("path")) + .and_then(Value::as_str) + .or(source_path) + .map(str::to_string), + line: value + .get("line") + .and_then(Value::as_i64) + .or((fallback_line > 0).then_some(fallback_line)), + column: value.get("column").and_then(Value::as_i64), + } +} + +pub(crate) fn parse_exception_breakpoint_filter( + value: &Value, +) -> Option { + let filter = value.get("filter")?.as_str()?.trim(); + let label = value.get("label")?.as_str()?.trim(); + if filter.is_empty() || label.is_empty() { + return None; + } + Some(DebugExceptionBreakpointFilter { + filter: filter.to_string(), + label: label.to_string(), + description: string_field(value, "description"), + default: bool_field(value, "default"), + supports_condition: bool_field(value, "supportsCondition"), + condition_description: string_field(value, "conditionDescription"), + }) +} diff --git a/rust/crates/debug/src/engine/execution.rs b/rust/crates/debug/src/engine/execution.rs new file mode 100644 index 00000000..40ce0700 --- /dev/null +++ b/rust/crates/debug/src/engine/execution.rs @@ -0,0 +1,162 @@ +//! Execution control: continue, step, pause, restart, goto and smart step, gated on the +//! adapter's capabilities. + +use super::*; + +/// Queues one continue, pause, or stepping request. +pub fn execute(request: ExecuteRequest) -> Result { + validate_identifier(&request.operation_id, "operationId")?; + with_session(&request.session_id, |session| { + if !matches!( + session.state, + DebugSessionState::Running | DebugSessionState::Paused + ) { + return Err(invalid_request( + "Execution control requires a running or paused debug session.", + )); + } + if matches!( + request.command, + DebugExecutionCommand::Next + | DebugExecutionCommand::StepIn + | DebugExecutionCommand::StepOut + | DebugExecutionCommand::StepBack + | DebugExecutionCommand::Goto + ) && session.state != DebugSessionState::Paused + { + return Err(invalid_request("Stepping requires a paused debug session.")); + } + if request.command == DebugExecutionCommand::Pause + && session.state != DebugSessionState::Running + { + return Err(invalid_request("Pause requires a running debug session.")); + } + if request.command == DebugExecutionCommand::Continue + && session.state != DebugSessionState::Paused + { + return Err(invalid_request("Continue requires a paused debug session.")); + } + if request.single_thread + && !session + .capabilities + .supports_single_thread_execution_requests + { + return Err(invalid_request( + "The debug adapter does not support single-thread execution control.", + )); + } + if request.command == DebugExecutionCommand::StepBack + && !session.capabilities.supports_step_back + { + return Err(invalid_request( + "The debug adapter does not support stepping backwards.", + )); + } + if request.command == DebugExecutionCommand::Goto + && !session.capabilities.supports_goto_targets_request + { + return Err(invalid_request( + "The debug adapter does not support run to cursor.", + )); + } + if request.command == DebugExecutionCommand::Restart + && !session.capabilities.supports_restart_request + { + return Err(invalid_request( + "The debug adapter does not support restart requests.", + )); + } + if request.command == DebugExecutionCommand::Terminate + && !session.capabilities.supports_terminate_request + { + return Err(invalid_request( + "The debug adapter does not support terminate requests.", + )); + } + if matches!( + request.command, + DebugExecutionCommand::Next + | DebugExecutionCommand::StepIn + | DebugExecutionCommand::StepOut + | DebugExecutionCommand::StepBack + | DebugExecutionCommand::Goto + ) && request.thread_id.is_none() + { + return Err(invalid_request("Stepping requires a selected thread.")); + } + if let Some(granularity) = request.granularity { + if !matches!( + request.command, + DebugExecutionCommand::Next + | DebugExecutionCommand::StepIn + | DebugExecutionCommand::StepOut + | DebugExecutionCommand::StepBack + ) { + return Err(invalid_request( + "Debug granularity is only valid for next, stepIn, stepOut or stepBack.", + )); + } + if !session.capabilities.supports_stepping_granularity { + return Err(invalid_request( + "The debug adapter does not support choosing the stepping granularity.", + ) + .with_details(granularity.argument().to_string())); + } + } + let mut arguments = Map::new(); + if !matches!( + request.command, + DebugExecutionCommand::Restart | DebugExecutionCommand::Terminate + ) { + if let Some(thread_id) = request.thread_id { + arguments.insert("threadId".to_string(), json!(thread_id)); + } + } + if request.command == DebugExecutionCommand::Goto { + insert_option( + &mut arguments, + "targetId", + Some(required_positive(request.target_id, "targetId")?), + ); + } else if request.command == DebugExecutionCommand::StepIn { + if let Some(target_id) = request.target_id { + if target_id < 1 { + return Err(invalid_request("Debug targetId must be positive.")); + } + arguments.insert("targetId".to_string(), json!(target_id)); + } + } else if request.target_id.is_some() { + return Err(invalid_request( + "Debug targetId is only valid for stepIn or goto.", + )); + } + if matches!( + request.command, + DebugExecutionCommand::Continue + | DebugExecutionCommand::Next + | DebugExecutionCommand::StepIn + | DebugExecutionCommand::StepOut + | DebugExecutionCommand::StepBack + | DebugExecutionCommand::Goto + | DebugExecutionCommand::Pause + ) { + arguments.insert( + "singleThread".to_string(), + Value::Bool(request.single_thread), + ); + } + if let Some(granularity) = request.granularity { + arguments.insert("granularity".to_string(), json!(granularity.argument())); + } + session.send_request( + request.command.command(), + Value::Object(arguments), + PendingRequest::Execute { + operation_id: request.operation_id, + command: request.command, + single_thread: request.single_thread, + }, + )?; + Ok(session.take_update()) + }) +} diff --git a/rust/crates/debug/src/engine/fields.rs b/rust/crates/debug/src/engine/fields.rs new file mode 100644 index 00000000..b7891d41 --- /dev/null +++ b/rust/crates/debug/src/engine/fields.rs @@ -0,0 +1,154 @@ +//! Field readers and validators for host and adapter payloads, and the errors they report. + +use super::*; + +pub(crate) fn validate_identifier(value: &str, field: &str) -> Result<(), CoreError> { + if value.trim().is_empty() || value.contains('\0') || value.len() > 512 { + return Err(invalid_request(&format!("Debug {field} was invalid."))); + } + Ok(()) +} + +pub(crate) fn validate_path(value: &str, field: &str) -> Result<(), CoreError> { + if value.trim().is_empty() || value.contains('\0') { + return Err(invalid_request(&format!("Debug {field} was invalid."))); + } + Ok(()) +} + +pub(crate) fn required_positive(value: Option, field: &str) -> Result { + value + .filter(|value| *value > 0) + .ok_or_else(|| invalid_request(&format!("Debug {field} must be positive."))) +} + +pub(crate) fn required_nonnegative(value: Option, field: &str) -> Result { + value + .filter(|value| *value >= 0) + .ok_or_else(|| invalid_request(&format!("Debug {field} cannot be negative."))) +} + +pub(crate) fn required_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, CoreError> { + value.get(field).and_then(Value::as_str).ok_or_else(|| { + CoreError::new( + ErrorCode::ParseFailed, + format!("DAP message did not contain a valid {field}."), + ) + }) +} + +pub(crate) fn required_i64(value: &Value, field: &str) -> Result { + value.get(field).and_then(Value::as_i64).ok_or_else(|| { + CoreError::new( + ErrorCode::ParseFailed, + format!("DAP message did not contain a valid {field}."), + ) + }) +} + +pub(crate) fn required_array<'a>( + value: &'a Value, + field: &str, +) -> Result<&'a Vec, CoreError> { + value.get(field).and_then(Value::as_array).ok_or_else(|| { + CoreError::new( + ErrorCode::ParseFailed, + format!("DAP response did not contain a valid {field} array."), + ) + }) +} + +pub(crate) fn string_field(value: &Value, field: &str) -> Option { + value.get(field).and_then(Value::as_str).map(str::to_string) +} + +/// The reason an adapter gave for rejecting a request, in the words meant for the user. +/// +/// DAP carries two texts: `message` is a short raw reason the client may interpret +/// (`cancelled`, `notStopped`), while `body.error.format` is the text to show, with +/// `{name}` placeholders filled from `body.error.variables`. Adapters such as +/// lldb-dap and debugpy put the useful explanation only in the latter. +pub(crate) fn adapter_failure_detail(response: &Value) -> Option { + let error = response.get("body").and_then(|body| body.get("error")); + let formatted = error + .and_then(|error| error.get("format")) + .and_then(Value::as_str) + .map(|format| { + let variables = error + .and_then(|error| error.get("variables")) + .and_then(Value::as_object); + fill_error_variables(format, variables) + }); + formatted + .or_else(|| string_field(response, "message")) + .map(|text| text.trim().to_string()) + .filter(|text| !text.is_empty()) +} + +/// Replaces `{name}` with the matching string variable; unknown placeholders stay as written. +fn fill_error_variables(format: &str, variables: Option<&Map>) -> String { + let Some(variables) = variables else { + return format.to_string(); + }; + let mut result = String::with_capacity(format.len()); + let mut rest = format; + while let Some(open) = rest.find('{') { + result.push_str(&rest[..open]); + let after = &rest[open + 1..]; + match after.find('}') { + Some(close) => { + let name = &after[..close]; + match variables.get(name).and_then(Value::as_str) { + Some(value) => result.push_str(value), + None => { + result.push('{'); + result.push_str(name); + result.push('}'); + } + } + rest = &after[close + 1..]; + } + None => { + result.push_str(&rest[open..]); + rest = ""; + } + } + } + result.push_str(rest); + result +} + +pub(crate) fn bool_field(value: &Value, field: &str) -> bool { + value.get(field).and_then(Value::as_bool).unwrap_or(false) +} + +pub(crate) fn insert_option( + map: &mut Map, + key: &str, + value: Option, +) { + if let Some(value) = value { + map.insert(key.to_string(), json!(value)); + } +} + +pub(crate) fn insert_nonempty(map: &mut Map, key: &str, value: Option<&str>) { + if let Some(value) = value.filter(|value| !value.is_empty()) { + map.insert(key.to_string(), Value::String(value.to_string())); + } +} + +pub(crate) fn normalize_optional_text(value: Option) -> Option { + value + .map(|text| text.trim().to_string()) + .filter(|text| !text.is_empty()) +} + +pub(crate) fn invalid_request(message: &str) -> CoreError { + CoreError::new(ErrorCode::InvalidRequest, message) +} + +pub(crate) fn session_not_found(session_id: &str) -> CoreError { + CoreError::new(ErrorCode::InvalidRequest, "Debug session was not found.") + .with_details(session_id.to_string()) +} diff --git a/rust/crates/debug/src/engine/inspection.rs b/rust/crates/debug/src/engine/inspection.rs new file mode 100644 index 00000000..9cd73fca --- /dev/null +++ b/rust/crates/debug/src/engine/inspection.rs @@ -0,0 +1,679 @@ +//! Inspection: threads, stacks, scopes, variables, evaluation, memory, disassembly and +//! completions, and their normalized results. + +use super::*; + +/// Longest opaque memory or instruction reference the Core forwards to an adapter. +const MAXIMUM_MEMORY_REFERENCE_LENGTH: usize = 256; + +/// Largest `readMemory` request, so one call cannot ask an adapter for unbounded bytes. +const MAXIMUM_READ_MEMORY_BYTES: i64 = 65_536; + +/// Largest `disassemble` window, enough for a scrolling view without flooding the transport. +const MAXIMUM_DISASSEMBLE_INSTRUCTIONS: i64 = 1_000; + +/// Queues one capability-gated variable mutation while execution is paused. +pub fn set_variable(mut request: SetVariableRequest) -> Result { + validate_identifier(&request.operation_id, "operationId")?; + if request.variables_reference < 1 { + return Err(invalid_request( + "Debug variablesReference must be positive.", + )); + } + request.name = request.name.trim().to_string(); + if request.name.is_empty() { + return Err(invalid_request( + "Debug setVariable requires a variable name.", + )); + } + with_session(&request.session_id, |session| { + if session.state != DebugSessionState::Paused { + return Err(invalid_request( + "Variable mutation requires a paused debug session.", + )); + } + if !session.capabilities.supports_set_variable { + return Err(invalid_request( + "The debug adapter does not support variable mutation.", + )); + } + session.send_request( + "setVariable", + json!({ + "variablesReference": request.variables_reference, + "name": request.name, + "value": request.value + }), + PendingRequest::SetVariable { + operation_id: request.operation_id, + name: request.name, + }, + )?; + Ok(session.take_update()) + }) +} + +/// Queues one typed thread, stack, scope, variable, or evaluation request. +pub fn inspect(request: InspectRequest) -> Result { + validate_identifier(&request.operation_id, "operationId")?; + let arguments = inspect_arguments(&request)?; + with_session(&request.session_id, |session| { + if !matches!( + session.state, + DebugSessionState::Running | DebugSessionState::Paused + ) { + return Err(invalid_request( + "Debugger inspection requires a running or paused session.", + )); + } + if request.kind == DebugInspectKind::RedefineClasses + && session.features.hot_code_replace.is_none() + { + return Err(invalid_request( + "The debug adapter does not declare hot code replacement.", + )); + } + if request.kind == DebugInspectKind::StepInTargets + && !session.capabilities.supports_step_in_targets_request + { + return Err(invalid_request( + "The debug adapter does not support smart step into.", + )); + } + if request.kind == DebugInspectKind::GotoTargets + && !session.capabilities.supports_goto_targets_request + { + return Err(invalid_request( + "The debug adapter does not support run to cursor.", + )); + } + if request.kind == DebugInspectKind::Completions + && !session.capabilities.supports_completions_request + { + return Err(invalid_request( + "The debug adapter does not support console completion.", + )); + } + if request.kind == DebugInspectKind::Disassemble { + if session.state != DebugSessionState::Paused { + return Err(invalid_request( + "Disassembly requires a paused debug session.", + )); + } + if !session.capabilities.supports_disassemble_request { + return Err(invalid_request( + "The debug adapter does not support disassembly.", + )); + } + } + if request.kind == DebugInspectKind::ReadMemory { + if session.state != DebugSessionState::Paused { + return Err(invalid_request( + "Reading memory requires a paused debug session.", + )); + } + if !session.capabilities.supports_read_memory_request { + return Err(invalid_request( + "The debug adapter does not support reading memory.", + )); + } + } + if request.kind == DebugInspectKind::ExceptionInfo { + if session.state != DebugSessionState::Paused { + return Err(invalid_request( + "Exception information requires a paused debug session.", + )); + } + if !session.capabilities.supports_exception_info_request { + return Err(invalid_request( + "The debug adapter does not support exception information.", + )); + } + } + let command = match (request.kind, &session.features.hot_code_replace) { + (DebugInspectKind::RedefineClasses, Some(feature)) => feature.request.clone(), + (kind, _) => kind.command().to_string(), + }; + session.send_request( + &command, + Value::Object(arguments), + PendingRequest::Inspect { + operation_id: request.operation_id, + kind: request.kind, + thread_id: request.thread_id, + frame_id: request.frame_id, + }, + )?; + Ok(session.take_update()) + }) +} + +impl DebugSession { + /// Emits the v2 projection of a stack trace or scopes reply beside the v1 result. + pub(crate) fn emit_inspection_projection( + &mut self, + kind: DebugInspectKind, + thread_id: Option, + frame_id: Option, + body: &Value, + ) { + let projection = match (kind, thread_id, frame_id) { + (DebugInspectKind::StackTrace, Some(thread_id), _) => stack_trace_projection( + &self.id, + self.stop_generation, + thread_id, + body, + &self.root_path, + |name, path, hint| { + stack_frame_matches_filters( + name, + path, + hint, + &self.stepping_filters, + &self.root_path, + ) + }, + ), + (DebugInspectKind::Scopes, _, Some(frame_id)) => scopes_projection( + &self.id, + self.stop_generation, + frame_id, + body, + &self.root_path, + ), + _ => None, + }; + if let Some(projection) = projection { + self.emit(DebugEventBody::Projection { projection }); + } + } +} + +pub(crate) fn inspect_arguments(request: &InspectRequest) -> Result, CoreError> { + // `readMemory` reuses `count` for its byte length; every other kind rejects paging fields. + let paging_allowed = request.kind == DebugInspectKind::Variables; + let count_allowed = paging_allowed || request.kind == DebugInspectKind::ReadMemory; + if (!paging_allowed && (request.variable_filter.is_some() || request.start.is_some())) + || (!count_allowed && request.count.is_some()) + { + return Err(invalid_request( + "Debug variable paging is only valid for variables inspection.", + )); + } + let is_memory_kind = matches!( + request.kind, + DebugInspectKind::Disassemble | DebugInspectKind::ReadMemory + ); + if !is_memory_kind + && (request.memory_reference.is_some() + || request.offset.is_some() + || request.instruction_offset.is_some() + || request.instruction_count.is_some() + || request.resolve_symbols.is_some()) + { + return Err(invalid_request( + "Debug memory fields are only valid for disassemble or readMemory.", + )); + } + let mut arguments = Map::new(); + match request.kind { + DebugInspectKind::Threads | DebugInspectKind::RedefineClasses => {} + DebugInspectKind::StackTrace => { + arguments.insert( + "threadId".to_string(), + json!(required_positive(request.thread_id, "threadId")?), + ); + } + DebugInspectKind::Scopes => { + arguments.insert( + "frameId".to_string(), + json!(required_nonnegative(request.frame_id, "frameId")?), + ); + } + DebugInspectKind::Variables => { + arguments.insert( + "variablesReference".to_string(), + json!(required_positive( + request.variables_reference, + "variablesReference" + )?), + ); + if let Some(filter) = request.variable_filter { + arguments.insert("filter".to_string(), json!(filter.argument())); + } + if let Some(start) = request.start { + arguments.insert( + "start".to_string(), + json!(required_nonnegative(Some(start), "start")?), + ); + } + if let Some(count) = request.count { + arguments.insert( + "count".to_string(), + json!(required_positive(Some(count), "count")?), + ); + } + } + DebugInspectKind::Evaluate => { + let expression = request.expression.as_deref().unwrap_or_default().trim(); + if expression.is_empty() { + return Err(invalid_request("Debug evaluate requires an expression.")); + } + arguments.insert("expression".to_string(), json!(expression)); + let context = request.context.as_deref().unwrap_or("watch"); + if !matches!( + context, + "watch" | "hover" | "repl" | "clipboard" | "variables" + ) { + return Err(invalid_request( + "Debug evaluate context must be watch, hover, repl, clipboard or variables.", + )); + } + arguments.insert("context".to_string(), json!(context)); + if let Some(frame_id) = request.frame_id { + if frame_id < 0 { + return Err(invalid_request("Debug frameId cannot be negative.")); + } + arguments.insert("frameId".to_string(), json!(frame_id)); + } + } + DebugInspectKind::ExceptionInfo => { + arguments.insert( + "threadId".to_string(), + json!(required_positive(request.thread_id, "threadId")?), + ); + } + DebugInspectKind::StepInTargets => { + arguments.insert( + "frameId".to_string(), + json!(required_nonnegative(request.frame_id, "frameId")?), + ); + } + DebugInspectKind::GotoTargets => { + let source_path = request.source_path.as_deref().unwrap_or_default().trim(); + if source_path.is_empty() { + return Err(invalid_request("Debug gotoTargets requires a source path.")); + } + arguments.insert("source".to_string(), json!({"path": source_path})); + arguments.insert( + "line".to_string(), + json!(required_positive(request.line, "line")?), + ); + if let Some(column) = request.column { + if column < 1 { + return Err(invalid_request("Debug column must be positive.")); + } + arguments.insert("column".to_string(), json!(column)); + } + } + DebugInspectKind::Disassemble => { + let reference = request.memory_reference.as_deref().unwrap_or_default(); + arguments.insert( + "memoryReference".to_string(), + json!(validated_memory_reference(reference)?), + ); + insert_option(&mut arguments, "offset", request.offset); + insert_option( + &mut arguments, + "instructionOffset", + request.instruction_offset, + ); + let count = required_positive(request.instruction_count, "instructionCount")?; + if count > MAXIMUM_DISASSEMBLE_INSTRUCTIONS { + return Err(invalid_request( + "Debug instructionCount cannot exceed 1000 instructions.", + )); + } + arguments.insert("instructionCount".to_string(), json!(count)); + arguments.insert( + "resolveSymbols".to_string(), + json!(request.resolve_symbols.unwrap_or(true)), + ); + } + DebugInspectKind::ReadMemory => { + let reference = request.memory_reference.as_deref().unwrap_or_default(); + arguments.insert( + "memoryReference".to_string(), + json!(validated_memory_reference(reference)?), + ); + insert_option(&mut arguments, "offset", request.offset); + let count = required_positive(request.count, "count")?; + if count > MAXIMUM_READ_MEMORY_BYTES { + return Err(invalid_request( + "Debug readMemory count cannot exceed 65536 bytes.", + )); + } + arguments.insert("count".to_string(), json!(count)); + } + DebugInspectKind::Completions => { + // Whitespace is significant: `break ` and `break` complete differently. + let text = request.expression.as_deref().unwrap_or_default(); + if text.is_empty() { + return Err(invalid_request("Debug completions require text.")); + } + arguments.insert("text".to_string(), json!(text)); + // The caret defaults to the end of the text. DAP columns count UTF-16 + // units from 1, matching the `columnsStartAt1` the session initialized with. + let end = text.encode_utf16().count() as i64 + 1; + let column = request.column.unwrap_or(end); + if column < 1 || column > end { + return Err(invalid_request( + "Debug completion column must lie within the text.", + )); + } + arguments.insert("column".to_string(), json!(column)); + if let Some(frame_id) = request.frame_id { + if frame_id < 0 { + return Err(invalid_request("Debug frameId cannot be negative.")); + } + arguments.insert("frameId".to_string(), json!(frame_id)); + } + } + } + Ok(arguments) +} + +pub(crate) fn normalize_inspection( + kind: DebugInspectKind, + body: &Value, + stepping_filters: &DebugSteppingFilters, + root_path: &str, +) -> Result { + match kind { + DebugInspectKind::Threads => Ok(DebugOperationResult::Threads { + threads: required_array(body, "threads")? + .iter() + .filter_map(parse_thread) + .collect(), + }), + DebugInspectKind::StackTrace => Ok(DebugOperationResult::StackTrace { + stack_frames: required_array(body, "stackFrames")? + .iter() + .filter_map(|value| parse_stack_frame(value, stepping_filters, root_path)) + .collect(), + }), + DebugInspectKind::Scopes => Ok(DebugOperationResult::Scopes { + scopes: required_array(body, "scopes")? + .iter() + .filter_map(parse_scope) + .collect(), + }), + DebugInspectKind::Variables => Ok(DebugOperationResult::Variables { + variables: required_array(body, "variables")? + .iter() + .filter_map(parse_variable) + .collect(), + }), + DebugInspectKind::Evaluate => Ok(DebugOperationResult::Evaluate { + variable: DebugVariable { + name: body + .get("evaluateName") + .and_then(Value::as_str) + .unwrap_or("Expression") + .to_string(), + value: required_str(body, "result")?.to_string(), + r#type: string_field(body, "type"), + evaluate_name: string_field(body, "evaluateName"), + variables_reference: body + .get("variablesReference") + .and_then(Value::as_i64) + .unwrap_or(0), + named_variables: nonnegative_count_field(body, "namedVariables"), + indexed_variables: nonnegative_count_field(body, "indexedVariables"), + }, + }), + DebugInspectKind::ExceptionInfo => Ok(DebugOperationResult::ExceptionInfo { + exception_info: DebugExceptionInfo { + exception_id: required_str(body, "exceptionId")?.to_string(), + description: string_field(body, "description"), + break_mode: required_str(body, "breakMode")?.to_string(), + details: body.get("details").and_then(parse_exception_details), + }, + }), + DebugInspectKind::RedefineClasses => { + // An adapter may return DAP success with an application-level error. + if let Some(message) = body + .get("errorMessage") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + { + return Err(invalid_request(message)); + } + let mut changed_classes = required_array(body, "changedClasses")? + .iter() + .map(|value| { + value + .as_str() + .map(str::to_owned) + .ok_or_else(|| invalid_request("Invalid changed class name.")) + }) + .collect::, _>>()?; + changed_classes.sort(); + changed_classes.dedup(); + Ok(DebugOperationResult::RedefineClasses { changed_classes }) + } + DebugInspectKind::StepInTargets => Ok(DebugOperationResult::StepInTargets { + targets: required_array(body, "targets")? + .iter() + .filter_map(parse_step_in_target) + .collect(), + }), + DebugInspectKind::GotoTargets => Ok(DebugOperationResult::GotoTargets { + targets: required_array(body, "targets")? + .iter() + .filter_map(parse_goto_target) + .collect(), + }), + DebugInspectKind::Completions => Ok(DebugOperationResult::Completions { + items: required_array(body, "targets")? + .iter() + .filter_map(parse_completion_item) + .collect(), + }), + DebugInspectKind::Disassemble => Ok(DebugOperationResult::Disassemble { + instructions: required_array(body, "instructions")? + .iter() + .filter_map(parse_disassembled_instruction) + .collect(), + }), + DebugInspectKind::ReadMemory => { + // Memory that is entirely unreadable comes back without `data`. + let data_base64 = string_field(body, "data").unwrap_or_default(); + if BASE64.decode(&data_base64).is_err() { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "DAP readMemory data was not valid base64.", + )); + } + Ok(DebugOperationResult::ReadMemory { + address: required_str(body, "address")?.to_string(), + unreadable_bytes: nonnegative_count_field(body, "unreadableBytes"), + data_base64, + }) + } + } +} + +/// Rejects empty, oversized or multi-line references; the value itself stays opaque. +pub(crate) fn validated_memory_reference(reference: &str) -> Result { + let reference = reference.trim(); + if reference.is_empty() + || reference.chars().count() > MAXIMUM_MEMORY_REFERENCE_LENGTH + || reference.chars().any(char::is_control) + { + return Err(invalid_request( + "Debug memory references must be short single-line adapter references.", + )); + } + Ok(reference.to_string()) +} + +fn parse_disassembled_instruction(value: &Value) -> Option { + let address = value.get("address")?.as_str()?; + let instruction = value.get("instruction")?.as_str()?; + if address.is_empty() { + return None; + } + Some(DebugDisassembledInstruction { + address: address.to_string(), + instruction_bytes: string_field(value, "instructionBytes").filter(|text| !text.is_empty()), + instruction: instruction.to_string(), + symbol: string_field(value, "symbol").filter(|text| !text.is_empty()), + source_path: value + .get("location") + .and_then(|source| source.get("path")) + .and_then(Value::as_str) + .map(str::to_string), + line: value + .get("line") + .and_then(Value::as_i64) + .filter(|line| *line > 0), + }) +} + +fn parse_exception_details(value: &Value) -> Option { + value.as_object()?; + Some(DebugExceptionDetails { + message: string_field(value, "message"), + type_name: string_field(value, "typeName"), + full_type_name: string_field(value, "fullTypeName"), + evaluate_name: string_field(value, "evaluateName"), + stack_trace: string_field(value, "stackTrace"), + inner_exceptions: value + .get("innerException") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(parse_exception_details) + .collect(), + }) +} + +fn parse_step_in_target(value: &Value) -> Option { + Some(DebugStepInTarget { + id: value.get("id")?.as_i64()?, + label: value.get("label")?.as_str()?.to_string(), + line: value.get("line").and_then(Value::as_i64), + column: value.get("column").and_then(Value::as_i64), + end_line: value.get("endLine").and_then(Value::as_i64), + end_column: value.get("endColumn").and_then(Value::as_i64), + }) +} + +fn parse_goto_target(value: &Value) -> Option { + Some(DebugGotoTarget { + id: value.get("id")?.as_i64()?, + label: value.get("label")?.as_str()?.to_string(), + line: value.get("line")?.as_i64()?, + column: value.get("column").and_then(Value::as_i64), + end_line: value.get("endLine").and_then(Value::as_i64), + end_column: value.get("endColumn").and_then(Value::as_i64), + instruction_pointer_reference: string_field(value, "instructionPointerReference"), + }) +} + +fn parse_completion_item(value: &Value) -> Option { + let label = value.get("label")?.as_str()?; + if label.is_empty() { + return None; + } + // The wire range counts from 1 with the session's `columnsStartAt1`; clients get 0. + let offset = |field: &str| { + value + .get(field) + .and_then(Value::as_i64) + .filter(|number| *number >= 0) + }; + Some(DebugCompletionItem { + label: label.to_string(), + text: string_field(value, "text") + .filter(|text| !text.is_empty()) + .unwrap_or_else(|| label.to_string()), + detail: string_field(value, "detail"), + kind: string_field(value, "type"), + start: offset("start").map(|start| (start - 1).max(0)), + length: offset("length"), + }) +} + +fn parse_thread(value: &Value) -> Option { + Some(DebugThread { + id: value.get("id")?.as_i64()?, + name: value.get("name")?.as_str()?.to_string(), + }) +} + +fn parse_stack_frame( + value: &Value, + stepping_filters: &DebugSteppingFilters, + root_path: &str, +) -> Option { + let name = value.get("name")?.as_str()?.to_string(); + let source_path = value + .get("source") + .and_then(|source| source.get("path")) + .and_then(Value::as_str) + .map(str::to_string); + let source_reference = value + .get("source") + .and_then(|source| source.get("sourceReference")) + .and_then(Value::as_i64) + .filter(|reference| *reference > 0); + let presentation_hint = value.get("presentationHint").and_then(Value::as_str); + // A frame in code without debug info has no file; the Core must not invent line 1 of + // nothing, so the UI can show the function and its instruction address instead. + let has_source = source_path.is_some() || source_reference.is_some(); + Some(DebugStackFrame { + id: value.get("id")?.as_i64()?, + is_filtered: stack_frame_matches_filters( + &name, + source_path.as_deref(), + presentation_hint, + stepping_filters, + root_path, + ), + name, + source_path, + source_reference, + line: value + .get("line") + .and_then(Value::as_i64) + .unwrap_or(if has_source { 1 } else { 0 }), + column: value.get("column").and_then(Value::as_i64).unwrap_or(1), + instruction_pointer_reference: string_field(value, "instructionPointerReference") + .filter(|reference| !reference.is_empty()), + }) +} + +fn parse_scope(value: &Value) -> Option { + Some(DebugScope { + name: value.get("name")?.as_str()?.to_string(), + variables_reference: value.get("variablesReference")?.as_i64()?, + expensive: value + .get("expensive") + .and_then(Value::as_bool) + .unwrap_or(false), + named_variables: nonnegative_count_field(value, "namedVariables"), + indexed_variables: nonnegative_count_field(value, "indexedVariables"), + presentation_hint: string_field(value, "presentationHint").filter(|hint| !hint.is_empty()), + }) +} + +fn parse_variable(value: &Value) -> Option { + Some(DebugVariable { + name: value.get("name")?.as_str()?.to_string(), + value: value.get("value")?.as_str()?.to_string(), + r#type: string_field(value, "type"), + evaluate_name: string_field(value, "evaluateName"), + variables_reference: value + .get("variablesReference") + .and_then(Value::as_i64) + .unwrap_or(0), + named_variables: nonnegative_count_field(value, "namedVariables"), + indexed_variables: nonnegative_count_field(value, "indexedVariables"), + }) +} + +pub(crate) fn nonnegative_count_field(value: &Value, key: &str) -> i64 { + value.get(key).and_then(Value::as_i64).unwrap_or(0).max(0) +} diff --git a/rust/crates/debug/src/engine/messages.rs b/rust/crates/debug/src/engine/messages.rs new file mode 100644 index 00000000..0d650938 --- /dev/null +++ b/rust/crates/debug/src/engine/messages.rs @@ -0,0 +1,694 @@ +//! DAP traffic of one session: outgoing requests and responses, and the responses, events and +//! reverse requests the adapter sends. + +use super::*; + +impl DebugSession { + pub(crate) fn send_request( + &mut self, + command: &str, + arguments: Value, + pending: PendingRequest, + ) -> Result<(), CoreError> { + let sequence = self.next_request_sequence; + self.next_request_sequence += 1; + let message = json!({ + "seq": sequence, + "type": "request", + "command": command, + "arguments": arguments + }); + self.outbound_frames.push(frame_message(&message)?); + self.request_stop_generations + .insert(sequence, self.stop_generation); + self.pending_requests.insert(sequence, pending); + Ok(()) + } + + pub(crate) fn send_response( + &mut self, + request_sequence: i64, + command: &str, + success: bool, + message: Option<&str>, + ) -> Result<(), CoreError> { + self.send_response_with_body(request_sequence, command, success, message, None) + } + + pub(crate) fn send_response_with_body( + &mut self, + request_sequence: i64, + command: &str, + success: bool, + message: Option<&str>, + body: Option, + ) -> Result<(), CoreError> { + let sequence = self.next_request_sequence; + self.next_request_sequence += 1; + let mut response = json!({ + "seq": sequence, + "type": "response", + "request_seq": request_sequence, + "success": success, + "command": command + }); + if let Some(message) = message { + response["message"] = Value::String(message.to_string()); + } + if let Some(body) = body { + response["body"] = body; + } + self.outbound_frames.push(frame_message(&response)?); + Ok(()) + } + + pub(crate) fn handle_message(&mut self, message: Value) -> Result<(), CoreError> { + match message.get("type").and_then(Value::as_str) { + Some("response") => self.handle_response(&message), + Some("event") => self.handle_event(&message), + Some("request") => self.handle_server_request(&message), + _ => Err(CoreError::new( + ErrorCode::ParseFailed, + "DAP message did not contain a supported type.", + )), + } + } + + fn handle_response(&mut self, message: &Value) -> Result<(), CoreError> { + let request_sequence = required_i64(message, "request_seq")?; + let Some(pending) = self.pending_requests.remove(&request_sequence) else { + return Ok(()); + }; + let issued_generation = self.request_stop_generations.remove(&request_sequence); + let success = message + .get("success") + .and_then(Value::as_bool) + .unwrap_or(false); + if !success { + let command = pending.command().to_string(); + let detail = adapter_failure_detail(message) + .unwrap_or_else(|| "The debug adapter rejected the request.".to_string()); + if let Some(operation_id) = pending.operation_id() { + self.emit(DebugEventBody::OperationFailed { + operation_id: operation_id.to_string(), + command, + code: DebugOperationFailureCode::AdapterRejected, + message: detail, + }); + } + if matches!( + pending, + PendingRequest::Initialize | PendingRequest::Launch { .. } + ) { + self.transition(DebugSessionState::Failed); + } + return Ok(()); + } + if pending.is_reference_bound() && issued_generation != Some(self.stop_generation) { + if let Some(operation_id) = pending.operation_id() { + self.emit(DebugEventBody::OperationFailed { + operation_id: operation_id.to_string(), + command: pending.command().to_string(), + code: DebugOperationFailureCode::StaleReference, + message: "The debuggee resumed or stopped again before the adapter replied." + .to_string(), + }); + } + return Ok(()); + } + let body = message.get("body").cloned().unwrap_or_else(|| json!({})); + match pending { + PendingRequest::Initialize => { + self.adapter_capabilities = body.as_object().cloned().unwrap_or_default(); + self.refresh_capabilities(false); + if !self.did_configure_exception_breakpoints { + self.exception_breakpoints = self.default_exception_breakpoints(); + } + self.supports_configuration_done = self.capabilities.supports_configuration_done; + self.transition(DebugSessionState::Ready); + if let Some((operation_id, configuration)) = self.pending_launch.take() { + self.perform_launch(operation_id, configuration)?; + } + } + PendingRequest::Launch { operation_id } => { + // Adapters may emit stopped or terminated before this acknowledgement. + // A late response completes the operation, but cannot resume or resurrect it. + if self.state == DebugSessionState::Launching { + self.transition(DebugSessionState::Running); + } + self.emit(DebugEventBody::OperationCompleted { + operation_id, + result: DebugOperationResult::Acknowledged { + command: "launch".to_string(), + }, + }); + } + PendingRequest::SetBreakpoints { + source_path, + requested, + generation, + } => { + if self.breakpoint_generations.get(&source_path) == Some(&generation) { + self.emit_breakpoint_results(&body, &source_path, &requested); + } + } + PendingRequest::SetExceptionBreakpoints => {} + PendingRequest::SetFunctionBreakpoints { requested } => { + self.emit_function_breakpoint_results(&body, &requested) + } + PendingRequest::SetInstructionBreakpoints { requested } => { + self.emit_instruction_breakpoint_results(&body, &requested) + } + PendingRequest::DataBreakpointInfo { operation_id } => { + self.emit(DebugEventBody::OperationCompleted { + operation_id, + result: DebugOperationResult::DataBreakpointInfo { + data_id: string_field(&body, "dataId"), + description: body + .get("description") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + access_types: body + .get("accessTypes") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::to_string) + .collect(), + can_persist: bool_field(&body, "canPersist"), + }, + }); + } + PendingRequest::SetDataBreakpoints { requested } => { + self.emit_data_breakpoint_results(&body, &requested) + } + PendingRequest::SetVariable { operation_id, name } => { + self.emit(DebugEventBody::OperationCompleted { + operation_id, + result: DebugOperationResult::SetVariable { + variable: DebugVariable { + name, + value: required_str(&body, "value")?.to_string(), + r#type: string_field(&body, "type"), + evaluate_name: None, + variables_reference: body + .get("variablesReference") + .and_then(Value::as_i64) + .unwrap_or(0), + named_variables: nonnegative_count_field(&body, "namedVariables"), + indexed_variables: nonnegative_count_field(&body, "indexedVariables"), + }, + }, + }); + } + PendingRequest::Cancel => {} + PendingRequest::ConfigurationDone => {} + PendingRequest::Execute { + operation_id, + command, + single_thread, + } => { + // Some adapters send `stopped` before the reply to `next`; a newer generation + // means an event already advanced the state, so the reply must not undo it. + if command != DebugExecutionCommand::Pause + && command != DebugExecutionCommand::Terminate + && !(single_thread && command == DebugExecutionCommand::Continue) + && issued_generation == Some(self.stop_generation) + { + self.stop_generation += 1; + self.transition(DebugSessionState::Running); + } + self.emit(DebugEventBody::OperationCompleted { + operation_id, + result: DebugOperationResult::Acknowledged { + command: command.command().to_string(), + }, + }); + } + PendingRequest::Inspect { + operation_id, + kind, + thread_id, + frame_id, + } => { + self.emit_inspection_projection(kind, thread_id, frame_id, &body); + let result = match (kind, &self.features.hot_code_replace) { + (DebugInspectKind::RedefineClasses, Some(feature)) => { + declared_replacement(feature, &body) + } + _ => normalize_inspection(kind, &body, &self.stepping_filters, &self.root_path), + }; + // HCR rejection and unreadable memory are operation failures, not a broken + // transport/session. + if matches!( + kind, + DebugInspectKind::RedefineClasses + | DebugInspectKind::Disassemble + | DebugInspectKind::ReadMemory + ) { + match result { + Ok(result) => self.emit(DebugEventBody::OperationCompleted { + operation_id, + result, + }), + Err(error) => self.emit(DebugEventBody::OperationFailed { + operation_id, + command: kind.command().into(), + code: DebugOperationFailureCode::AdapterRejected, + message: error.message, + }), + } + } else { + self.emit(DebugEventBody::OperationCompleted { + operation_id, + result: result?, + }); + } + } + PendingRequest::Disconnect => {} + } + Ok(()) + } + + fn handle_event(&mut self, message: &Value) -> Result<(), CoreError> { + let event = required_str(message, "event")?; + let body = message.get("body").cloned().unwrap_or_else(|| json!({})); + match event { + "initialized" => { + self.did_receive_initialized = true; + self.emit(DebugEventBody::Initialized); + self.send_exception_breakpoints()?; + if self.capabilities.supports_function_breakpoints { + self.send_function_breakpoints()?; + } + if self.capabilities.supports_instruction_breakpoints { + self.send_instruction_breakpoints()?; + } + if self.capabilities.supports_data_breakpoints { + self.send_data_breakpoints()?; + } + let sources: Vec = self.breakpoints.keys().cloned().collect(); + for source in sources { + self.send_breakpoints(&source)?; + } + if self.supports_configuration_done { + self.send_request( + "configurationDone", + json!({}), + PendingRequest::ConfigurationDone, + )?; + } + } + "output" => self.emit(DebugEventBody::Output { + category: body + .get("category") + .and_then(Value::as_str) + .map(str::to_string), + output: body + .get("output") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + }), + "stopped" => { + self.stop_generation += 1; + self.transition(DebugSessionState::Paused); + self.emit(DebugEventBody::Stopped { + reason: body + .get("reason") + .and_then(Value::as_str) + .unwrap_or("pause") + .to_string(), + thread_id: body.get("threadId").and_then(Value::as_i64), + description: body + .get("description") + .and_then(Value::as_str) + .map(str::to_string), + stop_generation: self.stop_generation, + }); + } + "continued" => { + // Resuming one thread leaves the frames and variables of the others valid. + if body + .get("allThreadsContinued") + .and_then(Value::as_bool) + .unwrap_or(true) + { + self.stop_generation += 1; + self.transition(DebugSessionState::Running); + } + self.emit(DebugEventBody::Continued { + thread_id: body.get("threadId").and_then(Value::as_i64), + }); + } + "terminated" => { + self.stop_generation += 1; + self.transition(DebugSessionState::Terminated); + self.request_children_stop(); + self.emit(DebugEventBody::Terminated { exit_code: None }); + } + "exited" => { + self.stop_generation += 1; + self.transition(DebugSessionState::Terminated); + self.request_children_stop(); + self.emit(DebugEventBody::Terminated { + exit_code: body.get("exitCode").and_then(Value::as_i64), + }); + } + "capabilities" => { + if let Some(updated) = body.get("capabilities").and_then(Value::as_object) { + self.adapter_capabilities.extend(updated.clone()); + self.refresh_capabilities(true); + } + } + "breakpoint" => { + if let Some(value) = body.get("breakpoint") { + let mut breakpoint = parse_breakpoint(value, None, None, 0); + if let Some(previous) = self.resolved_source_breakpoints.get(&breakpoint.id) { + breakpoint.requested_line = previous.requested_line; + breakpoint.source_path = breakpoint + .source_path + .or_else(|| previous.source_path.clone()); + breakpoint.line = breakpoint.line.or(previous.line); + breakpoint.column = breakpoint.column.or(previous.column); + self.resolved_source_breakpoints + .insert(breakpoint.id, breakpoint.clone()); + } + self.emit(DebugEventBody::Breakpoint { breakpoint }); + } + } + declared + if self + .features + .hot_code_replace + .as_ref() + .and_then(|feature| feature.event.as_deref()) + == Some(declared) => + { + // The declared progress event shows its message in the Debug Console. + if let Some(message) = body + .get("message") + .and_then(Value::as_str) + .filter(|message| !message.is_empty()) + { + self.emit(DebugEventBody::Output { + category: Some("console".to_string()), + output: format!("{message}\n"), + }); + } + } + _ => {} + } + Ok(()) + } + + fn handle_server_request(&mut self, message: &Value) -> Result<(), CoreError> { + let request_sequence = required_i64(message, "seq")?; + let command = required_str(message, "command")?; + if command == "runInTerminal" && self.supports_run_in_terminal_request { + let arguments = message.get("arguments").unwrap_or(&Value::Null); + match parse_run_in_terminal_request(arguments) { + Ok(request) => { + let request_id = format!("runInTerminal-{request_sequence}"); + if self + .pending_run_in_terminal_requests + .contains_key(&request_id) + { + // One DAP sequence can have only one response. Keep the + // original native launch pending instead of replacing + // it or starting a second process for a malformed retry. + return Ok(()); + } + self.pending_run_in_terminal_requests + .insert(request_id.clone(), request_sequence); + self.emit(DebugEventBody::RunInTerminalRequested { + request_id, + request, + }); + return Ok(()); + } + Err(error) => { + return self.send_response( + request_sequence, + command, + false, + Some(&error.message), + ); + } + } + } + if command == "startDebugging" && self.supports_start_debugging_request { + let arguments = message.get("arguments").unwrap_or(&Value::Null); + return match parse_start_debugging_request(arguments) { + Ok(request) => { + let request_id = format!("startDebugging-{request_sequence}"); + if self + .pending_start_debugging_requests + .contains_key(&request_id) + { + // A DAP sequence has one response; a duplicate must not + // open a second child session. + return Ok(()); + } + self.pending_start_debugging_requests + .insert(request_id.clone(), request_sequence); + self.emit(DebugEventBody::StartDebuggingRequested { + request_id, + request, + }); + Ok(()) + } + Err(error) => { + self.send_response(request_sequence, command, false, Some(&error.message)) + } + }; + } + self.send_response( + request_sequence, + command, + false, + Some("This debug adapter request is not supported by Lithe."), + ) + } + + pub(crate) fn fail_pending_start_debugging_requests( + &mut self, + message: &str, + ) -> Result<(), CoreError> { + let pending = std::mem::take(&mut self.pending_start_debugging_requests); + for request_sequence in pending.into_values() { + self.send_response(request_sequence, "startDebugging", false, Some(message))?; + } + Ok(()) + } + + pub(crate) fn fail_pending_run_in_terminal_requests( + &mut self, + message: &str, + ) -> Result<(), CoreError> { + let pending = std::mem::take(&mut self.pending_run_in_terminal_requests); + for (_, request_sequence) in pending { + self.send_response(request_sequence, "runInTerminal", false, Some(message))?; + } + Ok(()) + } +} + +pub(crate) fn parse_capabilities(value: &Value) -> DebugCapabilities { + DebugCapabilities { + supports_configuration_done: bool_field(value, "supportsConfigurationDoneRequest"), + supports_conditional_breakpoints: bool_field(value, "supportsConditionalBreakpoints"), + supports_hit_conditional_breakpoints: bool_field( + value, + "supportsHitConditionalBreakpoints", + ), + supports_log_points: bool_field(value, "supportsLogPoints"), + supports_function_breakpoints: bool_field(value, "supportsFunctionBreakpoints"), + supports_data_breakpoints: bool_field(value, "supportsDataBreakpoints"), + supports_exception_options: bool_field(value, "supportsExceptionOptions"), + supports_exception_filter_options: bool_field(value, "supportsExceptionFilterOptions"), + supports_set_variable: bool_field(value, "supportsSetVariable"), + supports_cancel_request: bool_field(value, "supportsCancelRequest"), + supports_single_thread_execution_requests: bool_field( + value, + "supportsSingleThreadExecutionRequests", + ), + supports_restart_request: bool_field(value, "supportsRestartRequest"), + supports_terminate_request: bool_field(value, "supportsTerminateRequest"), + supports_step_back: bool_field(value, "supportsStepBack"), + supports_exception_info_request: bool_field(value, "supportsExceptionInfoRequest"), + supports_step_in_targets_request: bool_field(value, "supportsStepInTargetsRequest"), + supports_goto_targets_request: bool_field(value, "supportsGotoTargetsRequest"), + supports_completions_request: bool_field(value, "supportsCompletionsRequest"), + supports_disassemble_request: bool_field(value, "supportsDisassembleRequest"), + supports_stepping_granularity: bool_field(value, "supportsSteppingGranularity"), + supports_instruction_breakpoints: bool_field(value, "supportsInstructionBreakpoints"), + supports_read_memory_request: bool_field(value, "supportsReadMemoryRequest"), + exception_breakpoint_filters: value + .get("exceptionBreakpointFilters") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(parse_exception_breakpoint_filter) + .collect(), + } +} + +fn parse_start_debugging_request(value: &Value) -> Result { + let arguments = value + .as_object() + .ok_or_else(|| invalid_request("Debug startDebugging arguments must be a JSON object."))?; + let mut configuration = arguments + .get("configuration") + .and_then(Value::as_object) + .cloned() + .ok_or_else(|| invalid_request("Debug startDebugging configuration must be an object."))?; + // DAP names the kind on the envelope; the configuration keeps the same word + // for adapters that read it, but Lithe routes on the typed field. + let kind = arguments + .get("request") + .or_else(|| configuration.get("request")) + .and_then(Value::as_str); + let request = match kind { + Some("launch") => DebugRequestKind::Launch, + Some("attach") => DebugRequestKind::Attach, + _ => { + return Err(invalid_request( + "Debug startDebugging request must be launch or attach.", + )) + } + }; + configuration.remove("request"); + Ok(DebugStartDebuggingRequest { + request, + configuration, + }) +} + +fn parse_run_in_terminal_request(value: &Value) -> Result { + const MAX_ARGUMENT_COUNT: usize = 4_096; + const MAX_ENVIRONMENT_COUNT: usize = 4_096; + + let object = value + .as_object() + .ok_or_else(|| invalid_request("Debug runInTerminal arguments must be a JSON object."))?; + let kind = match object.get("kind") { + None => DebugRunInTerminalKind::Integrated, + Some(Value::String(value)) if value == "integrated" => DebugRunInTerminalKind::Integrated, + Some(Value::String(value)) if value == "external" => DebugRunInTerminalKind::External, + _ => { + return Err(invalid_request( + "Debug runInTerminal kind must be integrated or external.", + )) + } + }; + let title = match object.get("title") { + None | Some(Value::Null) => None, + Some(Value::String(value)) => normalize_optional_text(Some(value.clone())), + _ => { + return Err(invalid_request( + "Debug runInTerminal title must be a string.", + )) + } + }; + let cwd = object + .get("cwd") + .and_then(Value::as_str) + .ok_or_else(|| invalid_request("Debug runInTerminal cwd must be a string."))? + .to_string(); + if cwd.contains('\0') { + return Err(invalid_request( + "Debug runInTerminal cwd contains an invalid null byte.", + )); + } + let argument_values = object + .get("args") + .and_then(Value::as_array) + .ok_or_else(|| invalid_request("Debug runInTerminal args must be an array."))?; + if argument_values.is_empty() || argument_values.len() > MAX_ARGUMENT_COUNT { + return Err(invalid_request( + "Debug runInTerminal args must contain between 1 and 4096 items.", + )); + } + let mut args = Vec::with_capacity(argument_values.len()); + for value in argument_values { + let argument = value.as_str().ok_or_else(|| { + invalid_request("Debug runInTerminal args must contain only strings.") + })?; + if argument.contains('\0') { + return Err(invalid_request( + "Debug runInTerminal args contain an invalid null byte.", + )); + } + args.push(argument.to_string()); + } + if args[0].trim().is_empty() { + return Err(invalid_request( + "Debug runInTerminal args must begin with an executable.", + )); + } + + let mut environment = Vec::new(); + if let Some(value) = object.get("env") { + let values = value + .as_object() + .ok_or_else(|| invalid_request("Debug runInTerminal env must be a JSON object."))?; + if values.len() > MAX_ENVIRONMENT_COUNT { + return Err(invalid_request( + "Debug runInTerminal env cannot exceed 4096 entries.", + )); + } + let mut names: Vec<&String> = values.keys().collect(); + names.sort(); + for name in names { + if name.is_empty() || name.contains(['=', '\0']) { + return Err(invalid_request( + "Debug runInTerminal env contains an invalid variable name.", + )); + } + let value = match &values[name] { + Value::Null => None, + Value::String(value) if !value.contains('\0') => Some(value.clone()), + _ => { + return Err(invalid_request( + "Debug runInTerminal env values must be strings or null.", + )) + } + }; + environment.push(DebugRunInTerminalEnvironmentVariable { + name: name.clone(), + value, + }); + } + } + let args_can_be_interpreted_by_shell = match object.get("argsCanBeInterpretedByShell") { + None => false, + Some(Value::Bool(value)) => *value, + _ => { + return Err(invalid_request( + "Debug runInTerminal argsCanBeInterpretedByShell must be a boolean.", + )) + } + }; + + Ok(DebugRunInTerminalRequest { + kind, + title, + cwd, + args, + environment, + args_can_be_interpreted_by_shell, + }) +} + +pub(crate) fn validate_dap_process_id(value: Option, field: &str) -> Result<(), CoreError> { + if value.is_some_and(|value| !(1..=i32::MAX as i64).contains(&value)) { + return Err(invalid_request(&format!( + "Debug runInTerminal {field} must be between 1 and {}.", + i32::MAX + ))); + } + Ok(()) +} diff --git a/rust/crates/debug/src/engine/state.rs b/rust/crates/debug/src/engine/state.rs new file mode 100644 index 00000000..6310d7f1 --- /dev/null +++ b/rust/crates/debug/src/engine/state.rs @@ -0,0 +1,306 @@ +//! Session state: the reduced DAP session, the requests it waits on, lifecycle +//! transitions and the update it publishes. + +use super::*; + +#[derive(Debug)] +pub(crate) struct DebugSession { + pub(crate) id: String, + pub(crate) adapter_id: String, + /// Catalog-safe adapter identity; never derived from DAP payload or user output. + pub(crate) log_adapter: String, + /// A failing launch operation already owns the session failure record. + pub(crate) logged_terminal_failure: bool, + pub(crate) root_path: String, + pub(crate) state: DebugSessionState, + pub(crate) next_request_sequence: i64, + pub(crate) next_event_sequence: u64, + pub(crate) read_buffer: Vec, + pub(crate) pending_requests: BTreeMap, + /// Pause generation each request was issued under; frame and variable references die with it. + pub(crate) request_stop_generations: BTreeMap, + pub(crate) stop_generation: u64, + pub(crate) parent_id: Option, + /// Live child sessions in creation order; the host owns their transports. + pub(crate) child_ids: Vec, + /// Children already asked to stop, so a retry or a later `terminated` does not repeat it. + pub(crate) stopping_children: BTreeSet, + pub(crate) breakpoints: BTreeMap>, + /// Rejects replies to superseded per-file breakpoint sets. + pub(crate) breakpoint_generations: BTreeMap, + /// Adapter IDs correlate location-free events with the original source request. + pub(crate) resolved_source_breakpoints: BTreeMap, + pub(crate) exception_breakpoints: Vec, + pub(crate) did_configure_exception_breakpoints: bool, + pub(crate) function_breakpoints: Vec, + pub(crate) instruction_breakpoints: Vec, + pub(crate) data_breakpoints: Vec, + /// Muted breakpoints stay stored but every kind is sent empty, so the adapter stops on + /// none of them; a child starts muted when its parent is. + pub(crate) breakpoints_muted: bool, + pub(crate) supports_run_in_terminal_request: bool, + pub(crate) pending_run_in_terminal_requests: BTreeMap, + pub(crate) supports_start_debugging_request: bool, + pub(crate) pending_start_debugging_requests: BTreeMap, + pub(crate) did_receive_initialized: bool, + pub(crate) supports_configuration_done: bool, + pub(crate) capabilities: DebugCapabilities, + /// Raw capability flags exactly as the adapter reported them (merged with `capabilities` events). + pub(crate) adapter_capabilities: Map, + pub(crate) disabled_capabilities: BTreeSet, + pub(crate) capability_states: BTreeMap, + pub(crate) capability_revision: u64, + pub(crate) stepping_filters: DebugSteppingFilters, + /// DAP extensions the adapter declared; empty for an undeclared adapter. + pub(crate) features: DebugAdapterFeatures, + /// Declared filters used when a launch names none. + pub(crate) default_stepping_filters: Option, + pub(crate) debug_request_kind: Option, + pub(crate) pending_launch: Option<(String, DebugLaunchConfiguration)>, + pub(crate) outbound_frames: Vec>, + pub(crate) events: Vec, +} + +#[derive(Debug)] +pub(crate) enum PendingRequest { + Initialize, + Launch { + operation_id: String, + }, + SetBreakpoints { + source_path: String, + requested: Vec, + generation: u64, + }, + SetExceptionBreakpoints, + SetFunctionBreakpoints { + requested: Vec, + }, + SetInstructionBreakpoints { + requested: Vec, + }, + DataBreakpointInfo { + operation_id: String, + }, + SetDataBreakpoints { + requested: Vec, + }, + SetVariable { + operation_id: String, + name: String, + }, + Cancel, + ConfigurationDone, + Execute { + operation_id: String, + command: DebugExecutionCommand, + single_thread: bool, + }, + Inspect { + operation_id: String, + kind: DebugInspectKind, + /// Identify the v2 projection of stack traces and scopes. + thread_id: Option, + frame_id: Option, + }, + Disconnect, +} + +impl DebugSession { + pub(crate) fn transition(&mut self, state: DebugSessionState) { + if self.state == state { + return; + } + self.state = state; + let event = match state { + DebugSessionState::Ready => "debug.session.initialized", + DebugSessionState::Terminated => "debug.session.finished", + DebugSessionState::Failed => { + self.log_session_failure("adapter_rejected"); + self.emit(DebugEventBody::StateChanged { state }); + return; + } + _ => "debug.session.state_changed", + }; + let fields = if event == "debug.session.state_changed" { + BTreeMap::from([( + "state".into(), + serde_json::to_value(state).expect("DAP state encodes"), + )]) + } else { + BTreeMap::new() + }; + self.log_fact(event, fields, Some(self.id.clone())); + self.emit(DebugEventBody::StateChanged { state }); + } + + pub(crate) fn default_exception_breakpoints(&self) -> Vec { + self.capabilities + .exception_breakpoint_filters + .iter() + .map(|filter| ExceptionBreakpoint { + filter: filter.filter.clone(), + enabled: filter.default, + condition: None, + }) + .collect() + } + + /// Asks the host to stop each child once; the host owns the child transports. + pub(crate) fn request_children_stop(&mut self) { + for child_session_id in self.child_ids.clone() { + if self.stopping_children.insert(child_session_id.clone()) { + self.emit(DebugEventBody::ChildSessionStopRequested { child_session_id }); + } + } + } + + /// Recomputes the effective capabilities and publishes them when any state changed. + /// + /// With `only_changes` false the first negotiation is always published. + pub(crate) fn refresh_capabilities(&mut self, only_changes: bool) { + let states = capability_states(&self.adapter_capabilities, &self.disabled_capabilities); + let capabilities = parse_capabilities(&effective_body(&self.adapter_capabilities, &states)); + // Non-boolean fields such as exception filters change without any flag flipping. + if only_changes && states == self.capability_states && capabilities == self.capabilities { + return; + } + self.capabilities = capabilities; + self.capability_states = states; + if only_changes && !self.did_configure_exception_breakpoints { + self.exception_breakpoints = self.default_exception_breakpoints(); + } + self.capability_revision += 1; + self.emit(DebugEventBody::Capabilities { + capabilities: self.capabilities.clone(), + }); + if let Some(projection) = capabilities_projection( + &self.id, + self.stop_generation, + self.capability_revision, + self.capability_states.clone(), + ) { + self.emit(DebugEventBody::Projection { projection }); + } + } + + pub(crate) fn log_fact( + &self, + event: &str, + mut fields: BTreeMap, + op: Option, + ) { + fields.insert("adapter".into(), json!(self.log_adapter)); + if lithe_diagnostics::core_events::record(event, fields, op).is_err() { + lithe_diagnostics::core_events::note_dropped(); + } + } + + pub(crate) fn log_session_failure(&mut self, code: &str) { + if self.logged_terminal_failure { + return; + } + self.logged_terminal_failure = true; + self.log_fact( + "debug.session.failed", + BTreeMap::from([("failure_code".into(), json!(code))]), + Some(self.id.clone()), + ); + } + + pub(crate) fn emit(&mut self, body: DebugEventBody) { + if let DebugEventBody::OperationFailed { + operation_id, + command, + code, + .. + } = &body + { + let (event, failure) = match code { + DebugOperationFailureCode::Cancelled => ("debug.operation.cancelled", "cancelled"), + DebugOperationFailureCode::StaleReference => { + ("debug.operation.cancelled", "stale_reference") + } + DebugOperationFailureCode::TimedOut => ("debug.operation.failed", "timed_out"), + DebugOperationFailureCode::AdapterRejected => { + ("debug.operation.failed", "adapter_rejected") + } + }; + // A launch cancellation is an expected terminal too: the following + // Failed state must not turn it into a second product error. + self.logged_terminal_failure |= matches!(command.as_str(), "launch" | "attach"); + self.log_fact( + event, + BTreeMap::from([("failure_code".into(), json!(failure))]), + Some(operation_id.clone()), + ); + } + let sequence = self.next_event_sequence; + self.next_event_sequence += 1; + self.events.push(DebugEvent { sequence, body }); + } + + pub(crate) fn take_update(&mut self) -> DebugSessionUpdate { + DebugSessionUpdate { + session_id: self.id.clone(), + state: self.state, + outbound_frames: std::mem::take(&mut self.outbound_frames) + .into_iter() + .map(|frame| BASE64.encode(frame)) + .collect(), + events: std::mem::take(&mut self.events), + } + } +} + +impl PendingRequest { + pub(crate) fn command(&self) -> &'static str { + match self { + Self::Initialize => "initialize", + Self::Launch { .. } => "launch", + Self::SetBreakpoints { .. } => "setBreakpoints", + Self::SetExceptionBreakpoints => "setExceptionBreakpoints", + Self::SetFunctionBreakpoints { .. } => "setFunctionBreakpoints", + Self::SetInstructionBreakpoints { .. } => "setInstructionBreakpoints", + Self::DataBreakpointInfo { .. } => "dataBreakpointInfo", + Self::SetDataBreakpoints { .. } => "setDataBreakpoints", + Self::SetVariable { .. } => "setVariable", + Self::Cancel => "cancel", + Self::ConfigurationDone => "configurationDone", + Self::Execute { command, .. } => command.command(), + Self::Inspect { kind, .. } => kind.command(), + Self::Disconnect => "disconnect", + } + } + + /// Frame IDs and variables references are only valid until the next stop or resume. + pub(crate) fn is_reference_bound(&self) -> bool { + match self { + Self::SetVariable { .. } => true, + Self::Inspect { kind, .. } => matches!( + kind, + DebugInspectKind::StackTrace + | DebugInspectKind::Scopes + | DebugInspectKind::Variables + | DebugInspectKind::Evaluate + | DebugInspectKind::StepInTargets + | DebugInspectKind::GotoTargets + | DebugInspectKind::Completions + | DebugInspectKind::Disassemble + | DebugInspectKind::ReadMemory + ), + _ => false, + } + } + + pub(crate) fn operation_id(&self) -> Option<&str> { + match self { + Self::Launch { operation_id } + | Self::Execute { operation_id, .. } + | Self::Inspect { operation_id, .. } + | Self::DataBreakpointInfo { operation_id } + | Self::SetVariable { operation_id, .. } => Some(operation_id), + _ => None, + } + } +} diff --git a/rust/crates/debug/src/engine/step_filters.rs b/rust/crates/debug/src/engine/step_filters.rs new file mode 100644 index 00000000..96e7a026 --- /dev/null +++ b/rust/crates/debug/src/engine/step_filters.rs @@ -0,0 +1,187 @@ +//! Stepping filters: normalization of the declared filters and the frame matching that marks +//! library code. + +use super::*; + +pub(crate) fn normalize_stepping_filters( + mut filters: DebugSteppingFilters, +) -> Result { + const MAXIMUM_FILTER_COUNT: usize = 256; + const MAXIMUM_FILTER_LENGTH: usize = 256; + + let mut normalized = Vec::with_capacity(filters.class_name_filters.len()); + for filter in filters.class_name_filters { + let filter = filter.trim(); + if filter.is_empty() { + continue; + } + if filter.chars().count() > MAXIMUM_FILTER_LENGTH || filter.chars().any(char::is_control) { + return Err(invalid_request( + "Debug stepping filters must be short single-line class patterns.", + )); + } + normalized.push(filter.to_string()); + } + normalized.sort(); + normalized.dedup(); + if normalized.len() > MAXIMUM_FILTER_COUNT { + return Err(invalid_request( + "Debug stepping filters cannot contain more than 256 class patterns.", + )); + } + filters.class_name_filters = normalized; + Ok(filters) +} + +/// Reads a declared hot code replacement reply through the fields the +/// adapter named for its failure message and its replaced units. +pub(crate) fn declared_replacement( + feature: &HotCodeReplaceFeature, + body: &Value, +) -> Result { + if let Some(message) = feature + .error_field + .as_deref() + .and_then(|field| body.get(field)) + .and_then(Value::as_str) + .filter(|message| !message.is_empty()) + { + return Err(invalid_request(message)); + } + let mut changed = match feature.changed_field.as_deref() { + None => Vec::new(), + Some(field) => body + .get(field) + .and_then(Value::as_array) + .ok_or_else(|| invalid_request("The hot code replacement reply has no change list."))? + .iter() + .map(|value| { + value + .as_str() + .map(str::to_owned) + .ok_or_else(|| invalid_request("Invalid replaced unit name.")) + }) + .collect::, _>>()?, + }; + changed.sort(); + changed.dedup(); + Ok(DebugOperationResult::RedefineClasses { + changed_classes: changed, + }) +} + +/// The stepping filters in the launch-argument shape adapters that take +/// them as one argument read. +pub(crate) fn step_filter_argument(filters: &DebugSteppingFilters) -> Value { + json!({ + "skipClasses": filters.class_name_filters, + "skipSynthetics": filters.skip_synthetics, + "skipStaticInitializers": filters.skip_static_initializers, + "skipConstructors": filters.skip_constructors + }) +} + +pub(crate) fn stack_frame_matches_filters( + name: &str, + source_path: Option<&str>, + presentation_hint: Option<&str>, + filters: &DebugSteppingFilters, + root_path: &str, +) -> bool { + filters + .class_name_filters + .iter() + .any(|filter| match filter.as_str() { + "$Libraries" => is_library_frame(source_path, presentation_hint, root_path), + pattern => class_pattern_matches_frame(pattern, name, source_path), + }) +} + +fn class_pattern_matches_frame(pattern: &str, name: &str, source_path: Option<&str>) -> bool { + if wildcard_match(pattern, name) || name == pattern || name.starts_with(&format!("{pattern}.")) + { + return true; + } + + let simple_type = name.split('.').next().unwrap_or(name); + if source_path.is_none() + && !pattern.contains('*') + && pattern + .rsplit('.') + .next() + .is_some_and(|value| value == simple_type) + { + return true; + } + + source_path.is_some_and(|source_path| source_path_matches_pattern(source_path, pattern)) +} + +fn source_path_matches_pattern(source_path: &str, pattern: &str) -> bool { + let source_path = source_path.replace('\\', "/"); + let slash_pattern = format!("*{}*", pattern.replace('.', "/")); + let dotted_pattern = format!("*{pattern}*"); + wildcard_match(&slash_pattern, &source_path) || wildcard_match(&dotted_pattern, &source_path) +} + +fn is_library_frame( + source_path: Option<&str>, + presentation_hint: Option<&str>, + root_path: &str, +) -> bool { + if presentation_hint == Some("subtle") || source_path.is_none() { + return true; + } + let source_path = source_path.unwrap_or_default(); + !source_path_is_within_root(source_path, root_path) && path_is_absolute_or_uri(source_path) +} + +fn source_path_is_within_root(source_path: &str, root_path: &str) -> bool { + let source_path = normalized_comparison_path(source_path); + let root_path = normalized_comparison_path(root_path); + source_path == root_path + || source_path + .strip_prefix(&root_path) + .is_some_and(|suffix| suffix.starts_with('/')) +} + +fn normalized_comparison_path(path: &str) -> String { + path.strip_prefix("file://") + .unwrap_or(path) + .replace('\\', "/") + .trim_end_matches('/') + .to_ascii_lowercase() +} + +fn path_is_absolute_or_uri(path: &str) -> bool { + let bytes = path.as_bytes(); + path.starts_with('/') || path.contains("://") || (bytes.len() >= 2 && bytes[1] == b':') +} + +fn wildcard_match(pattern: &str, value: &str) -> bool { + let pattern = pattern.as_bytes(); + let value = value.as_bytes(); + let (mut pattern_index, mut value_index) = (0, 0); + let (mut star_index, mut star_value_index) = (None, 0); + + while value_index < value.len() { + if pattern_index < pattern.len() && pattern[pattern_index] == value[value_index] { + pattern_index += 1; + value_index += 1; + } else if pattern_index < pattern.len() && pattern[pattern_index] == b'*' { + star_index = Some(pattern_index); + pattern_index += 1; + star_value_index = value_index; + } else if let Some(star) = star_index { + pattern_index = star + 1; + star_value_index += 1; + value_index = star_value_index; + } else { + return false; + } + } + while pattern_index < pattern.len() && pattern[pattern_index] == b'*' { + pattern_index += 1; + } + pattern_index == pattern.len() +} diff --git a/rust/crates/debug/src/engine/tests.rs b/rust/crates/debug/src/engine/tests.rs new file mode 100644 index 00000000..44b5dabc --- /dev/null +++ b/rust/crates/debug/src/engine/tests.rs @@ -0,0 +1,4111 @@ +use super::*; +use crate::projection_v2::{Projection, SessionProjection, Source}; + +fn request_message(sequence: i64, command: &str, arguments: Value) -> Value { + json!({ + "seq": sequence, + "type": "request", + "command": command, + "arguments": arguments + }) +} + +fn response_message(request_sequence: i64, command: &str, body: Value) -> Value { + json!({ + "seq": 100 + request_sequence, + "type": "response", + "request_seq": request_sequence, + "success": true, + "command": command, + "body": body + }) +} + +fn receive_messages(session_id: &str, messages: Vec) -> DebugSessionUpdate { + let bytes = messages + .into_iter() + .flat_map(|message| frame_message(&message).unwrap()) + .collect::>(); + receive(ReceiveRequest { + session_id: session_id.to_string(), + data_base64: BASE64.encode(bytes), + }) + .unwrap() +} + +fn decode_frame(frame: &str) -> Value { + let bytes = BASE64.decode(frame).unwrap(); + let body_start = bytes + .windows(4) + .position(|value| value == b"\r\n\r\n") + .unwrap() + + 4; + serde_json::from_slice(&bytes[body_start..]).unwrap() +} + +#[test] +fn delayed_launch_response_preserves_a_newer_stopped_or_terminal_event() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../fixtures/debug/launch-event-order.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let session_id = case["id"].as_str().unwrap(); + let event = case["event"].clone(); + let expected = case["state"].clone(); + create_session(child_request(session_id, None)).unwrap(); + struct Cleanup<'a>(&'a str); + impl Drop for Cleanup<'_> { + fn drop(&mut self) { + // log-exempt: Expected absence if the test already disposed this process-free session. + let _ = destroy_session(SessionRequest { + session_id: self.0.into(), + }); + } + } + let _cleanup = Cleanup(session_id); + receive_messages( + session_id, + vec![response_message(1, "initialize", json!({}))], + ); + let launching = launch(LaunchRequest { + session_id: session_id.into(), + operation_id: "launch".into(), + configuration: DebugLaunchConfiguration { + name: "native target".into(), + request: DebugRequestKind::Launch, + arguments: Map::new(), + stepping_filters: None, + }, + }) + .unwrap(); + let frame = decode_frame(&launching.outbound_frames[0]); + let stopped = receive_messages(session_id, vec![event]); + assert_eq!(serde_json::to_value(stopped.state).unwrap(), expected); + // LLDB can report the first stop before acknowledging the launch request. + let acknowledged = receive_messages( + session_id, + vec![response_message( + frame["seq"].as_i64().unwrap(), + "launch", + json!({}), + )], + ); + assert_eq!( + serde_json::to_value(acknowledged.state).unwrap(), + expected, + "a response must not resume or resurrect the target" + ); + } +} + +#[test] +fn dap_logging_keeps_catalog_identity_and_cancellation_private() { + let adapter = "lldb+private-adapter"; + let subject = identity_segment(adapter); + assert!(subject.starts_with("invalid-")); + assert_eq!(subject, identity_segment(adapter)); + assert!(!subject.contains("private")); + let mut fact = lithe_diagnostics::record::StructuredLogEvent { + ts: "2026-11-04T00:00:00.000Z".into(), + level: lithe_diagnostics::record::LogLevel::Debug, + seq: 1, + event: "debug.operation.cancelled".into(), + op: Some("owned-operation".into()), + msg: String::new(), + fields: BTreeMap::from([ + ("adapter".into(), json!(subject)), + ("failure_code".into(), json!("cancelled")), + ]), + }; + let channel = lithe_diagnostics::record::prepare_catalog_event(&mut fact, &json!({})).unwrap(); + assert_eq!(fact.level, lithe_diagnostics::record::LogLevel::Debug); + assert_eq!(fact.op.as_deref(), Some("owned-operation")); + assert_eq!(fact.fields.len(), 2); + assert!(channel.starts_with("debug/invalid-")); + fact.fields + .insert("message".into(), json!("forbidden protocol payload")); + assert!(lithe_diagnostics::record::prepare_catalog_event(&mut fact, &json!({})).is_err()); +} + +#[test] +fn dap_logging_failed_launch_owns_one_terminal_and_cancel_is_not_error() { + let session_id = "debug-log-owner"; + create_session(child_request(session_id, None)).unwrap(); + let result = with_session(session_id, |session| { + session.state = DebugSessionState::Launching; + session.emit(DebugEventBody::OperationFailed { + operation_id: "cancelled-launch".into(), + command: "launch".into(), + code: DebugOperationFailureCode::Cancelled, + message: "private adapter payload must never be logged".into(), + }); + assert!(session.logged_terminal_failure); + session.transition(DebugSessionState::Failed); + Ok(()) + }); + destroy_session(SessionRequest { + session_id: session_id.into(), + }) + .unwrap(); + result.unwrap(); +} + +#[test] +fn stack_frame_reports_adapter_served_source_references() { + let filters = DebugSteppingFilters::unfiltered(); + let body = json!({"stackFrames": [ + {"id": 1, "name": "main", "line": 9, "column": 1, + "source": {"name": "main.c", "path": "/workspace/main.c"}}, + {"id": 2, "name": "start", "line": 1664, "column": 1, + "source": {"name": "start", "path": "/usr/lib/dyld`start", "sourceReference": 1}}, + {"id": 3, "name": "stale", "line": 1, "column": 1, + "source": {"name": "stale.c", "path": "/workspace/stale.c", "sourceReference": 0}}, + ]}); + let frames = + normalize_inspection(DebugInspectKind::StackTrace, &body, &filters, "/workspace").unwrap(); + let value = serde_json::to_value(frames).unwrap(); + let frames = &value["stackFrames"]; + // A path with a positive reference is adapter-served content, not a file on disk. + assert!(frames[0].get("sourceReference").is_none()); + assert_eq!(frames[1]["sourceReference"], 1); + assert_eq!(frames[1]["sourcePath"], "/usr/lib/dyld`start"); + assert!(frames[2].get("sourceReference").is_none()); +} + +#[test] +fn hot_replace_normalizes_success_empty_and_error_bodies() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../fixtures/debug/hot-code-replace-v1.json" + ))) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let result = normalize_inspection( + DebugInspectKind::RedefineClasses, + &case["body"], + &DebugSteppingFilters::unfiltered(), + "/workspace", + ); + if case["failure"].as_bool() == Some(true) { + assert!(result.is_err(), "{}", case["name"]); + } else { + let value = serde_json::to_value(result.unwrap()).unwrap(); + assert_eq!(value, case["result"], "{}", case["name"]); + } + } +} + +#[test] +fn hot_replace_requires_the_declaration_and_preserves_paused_state() { + let hot_swapping: DeclaredAdapterBehavior = serde_json::from_value(json!({ + "features": { + "hotCodeReplace": { + "request": "redefineClasses", + "changedField": "changedClasses", + "errorField": "errorMessage" + } + } + })) + .unwrap(); + for (id, adapter, declared) in [ + ("hcr-declared", "hotswap", hot_swapping), + ("hcr-other", "python", DeclaredAdapterBehavior::default()), + ] { + let declares_hot_replace = declared + .features + .as_ref() + .is_some_and(|features| features.hot_code_replace.is_some()); + create_session(CreateSessionRequest { + session_id: id.into(), + adapter_id: adapter.into(), + root_path: "/workspace".into(), + supports_run_in_terminal_request: false, + supports_start_debugging_request: false, + disabled_capabilities: Vec::new(), + parent_session_id: None, + declared, + }) + .unwrap(); + // The guard always removes the process-free session, including on assertion failure. + struct Cleanup(&'static str); + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = destroy_session(SessionRequest { + session_id: self.0.into(), + }); + } + } + let _cleanup = Cleanup(id); + receive_messages(id, vec![response_message(1, "initialize", json!({}))]); + let launched = launch(LaunchRequest { + session_id: id.into(), + operation_id: "launch".into(), + configuration: DebugLaunchConfiguration { + name: "service".into(), + request: DebugRequestKind::Launch, + arguments: serde_json::from_value(json!({"vmArgs":"-Xmx256m"})).unwrap(), + stepping_filters: None, + }, + }) + .unwrap(); + let launch_frame = decode_frame(&launched.outbound_frames[0]); + // Launch arguments pass through untouched for every adapter. + assert_eq!(launch_frame["arguments"]["vmArgs"], "-Xmx256m"); + receive_messages( + id, + vec![response_message( + launch_frame["seq"].as_i64().unwrap(), + "launch", + json!({}), + )], + ); + receive_messages( + id, + vec![ + json!({"type":"event", "event":"stopped", "body":{"reason":"breakpoint","threadId":1}}), + ], + ); + let request: InspectRequest = serde_json::from_value(json!({ + "sessionId":id,"operationId":"apply","kind":"redefineClasses" + })) + .unwrap(); + let update = inspect(request); + if !declares_hot_replace { + assert!(update.is_err()); + continue; + } + let update = update.unwrap(); + let frame = decode_frame(&update.outbound_frames[0]); + assert_eq!(frame["command"], "redefineClasses"); + assert_eq!(frame["arguments"], json!({})); + let result = receive_messages( + id, + vec![response_message( + frame["seq"].as_i64().unwrap(), + "redefineClasses", + json!({"changedClasses":["example.Service"]}), + )], + ); + assert_eq!(result.state, DebugSessionState::Paused); + assert!(result.events.iter().any(|e| matches!(&e.body, + DebugEventBody::OperationCompleted { operation_id, result: DebugOperationResult::RedefineClasses { changed_classes } } + if operation_id == "apply" && changed_classes == &["example.Service".to_string()]))); + // A success envelope with a runtime error must not tear down the debug transport. + let rejected = inspect( + serde_json::from_value(json!({ + "sessionId":id,"operationId":"rejected","kind":"redefineClasses" + })) + .unwrap(), + ) + .unwrap(); + let frame = decode_frame(&rejected.outbound_frames[0]); + let result = receive_messages( + id, + vec![response_message( + frame["seq"].as_i64().unwrap(), + "redefineClasses", + json!({"changedClasses":[], "errorMessage":"Schema change not supported"}), + )], + ); + assert_eq!(result.state, DebugSessionState::Paused); + assert!(result.events.iter().any(|e| matches!(&e.body, + DebugEventBody::OperationFailed { operation_id, message, .. } + if operation_id == "rejected" && message == "Schema change not supported"))); + } +} + +#[test] +fn debug_update_serializes_variant_fields_with_contract_casing() { + let update = DebugSessionUpdate { + session_id: "debug-contract-casing".to_string(), + state: DebugSessionState::Paused, + outbound_frames: Vec::new(), + events: vec![ + DebugEvent { + sequence: 1, + body: DebugEventBody::Stopped { + reason: "breakpoint".to_string(), + thread_id: Some(13), + description: None, + stop_generation: 1, + }, + }, + DebugEvent { + sequence: 2, + body: DebugEventBody::OperationCompleted { + operation_id: "stack-1".to_string(), + result: DebugOperationResult::StackTrace { + stack_frames: vec![DebugStackFrame { + id: 7, + name: "example.Main.run".to_string(), + source_path: Some("/workspace/Main.c".to_string()), + source_reference: None, + line: 12, + column: 1, + is_filtered: false, + instruction_pointer_reference: None, + }], + }, + }, + }, + ], + }; + + let value = serde_json::to_value(update).unwrap(); + assert_eq!(value["events"][0]["threadId"], 13); + assert!(value["events"][0].get("thread_id").is_none()); + assert_eq!(value["events"][1]["operationId"], "stack-1"); + assert!(value["events"][1].get("operation_id").is_none()); + assert_eq!(value["events"][1]["result"]["kind"], "stackTrace"); + assert_eq!(value["events"][1]["result"]["stackFrames"][0]["id"], 7); + assert_eq!( + value["events"][1]["result"]["stackFrames"][0]["isFiltered"], + false + ); + assert!(value["events"][1]["result"].get("stack_frames").is_none()); +} + +#[test] +fn disconnect_policy_matches_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../fixtures/debug/disconnect-policy-v1.json" + ))) + .unwrap(); + + for case in fixture["cases"].as_array().unwrap() { + let request_name = case["request"].as_str().unwrap_or("unstarted"); + let session_id = format!("debug-disconnect-{request_name}"); + create_session(CreateSessionRequest { + session_id: session_id.clone(), + adapter_id: "lldb".to_string(), + root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, + supports_start_debugging_request: false, + disabled_capabilities: Vec::new(), + parent_session_id: None, + declared: Default::default(), + }) + .unwrap(); + + if case["request"].is_string() { + let request_kind = serde_json::from_value(case["request"].clone()).unwrap(); + launch(LaunchRequest { + session_id: session_id.clone(), + operation_id: format!("{request_name}-main"), + configuration: DebugLaunchConfiguration { + name: "Main".to_string(), + request: request_kind, + arguments: Map::new(), + stepping_filters: None, + }, + }) + .unwrap(); + let initialized = receive_messages( + &session_id, + vec![response_message(1, "initialize", json!({}))], + ); + assert_eq!( + decode_frame(&initialized.outbound_frames[0])["command"], + request_name + ); + } + + let disconnected = disconnect(DisconnectRequest { + session_id: session_id.clone(), + terminate_debuggee: case["requestedTerminateDebuggee"].as_bool(), + }) + .unwrap(); + let request = decode_frame(&disconnected.outbound_frames[0]); + assert_eq!(request["command"], "disconnect"); + assert_eq!(request["arguments"], case["expectedArguments"]); + destroy_session(SessionRequest { session_id }).unwrap(); + } +} + +#[test] +fn variable_paging_arguments_reject_invalid_combinations() { + let base = InspectRequest { + session_id: "debug-variable-validation".to_string(), + operation_id: "variables".to_string(), + kind: DebugInspectKind::Variables, + thread_id: None, + frame_id: None, + variables_reference: Some(700), + variable_filter: Some(DebugVariableFilter::Indexed), + start: Some(0), + count: Some(100), + expression: None, + context: None, + source_path: None, + line: None, + column: None, + memory_reference: None, + offset: None, + instruction_offset: None, + instruction_count: None, + resolve_symbols: None, + }; + + let mut negative_start = base.clone(); + negative_start.start = Some(-1); + let error = inspect_arguments(&negative_start).unwrap_err(); + assert!(matches!(error.code, ErrorCode::InvalidRequest)); + + let mut zero_count = base.clone(); + zero_count.count = Some(0); + let error = inspect_arguments(&zero_count).unwrap_err(); + assert!(matches!(error.code, ErrorCode::InvalidRequest)); + + let mut non_variable_request = base; + non_variable_request.kind = DebugInspectKind::Threads; + non_variable_request.variables_reference = None; + non_variable_request.start = None; + non_variable_request.count = None; + let error = inspect_arguments(&non_variable_request).unwrap_err(); + assert!(matches!(error.code, ErrorCode::InvalidRequest)); +} + +#[test] +fn variable_child_counts_are_normalized_for_scopes_and_evaluation() { + let scopes = normalize_inspection( + DebugInspectKind::Scopes, + &json!({ + "scopes": [{ + "name": "Locals", + "variablesReference": 700, + "expensive": false, + "namedVariables": -2, + "indexedVariables": 250 + }] + }), + &DebugSteppingFilters::default(), + "/workspace", + ) + .unwrap(); + let scopes = serde_json::to_value(scopes).unwrap(); + assert_eq!(scopes["scopes"][0]["namedVariables"], 0); + assert_eq!(scopes["scopes"][0]["indexedVariables"], 250); + + let evaluation = normalize_inspection( + DebugInspectKind::Evaluate, + &json!({ + "result": "Customer[250]", + "variablesReference": 701, + "namedVariables": 4, + "indexedVariables": -1 + }), + &DebugSteppingFilters::default(), + "/workspace", + ) + .unwrap(); + let evaluation = serde_json::to_value(evaluation).unwrap(); + assert_eq!(evaluation["variable"]["namedVariables"], 4); + assert_eq!(evaluation["variable"]["indexedVariables"], 0); +} + +#[test] +fn stepping_filters_are_normalized_projected_and_mark_stack_frames() { + // Without a declaration there are no built-in defaults for any adapter. + let defaults = stepping_filters(DebugSteppingFiltersRequest { + adapter_id: "lldb".to_string(), + filters: None, + }) + .unwrap(); + assert_eq!(defaults, DebugSteppingFilters::unfiltered()); + + let filters = stepping_filters(DebugSteppingFiltersRequest { + adapter_id: "lldb".to_string(), + filters: Some(DebugSteppingFilters { + class_name_filters: vec![ + " org.mocking.* ".to_string(), + "lib.core.*".to_string(), + "org.mocking.*".to_string(), + String::new(), + ], + skip_synthetics: true, + skip_static_initializers: false, + skip_constructors: true, + hide_filtered_stack_frames: true, + }), + }) + .unwrap(); + assert_eq!(filters.class_name_filters, ["lib.core.*", "org.mocking.*"]); + + let multiline_error = stepping_filters(DebugSteppingFiltersRequest { + adapter_id: "lldb".to_string(), + filters: Some(DebugSteppingFilters { + class_name_filters: vec!["example.Valid\nexample.Invalid".to_string()], + skip_synthetics: false, + skip_static_initializers: false, + skip_constructors: false, + hide_filtered_stack_frames: false, + }), + }) + .unwrap_err(); + assert!(matches!(multiline_error.code, ErrorCode::InvalidRequest)); + + let excessive_filter_error = stepping_filters(DebugSteppingFiltersRequest { + adapter_id: "lldb".to_string(), + filters: Some(DebugSteppingFilters { + class_name_filters: (0..257) + .map(|index| format!("example.Type{index}")) + .collect(), + skip_synthetics: false, + skip_static_initializers: false, + skip_constructors: false, + hide_filtered_stack_frames: false, + }), + }) + .unwrap_err(); + assert!(matches!( + excessive_filter_error.code, + ErrorCode::InvalidRequest + )); + + let session_id = "debug-stepping-filters"; + create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "lldb".to_string(), + root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, + supports_start_debugging_request: false, + disabled_capabilities: Vec::new(), + parent_session_id: None, + declared: serde_json::from_value(json!({ + "features": {"stepFilters": {"argument": "stepFilters"}} + })) + .unwrap(), + }) + .unwrap(); + launch(LaunchRequest { + session_id: session_id.to_string(), + operation_id: "launch".to_string(), + configuration: DebugLaunchConfiguration { + name: "Main".to_string(), + request: DebugRequestKind::Launch, + arguments: Map::new(), + stepping_filters: Some(filters), + }, + }) + .unwrap(); + let initialized = receive_messages( + session_id, + vec![response_message(1, "initialize", json!({}))], + ); + let launch_request = decode_frame(&initialized.outbound_frames[0]); + assert_eq!(launch_request["command"], "launch"); + assert_eq!( + launch_request["arguments"]["stepFilters"]["skipClasses"], + json!(["lib.core.*", "org.mocking.*"]) + ); + assert_eq!( + launch_request["arguments"]["stepFilters"]["skipConstructors"], + true + ); + receive_messages( + session_id, + vec![ + response_message(2, "launch", json!({})), + json!({ + "seq": 103, + "type": "event", + "event": "stopped", + "body": {"reason": "breakpoint", "threadId": 11} + }), + ], + ); + inspect(InspectRequest { + session_id: session_id.to_string(), + operation_id: "stack".to_string(), + kind: DebugInspectKind::StackTrace, + thread_id: Some(11), + frame_id: None, + variables_reference: None, + variable_filter: None, + start: None, + count: None, + expression: None, + context: None, + source_path: None, + line: None, + column: None, + memory_reference: None, + offset: None, + instruction_offset: None, + instruction_count: None, + resolve_symbols: None, + }) + .unwrap(); + let stack = receive_messages( + session_id, + vec![response_message( + 3, + "stackTrace", + json!({"stackFrames": [ + { + "id": 7, + "name": "lib.core.reflect.Method.invoke(Object,Object[])", + "source": { + "path": "/deps/lib/core/reflect/Method.class" + }, + "line": 1, + "column": 1 + }, + { + "id": 8, + "name": "MockMethodInterceptor.intercept(Object,Method,Object[],Invoker)", + "source": { + "path": "/deps/mocking-core/org/mocking/internal/MockMethodInterceptor.class" + }, + "line": 1, + "column": 1 + }, + { + "id": 9, + "name": "example.LoginService.authenticate", + "source": {"path": "/workspace/LoginService.c"}, + "line": 24, + "column": 5 + } + ]}), + )], + ); + assert!(stack.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::OperationCompleted { + operation_id, + result: DebugOperationResult::StackTrace { stack_frames } + } if operation_id == "stack" + && stack_frames.len() == 3 + && stack_frames[0].is_filtered + && stack_frames[1].is_filtered + && !stack_frames[2].is_filtered + ))); + let library_filters = DebugSteppingFilters { + class_name_filters: vec!["$Libraries".to_string()], + skip_synthetics: false, + skip_static_initializers: false, + skip_constructors: false, + hide_filtered_stack_frames: true, + }; + assert!(stack_frame_matches_filters( + "Dependency.call()", + Some("/dependencies/example/Dependency.c"), + None, + &library_filters, + "/workspace" + )); + assert!(!stack_frame_matches_filters( + "LoginService.authenticate()", + Some("/workspace/LoginService.c"), + None, + &library_filters, + "/workspace" + )); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); +} + +#[test] +fn initialize_launch_breakpoints_and_inspection_are_reduced_in_order() { + let session_id = "debug-engine-flow"; + let created = create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "lldb".to_string(), + root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, + supports_start_debugging_request: false, + disabled_capabilities: Vec::new(), + parent_session_id: None, + declared: Default::default(), + }) + .unwrap(); + assert_eq!(created.state, DebugSessionState::Initializing); + assert_eq!( + decode_frame(&created.outbound_frames[0])["command"], + "initialize" + ); + + let queued = launch(LaunchRequest { + session_id: session_id.to_string(), + operation_id: "launch-1".to_string(), + configuration: DebugLaunchConfiguration { + name: "Main".to_string(), + request: DebugRequestKind::Launch, + arguments: Map::from_iter([("mainClass".to_string(), json!("example.Main"))]), + stepping_filters: None, + }, + }) + .unwrap(); + assert!(queued.outbound_frames.is_empty()); + set_breakpoints(SetBreakpointsRequest { + session_id: session_id.to_string(), + source_path: "/workspace/src/Main.c".to_string(), + breakpoints: vec![ + SourceBreakpoint { + line: 12, + column: None, + enabled: true, + condition: Some("value > 1".to_string()), + hit_condition: Some("3".to_string()), + log_message: Some("value = {value}".to_string()), + }, + SourceBreakpoint { + line: 14, + column: None, + enabled: false, + condition: None, + hit_condition: None, + log_message: None, + }, + ], + }) + .unwrap(); + set_exception_breakpoints(SetExceptionBreakpointsRequest { + session_id: session_id.to_string(), + breakpoints: vec![ + ExceptionBreakpoint { + filter: "uncaught".to_string(), + enabled: false, + condition: None, + }, + ExceptionBreakpoint { + filter: "caught".to_string(), + enabled: true, + condition: Some(" example.CustomException ".to_string()), + }, + ], + }) + .unwrap(); + set_function_breakpoints(SetFunctionBreakpointsRequest { + session_id: session_id.to_string(), + breakpoints: vec![ + FunctionBreakpoint { + name: " example.Main.run ".to_string(), + enabled: true, + condition: Some("ready".to_string()), + hit_condition: Some("2".to_string()), + }, + FunctionBreakpoint { + name: "example.Main.skip".to_string(), + enabled: false, + condition: None, + hit_condition: None, + }, + ], + }) + .unwrap(); + + let initialized = receive_messages( + session_id, + vec![response_message( + 1, + "initialize", + json!({ + "supportsConfigurationDoneRequest": true, + "supportsConditionalBreakpoints": true, + "supportsHitConditionalBreakpoints": true, + "supportsLogPoints": true, + "supportsFunctionBreakpoints": true, + "supportsDataBreakpoints": true, + "supportsSetVariable": true, + "supportsRestartRequest": true, + "supportsExceptionFilterOptions": true, + "exceptionBreakpointFilters": [{ + "filter": "caught", + "label": "Caught Exceptions", + "default": false, + "supportsCondition": true + }] + }), + )], + ); + assert_eq!(initialized.state, DebugSessionState::Launching); + assert!(initialized.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::Capabilities { capabilities } + if capabilities.supports_conditional_breakpoints + && capabilities.supports_hit_conditional_breakpoints + && capabilities.supports_log_points + && capabilities.supports_function_breakpoints + && capabilities.supports_data_breakpoints + && capabilities.supports_set_variable + && capabilities.supports_restart_request + && capabilities.exception_breakpoint_filters.len() == 1 + ))); + assert_eq!( + decode_frame(&initialized.outbound_frames[0])["command"], + "launch" + ); + + let configured = receive_messages( + session_id, + vec![json!({"seq": 102, "type": "event", "event": "initialized"})], + ); + assert_eq!( + decode_frame(&configured.outbound_frames[0])["command"], + "setExceptionBreakpoints" + ); + assert_eq!( + decode_frame(&configured.outbound_frames[1])["command"], + "setFunctionBreakpoints" + ); + assert_eq!( + decode_frame(&configured.outbound_frames[2])["command"], + "setDataBreakpoints" + ); + assert_eq!( + decode_frame(&configured.outbound_frames[3])["command"], + "setBreakpoints" + ); + assert_eq!( + decode_frame(&configured.outbound_frames[4])["command"], + "configurationDone" + ); + assert_eq!( + decode_frame(&configured.outbound_frames[0])["arguments"]["filters"], + json!(["caught"]) + ); + assert_eq!( + decode_frame(&configured.outbound_frames[0])["arguments"]["filterOptions"][0]["condition"], + "example.CustomException" + ); + assert_eq!( + decode_frame(&configured.outbound_frames[1])["arguments"]["breakpoints"][0]["name"], + "example.Main.run" + ); + assert_eq!( + decode_frame(&configured.outbound_frames[1])["arguments"]["breakpoints"][0]["condition"], + "ready" + ); + assert_eq!( + decode_frame(&configured.outbound_frames[1])["arguments"]["breakpoints"][0]["hitCondition"], + "2" + ); + assert_eq!( + decode_frame(&configured.outbound_frames[1])["arguments"]["breakpoints"] + .as_array() + .unwrap() + .len(), + 1 + ); + assert_eq!( + decode_frame(&configured.outbound_frames[3])["arguments"]["breakpoints"][0]["condition"], + "value > 1" + ); + assert_eq!( + decode_frame(&configured.outbound_frames[3])["arguments"]["breakpoints"][0]["hitCondition"], + "3" + ); + assert_eq!( + decode_frame(&configured.outbound_frames[3])["arguments"]["breakpoints"][0]["logMessage"], + "value = {value}" + ); + assert_eq!( + decode_frame(&configured.outbound_frames[3])["arguments"]["breakpoints"] + .as_array() + .unwrap() + .len(), + 1 + ); + + let running = receive_messages( + session_id, + vec![ + response_message(2, "launch", json!({})), + response_message(3, "setExceptionBreakpoints", json!({})), + response_message( + 4, + "setFunctionBreakpoints", + json!({"breakpoints": [{"id": 8, "verified": true}]}), + ), + response_message(5, "setDataBreakpoints", json!({"breakpoints": []})), + response_message( + 6, + "setBreakpoints", + json!({"breakpoints": [{"id": 7, "verified": true, "line": 12}]}), + ), + response_message(7, "configurationDone", json!({})), + ], + ); + assert_eq!(running.state, DebugSessionState::Running); + assert!(running + .events + .iter() + .any(|event| matches!(event.body, DebugEventBody::Breakpoint { .. }))); + assert!(running.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::Breakpoint { breakpoint } + if breakpoint.function_name.as_deref() == Some("example.Main.run") + && breakpoint.verified + ))); + + let inspection = inspect(InspectRequest { + session_id: session_id.to_string(), + operation_id: "threads-1".to_string(), + kind: DebugInspectKind::Threads, + thread_id: None, + frame_id: None, + variables_reference: None, + variable_filter: None, + start: None, + count: None, + expression: None, + context: None, + source_path: None, + line: None, + column: None, + memory_reference: None, + offset: None, + instruction_offset: None, + instruction_count: None, + resolve_symbols: None, + }) + .unwrap(); + assert_eq!( + decode_frame(&inspection.outbound_frames[0])["command"], + "threads" + ); + let completed = receive_messages( + session_id, + vec![response_message( + 8, + "threads", + json!({"threads": [{"id": 1, "name": "main"}]}), + )], + ); + assert!(completed.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::OperationCompleted { operation_id, result: DebugOperationResult::Threads { threads } } + if operation_id == "threads-1" && threads.len() == 1 + ))); + + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); +} + +#[test] +fn source_breakpoint_binding_preserves_requested_identity_and_rejects_stale_replies() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../fixtures/debug/breakpoint-binding-v1.json" + ))) + .unwrap(); + let session_id = "source-breakpoint-binding"; + let source = fixture["sourcePath"].as_str().unwrap(); + create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "lldb".to_string(), + root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, + supports_start_debugging_request: false, + disabled_capabilities: Vec::new(), + parent_session_id: None, + declared: Default::default(), + }) + .unwrap(); + // This reducer owns no processes; always destroy its registry entry below. + receive_messages( + session_id, + vec![response_message(1, "initialize", json!({}))], + ); + receive_messages( + session_id, + vec![json!({"type":"event", "event":"initialized"})], + ); + let set = || { + set_breakpoints(SetBreakpointsRequest { + session_id: session_id.to_string(), + source_path: source.to_string(), + breakpoints: vec![SourceBreakpoint { + line: fixture["requestedLine"].as_i64().unwrap(), + column: None, + enabled: true, + condition: None, + hit_condition: None, + log_message: None, + }], + }) + .unwrap() + }; + let sequence = |update: DebugSessionUpdate| { + decode_frame(&update.outbound_frames[0])["seq"] + .as_i64() + .unwrap() + }; + let old_sequence = sequence(set()); + let latest_sequence = sequence(set()); + let stale = receive_messages( + session_id, + vec![response_message( + old_sequence, + "setBreakpoints", + json!({"breakpoints":[{"id":40,"verified":true,"line":8}]}), + )], + ); + assert!(!stale + .events + .iter() + .any(|e| matches!(e.body, DebugEventBody::Breakpoint { .. }))); + receive_messages( + session_id, + vec![response_message( + latest_sequence, + "setBreakpoints", + json!({"breakpoints":[fixture["response"].clone()]}), + )], + ); + let changed = receive_messages( + session_id, + vec![json!({"type":"event", "event":"breakpoint", + "body":{"reason":"changed", "breakpoint":fixture["changed"]}})], + ); + let result = changed + .events + .iter() + .find_map(|event| match &event.body { + DebugEventBody::Breakpoint { breakpoint } => { + Some(serde_json::to_value(breakpoint).unwrap()) + } + _ => None, + }) + .unwrap(); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); + for key in ["requestedLine", "line", "sourcePath", "verified"] { + assert_eq!(result[key], fixture["expected"][key], "{key}"); + } +} + +#[test] +fn start_debugging_request_is_normalized_completed_and_stale_safe() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../fixtures/debug/start-debugging-v1.json" + ))) + .unwrap(); + let session_id = "debug-start-debugging"; + create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "node".to_string(), + root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, + supports_start_debugging_request: true, + disabled_capabilities: Vec::new(), + parent_session_id: None, + declared: Default::default(), + }) + .unwrap(); + // This reducer owns no processes; always destroy its registry entry below. + let requested = receive_messages(session_id, vec![fixture["adapterRequest"].clone()]); + let request_id = requested + .events + .iter() + .find_map(|event| match &event.body { + DebugEventBody::StartDebuggingRequested { + request_id, + request, + } => { + assert_eq!( + serde_json::to_value(request).unwrap(), + fixture["expectedRequest"] + ); + Some(request_id.clone()) + } + _ => None, + }) + .unwrap(); + + let duplicate = receive_messages(session_id, vec![fixture["adapterRequest"].clone()]); + assert!(duplicate.outbound_frames.is_empty() && duplicate.events.is_empty()); + + let success = start_debugging_response(DebugStartDebuggingResponseRequest { + session_id: session_id.to_string(), + request_id: request_id.clone(), + success: true, + message: None, + }) + .unwrap(); + let response = decode_frame(&success.outbound_frames[0]); + assert_eq!(response["request_seq"], fixture["adapterRequest"]["seq"]); + assert_eq!(response["command"], "startDebugging"); + assert_eq!(response["success"], true); + + let stale = start_debugging_response(DebugStartDebuggingResponseRequest { + session_id: session_id.to_string(), + request_id, + success: false, + message: Some(fixture["failureMessage"].as_str().unwrap().to_string()), + }) + .unwrap(); + assert!(stale.outbound_frames.is_empty()); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); +} + +#[test] +fn start_debugging_failure_and_unsupported_hosts_answer_the_adapter() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../fixtures/debug/start-debugging-v1.json" + ))) + .unwrap(); + for (session_id, supported) in [ + ("debug-start-debugging-unsupported", false), + ("debug-start-debugging-failure", true), + ] { + create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "node".to_string(), + root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, + supports_start_debugging_request: supported, + disabled_capabilities: Vec::new(), + parent_session_id: None, + declared: Default::default(), + }) + .unwrap(); + let update = receive_messages(session_id, vec![fixture["adapterRequest"].clone()]); + if supported { + let request_id = update + .events + .iter() + .find_map(|event| match &event.body { + DebugEventBody::StartDebuggingRequested { request_id, .. } => { + Some(request_id.clone()) + } + _ => None, + }) + .unwrap(); + let failed = start_debugging_response(DebugStartDebuggingResponseRequest { + session_id: session_id.to_string(), + request_id, + success: false, + message: Some(fixture["failureMessage"].as_str().unwrap().to_string()), + }) + .unwrap(); + let response = decode_frame(&failed.outbound_frames[0]); + assert_eq!(response["success"], false); + assert_eq!(response["message"], fixture["failureMessage"]); + } else { + let response = decode_frame(&update.outbound_frames[0]); + assert_eq!(response["success"], false); + assert!(update.events.is_empty()); + } + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); + } +} + +#[test] +fn disconnect_fails_a_pending_start_debugging_request() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../fixtures/debug/start-debugging-v1.json" + ))) + .unwrap(); + let session_id = "debug-start-debugging-disconnect"; + create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "node".to_string(), + root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, + supports_start_debugging_request: true, + disabled_capabilities: Vec::new(), + parent_session_id: None, + declared: Default::default(), + }) + .unwrap(); + receive_messages(session_id, vec![fixture["adapterRequest"].clone()]); + let update = disconnect(DisconnectRequest { + session_id: session_id.to_string(), + terminate_debuggee: None, + }) + .unwrap(); + let response = decode_frame(&update.outbound_frames[0]); + assert_eq!(response["command"], "startDebugging"); + assert_eq!(response["success"], false); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); +} + +#[test] +fn node_source_mapped_locations_pass_through_unchanged() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../fixtures/debug/node-source-map-v1.json" + ))) + .unwrap(); + let session_id = "debug-node-source-map"; + let source = fixture["sourcePath"].as_str().unwrap(); + create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "node".to_string(), + root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, + supports_start_debugging_request: false, + disabled_capabilities: Vec::new(), + parent_session_id: None, + declared: Default::default(), + }) + .unwrap(); + // This reducer owns no processes; always destroy its registry entry below. + receive_messages( + session_id, + vec![response_message(1, "initialize", json!({}))], + ); + receive_messages( + session_id, + vec![json!({"type":"event", "event":"initialized"})], + ); + let sent = set_breakpoints(SetBreakpointsRequest { + session_id: session_id.to_string(), + source_path: source.to_string(), + breakpoints: vec![SourceBreakpoint { + line: fixture["requestedLine"].as_i64().unwrap(), + column: None, + enabled: true, + condition: None, + hit_condition: None, + log_message: None, + }], + }) + .unwrap(); + let request = decode_frame(&sent.outbound_frames[0]); + // Core never rewrites TypeScript to the generated file; the adapter owns mapping. + assert_eq!(request["arguments"]["source"]["path"], source); + let answered = receive_messages( + session_id, + vec![response_message( + request["seq"].as_i64().unwrap(), + "setBreakpoints", + json!({"breakpoints":[fixture["response"].clone()]}), + )], + ); + let breakpoint = answered + .events + .iter() + .find_map(|event| match &event.body { + DebugEventBody::Breakpoint { breakpoint } => { + Some(serde_json::to_value(breakpoint).unwrap()) + } + _ => None, + }) + .unwrap(); + for key in ["sourcePath", "requestedLine", "line", "verified"] { + assert_eq!(breakpoint[key], fixture["expectedBreakpoint"][key], "{key}"); + } + + receive_messages( + session_id, + vec![json!({"seq": 103, "type": "event", "event": "stopped", + "body": {"reason": "breakpoint", "threadId": 1}})], + ); + inspect(InspectRequest { + session_id: session_id.to_string(), + operation_id: "stack".to_string(), + kind: DebugInspectKind::StackTrace, + thread_id: Some(1), + frame_id: None, + variables_reference: None, + variable_filter: None, + start: None, + count: None, + expression: None, + context: None, + source_path: None, + line: None, + column: None, + memory_reference: None, + offset: None, + instruction_offset: None, + instruction_count: None, + resolve_symbols: None, + }) + .unwrap(); + let stack_sequence = 4; + let stack = receive_messages( + session_id, + vec![response_message( + stack_sequence, + "stackTrace", + json!({"stackFrames": fixture["stackFrames"].clone()}), + )], + ); + let frame = stack + .events + .iter() + .find_map(|event| match &event.body { + DebugEventBody::OperationCompleted { + result: DebugOperationResult::StackTrace { stack_frames }, + .. + } => Some(serde_json::to_value(&stack_frames[0]).unwrap()), + _ => None, + }) + .unwrap(); + assert_eq!(frame["sourcePath"], fixture["expectedFrame"]["sourcePath"]); + assert_eq!(frame["line"], fixture["expectedFrame"]["line"]); + assert_eq!(frame["column"], fixture["expectedFrame"]["column"]); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); +} + +#[test] +fn data_breakpoint_identity_and_verification_are_correlated() { + let session_id = "debug-data-breakpoint"; + create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "lldb".to_string(), + root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, + supports_start_debugging_request: false, + disabled_capabilities: Vec::new(), + parent_session_id: None, + declared: Default::default(), + }) + .unwrap(); + set_data_breakpoints(SetDataBreakpointsRequest { + session_id: session_id.to_string(), + breakpoints: vec![ + DataBreakpoint { + data_id: " field:count ".to_string(), + label: Some("count".to_string()), + enabled: true, + access_type: Some(" write ".to_string()), + condition: Some(" count > 1 ".to_string()), + hit_condition: Some(" 2 ".to_string()), + }, + DataBreakpoint { + data_id: "field:ignored".to_string(), + label: None, + enabled: false, + access_type: None, + condition: None, + hit_condition: None, + }, + ], + }) + .unwrap(); + receive_messages( + session_id, + vec![response_message( + 1, + "initialize", + json!({"supportsDataBreakpoints": true}), + )], + ); + let configured = receive_messages( + session_id, + vec![json!({"seq": 102, "type": "event", "event": "initialized"})], + ); + assert_eq!( + decode_frame(&configured.outbound_frames[1])["command"], + "setDataBreakpoints" + ); + let arguments = &decode_frame(&configured.outbound_frames[1])["arguments"]["breakpoints"]; + assert_eq!(arguments.as_array().unwrap().len(), 1); + assert_eq!(arguments[0]["dataId"], "field:count"); + assert_eq!(arguments[0]["accessType"], "write"); + assert_eq!(arguments[0]["condition"], "count > 1"); + assert_eq!(arguments[0]["hitCondition"], "2"); + + let verified = receive_messages( + session_id, + vec![ + response_message(2, "setExceptionBreakpoints", json!({})), + response_message( + 3, + "setDataBreakpoints", + json!({"breakpoints": [{"id": 9, "verified": true}]}), + ), + ], + ); + assert!(verified.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::Breakpoint { breakpoint } + if breakpoint.data_id.as_deref() == Some("field:count") + && breakpoint.verified + ))); + + let info = data_breakpoint_info(DataBreakpointInfoRequest { + session_id: session_id.to_string(), + operation_id: "field-info".to_string(), + name: " count ".to_string(), + variables_reference: Some(42), + frame_id: Some(7), + }) + .unwrap(); + let request = decode_frame(&info.outbound_frames[0]); + assert_eq!(request["command"], "dataBreakpointInfo"); + assert_eq!(request["arguments"]["name"], "count"); + assert_eq!(request["arguments"]["variablesReference"], 42); + assert_eq!(request["arguments"]["frameId"], 7); + let completed = receive_messages( + session_id, + vec![response_message( + 4, + "dataBreakpointInfo", + json!({ + "dataId": "field:count", + "description": "Main.count", + "accessTypes": ["read", "write"], + "canPersist": true + }), + )], + ); + assert!(completed.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::OperationCompleted { + operation_id, + result: DebugOperationResult::DataBreakpointInfo { + data_id, + description, + access_types, + can_persist + } + } if operation_id == "field-info" + && data_id.as_deref() == Some("field:count") + && description == "Main.count" + && access_types == &["read", "write"] + && *can_persist + ))); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); +} + +#[test] +fn variable_mutation_is_capability_gated_and_correlated() { + let session_id = "debug-set-variable"; + create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "lldb".to_string(), + root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, + supports_start_debugging_request: false, + disabled_capabilities: Vec::new(), + parent_session_id: None, + declared: Default::default(), + }) + .unwrap(); + launch(LaunchRequest { + session_id: session_id.to_string(), + operation_id: "launch".to_string(), + configuration: DebugLaunchConfiguration { + name: "Main".to_string(), + request: DebugRequestKind::Launch, + arguments: Map::new(), + stepping_filters: None, + }, + }) + .unwrap(); + receive_messages( + session_id, + vec![response_message( + 1, + "initialize", + json!({"supportsSetVariable": true}), + )], + ); + receive_messages(session_id, vec![response_message(2, "launch", json!({}))]); + receive_messages( + session_id, + vec![json!({ + "seq": 103, + "type": "event", + "event": "stopped", + "body": {"reason": "breakpoint", "threadId": 11} + })], + ); + + let update = set_variable(SetVariableRequest { + session_id: session_id.to_string(), + operation_id: "set-count".to_string(), + variables_reference: 42, + name: " count ".to_string(), + value: "7".to_string(), + }) + .unwrap(); + let request = decode_frame(&update.outbound_frames[0]); + assert_eq!(request["command"], "setVariable"); + assert_eq!(request["arguments"]["variablesReference"], 42); + assert_eq!(request["arguments"]["name"], "count"); + assert_eq!(request["arguments"]["value"], "7"); + + let completed = receive_messages( + session_id, + vec![response_message( + 3, + "setVariable", + json!({ + "value": "7", + "type": "int", + "variablesReference": 0, + "namedVariables": -1, + "indexedVariables": 2 + }), + )], + ); + assert!(completed.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::OperationCompleted { + operation_id, + result: DebugOperationResult::SetVariable { variable } + } if operation_id == "set-count" + && variable.name == "count" + && variable.value == "7" + && variable.r#type.as_deref() == Some("int") + && variable.named_variables == 0 + && variable.indexed_variables == 2 + ))); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); +} + +// Regression: adapters that explain a failure only in `body.error.format` used to surface +// as the generic "rejected the request" line, hiding the actionable reason from the user. +#[test] +fn adapter_failure_detail_prefers_the_user_visible_error_format() { + let response = json!({ + "success": false, + "message": "launch failed", + "body": {"error": { + "id": 2, + "format": "program '{path}' does not exist ({path})", + "variables": {"path": "/work/a.out"} + }} + }); + assert_eq!( + adapter_failure_detail(&response).as_deref(), + Some("program '/work/a.out' does not exist (/work/a.out)") + ); +} + +#[test] +fn adapter_failure_detail_falls_back_to_the_short_message_and_keeps_unknown_placeholders() { + let short_only = json!({"success": false, "message": " notStopped "}); + assert_eq!( + adapter_failure_detail(&short_only).as_deref(), + Some("notStopped") + ); + + // A placeholder without a matching variable must stay readable, not vanish. + let unknown = json!({ + "success": false, + "body": {"error": {"format": "cannot read {file} {", "variables": {"other": "x"}}} + }); + assert_eq!( + adapter_failure_detail(&unknown).as_deref(), + Some("cannot read {file} {") + ); + + let empty = json!({"success": false, "message": " ", "body": {"error": {"format": ""}}}); + assert_eq!(adapter_failure_detail(&empty), None); + assert_eq!(adapter_failure_detail(&json!({"success": false})), None); +} + +#[test] +fn rejected_launch_reports_the_adapters_own_explanation() { + let session_id = "debug-launch-rejected-detail"; + create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "lldb".to_string(), + root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, + supports_start_debugging_request: false, + disabled_capabilities: Vec::new(), + parent_session_id: None, + declared: Default::default(), + }) + .unwrap(); + launch(LaunchRequest { + session_id: session_id.to_string(), + operation_id: "launch".to_string(), + configuration: DebugLaunchConfiguration { + name: "Main".to_string(), + request: DebugRequestKind::Launch, + arguments: Map::new(), + stepping_filters: None, + }, + }) + .unwrap(); + receive_messages( + session_id, + vec![response_message(1, "initialize", json!({}))], + ); + let update = receive_messages( + session_id, + vec![json!({ + "seq": 102, + "type": "response", + "request_seq": 2, + "success": false, + "command": "launch", + "message": "launch failed", + "body": {"error": {"id": 1, "format": "no such file: {path}", "variables": {"path": "/work/a.out"}}} + })], + ); + let failure = update.events.iter().find_map(|event| match &event.body { + DebugEventBody::OperationFailed { + operation_id, + code, + message, + .. + } => Some((operation_id.clone(), *code, message.clone())), + _ => None, + }); + assert_eq!( + failure, + Some(( + "launch".to_string(), + DebugOperationFailureCode::AdapterRejected, + "no such file: /work/a.out".to_string() + )) + ); + assert_eq!(session_state(session_id), DebugSessionState::Failed); + cleanup(session_id); +} + +#[test] +fn cancelled_operation_forwards_dap_cancel_and_ignores_late_response() { + let session_id = "debug-cancel-operation"; + create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "lldb".to_string(), + root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, + supports_start_debugging_request: false, + disabled_capabilities: Vec::new(), + parent_session_id: None, + declared: Default::default(), + }) + .unwrap(); + launch(LaunchRequest { + session_id: session_id.to_string(), + operation_id: "launch".to_string(), + configuration: DebugLaunchConfiguration { + name: "Main".to_string(), + request: DebugRequestKind::Launch, + arguments: Map::new(), + stepping_filters: None, + }, + }) + .unwrap(); + receive_messages( + session_id, + vec![response_message( + 1, + "initialize", + json!({ + "supportsCancelRequest": true, + "supportsSingleThreadExecutionRequests": true + }), + )], + ); + receive_messages(session_id, vec![response_message(2, "launch", json!({}))]); + receive_messages( + session_id, + vec![json!({ + "seq": 103, + "type": "event", + "event": "stopped", + "body": {"reason": "breakpoint", "threadId": 11} + })], + ); + let pending = inspect(InspectRequest { + session_id: session_id.to_string(), + operation_id: "threads-timeout".to_string(), + kind: DebugInspectKind::Threads, + thread_id: None, + frame_id: None, + variables_reference: None, + variable_filter: None, + start: None, + count: None, + expression: None, + context: None, + source_path: None, + line: None, + column: None, + memory_reference: None, + offset: None, + instruction_offset: None, + instruction_count: None, + resolve_symbols: None, + }) + .unwrap(); + assert_eq!(decode_frame(&pending.outbound_frames[0])["seq"], 3); + + let cancelled = cancel_operation(CancelOperationRequest { + session_id: session_id.to_string(), + operation_id: "threads-timeout".to_string(), + reason: DebugCancellationReason::TimedOut, + }) + .unwrap(); + assert!(cancelled.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::OperationFailed { operation_id, command, code, message } + if operation_id == "threads-timeout" + && command == "threads" + && *code == DebugOperationFailureCode::TimedOut + && message == "Debug operation timed out." + ))); + let cancel = decode_frame(&cancelled.outbound_frames[0]); + assert_eq!(cancel["command"], "cancel"); + assert_eq!(cancel["arguments"]["requestId"], 3); + + let late = receive_messages( + session_id, + vec![ + response_message(3, "threads", json!({"threads": []})), + response_message(4, "cancel", json!({})), + ], + ); + assert!(!late + .events + .iter() + .any(|event| matches!(event.body, DebugEventBody::OperationCompleted { .. }))); + let resumed = execute(ExecuteRequest { + session_id: session_id.to_string(), + operation_id: "resume-main-thread".to_string(), + command: DebugExecutionCommand::Continue, + thread_id: Some(11), + target_id: None, + single_thread: true, + granularity: None, + }) + .unwrap(); + let resumed_request = decode_frame(&resumed.outbound_frames[0]); + assert_eq!(resumed_request["command"], "continue"); + assert_eq!(resumed_request["arguments"]["threadId"], 11); + assert_eq!(resumed_request["arguments"]["singleThread"], true); + let resumed = receive_messages( + session_id, + vec![ + response_message(5, "continue", json!({})), + json!({ + "seq": 108, + "type": "event", + "event": "continued", + "body": {"threadId": 11, "allThreadsContinued": false} + }), + ], + ); + assert_eq!(resumed.state, DebugSessionState::Paused); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); +} + +#[test] +fn advanced_execution_controls_are_capability_gated_and_correlated() { + let session_id = "debug-advanced-control"; + create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "lldb".to_string(), + root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, + supports_start_debugging_request: false, + disabled_capabilities: Vec::new(), + parent_session_id: None, + declared: Default::default(), + }) + .unwrap(); + launch(LaunchRequest { + session_id: session_id.to_string(), + operation_id: "launch".to_string(), + configuration: DebugLaunchConfiguration { + name: "Main".to_string(), + request: DebugRequestKind::Launch, + arguments: Map::new(), + stepping_filters: None, + }, + }) + .unwrap(); + receive_messages( + session_id, + vec![response_message( + 1, + "initialize", + json!({ + "supportsStepBack": true, + "supportsRestartRequest": true, + "supportsTerminateRequest": true + }), + )], + ); + receive_messages(session_id, vec![response_message(2, "launch", json!({}))]); + receive_messages( + session_id, + vec![json!({ + "seq": 103, + "type": "event", + "event": "stopped", + "body": {"reason": "breakpoint", "threadId": 11} + })], + ); + + let step_back = execute(ExecuteRequest { + session_id: session_id.to_string(), + operation_id: "step-back".to_string(), + command: DebugExecutionCommand::StepBack, + thread_id: Some(11), + target_id: None, + single_thread: false, + granularity: None, + }) + .unwrap(); + let request = decode_frame(&step_back.outbound_frames[0]); + assert_eq!(request["command"], "stepBack"); + assert_eq!(request["arguments"]["threadId"], 11); + assert_eq!(request["arguments"]["singleThread"], false); + let stepped = receive_messages(session_id, vec![response_message(3, "stepBack", json!({}))]); + assert!(stepped.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::OperationCompleted { operation_id, result: DebugOperationResult::Acknowledged { command } } + if operation_id == "step-back" && command == "stepBack" + ))); + + let restart = execute(ExecuteRequest { + session_id: session_id.to_string(), + operation_id: "restart".to_string(), + command: DebugExecutionCommand::Restart, + thread_id: Some(11), + target_id: None, + single_thread: false, + granularity: None, + }) + .unwrap(); + let request = decode_frame(&restart.outbound_frames[0]); + assert_eq!(request["command"], "restart"); + assert!(request["arguments"].get("threadId").is_none()); + receive_messages(session_id, vec![response_message(4, "restart", json!({}))]); + + let terminate = execute(ExecuteRequest { + session_id: session_id.to_string(), + operation_id: "terminate".to_string(), + command: DebugExecutionCommand::Terminate, + thread_id: Some(11), + target_id: None, + single_thread: false, + granularity: None, + }) + .unwrap(); + let request = decode_frame(&terminate.outbound_frames[0]); + assert_eq!(request["command"], "terminate"); + assert!(request["arguments"].get("threadId").is_none()); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); +} + +#[test] +fn smart_step_and_goto_targets_are_normalized_before_targeted_execution() { + let session_id = "debug-targeted-control"; + create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "lldb".to_string(), + root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, + supports_start_debugging_request: false, + disabled_capabilities: Vec::new(), + parent_session_id: None, + declared: Default::default(), + }) + .unwrap(); + launch(LaunchRequest { + session_id: session_id.to_string(), + operation_id: "launch".to_string(), + configuration: DebugLaunchConfiguration { + name: "Main".to_string(), + request: DebugRequestKind::Launch, + arguments: Map::new(), + stepping_filters: None, + }, + }) + .unwrap(); + receive_messages( + session_id, + vec![response_message( + 1, + "initialize", + json!({ + "supportsStepInTargetsRequest": true, + "supportsGotoTargetsRequest": true + }), + )], + ); + receive_messages(session_id, vec![response_message(2, "launch", json!({}))]); + receive_messages( + session_id, + vec![json!({ + "seq": 103, + "type": "event", + "event": "stopped", + "body": {"reason": "breakpoint", "threadId": 11} + })], + ); + + let step_targets = inspect(InspectRequest { + session_id: session_id.to_string(), + operation_id: "step-targets".to_string(), + kind: DebugInspectKind::StepInTargets, + thread_id: None, + frame_id: Some(7), + variables_reference: None, + variable_filter: None, + start: None, + count: None, + expression: None, + context: None, + source_path: None, + line: None, + column: None, + memory_reference: None, + offset: None, + instruction_offset: None, + instruction_count: None, + resolve_symbols: None, + }) + .unwrap(); + let request = decode_frame(&step_targets.outbound_frames[0]); + assert_eq!(request["command"], "stepInTargets"); + assert_eq!(request["arguments"]["frameId"], 7); + let step_targets = receive_messages( + session_id, + vec![response_message( + 3, + "stepInTargets", + json!({"targets": [{ + "id": 21, + "label": "service.load()", + "line": 12, + "column": 9, + "endLine": 12, + "endColumn": 23 + }]}), + )], + ); + assert!(step_targets.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::OperationCompleted { + operation_id, + result: DebugOperationResult::StepInTargets { targets } + } if operation_id == "step-targets" + && targets.first().map(|target| target.id) == Some(21) + ))); + let targeted_step = execute(ExecuteRequest { + session_id: session_id.to_string(), + operation_id: "targeted-step".to_string(), + command: DebugExecutionCommand::StepIn, + thread_id: Some(11), + target_id: Some(21), + single_thread: false, + granularity: None, + }) + .unwrap(); + assert_eq!( + decode_frame(&targeted_step.outbound_frames[0])["arguments"]["targetId"], + 21 + ); + receive_messages(session_id, vec![response_message(4, "stepIn", json!({}))]); + receive_messages( + session_id, + vec![json!({ + "seq": 105, + "type": "event", + "event": "stopped", + "body": {"reason": "step", "threadId": 11} + })], + ); + + let goto_targets = inspect(InspectRequest { + session_id: session_id.to_string(), + operation_id: "goto-targets".to_string(), + kind: DebugInspectKind::GotoTargets, + thread_id: None, + frame_id: None, + variables_reference: None, + variable_filter: None, + start: None, + count: None, + expression: None, + context: None, + source_path: Some("/workspace/src/Main.c".to_string()), + line: Some(20), + column: Some(5), + memory_reference: None, + offset: None, + instruction_offset: None, + instruction_count: None, + resolve_symbols: None, + }) + .unwrap(); + let request = decode_frame(&goto_targets.outbound_frames[0]); + assert_eq!(request["command"], "gotoTargets"); + assert_eq!( + request["arguments"]["source"]["path"], + "/workspace/src/Main.c" + ); + assert_eq!(request["arguments"]["line"], 20); + let goto_targets = receive_messages( + session_id, + vec![response_message( + 5, + "gotoTargets", + json!({"targets": [{"id": 31, "label": "main.c:20", "line": 20}]}), + )], + ); + assert!(goto_targets.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::OperationCompleted { + operation_id, + result: DebugOperationResult::GotoTargets { targets } + } if operation_id == "goto-targets" + && targets.first().map(|target| target.id) == Some(31) + ))); + let goto = execute(ExecuteRequest { + session_id: session_id.to_string(), + operation_id: "goto".to_string(), + command: DebugExecutionCommand::Goto, + thread_id: Some(11), + target_id: Some(31), + single_thread: false, + granularity: None, + }) + .unwrap(); + let request = decode_frame(&goto.outbound_frames[0]); + assert_eq!(request["command"], "goto"); + assert_eq!(request["arguments"]["targetId"], 31); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); +} + +fn completions_request(session_id: &str, text: &str, column: Option) -> InspectRequest { + InspectRequest { + session_id: session_id.to_string(), + operation_id: "complete".to_string(), + kind: DebugInspectKind::Completions, + thread_id: None, + frame_id: Some(7), + variables_reference: None, + variable_filter: None, + start: None, + count: None, + expression: Some(text.to_string()), + context: None, + source_path: None, + line: None, + column, + memory_reference: None, + offset: None, + instruction_offset: None, + instruction_count: None, + resolve_symbols: None, + } +} + +#[test] +fn console_completions_are_capability_gated_and_normalized() { + let unsupported = "debug-completions-unsupported"; + paused_session(unsupported); + assert!(inspect(completions_request(unsupported, "fra", None)).is_err()); + destroy_session(SessionRequest { + session_id: unsupported.to_string(), + }) + .unwrap(); + + let session_id = "debug-completions"; + paused_session_with_capabilities(session_id, json!({"supportsCompletionsRequest": true})); + // The caret must lie inside the typed text, counted in UTF-16 units. + assert!(inspect(completions_request(session_id, "fra", Some(5))).is_err()); + assert!(inspect(completions_request(session_id, "fra", Some(0))).is_err()); + assert!(inspect(completions_request(session_id, "", None)).is_err()); + + let update = inspect(completions_request(session_id, "p 😀fra", None)).unwrap(); + let request = decode_frame(&update.outbound_frames[0]); + assert_eq!(request["command"], "completions"); + assert_eq!(request["arguments"]["text"], "p 😀fra"); + // "p ", the emoji (two UTF-16 units) and "fra" make seven units; the caret is the eighth. + assert_eq!(request["arguments"]["column"], 8); + assert_eq!(request["arguments"]["frameId"], 7); + + let completed = receive_messages( + session_id, + vec![response_message( + 3, + "completions", + json!({"targets": [ + {"label": "frame -- Commands for stack frames.", "text": "frame"}, + {"label": "format", "start": 3, "length": 2, "type": "function"}, + {"label": ""}, + {"text": "no label"} + ]}), + )], + ); + let items = completed + .events + .iter() + .find_map(|event| match &event.body { + DebugEventBody::OperationCompleted { + operation_id, + result: DebugOperationResult::Completions { items }, + } if operation_id == "complete" => Some(items), + _ => None, + }) + .expect("completions result"); + assert_eq!(items.len(), 2); + assert_eq!(items[0].text, "frame"); + assert_eq!(items[0].start, None); + // Without `text` the label is inserted; the 1-based wire range becomes 0-based. + assert_eq!(items[1].text, "format"); + assert_eq!(items[1].start, Some(2)); + assert_eq!(items[1].length, Some(2)); + assert_eq!(items[1].kind.as_deref(), Some("function")); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); +} + +#[test] +fn exception_information_is_capability_gated_and_normalized() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../fixtures/debug/exception-info-v1.json" + ))) + .unwrap(); + let session_id = "debug-exception-info"; + create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "lldb".to_string(), + root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, + supports_start_debugging_request: false, + disabled_capabilities: Vec::new(), + parent_session_id: None, + declared: Default::default(), + }) + .unwrap(); + launch(LaunchRequest { + session_id: session_id.to_string(), + operation_id: "launch".to_string(), + configuration: DebugLaunchConfiguration { + name: "Main".to_string(), + request: DebugRequestKind::Launch, + arguments: Map::new(), + stepping_filters: None, + }, + }) + .unwrap(); + let initialized = receive_messages( + session_id, + vec![response_message( + 1, + "initialize", + json!({"supportsExceptionInfoRequest": true}), + )], + ); + assert!(initialized.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::Capabilities { capabilities } + if capabilities.supports_exception_info_request + ))); + receive_messages(session_id, vec![response_message(2, "launch", json!({}))]); + receive_messages( + session_id, + vec![json!({ + "seq": 103, + "type": "event", + "event": "stopped", + "body": {"reason": "exception", "threadId": fixture["request"]["threadId"]} + })], + ); + + let inspection = inspect(InspectRequest { + session_id: session_id.to_string(), + operation_id: fixture["request"]["operationId"] + .as_str() + .unwrap() + .to_string(), + kind: DebugInspectKind::ExceptionInfo, + thread_id: fixture["request"]["threadId"].as_i64(), + frame_id: None, + variables_reference: None, + variable_filter: None, + start: None, + count: None, + expression: None, + context: None, + source_path: None, + line: None, + column: None, + memory_reference: None, + offset: None, + instruction_offset: None, + instruction_count: None, + resolve_symbols: None, + }) + .unwrap(); + let request = decode_frame(&inspection.outbound_frames[0]); + assert_eq!(request["command"], "exceptionInfo"); + assert_eq!( + request["arguments"]["threadId"], + fixture["request"]["threadId"] + ); + + let completed = receive_messages( + session_id, + vec![response_message( + 3, + "exceptionInfo", + fixture["adapterResponse"].clone(), + )], + ); + let result = completed.events.iter().find_map(|event| match &event.body { + DebugEventBody::OperationCompleted { + operation_id, + result, + } if operation_id == fixture["request"]["operationId"].as_str().unwrap() => Some(result), + _ => None, + }); + assert_eq!( + serde_json::to_value(result.unwrap()).unwrap(), + fixture["expected"] + ); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); +} + +#[test] +fn variable_paging_is_forwarded_and_normalized_from_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../fixtures/debug/variable-paging-v1.json" + ))) + .unwrap(); + let session_id = "debug-variable-paging"; + create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "lldb".to_string(), + root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, + supports_start_debugging_request: false, + disabled_capabilities: Vec::new(), + parent_session_id: None, + declared: Default::default(), + }) + .unwrap(); + launch(LaunchRequest { + session_id: session_id.to_string(), + operation_id: "launch".to_string(), + configuration: DebugLaunchConfiguration { + name: "Main".to_string(), + request: DebugRequestKind::Launch, + arguments: Map::new(), + stepping_filters: None, + }, + }) + .unwrap(); + receive_messages( + session_id, + vec![response_message(1, "initialize", json!({}))], + ); + receive_messages(session_id, vec![response_message(2, "launch", json!({}))]); + receive_messages( + session_id, + vec![json!({ + "seq": 103, + "type": "event", + "event": "stopped", + "body": {"reason": "breakpoint", "threadId": 11} + })], + ); + + let inspection = inspect(InspectRequest { + session_id: session_id.to_string(), + operation_id: fixture["request"]["operationId"] + .as_str() + .unwrap() + .to_string(), + kind: DebugInspectKind::Variables, + thread_id: None, + frame_id: None, + variables_reference: fixture["request"]["variablesReference"].as_i64(), + variable_filter: serde_json::from_value(fixture["request"]["variableFilter"].clone()) + .unwrap(), + start: fixture["request"]["start"].as_i64(), + count: fixture["request"]["count"].as_i64(), + expression: None, + context: None, + source_path: None, + line: None, + column: None, + memory_reference: None, + offset: None, + instruction_offset: None, + instruction_count: None, + resolve_symbols: None, + }) + .unwrap(); + let request = decode_frame(&inspection.outbound_frames[0]); + assert_eq!(request["command"], "variables"); + assert_eq!(request["arguments"]["variablesReference"], 700); + assert_eq!(request["arguments"]["filter"], "indexed"); + assert_eq!(request["arguments"]["start"], 100); + assert_eq!(request["arguments"]["count"], 2); + + let completed = receive_messages( + session_id, + vec![response_message( + 3, + "variables", + fixture["adapterResponse"].clone(), + )], + ); + let result = completed.events.iter().find_map(|event| match &event.body { + DebugEventBody::OperationCompleted { + operation_id, + result, + } if operation_id == fixture["request"]["operationId"].as_str().unwrap() => Some(result), + _ => None, + }); + assert_eq!( + serde_json::to_value(result.unwrap()).unwrap(), + fixture["expected"] + ); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); +} + +#[test] +fn exception_information_is_rejected_when_the_adapter_does_not_support_it() { + let session_id = "debug-exception-info-unsupported"; + create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "lldb".to_string(), + root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, + supports_start_debugging_request: false, + disabled_capabilities: Vec::new(), + parent_session_id: None, + declared: Default::default(), + }) + .unwrap(); + launch(LaunchRequest { + session_id: session_id.to_string(), + operation_id: "launch".to_string(), + configuration: DebugLaunchConfiguration { + name: "Main".to_string(), + request: DebugRequestKind::Launch, + arguments: Map::new(), + stepping_filters: None, + }, + }) + .unwrap(); + receive_messages( + session_id, + vec![response_message(1, "initialize", json!({}))], + ); + receive_messages(session_id, vec![response_message(2, "launch", json!({}))]); + receive_messages( + session_id, + vec![json!({ + "seq": 103, + "type": "event", + "event": "stopped", + "body": {"reason": "exception", "threadId": 13} + })], + ); + + let error = inspect(InspectRequest { + session_id: session_id.to_string(), + operation_id: "exception-main".to_string(), + kind: DebugInspectKind::ExceptionInfo, + thread_id: Some(13), + frame_id: None, + variables_reference: None, + variable_filter: None, + start: None, + count: None, + expression: None, + context: None, + source_path: None, + line: None, + column: None, + memory_reference: None, + offset: None, + instruction_offset: None, + instruction_count: None, + resolve_symbols: None, + }) + .unwrap_err(); + assert!(matches!(error.code, ErrorCode::InvalidRequest)); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); +} + +#[test] +fn run_in_terminal_request_is_normalized_completed_and_stale_safe() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../fixtures/debug/run-in-terminal-v1.json" + ))) + .unwrap(); + let session_id = "debug-run-in-terminal"; + let created = create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "lldb".to_string(), + root_path: "/workspace".to_string(), + supports_run_in_terminal_request: true, + supports_start_debugging_request: false, + disabled_capabilities: Vec::new(), + parent_session_id: None, + declared: Default::default(), + }) + .unwrap(); + assert_eq!( + decode_frame(&created.outbound_frames[0])["arguments"]["supportsRunInTerminalRequest"], + true + ); + + let requested = receive_messages(session_id, vec![fixture["adapterRequest"].clone()]); + assert!(requested.outbound_frames.is_empty()); + let request_id = requested + .events + .iter() + .find_map(|event| match &event.body { + DebugEventBody::RunInTerminalRequested { + request_id, + request, + } => { + assert_eq!( + serde_json::to_value(request).unwrap(), + fixture["expectedRequest"] + ); + Some(request_id.clone()) + } + _ => None, + }) + .unwrap(); + + let duplicate = receive_messages(session_id, vec![fixture["adapterRequest"].clone()]); + assert!(duplicate.outbound_frames.is_empty()); + assert!(duplicate.events.is_empty()); + + let invalid = run_in_terminal_response(DebugRunInTerminalResponseRequest { + session_id: session_id.to_string(), + request_id: request_id.clone(), + success: true, + process_id: Some(0), + shell_process_id: None, + message: None, + }) + .unwrap_err(); + assert!(matches!(invalid.code, ErrorCode::InvalidRequest)); + + let success = run_in_terminal_response(DebugRunInTerminalResponseRequest { + session_id: session_id.to_string(), + request_id: request_id.clone(), + success: true, + process_id: fixture["successResponse"]["processId"].as_i64(), + shell_process_id: fixture["successResponse"]["shellProcessId"].as_i64(), + message: None, + }) + .unwrap(); + let response = decode_frame(&success.outbound_frames[0]); + assert_eq!(response["request_seq"], fixture["adapterRequest"]["seq"]); + assert_eq!(response["success"], true); + assert_eq!(response["body"], fixture["expectedSuccessBody"]); + + let stale = run_in_terminal_response(DebugRunInTerminalResponseRequest { + session_id: session_id.to_string(), + request_id, + success: false, + process_id: None, + shell_process_id: None, + message: Some(fixture["failureMessage"].as_str().unwrap().to_string()), + }) + .unwrap(); + assert!(stale.outbound_frames.is_empty()); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); +} + +#[test] +fn malformed_run_in_terminal_request_gets_an_explicit_failure_response() { + let session_id = "debug-run-in-terminal-invalid"; + create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "lldb".to_string(), + root_path: "/workspace".to_string(), + supports_run_in_terminal_request: true, + supports_start_debugging_request: false, + disabled_capabilities: Vec::new(), + parent_session_id: None, + declared: Default::default(), + }) + .unwrap(); + + let update = receive_messages( + session_id, + vec![request_message( + 45, + "runInTerminal", + json!({"cwd": "/workspace", "args": []}), + )], + ); + + let response = decode_frame(&update.outbound_frames[0]); + assert_eq!(response["request_seq"], 45); + assert_eq!(response["success"], false); + assert!(response["message"] + .as_str() + .unwrap() + .contains("between 1 and 4096")); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); +} + +#[test] +fn unknown_server_request_gets_an_explicit_failure_response() { + let session_id = "debug-server-request"; + create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "lldb".to_string(), + root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, + supports_start_debugging_request: false, + disabled_capabilities: Vec::new(), + parent_session_id: None, + declared: Default::default(), + }) + .unwrap(); + + let update = receive_messages( + session_id, + vec![request_message(44, "runInTerminal", json!({}))], + ); + + let response = decode_frame(&update.outbound_frames[0]); + assert_eq!(response["request_seq"], 44); + assert_eq!(response["success"], false); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); +} + +fn snapshot_of(update: &DebugSessionUpdate) -> Option<(u64, BTreeMap)> { + update.events.iter().find_map(|event| match &event.body { + DebugEventBody::Projection { + projection: + SessionProjection { + projection: Projection::Capabilities(payload), + .. + }, + } => Some((payload.revision, payload.capabilities.clone())), + _ => None, + }) +} + +#[test] +fn initialize_publishes_a_snapshot_and_forces_disabled_capabilities_off() { + let session_id = "debug-capability-snapshot"; + create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "lldb".to_string(), + root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, + supports_start_debugging_request: false, + disabled_capabilities: vec!["supportsRestartRequest".to_string()], + parent_session_id: None, + declared: Default::default(), + }) + .unwrap(); + launch(LaunchRequest { + session_id: session_id.to_string(), + operation_id: "launch".to_string(), + configuration: DebugLaunchConfiguration { + name: "Main".to_string(), + request: DebugRequestKind::Launch, + arguments: Map::new(), + stepping_filters: None, + }, + }) + .unwrap(); + + let update = receive_messages( + session_id, + vec![response_message( + 1, + "initialize", + json!({"supportsRestartRequest": true, "supportsWriteMemoryRequest": true}), + )], + ); + let (revision, states) = snapshot_of(&update).expect("initialize publishes a snapshot"); + assert_eq!(revision, 1); + let restart = &states["supportsRestartRequest"]; + assert!(restart.adapter && restart.extension && !restart.authorized && !restart.supported); + // The adapter reports memory reads, but no extension in this build handles it. + let step_back = &states["supportsWriteMemoryRequest"]; + assert!(step_back.adapter && !step_back.extension && !step_back.supported); + + let late = receive_messages( + session_id, + vec![json!({ + "seq": 90, + "type": "event", + "event": "capabilities", + "body": {"capabilities": {"supportsRestartRequest": true}} + })], + ); + assert!( + snapshot_of(&late).is_none(), + "an unchanged state must not bump the revision" + ); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); +} + +fn child_request(session_id: &str, parent: Option<&str>) -> CreateSessionRequest { + CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "lldb".to_string(), + root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, + supports_start_debugging_request: false, + disabled_capabilities: Vec::new(), + parent_session_id: parent.map(str::to_string), + declared: Default::default(), + } +} + +fn child_events(update: &DebugSessionUpdate) -> Vec { + update + .events + .iter() + .filter_map(|event| match &event.body { + DebugEventBody::ChildSessionStopRequested { child_session_id } => { + Some(format!("stop:{child_session_id}")) + } + _ => None, + }) + .collect() +} + +fn destroy(session_id: &str) -> Vec { + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap() +} + +#[test] +fn child_sessions_stop_with_the_parent_and_leave_the_tree_when_destroyed() { + let parent = "debug-tree-parent"; + let child = "debug-tree-child"; + create_session(child_request(parent, None)).unwrap(); + assert!(create_session(child_request("debug-tree-orphan", Some("missing"))).is_err()); + create_session(child_request(child, Some(parent))).unwrap(); + + let stopping = disconnect(DisconnectRequest { + session_id: parent.to_string(), + terminate_debuggee: None, + }) + .unwrap(); + assert_eq!(child_events(&stopping), vec![format!("stop:{child}")]); + let again = disconnect(DisconnectRequest { + session_id: parent.to_string(), + terminate_debuggee: None, + }) + .unwrap(); + assert!(child_events(&again).is_empty(), "stop is requested once"); + assert!( + create_session(child_request("debug-tree-late", Some(parent))).is_err(), + "a stopping parent accepts no new children" + ); + + assert!(destroy(child).is_empty()); + with_session(parent, |session| { + assert!(session.child_ids.is_empty()); + Ok(()) + }) + .unwrap(); + assert!(destroy(parent).is_empty()); +} + +#[test] +fn destroying_a_parent_destroys_its_whole_child_tree() { + let (root, child, grandchild) = ( + "debug-cascade-root", + "debug-cascade-child", + "debug-cascade-leaf", + ); + create_session(child_request(root, None)).unwrap(); + create_session(child_request(child, Some(root))).unwrap(); + create_session(child_request(grandchild, Some(child))).unwrap(); + + assert_eq!( + destroy(root), + vec![grandchild.to_string(), child.to_string()] + ); + for id in [root, child, grandchild] { + assert!( + with_session(id, |_| Ok(())).is_err(), + "{id} must not outlive the root" + ); + } +} + +#[test] +fn stack_trace_and_scopes_emit_v2_projections_with_one_source_identity() { + let session_id = "debug-v2-projection"; + paused_session(session_id); + let mut request = evaluate_request(session_id, "stack", None); + request.kind = DebugInspectKind::StackTrace; + request.thread_id = Some(11); + request.expression = None; + inspect(request).unwrap(); + let update = receive_messages( + session_id, + vec![response_message( + 3, + "stackTrace", + json!({"totalFrames": 3, "stackFrames": [ + {"id": 1, "name": "main", "line": 4, "column": 2, + "source": {"path": "/workspace/src/Main.c"}}, + {"id": 2, "name": "decompiled", "line": 9, + "source": {"name": "Lib.class", "sourceReference": 77}}, + {"id": 3, "name": "jit", "line": 5, + "source": {"path": "/elsewhere/Lib.c"}, + "instructionPointerReference": "0xFFFFFFFFFFFFFFFF"} + ]}), + )], + ); + let projection = update + .events + .iter() + .find_map(|event| match &event.body { + DebugEventBody::Projection { projection } => Some(projection.clone()), + _ => None, + }) + .expect("stack trace emits a v2 projection"); + assert_eq!(projection.stop_generation, 1); + let Projection::StackTrace(payload) = projection.projection else { + panic!("expected a stack trace projection"); + }; + assert_eq!(payload.thread_id, 11); + assert!(matches!( + &payload.frames[0].source, + Some(Source::Workspace(source)) if source.path == "src/Main.c" + )); + assert!(matches!( + &payload.frames[1].source, + Some(Source::Session(source)) if source.source_reference == 77 + )); + // A path outside the workspace has no grant, so the frame carries no location. + assert!(payload.frames[2].source.is_none() && payload.frames[2].line.is_none()); + assert_eq!( + payload.frames[2].instruction_pointer_reference.as_deref(), + Some("0xFFFFFFFFFFFFFFFF") + ); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); +} + +/// Creates a paused session so reference-bound inspections can be issued. +fn paused_session(session_id: &str) { + paused_session_with_capabilities(session_id, json!({})); +} + +/// A paused session whose adapter answered `initialize` with `capabilities`. +fn paused_session_with_capabilities(session_id: &str, capabilities: Value) { + create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "lldb".to_string(), + root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, + supports_start_debugging_request: false, + disabled_capabilities: Vec::new(), + parent_session_id: None, + declared: Default::default(), + }) + .unwrap(); + launch(LaunchRequest { + session_id: session_id.to_string(), + operation_id: "launch".to_string(), + configuration: DebugLaunchConfiguration { + name: "Main".to_string(), + request: DebugRequestKind::Launch, + arguments: Map::new(), + stepping_filters: None, + }, + }) + .unwrap(); + receive_messages( + session_id, + vec![response_message(1, "initialize", capabilities)], + ); + receive_messages(session_id, vec![response_message(2, "launch", json!({}))]); + receive_messages(session_id, vec![stopped_event(103)]); +} + +fn stopped_event(sequence: i64) -> Value { + json!({ + "seq": sequence, + "type": "event", + "event": "stopped", + "body": {"reason": "breakpoint", "threadId": 11} + }) +} + +fn evaluate_request(session_id: &str, operation_id: &str, context: Option<&str>) -> InspectRequest { + InspectRequest { + session_id: session_id.to_string(), + operation_id: operation_id.to_string(), + kind: DebugInspectKind::Evaluate, + thread_id: None, + frame_id: Some(7), + variables_reference: None, + variable_filter: None, + start: None, + count: None, + expression: Some("a + b".to_string()), + context: context.map(str::to_string), + source_path: None, + line: None, + column: None, + memory_reference: None, + offset: None, + instruction_offset: None, + instruction_count: None, + resolve_symbols: None, + } +} + +#[test] +fn evaluate_forwards_each_supported_context_and_rejects_unknown_ones() { + let session_id = "debug-evaluate-context"; + paused_session(session_id); + let update = inspect(evaluate_request(session_id, "default", None)).unwrap(); + assert_eq!( + decode_frame(&update.outbound_frames[0])["arguments"]["context"], + "watch" + ); + for context in ["watch", "hover", "repl", "clipboard", "variables"] { + let update = inspect(evaluate_request(session_id, context, Some(context))).unwrap(); + assert_eq!( + decode_frame(&update.outbound_frames[0])["arguments"]["context"], + context + ); + } + assert!(inspect(evaluate_request(session_id, "bad", Some("debugger"))).is_err()); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); +} + +#[test] +fn reference_bound_replies_after_a_new_stop_fail_as_stale() { + let session_id = "debug-stale-reference"; + paused_session(session_id); + let update = inspect(evaluate_request(session_id, "old", None)).unwrap(); + let request_seq = decode_frame(&update.outbound_frames[0])["seq"] + .as_i64() + .unwrap(); + receive_messages(session_id, vec![stopped_event(104)]); + let update = receive_messages( + session_id, + vec![response_message( + request_seq, + "evaluate", + json!({"result": "3"}), + )], + ); + assert!(update.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::OperationFailed { operation_id, code: DebugOperationFailureCode::StaleReference, .. } + if operation_id == "old" + ))); + assert!(!update + .events + .iter() + .any(|event| matches!(event.body, DebugEventBody::OperationCompleted { .. }))); + + let update = inspect(evaluate_request(session_id, "fresh", None)).unwrap(); + let request_seq = decode_frame(&update.outbound_frames[0])["seq"] + .as_i64() + .unwrap(); + let update = receive_messages( + session_id, + vec![response_message( + request_seq, + "evaluate", + json!({"result": "3"}), + )], + ); + assert!(update.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::OperationCompleted { operation_id, .. } if operation_id == "fresh" + ))); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); +} + +#[test] +fn stale_reference_fixture_matches_core_behavior() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../fixtures/debug/stale-reference-v1.json" + ))) + .unwrap(); + let session_id = "debug-stale-fixture"; + paused_session(session_id); + let stale_id = fixture["staleOperationId"].as_str().unwrap(); + let update = inspect(evaluate_request(session_id, stale_id, None)).unwrap(); + let request_seq = decode_frame(&update.outbound_frames[0])["seq"] + .as_i64() + .unwrap(); + receive_messages(session_id, vec![stopped_event(104)]); + let update = receive_messages( + session_id, + vec![response_message( + request_seq, + "evaluate", + fixture["evaluateResponseBody"].clone(), + )], + ); + let failed = update + .events + .iter() + .find_map(|event| match &event.body { + DebugEventBody::OperationFailed { + operation_id, code, .. + } => Some((operation_id.clone(), serde_json::to_value(code).unwrap())), + _ => None, + }) + .expect("stale reply must fail"); + assert_eq!(failed.0, stale_id); + assert_eq!(failed.1, fixture["expectedFailureCode"]); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); +} + +fn session_state(session_id: &str) -> DebugSessionState { + with_session(session_id, |session| Ok(session.state)).unwrap() +} + +fn cleanup(session_id: &str) { + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); +} + +#[test] +fn step_reply_arriving_after_the_stopped_event_keeps_the_session_paused() { + let session_id = "debug-step-race"; + paused_session(session_id); + let update = execute(ExecuteRequest { + session_id: session_id.to_string(), + operation_id: "step".to_string(), + command: DebugExecutionCommand::Next, + thread_id: Some(11), + target_id: None, + single_thread: false, + granularity: None, + }) + .unwrap(); + let request_seq = decode_frame(&update.outbound_frames[0])["seq"] + .as_i64() + .unwrap(); + let stopped = receive_messages(session_id, vec![stopped_event(104)]); + let generation = stopped + .events + .iter() + .find_map(|event| match event.body { + DebugEventBody::Stopped { + stop_generation, .. + } => Some(stop_generation), + _ => None, + }) + .unwrap(); + receive_messages( + session_id, + vec![response_message(request_seq, "next", json!({}))], + ); + assert_eq!(session_state(session_id), DebugSessionState::Paused); + let current = with_session(session_id, |session| Ok(session.stop_generation)).unwrap(); + assert_eq!(current, generation); + cleanup(session_id); +} + +#[test] +fn single_thread_continue_event_keeps_other_threads_references_valid() { + let session_id = "debug-single-thread-continue"; + paused_session(session_id); + let update = inspect(evaluate_request(session_id, "other", None)).unwrap(); + let request_seq = decode_frame(&update.outbound_frames[0])["seq"] + .as_i64() + .unwrap(); + receive_messages( + session_id, + vec![json!({"seq": 105, "type": "event", "event": "continued", + "body": {"threadId": 12, "allThreadsContinued": false}})], + ); + let update = receive_messages( + session_id, + vec![response_message( + request_seq, + "evaluate", + json!({"result": "1"}), + )], + ); + assert!(update.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::OperationCompleted { operation_id, .. } if operation_id == "other" + ))); + assert_eq!(session_state(session_id), DebugSessionState::Paused); + cleanup(session_id); +} + +#[test] +fn stale_stack_trace_reply_is_not_published_under_the_new_generation() { + let session_id = "debug-stale-stack"; + paused_session(session_id); + let mut request = evaluate_request(session_id, "stack", None); + request.kind = DebugInspectKind::StackTrace; + request.thread_id = Some(11); + request.expression = None; + let update = inspect(request).unwrap(); + let request_seq = decode_frame(&update.outbound_frames[0])["seq"] + .as_i64() + .unwrap(); + receive_messages(session_id, vec![stopped_event(104)]); + let update = receive_messages( + session_id, + vec![response_message( + request_seq, + "stackTrace", + json!({"stackFrames": [{"id": 1, "name": "main", "line": 1}]}), + )], + ); + assert!(update.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::OperationFailed { + code: DebugOperationFailureCode::StaleReference, + .. + } + ))); + assert!(!update + .events + .iter() + .any(|event| matches!(event.body, DebugEventBody::Projection { .. }))); + cleanup(session_id); +} + +#[test] +fn capabilities_event_with_new_exception_filters_is_published() { + let session_id = "debug-late-filters"; + paused_session(session_id); + let update = receive_messages( + session_id, + vec![json!({"seq": 106, "type": "event", "event": "capabilities", + "body": {"capabilities": {"exceptionBreakpointFilters": [ + {"filter": "uncaught", "label": "Uncaught", "default": true} + ]}}})], + ); + let filters = update.events.iter().find_map(|event| match &event.body { + DebugEventBody::Capabilities { capabilities } => { + Some(capabilities.exception_breakpoint_filters.len()) + } + _ => None, + }); + assert_eq!(filters, Some(1)); + cleanup(session_id); +} + +#[test] +fn terminated_parent_asks_each_child_to_stop_once() { + let parent = "debug-term-parent"; + let child = "debug-term-child"; + create_session(child_request(parent, None)).unwrap(); + create_session(child_request(child, Some(parent))).unwrap(); + let update = receive_messages( + parent, + vec![json!({"seq": 9, "type": "event", "event": "terminated", "body": {}})], + ); + assert_eq!(child_events(&update), vec![format!("stop:{child}")]); + let update = disconnect(DisconnectRequest { + session_id: parent.to_string(), + terminate_debuggee: None, + }) + .unwrap(); + assert!(child_events(&update).is_empty()); + cleanup(child); + cleanup(parent); +} + +#[test] +fn stop_generation_increments_on_every_stop() { + let session_id = "debug-stop-generation"; + paused_session(session_id); + let update = receive_messages(session_id, vec![stopped_event(104)]); + assert!(update.events.iter().any(|event| matches!( + event.body, + DebugEventBody::Stopped { + stop_generation: 2, + .. + } + ))); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); +} + +/// Removes the process-free session even when an assertion fails first. +struct SessionGuard(&'static str); + +impl Drop for SessionGuard { + fn drop(&mut self) { + let _ = destroy_session(SessionRequest { + session_id: self.0.to_string(), + }); + } +} + +fn memory_request(session_id: &str, operation_id: &str, kind: DebugInspectKind) -> InspectRequest { + InspectRequest { + session_id: session_id.to_string(), + operation_id: operation_id.to_string(), + kind, + thread_id: Some(11), + frame_id: Some(7), + variables_reference: None, + variable_filter: None, + start: None, + count: None, + expression: None, + context: None, + source_path: None, + line: None, + column: None, + memory_reference: Some("0x100003f50".to_string()), + offset: None, + instruction_offset: None, + instruction_count: None, + resolve_symbols: None, + } +} + +fn step_request( + session_id: &str, + command: DebugExecutionCommand, + granularity: Option, +) -> ExecuteRequest { + ExecuteRequest { + session_id: session_id.to_string(), + operation_id: "step".to_string(), + command, + thread_id: Some(11), + target_id: None, + single_thread: false, + granularity, + } +} + +fn instruction_breakpoint( + reference: &str, + offset: Option, + enabled: bool, + condition: Option<&str>, +) -> InstructionBreakpoint { + InstructionBreakpoint { + instruction_reference: reference.to_string(), + offset, + enabled, + condition: condition.map(str::to_string), + hit_condition: None, + } +} + +fn request_sequence(update: &DebugSessionUpdate) -> i64 { + decode_frame(&update.outbound_frames[0])["seq"] + .as_i64() + .unwrap() +} + +#[test] +fn sourceless_frames_keep_the_instruction_pointer_without_a_fake_line() { + let filters = DebugSteppingFilters::unfiltered(); + let body = json!({"stackFrames": [ + {"id": 1, "name": "main", "line": 9, "column": 1, + "instructionPointerReference": "0x100003f50", + "source": {"name": "main.c", "path": "/workspace/main.c"}}, + {"id": 2, "name": "start", + "instructionPointerReference": "0xffffffffffffffff"}, + {"id": 3, "name": "no-line", + "source": {"name": "a.c", "path": "/workspace/a.c"}}, + {"id": 4, "name": "empty-reference", "instructionPointerReference": ""}, + ]}); + let frames = + normalize_inspection(DebugInspectKind::StackTrace, &body, &filters, "/workspace").unwrap(); + let value = serde_json::to_value(frames).unwrap(); + let frames = &value["stackFrames"]; + assert_eq!(frames[0]["line"], 9); + assert_eq!(frames[0]["instructionPointerReference"], "0x100003f50"); + // No file means no line: the UI shows the function and its address instead. + assert_eq!(frames[1]["line"], 0); + assert!(frames[1]["sourcePath"].is_null()); + // A 64-bit address stays an opaque string; it never passes through a JSON number. + assert_eq!( + frames[1]["instructionPointerReference"], + "0xffffffffffffffff" + ); + // A frame that does name a file keeps the historical line-1 fallback. + assert_eq!(frames[2]["line"], 1); + assert!(frames[2].get("instructionPointerReference").is_none()); + assert!(frames[3].get("instructionPointerReference").is_none()); +} + +#[test] +fn registers_scope_keeps_its_presentation_hint() { + let filters = DebugSteppingFilters::unfiltered(); + let body = json!({"scopes": [ + {"name": "Locals", "variablesReference": 3}, + {"name": "Registers", "variablesReference": 4, + "presentationHint": "registers", "expensive": true}, + {"name": "Blank", "variablesReference": 5, "presentationHint": ""}, + ]}); + let scopes = + normalize_inspection(DebugInspectKind::Scopes, &body, &filters, "/workspace").unwrap(); + let value = serde_json::to_value(scopes).unwrap(); + let scopes = &value["scopes"]; + assert!(scopes[0].get("presentationHint").is_none()); + assert_eq!(scopes[1]["presentationHint"], "registers"); + assert!(scopes[2].get("presentationHint").is_none()); +} + +#[test] +fn stepping_granularity_is_gated_and_forwarded() { + let unsupported = "debug-granularity-unsupported"; + let _unsupported_guard = SessionGuard(unsupported); + paused_session(unsupported); + let error = execute(step_request( + unsupported, + DebugExecutionCommand::StepIn, + Some(DebugSteppingGranularity::Instruction), + )) + .unwrap_err(); + assert!(matches!(error.code, ErrorCode::InvalidRequest)); + // The rejected request left the session paused, so a plain step still works. + let update = execute(step_request(unsupported, DebugExecutionCommand::Next, None)).unwrap(); + let request = decode_frame(&update.outbound_frames[0]); + assert_eq!(request["command"], "next"); + assert!(request["arguments"].get("granularity").is_none()); + + let supported = "debug-granularity-supported"; + let _supported_guard = SessionGuard(supported); + paused_session_with_capabilities(supported, json!({"supportsSteppingGranularity": true})); + // Granularity only describes how far a step moves. + for command in [ + DebugExecutionCommand::Continue, + DebugExecutionCommand::Pause, + DebugExecutionCommand::Restart, + ] { + assert!(execute(step_request( + supported, + command, + Some(DebugSteppingGranularity::Instruction) + )) + .is_err()); + } + let update = execute(step_request( + supported, + DebugExecutionCommand::StepIn, + Some(DebugSteppingGranularity::Instruction), + )) + .unwrap(); + let request = decode_frame(&update.outbound_frames[0]); + assert_eq!(request["command"], "stepIn"); + assert_eq!(request["arguments"]["granularity"], "instruction"); + assert_eq!(request["arguments"]["threadId"], 11); +} + +#[test] +fn instruction_breakpoints_are_validated_deduplicated_and_sent_after_initialized() { + let session_id = "debug-instruction-breakpoints"; + let _guard = SessionGuard(session_id); + create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "lldb".to_string(), + root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, + supports_start_debugging_request: false, + disabled_capabilities: Vec::new(), + parent_session_id: None, + declared: Default::default(), + }) + .unwrap(); + launch(LaunchRequest { + session_id: session_id.to_string(), + operation_id: "launch".to_string(), + configuration: DebugLaunchConfiguration { + name: "Main".to_string(), + request: DebugRequestKind::Launch, + arguments: Map::new(), + stepping_filters: None, + }, + }) + .unwrap(); + for invalid in [" ", "0x1000\n0x2000", &"9".repeat(300)] { + assert!( + set_instruction_breakpoints(SetInstructionBreakpointsRequest { + session_id: session_id.to_string(), + breakpoints: vec![instruction_breakpoint(invalid, None, true, None)], + }) + .is_err(), + "{invalid:?}" + ); + } + let queued = set_instruction_breakpoints(SetInstructionBreakpointsRequest { + session_id: session_id.to_string(), + breakpoints: vec![ + instruction_breakpoint(" 0x100003f60 ", Some(4), true, None), + instruction_breakpoint(" 0x100003f50 ", None, true, Some(" x > 1 ")), + instruction_breakpoint("0x100003f50", None, true, Some("x > 1")), + instruction_breakpoint("0x100003f70", None, false, None), + ], + }) + .unwrap(); + // The session has not seen `initialized`, so nothing is sent yet. + assert!(queued.outbound_frames.is_empty()); + + receive_messages( + session_id, + vec![response_message( + 1, + "initialize", + json!({ + "supportsConfigurationDoneRequest": true, + "supportsInstructionBreakpoints": true + }), + )], + ); + let configured = receive_messages( + session_id, + vec![json!({"seq": 102, "type": "event", "event": "initialized"})], + ); + let frames: Vec = configured + .outbound_frames + .iter() + .map(|frame| decode_frame(frame)) + .collect(); + let sent = frames + .iter() + .find(|frame| frame["command"] == "setInstructionBreakpoints") + .expect("instruction breakpoints are sent after initialized"); + let breakpoints = sent["arguments"]["breakpoints"].as_array().unwrap(); + // The duplicate address collapsed and the disabled one is not sent. + assert_eq!(breakpoints.len(), 2); + assert_eq!(breakpoints[0]["instructionReference"], "0x100003f50"); + assert_eq!(breakpoints[0]["condition"], "x > 1"); + assert!(breakpoints[0].get("offset").is_none()); + assert_eq!(breakpoints[1]["instructionReference"], "0x100003f60"); + assert_eq!(breakpoints[1]["offset"], 4); + + let results = receive_messages( + session_id, + vec![response_message( + sent["seq"].as_i64().unwrap(), + "setInstructionBreakpoints", + json!({"breakpoints": [ + {"id": 5, "verified": true, "instructionReference": "0x100003f50"}, + {"verified": false, "message": "no code at this address"} + ]}), + )], + ); + let verified: Vec<(Option, bool)> = results + .events + .iter() + .filter_map(|event| match &event.body { + DebugEventBody::Breakpoint { breakpoint } => Some(( + breakpoint.instruction_reference.clone(), + breakpoint.verified, + )), + _ => None, + }) + .collect(); + // The second reply omitted the address, so the request at the same index names it. + assert_eq!( + verified, + vec![ + (Some("0x100003f50".to_string()), true), + (Some("0x100003f60".to_string()), false) + ] + ); + + // Once initialized, a change is sent immediately and an empty list clears the set. + let cleared = set_instruction_breakpoints(SetInstructionBreakpointsRequest { + session_id: session_id.to_string(), + breakpoints: Vec::new(), + }) + .unwrap(); + let request = decode_frame(&cleared.outbound_frames[0]); + assert_eq!(request["command"], "setInstructionBreakpoints"); + assert_eq!(request["arguments"]["breakpoints"], json!([])); +} + +#[test] +fn instruction_breakpoints_are_not_sent_without_adapter_support() { + let session_id = "debug-instruction-breakpoints-unsupported"; + let _guard = SessionGuard(session_id); + paused_session(session_id); + let update = set_instruction_breakpoints(SetInstructionBreakpointsRequest { + session_id: session_id.to_string(), + breakpoints: vec![instruction_breakpoint("0x100003f50", None, true, None)], + }) + .unwrap(); + assert!(update.outbound_frames.is_empty()); +} + +#[test] +fn disassemble_is_gated_bounded_and_normalized() { + let unsupported = "debug-disassemble-unsupported"; + let _unsupported_guard = SessionGuard(unsupported); + paused_session(unsupported); + let mut request = memory_request(unsupported, "dis", DebugInspectKind::Disassemble); + request.instruction_count = Some(4); + assert!(inspect(request).is_err()); + + let session_id = "debug-disassemble"; + let _guard = SessionGuard(session_id); + paused_session_with_capabilities(session_id, json!({"supportsDisassembleRequest": true})); + let valid = |count: Option| { + let mut request = memory_request(session_id, "dis", DebugInspectKind::Disassemble); + request.instruction_count = count; + request + }; + assert!(inspect(valid(None)).is_err()); + assert!(inspect(valid(Some(0))).is_err()); + assert!(inspect(valid(Some(1001))).is_err()); + let mut no_reference = valid(Some(4)); + no_reference.memory_reference = None; + assert!(inspect(no_reference).is_err()); + // Memory fields belong to memory requests only. + let mut misplaced = memory_request(session_id, "threads", DebugInspectKind::Threads); + misplaced.memory_reference = Some("0x100003f50".to_string()); + assert!(inspect(misplaced).is_err()); + + let mut request = valid(Some(5)); + request.instruction_offset = Some(-2); + request.offset = Some(0); + let update = inspect(request).unwrap(); + let sent = decode_frame(&update.outbound_frames[0]); + assert_eq!(sent["command"], "disassemble"); + assert_eq!(sent["arguments"]["memoryReference"], "0x100003f50"); + assert_eq!(sent["arguments"]["instructionOffset"], -2); + assert_eq!(sent["arguments"]["instructionCount"], 5); + assert_eq!(sent["arguments"]["resolveSymbols"], true); + + let completed = receive_messages( + session_id, + vec![response_message( + sent["seq"].as_i64().unwrap(), + "disassemble", + json!({"instructions": [ + {"address": "0x100003f48", "instructionBytes": "fd 7b bf a9", + "instruction": "stp x29, x30, [sp, #-0x10]!", "symbol": "main"}, + {"address": "0xffffffffffffff00", "instruction": "mov x29, sp", + "location": {"path": "/workspace/main.c"}, "line": 9}, + {"instruction": "no address is dropped"}, + {"address": "0x100003f50"} + ]}), + )], + ); + let instructions = completed + .events + .iter() + .find_map(|event| match &event.body { + DebugEventBody::OperationCompleted { + operation_id, + result: DebugOperationResult::Disassemble { instructions }, + } if operation_id == "dis" => Some(instructions), + _ => None, + }) + .expect("disassemble result"); + assert_eq!(instructions.len(), 2); + assert_eq!(instructions[0].address, "0x100003f48"); + assert_eq!( + instructions[0].instruction_bytes.as_deref(), + Some("fd 7b bf a9") + ); + assert_eq!(instructions[0].symbol.as_deref(), Some("main")); + assert_eq!(instructions[1].address, "0xffffffffffffff00"); + assert_eq!( + instructions[1].source_path.as_deref(), + Some("/workspace/main.c") + ); + assert_eq!(instructions[1].line, Some(9)); +} + +#[test] +fn disassemble_failure_and_stale_replies_are_operation_failures() { + let session_id = "debug-disassemble-failure"; + let _guard = SessionGuard(session_id); + paused_session_with_capabilities(session_id, json!({"supportsDisassembleRequest": true})); + let mut request = memory_request(session_id, "bad", DebugInspectKind::Disassemble); + request.instruction_count = Some(3); + // A body without `instructions` is a rejected request, not a broken session. + let update = inspect(request.clone()).unwrap(); + let failed = receive_messages( + session_id, + vec![response_message( + request_sequence(&update), + "disassemble", + json!({}), + )], + ); + assert!(failed.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::OperationFailed { operation_id, code: DebugOperationFailureCode::AdapterRejected, .. } + if operation_id == "bad" + ))); + assert_eq!(session_state(session_id), DebugSessionState::Paused); + + // Addresses belong to one stop: a reply after the next stop is stale. + request.operation_id = "old".to_string(); + let update = inspect(request).unwrap(); + let sequence = request_sequence(&update); + receive_messages(session_id, vec![stopped_event(104)]); + let stale = receive_messages( + session_id, + vec![response_message( + sequence, + "disassemble", + json!({"instructions": [{"address": "0x1", "instruction": "nop"}]}), + )], + ); + assert!(stale.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::OperationFailed { operation_id, code: DebugOperationFailureCode::StaleReference, .. } + if operation_id == "old" + ))); +} + +#[test] +fn read_memory_is_gated_bounded_and_returns_validated_bytes() { + let unsupported = "debug-read-memory-unsupported"; + let _unsupported_guard = SessionGuard(unsupported); + paused_session(unsupported); + let mut request = memory_request(unsupported, "mem", DebugInspectKind::ReadMemory); + request.count = Some(16); + assert!(inspect(request).is_err()); + + let session_id = "debug-read-memory"; + let _guard = SessionGuard(session_id); + paused_session_with_capabilities(session_id, json!({"supportsReadMemoryRequest": true})); + let sized = |count: Option| { + let mut request = memory_request(session_id, "mem", DebugInspectKind::ReadMemory); + request.count = count; + request + }; + assert!(inspect(sized(None)).is_err()); + assert!(inspect(sized(Some(0))).is_err()); + assert!(inspect(sized(Some(65_537))).is_err()); + + let mut request = sized(Some(16)); + request.offset = Some(8); + let update = inspect(request).unwrap(); + let sent = decode_frame(&update.outbound_frames[0]); + assert_eq!(sent["command"], "readMemory"); + assert_eq!(sent["arguments"]["memoryReference"], "0x100003f50"); + assert_eq!(sent["arguments"]["offset"], 8); + assert_eq!(sent["arguments"]["count"], 16); + + let completed = receive_messages( + session_id, + vec![response_message( + sent["seq"].as_i64().unwrap(), + "readMemory", + json!({"address": "0x100003f58", "unreadableBytes": 8, "data": "3q2+7w=="}), + )], + ); + assert!(completed.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::OperationCompleted { + operation_id, + result: DebugOperationResult::ReadMemory { address, unreadable_bytes: 8, data_base64 } + } if operation_id == "mem" && address == "0x100003f58" && data_base64 == "3q2+7w==" + ))); + + // Entirely unreadable memory has no `data`; invalid base64 is rejected, not forwarded. + let update = inspect(sized(Some(4))).unwrap(); + let unreadable = receive_messages( + session_id, + vec![response_message( + request_sequence(&update), + "readMemory", + json!({"address": "0x100003f50", "unreadableBytes": 4}), + )], + ); + assert!(unreadable.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::OperationCompleted { + result: DebugOperationResult::ReadMemory { unreadable_bytes: 4, data_base64, .. }, + .. + } if data_base64.is_empty() + ))); + let update = inspect(sized(Some(4))).unwrap(); + let malformed = receive_messages( + session_id, + vec![response_message( + request_sequence(&update), + "readMemory", + json!({"address": "0x100003f50", "data": "***not base64***"}), + )], + ); + assert!(malformed.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::OperationFailed { + code: DebugOperationFailureCode::AdapterRejected, + .. + } + ))); + assert_eq!(session_state(session_id), DebugSessionState::Paused); +} + +/// A scripting-language adapter that declares the DAP extensions it implements. +fn scripting_adapter() -> DeclaredAdapterBehavior { + serde_json::from_value(json!({ + "features": { + "hotCodeReplace": { + "request": "script.reload", + "event": "script.reloaded", + "changedField": "modules", + "errorField": "error" + }, + "stepFilters": {"argument": "skipFilters"} + }, + "defaults": { + "stepFilters": { + "classNameFilters": ["site-packages/*"], + "skipSynthetics": true + } + } + })) + .unwrap() +} + +/// Creates a session, answers `initialize`, and launches with `arguments`. +/// Returns the launch request the adapter received. +fn launch_session( + session_id: &str, + adapter_id: &str, + declared: DeclaredAdapterBehavior, + arguments: Value, +) -> Value { + create_session(CreateSessionRequest { + session_id: session_id.into(), + adapter_id: adapter_id.into(), + root_path: "/workspace".into(), + supports_run_in_terminal_request: false, + supports_start_debugging_request: false, + disabled_capabilities: Vec::new(), + parent_session_id: None, + declared, + }) + .unwrap(); + receive_messages( + session_id, + vec![response_message(1, "initialize", json!({}))], + ); + let launched = launch(LaunchRequest { + session_id: session_id.into(), + operation_id: "launch".into(), + configuration: DebugLaunchConfiguration { + name: "script".into(), + request: DebugRequestKind::Launch, + arguments: serde_json::from_value(arguments).unwrap(), + stepping_filters: None, + }, + }) + .unwrap(); + decode_frame(&launched.outbound_frames[0]) +} + +/// Answers the launch request and stops the debuggee at a breakpoint. +fn pause_session(session_id: &str, launch_frame: &Value) { + receive_messages( + session_id, + vec![ + response_message(launch_frame["seq"].as_i64().unwrap(), "launch", json!({})), + json!({"type": "event", "event": "stopped", + "body": {"reason": "breakpoint", "threadId": 1}}), + ], + ); +} + +fn redefine_request(session_id: &str, operation_id: &str) -> InspectRequest { + serde_json::from_value(json!({ + "sessionId": session_id, "operationId": operation_id, "kind": "redefineClasses" + })) + .unwrap() +} + +#[test] +fn declared_hot_code_replace_sends_the_declared_request_and_reads_its_fields() { + let session_id = "debug-declared-hot-replace"; + let _guard = SessionGuard(session_id); + let launched = launch_session(session_id, "debugpy", scripting_adapter(), json!({})); + pause_session(session_id, &launched); + + let update = inspect(redefine_request(session_id, "apply")).unwrap(); + let frame = decode_frame(&update.outbound_frames[0]); + assert_eq!(frame["command"], "script.reload"); + let replaced = receive_messages( + session_id, + vec![response_message( + frame["seq"].as_i64().unwrap(), + "script.reload", + json!({"modules": ["pkg.util", "pkg.app", "pkg.util"]}), + )], + ); + assert_eq!(replaced.state, DebugSessionState::Paused); + assert!(replaced.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::OperationCompleted { + operation_id, + result: DebugOperationResult::RedefineClasses { changed_classes }, + } if operation_id == "apply" + && changed_classes == &["pkg.app".to_string(), "pkg.util".to_string()] + ))); + + // A failure the adapter reports inside a successful response fails the + // operation without breaking the session. + let update = inspect(redefine_request(session_id, "rejected")).unwrap(); + let rejected = receive_messages( + session_id, + vec![response_message( + request_sequence(&update), + "script.reload", + json!({"modules": [], "error": "Syntax error in pkg.app"}), + )], + ); + assert_eq!(rejected.state, DebugSessionState::Paused); + assert!(rejected.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::OperationFailed { operation_id, message, .. } + if operation_id == "rejected" && message == "Syntax error in pkg.app" + ))); +} + +#[test] +fn declared_hot_code_replace_event_message_reaches_the_debug_console() { + let session_id = "debug-declared-hot-replace-event"; + let _guard = SessionGuard(session_id); + let launched = launch_session(session_id, "debugpy", scripting_adapter(), json!({})); + pause_session(session_id, &launched); + + let update = receive_messages( + session_id, + vec![json!({"type": "event", "event": "script.reloaded", + "body": {"message": "Reloaded 2 modules"}})], + ); + assert!(update.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::Output { category: Some(category), output } + if category == "console" && output == "Reloaded 2 modules\n" + ))); +} + +#[test] +fn an_adapter_without_the_declaration_has_no_hot_code_replace() { + let session_id = "debug-undeclared-hot-replace"; + let _guard = SessionGuard(session_id); + let launched = launch_session( + session_id, + "debugpy", + DeclaredAdapterBehavior::default(), + json!({}), + ); + pause_session(session_id, &launched); + + assert!(inspect(redefine_request(session_id, "apply")).is_err()); +} + +#[test] +fn declared_step_filters_use_the_declared_argument_and_defaults() { + let session_id = "debug-declared-step-filters"; + let _guard = SessionGuard(session_id); + let launched = launch_session(session_id, "debugpy", scripting_adapter(), json!({})); + + let filters = &launched["arguments"]["skipFilters"]; + assert_eq!(filters["skipClasses"], json!(["site-packages/*"])); + assert_eq!(filters["skipSynthetics"], true); + assert_eq!(filters["skipConstructors"], false); + // The declaration names the argument; no other argument is invented. + assert!(launched["arguments"].get("stepFilters").is_none()); + assert!(launched["arguments"].get("vmArgs").is_none()); +} + +#[test] +fn declared_step_filters_keep_what_the_launch_configuration_sets() { + let session_id = "debug-declared-step-filters-explicit"; + let _guard = SessionGuard(session_id); + let launched = launch_session( + session_id, + "debugpy", + scripting_adapter(), + json!({"skipFilters": {"skipClasses": ["mine/*"]}}), + ); + + assert_eq!( + launched["arguments"]["skipFilters"], + json!({"skipClasses": ["mine/*"]}) + ); +} + +#[test] +fn invalid_declarations_are_refused_when_the_session_is_created() { + let declared = serde_json::from_value(json!({ + "features": {"hotCodeReplace": {"request": "not a command"}} + })) + .unwrap(); + let error = create_session(CreateSessionRequest { + session_id: "debug-invalid-declaration".into(), + adapter_id: "debugpy".into(), + root_path: "/workspace".into(), + supports_run_in_terminal_request: false, + supports_start_debugging_request: false, + disabled_capabilities: Vec::new(), + parent_session_id: None, + declared, + }) + .unwrap_err(); + assert!(matches!(error.code, ErrorCode::InvalidRequest)); + assert_eq!( + error.details.as_deref(), + Some("features.hotCodeReplace.request") + ); +} + +fn sent(update: &DebugSessionUpdate) -> Vec<(String, Value)> { + update + .outbound_frames + .iter() + .map(|frame| { + let message = decode_frame(frame); + ( + message["command"].as_str().unwrap_or_default().to_string(), + message["arguments"].clone(), + ) + }) + .collect() +} + +// Muting sends every kind of stored breakpoint empty, the adapter's default exception +// filters included, and unmuting sends them as stored; a child starts muted. +#[test] +fn muting_empties_every_breakpoint_kind_and_children_inherit_it() { + let parent = "debug-mute-parent"; + let child = "debug-mute-child"; + struct Cleanup; + impl Drop for Cleanup { + fn drop(&mut self) { + for session_id in ["debug-mute-child", "debug-mute-parent"] { + // log-exempt: Expected absence if the test already disposed this process-free session. + let _ = destroy_session(SessionRequest { + session_id: session_id.into(), + }); + } + } + } + let _cleanup = Cleanup; + let capabilities = json!({ + "supportsFunctionBreakpoints": true, + "supportsInstructionBreakpoints": true, + "supportsDataBreakpoints": true, + "exceptionBreakpointFilters": [{ + "filter": "uncaught", + "label": "Uncaught Exceptions", + "default": true + }] + }); + create_session(child_request(parent, None)).unwrap(); + receive_messages( + parent, + vec![response_message(1, "initialize", capabilities.clone())], + ); + let line = |line: i64, enabled: bool| SourceBreakpoint { + line, + column: None, + enabled, + condition: None, + hit_condition: None, + log_message: None, + }; + set_breakpoints(SetBreakpointsRequest { + session_id: parent.into(), + source_path: "/workspace/main.c".into(), + breakpoints: vec![line(4, true), line(9, false)], + }) + .unwrap(); + set_breakpoints(SetBreakpointsRequest { + session_id: parent.into(), + source_path: "/workspace/off.c".into(), + breakpoints: vec![line(2, false)], + }) + .unwrap(); + set_function_breakpoints(SetFunctionBreakpointsRequest { + session_id: parent.into(), + breakpoints: vec![FunctionBreakpoint { + name: "main".into(), + enabled: true, + condition: None, + hit_condition: None, + }], + }) + .unwrap(); + set_instruction_breakpoints(SetInstructionBreakpointsRequest { + session_id: parent.into(), + breakpoints: vec![InstructionBreakpoint { + instruction_reference: "0x1000".into(), + offset: None, + enabled: true, + condition: None, + hit_condition: None, + }], + }) + .unwrap(); + let configured = receive_messages( + parent, + vec![json!({"seq": 102, "type": "event", "event": "initialized"})], + ); + assert_eq!(sent(&configured)[0].1["filters"], json!(["uncaught"])); + + let muted = set_breakpoints_muted(SetBreakpointsMutedRequest { + session_id: parent.into(), + muted: true, + }) + .unwrap(); + // The data set and the file holding only a disabled line send the same either way. + assert_eq!( + sent(&muted), + vec![ + ("setExceptionBreakpoints".into(), json!({"filters": []})), + ("setFunctionBreakpoints".into(), json!({"breakpoints": []})), + ( + "setInstructionBreakpoints".into(), + json!({"breakpoints": []}) + ), + ( + "setBreakpoints".into(), + json!({ + "source": {"name": "main.c", "path": "/workspace/main.c"}, + "breakpoints": [], + "sourceModified": false + }) + ), + ] + ); + let again = set_breakpoints_muted(SetBreakpointsMutedRequest { + session_id: parent.into(), + muted: true, + }) + .unwrap(); + assert!(again.outbound_frames.is_empty()); + // A set replaced while muted is stored and sent empty. + let replaced = set_function_breakpoints(SetFunctionBreakpointsRequest { + session_id: parent.into(), + breakpoints: vec![FunctionBreakpoint { + name: "worker".into(), + enabled: true, + condition: None, + hit_condition: None, + }], + }) + .unwrap(); + assert_eq!(sent(&replaced)[0].1, json!({"breakpoints": []})); + + create_session(child_request(child, Some(parent))).unwrap(); + receive_messages(child, vec![response_message(1, "initialize", capabilities)]); + set_breakpoints(SetBreakpointsRequest { + session_id: child.into(), + source_path: "/workspace/main.c".into(), + breakpoints: vec![line(4, true)], + }) + .unwrap(); + let child_configured = receive_messages( + child, + vec![json!({"seq": 102, "type": "event", "event": "initialized"})], + ); + let child_sent = sent(&child_configured); + assert_eq!( + child_sent[0], + ("setExceptionBreakpoints".into(), json!({"filters": []})) + ); + assert!(child_sent + .iter() + .filter(|(command, _)| command == "setBreakpoints") + .all(|(_, arguments)| arguments["breakpoints"] == json!([]))); + + let unmuted = set_breakpoints_muted(SetBreakpointsMutedRequest { + session_id: parent.into(), + muted: false, + }) + .unwrap(); + let unmuted = sent(&unmuted); + assert_eq!(unmuted[0].1["filters"], json!(["uncaught"])); + assert_eq!(unmuted[1].1["breakpoints"][0]["name"], "worker"); + assert_eq!( + unmuted[2].1["breakpoints"][0]["instructionReference"], + "0x1000" + ); + assert_eq!(unmuted[3].1["breakpoints"], json!([{"line": 4}])); + assert_eq!(unmuted.len(), 4); +} diff --git a/rust/crates/debug/src/lib.rs b/rust/crates/debug/src/lib.rs new file mode 100644 index 00000000..8dea009a --- /dev/null +++ b/rust/crates/debug/src/lib.rs @@ -0,0 +1,48 @@ +//! Transport-neutral Debug Adapter Protocol state and normalized debugger models. + +pub mod adapters; +mod backend_start; +mod breakpoint_relocation; +mod capabilities; +mod disassembly_paging; +mod endpoint; +mod engine; +mod memory_window; +mod projection_v2; +mod protocol; +mod shortcut; +mod transport_lifecycle; +mod types; +mod variable_paging; + +pub use backend_start::decode_backend_configuration_command; +pub use breakpoint_relocation::{relocate_breakpoints, RelocateBreakpointsRequest}; +pub use disassembly_paging::{plan_disassembly_window, reduce_disassembly_page}; +pub use endpoint::validate_adapter_endpoint; +pub use engine::{ + cancel_operation, create_session, data_breakpoint_info, destroy_session, disconnect, execute, + inspect, launch, receive, run_in_terminal_response, set_breakpoints, set_breakpoints_muted, + set_data_breakpoints, set_exception_breakpoints, set_function_breakpoints, + set_instruction_breakpoints, set_variable, start_debugging_response, stepping_filters, +}; +pub use memory_window::{locate_memory_window, plan_memory_window}; +pub use shortcut::start_or_continue; +pub use transport_lifecycle::{handle_transport_event, release_transport, transport_policy}; +pub use types::{ + CancelOperationRequest, CreateSessionRequest, DataBreakpointInfoRequest, + DebugRunInTerminalResponseRequest, DebugStartDebuggingResponseRequest, + DebugSteppingFiltersRequest, DisconnectRequest, ExecuteRequest, InspectRequest, LaunchRequest, + ReceiveRequest, SessionRequest, SetBreakpointsRequest, SetDataBreakpointsRequest, + SetExceptionBreakpointsRequest, SetFunctionBreakpointsRequest, + SetInstructionBreakpointsRequest, SetVariableRequest, +}; +pub use variable_paging::{plan_variable_page, reduce_variable_page}; + +use lithe_plugin_model::PluginManifest; + +/// What `debug.decodeBackendConfiguration` reads from one plugin manifest that +/// the plugin host validated; the host's manifest type implements it. +pub trait DebugManifest: PluginManifest { + /// The manifest's validated `debugAdapters`. + fn debug_adapters(&self) -> &[adapters::DebugAdapterDeclaration]; +} diff --git a/rust/crates/core/src/debug/memory_window.rs b/rust/crates/debug/src/memory_window.rs similarity index 95% rename from rust/crates/core/src/debug/memory_window.rs rename to rust/crates/debug/src/memory_window.rs index 19b9b416..75860f2b 100644 --- a/rust/crates/core/src/debug/memory_window.rs +++ b/rust/crates/debug/src/memory_window.rs @@ -14,7 +14,7 @@ //! Addresses cross the protocol as strings, never JSON numbers, because a //! 64-bit address can exceed the JavaScript safe integer range. -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; /// Bytes one memory window reads. @@ -28,7 +28,7 @@ const MAX_ADDRESS_CHARS: usize = 256; /// Where the memory view reads. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanMemoryWindowRequest { +pub struct PlanMemoryWindowRequest { /// The address the user typed, or the selected frame's instruction pointer reference. pub(crate) address: String, } @@ -36,7 +36,7 @@ pub(crate) struct PlanMemoryWindowRequest { /// One readMemory window, echoed by the host with the adapter's reply. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct MemoryWindow { +pub struct MemoryWindow { /// The address read at as the address field shows it: a number in lowercase /// `0x` hexadecimal, any other reference trimmed and otherwise as given. pub(crate) address: String, @@ -57,7 +57,7 @@ pub(crate) struct ReadMemoryArguments { /// The adapter's reply to one window's `readMemory` request. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct LocateMemoryWindowRequest { +pub struct LocateMemoryWindowRequest { pub(crate) window: MemoryWindow, /// The reply's `address`, where the returned bytes start; absent when the adapter gave none. #[serde(default)] @@ -69,7 +69,7 @@ pub(crate) struct LocateMemoryWindowRequest { /// Where the returned bytes sit. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] -pub(crate) struct MemoryWindowLocation { +pub struct MemoryWindowLocation { /// The first returned byte's address in lowercase `0x` hexadecimal; hosts label each row /// with its offset added to it. Absent when neither the reply nor the window gives a /// number, and rows are labelled with their offset in the window. @@ -81,9 +81,7 @@ pub(crate) struct MemoryWindowLocation { } /// Places one window at an address. -pub(crate) fn plan_memory_window( - request: PlanMemoryWindowRequest, -) -> Result { +pub fn plan_memory_window(request: PlanMemoryWindowRequest) -> Result { let address = request.address.trim(); if address.is_empty() || address.chars().count() > MAX_ADDRESS_CHARS @@ -118,7 +116,7 @@ pub(crate) fn plan_memory_window( } /// Finds where the bytes of one reply start and which of them is at the address read. -pub(crate) fn locate_memory_window( +pub fn locate_memory_window( request: LocateMemoryWindowRequest, ) -> Result { let LocateMemoryWindowRequest { @@ -178,7 +176,7 @@ mod tests { fn fixture() -> Value { serde_json::from_str(include_str!( - "../../../../fixtures/debug/memory-window-v1.json" + "../../../fixtures/debug/memory-window-v1.json" )) .unwrap() } diff --git a/rust/crates/core/src/debug/projection_v2.rs b/rust/crates/debug/src/projection_v2.rs similarity index 99% rename from rust/crates/core/src/debug/projection_v2.rs rename to rust/crates/debug/src/projection_v2.rs index 399aef73..3bf2da76 100644 --- a/rust/crates/core/src/debug/projection_v2.rs +++ b/rust/crates/debug/src/projection_v2.rs @@ -5,7 +5,7 @@ //! beside it, built here from the raw adapter body so no v1 narrowing leaks into v2. #[cfg(test)] -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::BTreeMap; @@ -512,7 +512,7 @@ mod tests { fn examples() -> Value { serde_json::from_str(include_str!( - "../../../../fixtures/debug/projection-v2-examples.json" + "../../../fixtures/debug/projection-v2-examples.json" )) .unwrap() } diff --git a/rust/crates/core/src/debug/protocol.rs b/rust/crates/debug/src/protocol.rs similarity index 98% rename from rust/crates/core/src/debug/protocol.rs rename to rust/crates/debug/src/protocol.rs index 64dd12ad..a2e4d135 100644 --- a/rust/crates/core/src/debug/protocol.rs +++ b/rust/crates/debug/src/protocol.rs @@ -1,6 +1,6 @@ //! Bounded DAP framing and JSON message helpers independent of native transport. -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde_json::Value; const MAX_HEADER_BYTES: usize = 64 * 1024; diff --git a/rust/crates/core/src/debug/shortcut.rs b/rust/crates/debug/src/shortcut.rs similarity index 93% rename from rust/crates/core/src/debug/shortcut.rs rename to rust/crates/debug/src/shortcut.rs index 459b0c13..b563ab92 100644 --- a/rust/crates/core/src/debug/shortcut.rs +++ b/rust/crates/debug/src/shortcut.rs @@ -1,11 +1,11 @@ //! Portable decision for a combined start-or-continue debugger command. -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::Deserialize; use serde_json::{json, Value}; /// Chooses one action without restarting a running session or overlapping a request. -pub(crate) fn start_or_continue(payload: Value) -> Result { +pub fn start_or_continue(payload: Value) -> Result { #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct Request { diff --git a/rust/crates/core/src/debug/transport_lifecycle.rs b/rust/crates/debug/src/transport_lifecycle.rs similarity index 99% rename from rust/crates/core/src/debug/transport_lifecycle.rs rename to rust/crates/debug/src/transport_lifecycle.rs index 0b029566..69068144 100644 --- a/rust/crates/core/src/debug/transport_lifecycle.rs +++ b/rust/crates/debug/src/transport_lifecycle.rs @@ -14,7 +14,7 @@ use std::sync::{Mutex, OnceLock}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; /// Endpoint discovery, such as a cold language-server start. const DEFAULT_DISCOVERY_MILLISECONDS: u64 = 10_000; @@ -724,7 +724,7 @@ fn validate_transport_id(transport_id: &str) -> Result<(), CoreError> { /// Returns the default deadlines and budgets, and the session `initialize` /// deadline that must outlast a connect, without creating any state. -pub(crate) fn transport_policy(_payload: Value) -> Result { +pub fn transport_policy(_payload: Value) -> Result { let limits = TransportLimits::default(); Ok(json!({ "limits": limits, @@ -734,7 +734,7 @@ pub(crate) fn transport_policy(_payload: Value) -> Result { /// Applies one event to a transport's lifecycle and returns the effects to run. /// Only `start` creates a transport; a late callback for a released one is stale. -pub(crate) fn handle_transport_event(payload: Value) -> Result { +pub fn handle_transport_event(payload: Value) -> Result { let request: TransportEventRequest = parse(payload)?; validate_transport_id(&request.transport_id)?; let mut transports = transports_lock()?; @@ -761,7 +761,7 @@ pub(crate) fn handle_transport_event(payload: Value) -> Result } /// Forgets a transport once the host has stopped it and dropped its handles. -pub(crate) fn release_transport(payload: Value) -> Result { +pub fn release_transport(payload: Value) -> Result { let request: TransportRequest = parse(payload)?; validate_transport_id(&request.transport_id)?; let released = transports_lock()?.remove(&request.transport_id).is_some(); diff --git a/rust/crates/core/src/debug/types.rs b/rust/crates/debug/src/types.rs similarity index 74% rename from rust/crates/core/src/debug/types.rs rename to rust/crates/debug/src/types.rs index 28bf4ba5..cf18307f 100644 --- a/rust/crates/core/src/debug/types.rs +++ b/rust/crates/debug/src/types.rs @@ -22,25 +22,25 @@ pub enum DebugSessionState { #[serde(rename_all = "camelCase")] /// Creates a protocol session without opening a socket or starting a process. pub struct CreateSessionRequest { - pub session_id: String, - pub adapter_id: String, - pub root_path: String, + pub(crate) session_id: String, + pub(crate) adapter_id: String, + pub(crate) root_path: String, /// Whether the native host can launch adapter-requested processes in a terminal. #[serde(default)] - pub supports_run_in_terminal_request: bool, + pub(crate) supports_run_in_terminal_request: bool, /// Whether the native host can open a child debug session for an adapter /// `startDebugging` reverse request (for example Node worker or child processes). #[serde(default)] - pub supports_start_debugging_request: bool, + pub(crate) supports_start_debugging_request: bool, /// Standard DAP capabilities a plugin declared it disables (`capabilities.disabled`). #[serde(default)] - pub disabled_capabilities: Vec, + pub(crate) disabled_capabilities: Vec, /// The session whose adapter asked for this one through `startDebugging`. #[serde(default)] - pub parent_session_id: Option, + pub(crate) parent_session_id: Option, /// What the plugin declared for its adapter, passed through by the host. #[serde(flatten)] - pub declared: DeclaredAdapterBehavior, + pub(crate) declared: DeclaredAdapterBehavior, } /// The `features` and `defaults` of a `debugAdapters` declaration. The @@ -49,9 +49,9 @@ pub struct CreateSessionRequest { #[serde(rename_all = "camelCase")] pub struct DeclaredAdapterBehavior { #[serde(default, skip_serializing_if = "Option::is_none")] - pub features: Option, + pub(crate) features: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub defaults: Option, + pub(crate) defaults: Option, } /// Adapter-specific DAP extensions the adapter declares it implements. @@ -59,9 +59,9 @@ pub struct DeclaredAdapterBehavior { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct DebugAdapterFeatures { #[serde(default, skip_serializing_if = "Option::is_none")] - pub hot_code_replace: Option, + pub(crate) hot_code_replace: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub step_filters: Option, + pub(crate) step_filters: Option, } /// A custom request that applies changed code to the running debuggee. @@ -69,18 +69,18 @@ pub struct DebugAdapterFeatures { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct HotCodeReplaceFeature { /// DAP command the engine sends for a `redefineClasses` inspection. - pub request: String, + pub(crate) request: String, /// DAP event whose `message` the Debug Console shows as progress. #[serde(default, skip_serializing_if = "Option::is_none")] - pub event: Option, + pub(crate) event: Option, /// Response body field listing the replaced units; none are reported /// when absent. #[serde(default, skip_serializing_if = "Option::is_none")] - pub changed_field: Option, + pub(crate) changed_field: Option, /// Response body field with a failure message the adapter reports in a /// successful response. #[serde(default, skip_serializing_if = "Option::is_none")] - pub error_field: Option, + pub(crate) error_field: Option, } /// The launch argument through which the adapter receives stepping filters, @@ -88,7 +88,7 @@ pub struct HotCodeReplaceFeature { #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct StepFiltersFeature { - pub argument: String, + pub(crate) argument: String, } /// Values the adapter starts with when the user configured none. @@ -96,7 +96,7 @@ pub struct StepFiltersFeature { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct DebugAdapterDefaults { #[serde(default, skip_serializing_if = "Option::is_none")] - pub step_filters: Option, + pub(crate) step_filters: Option, } /// Declared default stepping filters; the same fields as @@ -105,15 +105,15 @@ pub struct DebugAdapterDefaults { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct DeclaredStepFilters { #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub class_name_filters: Vec, + pub(crate) class_name_filters: Vec, #[serde(default)] - pub skip_synthetics: bool, + pub(crate) skip_synthetics: bool, #[serde(default)] - pub skip_static_initializers: bool, + pub(crate) skip_static_initializers: bool, #[serde(default)] - pub skip_constructors: bool, + pub(crate) skip_constructors: bool, #[serde(default)] - pub hide_filtered_stack_frames: bool, + pub(crate) hide_filtered_stack_frames: bool, } /// Longest DAP command, event, field, or argument name an adapter declares. @@ -206,19 +206,19 @@ impl From<&DeclaredStepFilters> for DebugSteppingFilters { #[serde(rename_all = "camelCase")] /// Identifies one existing debug session. pub struct SessionRequest { - pub session_id: String, + pub(crate) session_id: String, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] /// Identifies one session for `debug.disconnect` and carries the user's debuggee policy. pub struct DisconnectRequest { - pub session_id: String, + pub(crate) session_id: String, /// Optional user choice for a launch session's local debuggee. When absent, launch /// terminates it. An attach session never terminates the external process, even if /// this is `true`, so a remote detach cannot kill a process the IDE does not own. #[serde(default)] - pub terminate_debuggee: Option, + pub(crate) terminate_debuggee: Option, } #[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq, Serialize)] @@ -242,13 +242,13 @@ impl DebugRequestKind { #[serde(rename_all = "camelCase")] /// Provider-specific launch arguments wrapped in a language-neutral contract. pub struct DebugLaunchConfiguration { - pub name: String, - pub request: DebugRequestKind, + pub(crate) name: String, + pub(crate) request: DebugRequestKind, #[serde(default)] - pub arguments: serde_json::Map, + pub(crate) arguments: serde_json::Map, /// Optional portable stepping policy projected into adapter-specific launch arguments. #[serde(default)] - pub stepping_filters: Option, + pub(crate) stepping_filters: Option, } #[derive(Debug, Clone, Deserialize, Eq, PartialEq, Serialize)] @@ -257,19 +257,19 @@ pub struct DebugLaunchConfiguration { pub struct DebugSteppingFilters { /// Adapter-neutral class-name patterns; `$Libraries` matches frames outside the workspace. #[serde(default)] - pub class_name_filters: Vec, + pub(crate) class_name_filters: Vec, /// Whether compiler-generated methods should be skipped. #[serde(default)] - pub skip_synthetics: bool, + pub(crate) skip_synthetics: bool, /// Whether class static initializers should be skipped. #[serde(default)] - pub skip_static_initializers: bool, + pub(crate) skip_static_initializers: bool, /// Whether constructors should be skipped. #[serde(default)] - pub skip_constructors: bool, + pub(crate) skip_constructors: bool, /// Whether native clients should collapse stack frames matched by the class filters. #[serde(default)] - pub hide_filtered_stack_frames: bool, + pub(crate) hide_filtered_stack_frames: bool, } impl DebugSteppingFilters { @@ -296,36 +296,36 @@ impl Default for DebugSteppingFilters { /// Returns an adapter's defaults or normalizes a native client's stepping policy. pub struct DebugSteppingFiltersRequest { /// Stable adapter identifier, such as `lldb`. - pub adapter_id: String, + pub(crate) adapter_id: String, /// Optional client override; omission requests the adapter defaults. #[serde(default)] - pub filters: Option, + pub(crate) filters: Option, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] /// Queues launch or attach, waiting for initialization when necessary. pub struct LaunchRequest { - pub session_id: String, - pub operation_id: String, - pub configuration: DebugLaunchConfiguration, + pub(crate) session_id: String, + pub(crate) operation_id: String, + pub(crate) configuration: DebugLaunchConfiguration, } #[derive(Debug, Clone, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] /// One requested source breakpoint using one-based DAP coordinates. pub struct SourceBreakpoint { - pub line: i64, + pub(crate) line: i64, #[serde(default)] - pub column: Option, + pub(crate) column: Option, #[serde(default = "default_true")] - pub enabled: bool, + pub(crate) enabled: bool, #[serde(default)] - pub condition: Option, + pub(crate) condition: Option, #[serde(default)] - pub hit_condition: Option, + pub(crate) hit_condition: Option, #[serde(default)] - pub log_message: Option, + pub(crate) log_message: Option, } fn default_true() -> bool { @@ -336,52 +336,52 @@ fn default_true() -> bool { #[serde(rename_all = "camelCase")] /// Replaces the complete breakpoint set for one absolute source path. pub struct SetBreakpointsRequest { - pub session_id: String, - pub source_path: String, + pub(crate) session_id: String, + pub(crate) source_path: String, #[serde(default)] - pub breakpoints: Vec, + pub(crate) breakpoints: Vec, } #[derive(Debug, Clone, Deserialize, Eq, PartialEq)] #[serde(rename_all = "camelCase")] /// One adapter-defined exception filter and its optional exception condition. pub struct ExceptionBreakpoint { - pub filter: String, + pub(crate) filter: String, #[serde(default = "default_true")] - pub enabled: bool, + pub(crate) enabled: bool, #[serde(default)] - pub condition: Option, + pub(crate) condition: Option, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] /// Replaces the complete exception breakpoint selection for one session. pub struct SetExceptionBreakpointsRequest { - pub session_id: String, + pub(crate) session_id: String, #[serde(default)] - pub breakpoints: Vec, + pub(crate) breakpoints: Vec, } #[derive(Debug, Clone, Deserialize, Eq, PartialEq)] #[serde(rename_all = "camelCase")] /// One named function or method breakpoint understood by the active adapter. pub struct FunctionBreakpoint { - pub name: String, + pub(crate) name: String, #[serde(default = "default_true")] - pub enabled: bool, + pub(crate) enabled: bool, #[serde(default)] - pub condition: Option, + pub(crate) condition: Option, #[serde(default)] - pub hit_condition: Option, + pub(crate) hit_condition: Option, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] /// Replaces the complete function breakpoint set for one session. pub struct SetFunctionBreakpointsRequest { - pub session_id: String, + pub(crate) session_id: String, #[serde(default)] - pub breakpoints: Vec, + pub(crate) breakpoints: Vec, } #[derive(Debug, Clone, Deserialize, Eq, PartialEq)] @@ -391,83 +391,83 @@ pub struct SetFunctionBreakpointsRequest { /// The reference is the opaque address string the adapter reported (a 64-bit address can /// exceed the JavaScript safe integer range), so the Core never parses or adds to it. pub struct InstructionBreakpoint { - pub instruction_reference: String, + pub(crate) instruction_reference: String, /// Byte offset from the reference, for adapters that resolve symbol-relative locations. #[serde(default)] - pub offset: Option, + pub(crate) offset: Option, #[serde(default = "default_true")] - pub enabled: bool, + pub(crate) enabled: bool, #[serde(default)] - pub condition: Option, + pub(crate) condition: Option, #[serde(default)] - pub hit_condition: Option, + pub(crate) hit_condition: Option, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] /// Replaces the complete instruction breakpoint set for one session. pub struct SetInstructionBreakpointsRequest { - pub session_id: String, + pub(crate) session_id: String, #[serde(default)] - pub breakpoints: Vec, + pub(crate) breakpoints: Vec, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] /// Resolves an adapter-owned data identifier for one visible variable or expression. pub struct DataBreakpointInfoRequest { - pub session_id: String, - pub operation_id: String, - pub name: String, + pub(crate) session_id: String, + pub(crate) operation_id: String, + pub(crate) name: String, #[serde(default)] - pub variables_reference: Option, + pub(crate) variables_reference: Option, #[serde(default)] - pub frame_id: Option, + pub(crate) frame_id: Option, } #[derive(Debug, Clone, Deserialize, Eq, PartialEq)] #[serde(rename_all = "camelCase")] /// One adapter-resolved field or data breakpoint. pub struct DataBreakpoint { - pub data_id: String, + pub(crate) data_id: String, #[serde(default)] - pub label: Option, + pub(crate) label: Option, #[serde(default = "default_true")] - pub enabled: bool, + pub(crate) enabled: bool, #[serde(default)] - pub access_type: Option, + pub(crate) access_type: Option, #[serde(default)] - pub condition: Option, + pub(crate) condition: Option, #[serde(default)] - pub hit_condition: Option, + pub(crate) hit_condition: Option, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] /// Replaces the complete adapter-resolved data breakpoint set for one session. pub struct SetDataBreakpointsRequest { - pub session_id: String, + pub(crate) session_id: String, #[serde(default)] - pub breakpoints: Vec, + pub(crate) breakpoints: Vec, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] /// Mutes or unmutes every breakpoint stored for one session. pub struct SetBreakpointsMutedRequest { - pub session_id: String, - pub muted: bool, + pub(crate) session_id: String, + pub(crate) muted: bool, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] /// Replaces one visible variable value in its adapter-owned parent container. pub struct SetVariableRequest { - pub session_id: String, - pub operation_id: String, - pub variables_reference: i64, - pub name: String, - pub value: String, + pub(crate) session_id: String, + pub(crate) operation_id: String, + pub(crate) variables_reference: i64, + pub(crate) name: String, + pub(crate) value: String, } #[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq)] @@ -482,9 +482,9 @@ pub enum DebugCancellationReason { #[serde(rename_all = "camelCase")] /// Ends one pending operation and optionally forwards DAP cancellation. pub struct CancelOperationRequest { - pub session_id: String, - pub operation_id: String, - pub reason: DebugCancellationReason, + pub(crate) session_id: String, + pub(crate) operation_id: String, + pub(crate) reason: DebugCancellationReason, } #[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq)] @@ -522,18 +522,18 @@ impl DebugExecutionCommand { #[serde(rename_all = "camelCase")] /// Queues a control request and correlates its eventual result to an operation. pub struct ExecuteRequest { - pub session_id: String, - pub operation_id: String, - pub command: DebugExecutionCommand, + pub(crate) session_id: String, + pub(crate) operation_id: String, + pub(crate) command: DebugExecutionCommand, #[serde(default)] - pub thread_id: Option, + pub(crate) thread_id: Option, #[serde(default)] - pub target_id: Option, + pub(crate) target_id: Option, #[serde(default)] - pub single_thread: bool, + pub(crate) single_thread: bool, /// Step size for `next`, `stepIn`, `stepOut` and `stepBack`; the adapter default when absent. #[serde(default)] - pub granularity: Option, + pub(crate) granularity: Option, } #[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq)] @@ -617,57 +617,57 @@ impl DebugVariableFilter { #[serde(rename_all = "camelCase")] /// Parameters for one thread, frame, variable, or expression inspection. pub struct InspectRequest { - pub session_id: String, - pub operation_id: String, - pub kind: DebugInspectKind, + pub(crate) session_id: String, + pub(crate) operation_id: String, + pub(crate) kind: DebugInspectKind, #[serde(default)] - pub thread_id: Option, + pub(crate) thread_id: Option, #[serde(default)] - pub frame_id: Option, + pub(crate) frame_id: Option, #[serde(default)] - pub variables_reference: Option, + pub(crate) variables_reference: Option, #[serde(default)] - pub variable_filter: Option, + pub(crate) variable_filter: Option, /// Zero-based child offset for a paged variables request. #[serde(default)] - pub start: Option, + pub(crate) start: Option, /// Maximum child count for a paged variables request. #[serde(default)] - pub count: Option, + pub(crate) count: Option, #[serde(default)] - pub expression: Option, + pub(crate) expression: Option, /// DAP evaluate context: `watch` (default), `hover`, `repl`, `clipboard` or `variables`. #[serde(default)] - pub context: Option, + pub(crate) context: Option, #[serde(default)] - pub source_path: Option, + pub(crate) source_path: Option, #[serde(default)] - pub line: Option, + pub(crate) line: Option, #[serde(default)] - pub column: Option, + pub(crate) column: Option, /// Opaque address or memory reference for `disassemble` and `readMemory`. #[serde(default)] - pub memory_reference: Option, + pub(crate) memory_reference: Option, /// Byte offset from `memoryReference` (may be negative for `disassemble`). #[serde(default)] - pub offset: Option, + pub(crate) offset: Option, /// Instruction offset from the resolved start; negative asks for instructions before it. #[serde(default)] - pub instruction_offset: Option, + pub(crate) instruction_offset: Option, /// Number of instructions `disassemble` returns. #[serde(default)] - pub instruction_count: Option, + pub(crate) instruction_count: Option, /// `disassemble` asks the adapter to resolve symbol names; defaults to true. #[serde(default)] - pub resolve_symbols: Option, + pub(crate) resolve_symbols: Option, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] /// Base64-encoded bytes received from a platform-owned DAP transport. pub struct ReceiveRequest { - pub session_id: String, - pub data_base64: String, + pub(crate) session_id: String, + pub(crate) data_base64: String, } #[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq, Serialize)] @@ -682,23 +682,23 @@ pub enum DebugRunInTerminalKind { #[serde(rename_all = "camelCase")] /// One deterministic environment mutation requested for a debuggee process. pub struct DebugRunInTerminalEnvironmentVariable { - pub name: String, + pub(crate) name: String, /// A missing value removes the inherited variable from the child environment. - pub value: Option, + pub(crate) value: Option, } #[derive(Debug, Clone, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] /// Normalized platform request for launching a debuggee inside a terminal. pub struct DebugRunInTerminalRequest { - pub kind: DebugRunInTerminalKind, - pub title: Option, - pub cwd: String, + pub(crate) kind: DebugRunInTerminalKind, + pub(crate) title: Option, + pub(crate) cwd: String, /// The executable is the first item; remaining items are passed without a shell. - pub args: Vec, - pub environment: Vec, + pub(crate) args: Vec, + pub(crate) environment: Vec, /// True only when the adapter intentionally supplied shell-language arguments. - pub args_can_be_interpreted_by_shell: bool, + pub(crate) args_can_be_interpreted_by_shell: bool, } #[derive(Debug, Clone, PartialEq, Serialize)] @@ -706,97 +706,97 @@ pub struct DebugRunInTerminalRequest { /// Normalized adapter request to start a child debug session. pub struct DebugStartDebuggingRequest { /// Whether the child session launches or attaches to its debuggee. - pub request: DebugRequestKind, + pub(crate) request: DebugRequestKind, /// Adapter-provided child configuration with the `request` key removed. /// It is passed through verbatim so adapter-specific fields survive. - pub configuration: serde_json::Map, + pub(crate) configuration: serde_json::Map, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] /// Completes one pending `startDebugging` reverse request from the native host. pub struct DebugStartDebuggingResponseRequest { - pub session_id: String, - pub request_id: String, - pub success: bool, + pub(crate) session_id: String, + pub(crate) request_id: String, + pub(crate) success: bool, #[serde(default)] - pub message: Option, + pub(crate) message: Option, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] /// Completes one pending `runInTerminal' reverse request from the native host. pub struct DebugRunInTerminalResponseRequest { - pub session_id: String, - pub request_id: String, - pub success: bool, + pub(crate) session_id: String, + pub(crate) request_id: String, + pub(crate) success: bool, #[serde(default)] - pub process_id: Option, + pub(crate) process_id: Option, #[serde(default)] - pub shell_process_id: Option, + pub(crate) shell_process_id: Option, #[serde(default)] - pub message: Option, + pub(crate) message: Option, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// Effects produced by one deterministic session reduction. pub struct DebugSessionUpdate { - pub session_id: String, - pub state: DebugSessionState, + pub(crate) session_id: String, + pub(crate) state: DebugSessionState, /// Complete framed byte sequences, base64 encoded in send order. - pub outbound_frames: Vec, - pub events: Vec, + pub(crate) outbound_frames: Vec, + pub(crate) events: Vec, } #[derive(Debug, Clone, Default, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] /// Adapter abilities negotiated by the DAP initialize response. pub struct DebugCapabilities { - pub supports_configuration_done: bool, - pub supports_conditional_breakpoints: bool, - pub supports_hit_conditional_breakpoints: bool, - pub supports_log_points: bool, - pub supports_function_breakpoints: bool, - pub supports_data_breakpoints: bool, - pub supports_exception_options: bool, - pub supports_exception_filter_options: bool, - pub supports_set_variable: bool, - pub supports_cancel_request: bool, - pub supports_single_thread_execution_requests: bool, - pub supports_restart_request: bool, - pub supports_terminate_request: bool, - pub supports_step_back: bool, - pub supports_exception_info_request: bool, - pub supports_step_in_targets_request: bool, - pub supports_goto_targets_request: bool, - pub supports_completions_request: bool, - pub supports_disassemble_request: bool, - pub supports_stepping_granularity: bool, - pub supports_instruction_breakpoints: bool, - pub supports_read_memory_request: bool, - pub exception_breakpoint_filters: Vec, + pub(crate) supports_configuration_done: bool, + pub(crate) supports_conditional_breakpoints: bool, + pub(crate) supports_hit_conditional_breakpoints: bool, + pub(crate) supports_log_points: bool, + pub(crate) supports_function_breakpoints: bool, + pub(crate) supports_data_breakpoints: bool, + pub(crate) supports_exception_options: bool, + pub(crate) supports_exception_filter_options: bool, + pub(crate) supports_set_variable: bool, + pub(crate) supports_cancel_request: bool, + pub(crate) supports_single_thread_execution_requests: bool, + pub(crate) supports_restart_request: bool, + pub(crate) supports_terminate_request: bool, + pub(crate) supports_step_back: bool, + pub(crate) supports_exception_info_request: bool, + pub(crate) supports_step_in_targets_request: bool, + pub(crate) supports_goto_targets_request: bool, + pub(crate) supports_completions_request: bool, + pub(crate) supports_disassemble_request: bool, + pub(crate) supports_stepping_granularity: bool, + pub(crate) supports_instruction_breakpoints: bool, + pub(crate) supports_read_memory_request: bool, + pub(crate) exception_breakpoint_filters: Vec, } #[derive(Debug, Clone, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] /// One adapter-defined exception category presented by native clients. pub struct DebugExceptionBreakpointFilter { - pub filter: String, - pub label: String, - pub description: Option, - pub default: bool, - pub supports_condition: bool, - pub condition_description: Option, + pub(crate) filter: String, + pub(crate) label: String, + pub(crate) description: Option, + pub(crate) default: bool, + pub(crate) supports_condition: bool, + pub(crate) condition_description: Option, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// Ordered event projected to platform feature models. pub struct DebugEvent { - pub sequence: u64, + pub(crate) sequence: u64, #[serde(flatten)] - pub body: DebugEventBody, + pub(crate) body: DebugEventBody, } #[derive(Debug, Clone, Serialize)] @@ -941,13 +941,13 @@ pub enum DebugOperationResult { /// One machine instruction. `address` and `instruction_bytes` stay adapter-formatted /// strings, so 64-bit addresses are never rounded by a JSON number. pub struct DebugDisassembledInstruction { - pub address: String, - pub instruction_bytes: Option, - pub instruction: String, + pub(crate) address: String, + pub(crate) instruction_bytes: Option, + pub(crate) instruction: String, /// Symbol the instruction belongs to, reported only on the first instruction of it. - pub symbol: Option, - pub source_path: Option, - pub line: Option, + pub(crate) symbol: Option, + pub(crate) source_path: Option, + pub(crate) line: Option, } #[derive(Debug, Clone, Serialize)] @@ -955,142 +955,142 @@ pub struct DebugDisassembledInstruction { /// One Debug Console proposal. `start`/`length` select the typed text the /// proposal replaces, in UTF-16 units from 0; absent means the word at the caret. pub struct DebugCompletionItem { - pub label: String, + pub(crate) label: String, /// Text inserted in place of the replaced range; the label when the adapter sends none. - pub text: String, - pub detail: Option, + pub(crate) text: String, + pub(crate) detail: Option, /// The adapter's DAP `CompletionItemType` (`method`, `variable`, ...). - pub kind: Option, - pub start: Option, - pub length: Option, + pub(crate) kind: Option, + pub(crate) start: Option, + pub(crate) length: Option, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// One adapter-selected call expression eligible for targeted step-in. pub struct DebugStepInTarget { - pub id: i64, - pub label: String, - pub line: Option, - pub column: Option, - pub end_line: Option, - pub end_column: Option, + pub(crate) id: i64, + pub(crate) label: String, + pub(crate) line: Option, + pub(crate) column: Option, + pub(crate) end_line: Option, + pub(crate) end_column: Option, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// One executable location returned for a run-to-cursor request. pub struct DebugGotoTarget { - pub id: i64, - pub label: String, - pub line: i64, - pub column: Option, - pub end_line: Option, - pub end_column: Option, - pub instruction_pointer_reference: Option, + pub(crate) id: i64, + pub(crate) label: String, + pub(crate) line: i64, + pub(crate) column: Option, + pub(crate) end_line: Option, + pub(crate) end_column: Option, + pub(crate) instruction_pointer_reference: Option, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// Adapter-verified breakpoint and its optional resolved source location. pub struct DebugBreakpoint { - pub id: i64, - pub verified: bool, - pub message: Option, + pub(crate) id: i64, + pub(crate) verified: bool, + pub(crate) message: Option, /// Requested function name for a function-breakpoint verification result. - pub function_name: Option, + pub(crate) function_name: Option, /// Adapter-owned identity for a data-breakpoint verification result. - pub data_id: Option, + pub(crate) data_id: Option, /// Requested address of an instruction-breakpoint verification result. #[serde(skip_serializing_if = "Option::is_none")] - pub instruction_reference: Option, - pub source_path: Option, + pub(crate) instruction_reference: Option, + pub(crate) source_path: Option, /// Original client line, retained when an adapter resolves a different location. #[serde(skip_serializing_if = "Option::is_none")] - pub requested_line: Option, - pub line: Option, - pub column: Option, + pub(crate) requested_line: Option, + pub(crate) line: Option, + pub(crate) column: Option, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// One debuggee thread. pub struct DebugThread { - pub id: i64, - pub name: String, + pub(crate) id: i64, + pub(crate) name: String, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// One stack frame using one-based DAP source coordinates. pub struct DebugStackFrame { - pub id: i64, - pub name: String, - pub source_path: Option, + pub(crate) id: i64, + pub(crate) name: String, + pub(crate) source_path: Option, /// The adapter's `sourceReference` when it serves this frame's source itself, such as /// the disassembly of a function without debug info. `source_path` then names no file /// on disk, even when it looks like an absolute path. #[serde(skip_serializing_if = "Option::is_none")] - pub source_reference: Option, + pub(crate) source_reference: Option, /// Opaque address of the frame's current instruction. A frame without debug info has no /// file, so this is what the disassembly view anchors on; `line` is 0 for such a frame. #[serde(skip_serializing_if = "Option::is_none")] - pub instruction_pointer_reference: Option, - pub line: i64, - pub column: i64, + pub(crate) instruction_pointer_reference: Option, + pub(crate) line: i64, + pub(crate) column: i64, /// True when the active portable stepping policy matches this frame. - pub is_filtered: bool, + pub(crate) is_filtered: bool, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// One variable scope associated with a selected stack frame. pub struct DebugScope { - pub name: String, - pub variables_reference: i64, - pub expensive: bool, + pub(crate) name: String, + pub(crate) variables_reference: i64, + pub(crate) expensive: bool, /// Adapter-reported count of named children, or zero when unavailable. - pub named_variables: i64, + pub(crate) named_variables: i64, /// Adapter-reported count of indexed children, or zero when unavailable. - pub indexed_variables: i64, + pub(crate) indexed_variables: i64, /// DAP `presentationHint` (`arguments`, `locals`, `registers`), or none. #[serde(skip_serializing_if = "Option::is_none")] - pub presentation_hint: Option, + pub(crate) presentation_hint: Option, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// A debugger variable or evaluated expression result. pub struct DebugVariable { - pub name: String, - pub value: String, + pub(crate) name: String, + pub(crate) value: String, pub r#type: Option, - pub evaluate_name: Option, - pub variables_reference: i64, + pub(crate) evaluate_name: Option, + pub(crate) variables_reference: i64, /// Adapter-reported count of named children, or zero when unavailable. - pub named_variables: i64, + pub(crate) named_variables: i64, /// Adapter-reported count of indexed children, or zero when unavailable. - pub indexed_variables: i64, + pub(crate) indexed_variables: i64, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// Exception metadata returned for the thread that caused an exception pause. pub struct DebugExceptionInfo { - pub exception_id: String, - pub description: Option, - pub break_mode: String, - pub details: Option, + pub(crate) exception_id: String, + pub(crate) description: Option, + pub(crate) break_mode: String, + pub(crate) details: Option, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// Optional adapter-provided exception detail tree. pub struct DebugExceptionDetails { - pub message: Option, - pub type_name: Option, - pub full_type_name: Option, - pub evaluate_name: Option, - pub stack_trace: Option, - pub inner_exceptions: Vec, + pub(crate) message: Option, + pub(crate) type_name: Option, + pub(crate) full_type_name: Option, + pub(crate) evaluate_name: Option, + pub(crate) stack_trace: Option, + pub(crate) inner_exceptions: Vec, } diff --git a/rust/crates/core/src/debug/variable_paging.rs b/rust/crates/debug/src/variable_paging.rs similarity index 97% rename from rust/crates/core/src/debug/variable_paging.rs rename to rust/crates/debug/src/variable_paging.rs index a97da541..c9273514 100644 --- a/rust/crates/core/src/debug/variable_paging.rs +++ b/rust/crates/debug/src/variable_paging.rs @@ -10,7 +10,7 @@ //! The state crosses the boundary as JSON on every page. It holds only cursors //! and page fingerprints, never variable values beyond the page being reduced. -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -57,7 +57,7 @@ pub(crate) struct VariablePageRequest { /// Paging cursor of one variable container, owned by Core and echoed by the host. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct VariablePageState { +pub struct VariablePageState { pub(crate) variables_reference: i64, pub(crate) page_size: u32, pub(crate) segments: Vec, @@ -72,7 +72,7 @@ pub(crate) struct VariablePageState { /// Input of `debug.planVariablePage`. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanVariablePageRequest { +pub struct PlanVariablePageRequest { pub(crate) variables_reference: i64, #[serde(default)] pub(crate) named_variables: i64, @@ -99,7 +99,7 @@ pub(crate) struct VariablePageItem { /// Input of `debug.reduceVariablePage`. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct ReduceVariablePageRequest { +pub struct ReduceVariablePageRequest { pub(crate) state: VariablePageState, /// The request this page answers; a reply to an older cursor is ignored. pub(crate) requested: VariablePageRequest, @@ -111,7 +111,7 @@ pub(crate) struct ReduceVariablePageRequest { /// Output of `debug.reduceVariablePage`. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] -pub(crate) struct ReduceVariablePageResult { +pub struct ReduceVariablePageResult { /// False when the host must drop the page: it answers an older cursor or /// repeats a page already shown. pub(crate) accept: bool, @@ -123,7 +123,7 @@ pub(crate) struct ReduceVariablePageResult { /// Named children page before indexed ones. Negative counts mean "unknown" in /// practice and are treated as zero; with neither count the container pages /// unfiltered until a short or empty page. -pub(crate) fn plan_variable_page( +pub fn plan_variable_page( request: PlanVariablePageRequest, ) -> Result { validate_page_size(request.page_size)?; @@ -168,7 +168,7 @@ pub(crate) fn plan_variable_page( /// A container stops paging when the adapter returns more than was asked for, /// repeats a page, returns only children the host already shows, reaches the /// reported count, or (without a count) returns a short or empty page. -pub(crate) fn reduce_variable_page( +pub fn reduce_variable_page( request: ReduceVariablePageRequest, ) -> Result { let ReduceVariablePageRequest { @@ -250,7 +250,7 @@ fn next_request(state: &VariablePageState) -> Option { .try_fold(0u64, |sum, remaining| { remaining.map(|value| sum.saturating_add(value)) }); - (count > 0).then(|| VariablePageRequest { + (count > 0).then_some(VariablePageRequest { filter: segment.filter, start: segment.next_start, count: count as u32, @@ -288,7 +288,7 @@ mod tests { fn fixture() -> Value { serde_json::from_str(include_str!( - "../../../../fixtures/debug/variable-page-plan-v1.json" + "../../../fixtures/debug/variable-page-plan-v1.json" )) .unwrap() } diff --git a/rust/crates/diagnostics/Cargo.toml b/rust/crates/diagnostics/Cargo.toml new file mode 100644 index 00000000..65e24ef0 --- /dev/null +++ b/rust/crates/diagnostics/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "lithe-diagnostics" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Structured log catalog, channels, records, queries and the bounded Core event queue" + +[features] +# Exposes the per-thread event capture and the declarative-operation record to +# other crates' tests. Only dev-dependencies enable it. +test-support = [] + +[dependencies] +lithe-protocol = { path = "../protocol" } +chrono = { version = "0.4", default-features = false, features = ["std", "clock"] } +regex = "1.11" +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0", features = ["preserve_order"] } +sha2 = "0.10" diff --git a/rust/crates/core/src/diagnostics/catalog.rs b/rust/crates/diagnostics/src/catalog.rs similarity index 100% rename from rust/crates/core/src/diagnostics/catalog.rs rename to rust/crates/diagnostics/src/catalog.rs diff --git a/rust/crates/core/src/diagnostics/catalog_generated.rs b/rust/crates/diagnostics/src/catalog_generated.rs similarity index 97% rename from rust/crates/core/src/diagnostics/catalog_generated.rs rename to rust/crates/diagnostics/src/catalog_generated.rs index df34eabf..fa69a86c 100644 --- a/rust/crates/core/src/diagnostics/catalog_generated.rs +++ b/rust/crates/diagnostics/src/catalog_generated.rs @@ -6,109 +6,6 @@ use super::{EventMetadata, FieldMetadata, FieldType, LogLevel, SpanPhase}; /// Sorted, immutable metadata for every expanded catalog event. #[rustfmt::skip] pub static EVENTS: &[EventMetadata] = &[ - EventMetadata { - id: "agent.history.prepare_failed", - channel: "agent", - level: LogLevel::Warn, - message: "Codex history context reporting is unavailable", - fields: &[ - FieldMetadata { name: "error", field_type: FieldType::Error, required: true, description: "Fixed summary and stable failure category from agent.history.prepare_failed; never error display text", reason: None }, - FieldMetadata { name: "stage", field_type: FieldType::Token, required: true, description: "Controlled agent.history.prepare_failed operation stage; excludes paths and peer input", reason: None }, - ], - span: None, - rate: 60, - owner: "lithe-core", - }, - EventMetadata { - id: "agent.host.operation_failed", - channel: "agent", - level: LogLevel::Warn, - message: "Agent host operation failed", - fields: &[ - FieldMetadata { name: "error", field_type: FieldType::Error, required: true, description: "Fixed summary and stable failure category from agent.host.operation_failed; never error display text", reason: None }, - FieldMetadata { name: "stage", field_type: FieldType::Token, required: true, description: "Controlled agent.host.operation_failed operation stage; excludes paths and peer input", reason: None }, - ], - span: None, - rate: 60, - owner: "lithe-core", - }, - EventMetadata { - id: "agent.images.cleanup_failed", - channel: "agent", - level: LogLevel::Warn, - message: "Expired pasted image cleanup failed", - fields: &[ - FieldMetadata { name: "count", field_type: FieldType::Int, required: true, description: "Number of expired image removals that failed", reason: None }, - FieldMetadata { name: "error", field_type: FieldType::Error, required: true, description: "Underlying failure at this operation boundary, preserving stable domain and code", reason: None }, - ], - span: None, - rate: 60, - owner: "Lithe", - }, - EventMetadata { - id: "agent.observation.start_failed", - channel: "agent", - level: LogLevel::Warn, - message: "Agent model-call observation is unavailable", - fields: &[ - FieldMetadata { name: "error", field_type: FieldType::Error, required: true, description: "Fixed summary and stable failure category from agent.observation.start_failed; never error display text", reason: None }, - FieldMetadata { name: "stage", field_type: FieldType::Token, required: true, description: "Controlled agent.observation.start_failed operation stage; excludes paths and peer input", reason: None }, - ], - span: None, - rate: 60, - owner: "lithe-core", - }, - EventMetadata { - id: "agent.retry.cleanup_failed", - channel: "agent", - level: LogLevel::Warn, - message: "Codex retry launcher cleanup failed", - fields: &[ - FieldMetadata { name: "error", field_type: FieldType::Error, required: true, description: "Fixed summary and stable failure category from agent.retry.cleanup_failed; never error display text", reason: None }, - FieldMetadata { name: "stage", field_type: FieldType::Token, required: true, description: "Controlled agent.retry.cleanup_failed operation stage; excludes paths and peer input", reason: None }, - ], - span: None, - rate: 60, - owner: "lithe-core", - }, - EventMetadata { - id: "agent.skills.cleanup_failed", - channel: "agent", - level: LogLevel::Warn, - message: "Agent skill staging cleanup failed", - fields: &[ - FieldMetadata { name: "error", field_type: FieldType::Error, required: true, description: "Fixed summary and stable failure category from agent.skills.cleanup_failed; never error display text", reason: None }, - FieldMetadata { name: "stage", field_type: FieldType::Token, required: true, description: "Controlled agent.skills.cleanup_failed operation stage; excludes paths and peer input", reason: None }, - ], - span: None, - rate: 60, - owner: "lithe-core", - }, - EventMetadata { - id: "agent.skills.discovery_failed", - channel: "agent", - level: LogLevel::Warn, - message: "Agent skill discovery failed", - fields: &[ - FieldMetadata { name: "error", field_type: FieldType::Error, required: true, description: "Underlying failure at this operation boundary, preserving stable domain and code", reason: None }, - FieldMetadata { name: "stage", field_type: FieldType::Token, required: true, description: "Skill discovery step that failed, listing or directories", reason: None }, - ], - span: None, - rate: 60, - owner: "Lithe", - }, - EventMetadata { - id: "agent.statistics.decode_failed", - channel: "agent", - level: LogLevel::Warn, - message: "Agent statistics used defaults", - fields: &[ - FieldMetadata { name: "error", field_type: FieldType::Error, required: true, description: "Underlying failure at this operation boundary, preserving stable domain and code", reason: None }, - ], - span: None, - rate: 60, - owner: "LitheAgentConversationModule", - }, EventMetadata { id: "app.action.performed", channel: "app", diff --git a/rust/crates/core/src/diagnostics/channel.rs b/rust/crates/diagnostics/src/channel.rs similarity index 100% rename from rust/crates/core/src/diagnostics/channel.rs rename to rust/crates/diagnostics/src/channel.rs diff --git a/rust/crates/core/src/diagnostics/collection.rs b/rust/crates/diagnostics/src/collection.rs similarity index 96% rename from rust/crates/core/src/diagnostics/collection.rs rename to rust/crates/diagnostics/src/collection.rs index 5d935f8b..10a8d1d3 100644 --- a/rust/crates/core/src/diagnostics/collection.rs +++ b/rust/crates/diagnostics/src/collection.rs @@ -241,12 +241,7 @@ mod tests { } #[test] - fn collection_dispatch_rejects_duplicate_inventories_with_a_distinct_code() { - let request = serde_json::json!({"version":1,"command":"diagnostics.planLogCollection","payload":{ - "files":[],"currentSession":"","session":"all"}}); - let response: serde_json::Value = - serde_json::from_str(&crate::runtime::execute_json(&request.to_string())).unwrap(); - assert_eq!(response["data"]["result"]["reads"], serde_json::json!([])); + fn collection_rejects_duplicate_inventories_with_a_distinct_code() { let file = serde_json::json!({"relativePath":"sessions/2026-11-03T00-00-00_abcdef12/app.log","sizeBytes":1,"modifiedAt":"2026-11-03T01:00:00Z"}); let input = serde_json::json!({"files":[file.clone(),file],"currentSession":"","session":"all"}); @@ -261,7 +256,7 @@ mod tests { #[test] fn shared_collection_fixture_keeps_active_channels_and_category_compatibility() { let fixture: serde_json::Value = serde_json::from_str(include_str!( - "../../../../fixtures/diagnostics/logging-v2.json" + "../../../fixtures/diagnostics/logging-v2.json" )) .unwrap(); for case in fixture["collection"].as_array().unwrap() { diff --git a/rust/crates/core/src/diagnostics/core_events.rs b/rust/crates/diagnostics/src/core_events.rs similarity index 79% rename from rust/crates/core/src/diagnostics/core_events.rs rename to rust/crates/diagnostics/src/core_events.rs index 14d051c4..43ad911c 100644 --- a/rust/crates/core/src/diagnostics/core_events.rs +++ b/rust/crates/diagnostics/src/core_events.rs @@ -113,7 +113,7 @@ pub fn record( } } let fact = prepare(event, fields, op)?; - #[cfg(test)] + #[cfg(any(test, feature = "test-support"))] let Some(fact) = capture::divert(fact) else { return Ok(()); }; @@ -154,7 +154,7 @@ fn wake() { /// `callback` must not unwind, must not call back into Core synchronously, and /// must be safe to run on any thread. `context` must stay valid until /// [`remove_waker`] returns for the same context or another install replaces it. -pub(crate) unsafe fn install_waker(callback: unsafe extern "C" fn(*mut c_void), context: usize) { +pub unsafe fn install_waker(callback: unsafe extern "C" fn(*mut c_void), context: usize) { *WAKER.lock().unwrap_or_else(|e| e.into_inner()) = Some(Waker { callback, context }); // A request raised while no writer was installed was never delivered. WAKE_REQUESTED.store(false, Ordering::Release); @@ -165,7 +165,7 @@ pub(crate) unsafe fn install_waker(callback: unsafe extern "C" fn(*mut c_void), /// Removes the callback only when `context` still owns it. Returns whether it /// was removed; either way no callback for `context` is running afterward. -pub(crate) fn remove_waker(context: usize) -> bool { +pub fn remove_waker(context: usize) -> bool { let mut waker = WAKER.lock().unwrap_or_else(|e| e.into_inner()); if waker.is_some_and(|current| current.context == context) { *waker = None; @@ -177,11 +177,11 @@ pub(crate) fn remove_waker(context: usize) -> bool { /// Reports whether `record` would keep a fact of this level and channel root. /// Callers check it before building fields so rejected facts cost one branch. -pub(crate) fn accepts(level: LogLevel, root: &str) -> bool { +pub fn accepts(level: LogLevel, root: &str) -> bool { if level != LogLevel::Debug { return true; } - #[cfg(test)] + #[cfg(any(test, feature = "test-support"))] if let Some(debug) = capture::debug_override() { return debug; } @@ -195,7 +195,7 @@ pub(crate) fn accepts(level: LogLevel, root: &str) -> bool { /// Applies the host's detailed-diagnostics state; `roots: None` enables every root. /// The default is disabled, so a host that never calls this receives no DEBUG facts. -pub(crate) fn configure(debug: bool, roots: Option>) { +pub fn configure(debug: bool, roots: Option>) { *DEBUG_ROOTS.write().unwrap_or_else(|e| e.into_inner()) = roots; DEBUG_ENABLED.store(debug, Ordering::Release); } @@ -204,7 +204,9 @@ fn channel_root(channel: &str) -> &str { channel.split('/').next().unwrap_or(channel) } -pub(super) fn prepare( +/// Validates one Core-owned fact against the catalog and normalizes its fields +/// without enqueueing it. [`record`] enqueues what this returns. +pub fn prepare( event: &str, fields: BTreeMap, op: Option, @@ -245,7 +247,7 @@ pub fn drain() -> DrainedCoreEvents { /// Records a known category-only cleanup fact; invalid declarations count as lost /// evidence instead of silently disappearing or falling back to a second writer. -pub(crate) fn cleanup_failed(event: &str, category: &str) { +pub fn cleanup_failed(event: &str, category: &str) { if record( event, if event == "app.workspace.scratch_cleanup_failed" { @@ -266,7 +268,7 @@ pub(crate) fn cleanup_failed(event: &str, category: &str) { } /// Records only workspace-relative traversal identity and a stable Core category. -pub(crate) fn directory_skipped(event: &str, path: &str, code: &crate::protocol::ErrorCode) { +pub fn directory_skipped(event: &str, path: &str, code: &lithe_protocol::ErrorCode) { let category = serde_json::to_value(code).expect("Core error category encodes"); if record( event, @@ -284,7 +286,8 @@ pub(crate) fn directory_skipped(event: &str, path: &str, code: &crate::protocol: } } -pub(crate) fn note_dropped() { +/// Counts one fact that could not be recorded, so the next drain reports it. +pub fn note_dropped() { { let mut queue = queue(); queue.dropped = queue.dropped.saturating_add(1); @@ -295,7 +298,7 @@ pub(crate) fn note_dropped() { /// Hands a category-only recovery failure to the host; callers own the decision /// to continue, so WARN describes degradation without exposing error payloads. -pub(crate) fn recovery_failed(event: &str, stage: &str, code: &str, domain: &str) { +pub fn recovery_failed(event: &str, stage: &str, code: &str, domain: &str) { let fields = BTreeMap::from([ ("stage".into(), json!(stage)), ("error".into(), json!("Core recovery failed")), @@ -309,14 +312,14 @@ pub(crate) fn recovery_failed(event: &str, stage: &str, code: &str, domain: &str /// Fixed-size cleanup summary: batch callers emit once rather than once per file. #[derive(Default)] -pub(crate) struct StorageCleanup { +pub struct StorageCleanup { count: i64, first: Option, } impl StorageCleanup { /// Missing staging files are normal after failed creation or concurrent eviction. - pub(crate) fn observe(&mut self, result: std::io::Result<()>) { + pub fn observe(&mut self, result: std::io::Result<()>) { match result { Ok(()) => {} // log-exempt: Expected absence: creation may have failed before a file existed. @@ -329,7 +332,7 @@ impl StorageCleanup { } /// Removes a directory only when empty; retained contents belong to the caller. - pub(crate) fn empty_directory(&mut self, path: &std::path::Path) { + pub fn empty_directory(&mut self, path: &std::path::Path) { match std::fs::read_dir(path) { Ok(mut entries) => match entries.next() { None => self.observe(std::fs::remove_dir(path)), @@ -341,7 +344,7 @@ impl StorageCleanup { } /// Records one WARN at the storage recovery boundary, preserving normal results. - pub(crate) fn finish(self, stage: &str) { + pub fn finish(self, stage: &str) { if let Some(kind) = self.first { if record( "app.storage.cleanup_failed", @@ -362,10 +365,20 @@ impl StorageCleanup { } } +/// Records a recovery failure whose cause is a +/// [`CoreError`](lithe_protocol::CoreError), keeping only its +/// stable code token so details and paths never reach the log. +pub fn core_failed(event: &str, stage: &str, error: &lithe_protocol::CoreError) { + let code = serde_json::to_value(&error.code).expect("Core error codes serialize"); + let code = code.as_str().expect("Core error codes serialize as tokens"); + recovery_failed(event, stage, code, "core"); +} + /// Test-only per-thread sink: traces recorded on the capturing thread bypass the /// process queue, so parallel tests never observe or drain each other's facts. -#[cfg(test)] -pub(crate) mod capture { +/// Other crates' tests reach it through the `test-support` feature. +#[cfg(any(test, feature = "test-support"))] +pub mod capture { use super::CoreEvent; use std::cell::RefCell; @@ -379,7 +392,7 @@ pub(crate) mod capture { } /// Runs `body` with this thread's facts captured; `debug` replaces the global gate. - pub(crate) fn run(debug: bool, body: impl FnOnce() -> T) -> (T, Vec) { + pub fn run(debug: bool, body: impl FnOnce() -> T) -> (T, Vec) { let previous = CURRENT.with(|current| { current.borrow_mut().replace(Capture { debug, @@ -440,46 +453,6 @@ mod tests { assert_eq!(queue.drain().dropped, 0); } - #[test] - fn remote_failure_categories_never_contain_peer_details() { - use lithe_remote::client::session::ClientError; - use lithe_remote::protocol::{ErrorCode, RemoteError}; - for (error, expected) in [ - (ClientError::Timeout, "timeout"), - ( - ClientError::Disconnected { - diagnostics: "/private/address".into(), - }, - "closed", - ), - (ClientError::Protocol("private payload".into()), "protocol"), - ( - ClientError::Remote(RemoteError::new( - ErrorCode::PermissionDenied, - "private scratch", - )), - "remote_permission_denied", - ), - ] { - assert_eq!(client_failure_category(&error), expected); - assert!(!remote_resource_already_gone(&error)); - } - assert!(remote_resource_already_gone(&ClientError::Remote( - RemoteError::new(ErrorCode::UnknownResource, "process 7 is not active") - ))); - let safe = prepare( - "app.workspace.scratch_cleanup_failed", - BTreeMap::from([ - ("error".into(), json!("Scratch cleanup failed")), - ("error_code".into(), json!("PermissionDenied")), - ("error_domain".into(), json!("io")), - ]), - None, - ) - .unwrap(); - assert_eq!(safe.channel, "app"); - } - #[test] fn queue_rejects_non_core_owner_and_invalid_fields() { assert_eq!( @@ -502,46 +475,3 @@ mod tests { .is_err()); } } - -/// Records a recovery failure whose cause is a [`CoreError`], keeping only its -/// stable code token so details and paths never reach the log. -pub(crate) fn core_failed(event: &str, stage: &str, error: &crate::protocol::CoreError) { - let code = serde_json::to_value(&error.code).expect("Core error codes serialize"); - let code = code.as_str().expect("Core error codes serialize as tokens"); - recovery_failed(event, stage, code, "core"); -} - -/// True when a remote cleanup request found nothing to clean: the host prunes -/// exited processes, stopped watches and closed terminals, so `UnknownResource` -/// is the expected missing case rather than a cleanup failure. -pub(crate) fn remote_resource_already_gone( - error: &lithe_remote::client::session::ClientError, -) -> bool { - matches!( - error, - lithe_remote::client::session::ClientError::Remote(remote) - if remote.code == lithe_remote::protocol::ErrorCode::UnknownResource - ) -} - -/// Stable transport/protocol category; never copies a peer message or address. -pub(crate) fn client_failure_category( - error: &lithe_remote::client::session::ClientError, -) -> String { - use lithe_remote::client::session::ClientError; - match error { - ClientError::Timeout => "timeout".into(), - ClientError::Disconnected { .. } => "closed".into(), - ClientError::Transport(_) => "transport".into(), - ClientError::Spawn(_) => "spawn".into(), - ClientError::Protocol(_) => "protocol".into(), - ClientError::Remote(error) => format!( - "remote_{}", - serde_json::to_value(error.code) - .expect("protocol code encodes") - .as_str() - .expect("protocol code is a token") - .to_ascii_lowercase() - ), - } -} diff --git a/rust/crates/core/src/diagnostics/filter.rs b/rust/crates/diagnostics/src/filter.rs similarity index 99% rename from rust/crates/core/src/diagnostics/filter.rs rename to rust/crates/diagnostics/src/filter.rs index c5043579..5b3ce236 100644 --- a/rust/crates/core/src/diagnostics/filter.rs +++ b/rust/crates/diagnostics/src/filter.rs @@ -44,7 +44,7 @@ pub struct FilterTerm { #[serde(default)] pub negate: bool, } -/// Half-open UTF-16 offsets include the negation prefix and quotes, for SwiftUI highlighting. +/// Half-open UTF-16 offsets include the negation prefix and quotes, so hosts can highlight each term. #[derive(Debug, Serialize)] pub struct ParsedFilterTerm { /// Owned predicate independent of its source spelling. diff --git a/rust/crates/core/src/diagnostics/format.rs b/rust/crates/diagnostics/src/format.rs similarity index 100% rename from rust/crates/core/src/diagnostics/format.rs rename to rust/crates/diagnostics/src/format.rs diff --git a/rust/crates/diagnostics/src/lib.rs b/rust/crates/diagnostics/src/lib.rs new file mode 100644 index 00000000..7c00540a --- /dev/null +++ b/rust/crates/diagnostics/src/lib.rs @@ -0,0 +1,23 @@ +//! Structured diagnostics shared by every host: the generated event catalog, +//! log channels and records, filters, queries, formatting, retention and +//! collection plans, diagnostic-bundle redaction and manifests, and the bounded +//! queue that hands Core-owned facts to the host writer. +//! +//! Everything here is pure and deterministic. The queue owns observation +//! timestamps but no file writer; hosts gather log files, panic reports and +//! environment facts natively, per `develop-lithe`'s platform-adapter boundary. +//! The `diagnostics.*` JSON commands are dispatched by `lithe-core`. + +pub mod catalog; +pub mod channel; +pub mod collection; +pub mod core_events; +pub mod filter; +pub mod format; +pub mod logging; +pub mod manifest; +pub mod plugin_operations; +pub mod query_v2; +pub mod record; +pub mod redaction; +pub mod retention; diff --git a/rust/crates/core/src/diagnostics/logging.rs b/rust/crates/diagnostics/src/logging.rs similarity index 99% rename from rust/crates/core/src/diagnostics/logging.rs rename to rust/crates/diagnostics/src/logging.rs index 8e623da5..c544c20f 100644 --- a/rust/crates/core/src/diagnostics/logging.rs +++ b/rust/crates/diagnostics/src/logging.rs @@ -440,7 +440,7 @@ mod tests { #[test] fn time_filters_and_ordering_compare_instants_from_the_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../fixtures/diagnostics/logging-v1.json" + "../../../fixtures/diagnostics/logging-v1.json" )) .unwrap(); for case in fixture["timeQueries"].as_array().unwrap() { @@ -457,7 +457,7 @@ mod tests { #[test] fn shared_fixture_pins_domains_and_budgets() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../fixtures/diagnostics/logging-v1.json" + "../../../fixtures/diagnostics/logging-v1.json" )) .unwrap(); assert_eq!(fixture["categories"], serde_json::json!(LOG_CATEGORIES)); diff --git a/rust/crates/core/src/diagnostics/manifest.rs b/rust/crates/diagnostics/src/manifest.rs similarity index 89% rename from rust/crates/core/src/diagnostics/manifest.rs rename to rust/crates/diagnostics/src/manifest.rs index d743fc56..947aa6a5 100644 --- a/rust/crates/core/src/diagnostics/manifest.rs +++ b/rust/crates/diagnostics/src/manifest.rs @@ -19,11 +19,17 @@ pub struct DiagnosticsFileEntry { /// Environment facts a host gathers natively and passes in for the manifest; the /// Core never reads the filesystem or process table itself. pub struct DiagnosticsEnvironmentInfo { + /// Application version string as the host displays it. pub app_version: String, + /// Operating system name, such as `macOS` or `Windows`. pub os_name: String, + /// Operating system version string. pub os_version: String, + /// Logical CPU cores available to the process. pub cpu_core_count: u32, + /// Resident memory of the host process in bytes. pub memory_rss_bytes: u64, + /// Free bytes on the volume that holds the logs. pub disk_free_bytes: u64, } @@ -31,7 +37,9 @@ pub struct DiagnosticsEnvironmentInfo { #[serde(rename_all = "camelCase")] /// Request to shape one deterministic manifest from host-gathered facts. pub struct BuildManifestRequest { + /// Environment facts the host gathered. pub environment: DiagnosticsEnvironmentInfo, + /// Files the host already redacted and staged, in any order. pub files: Vec, /// Milliseconds since epoch when the export was requested, supplied by the /// host so the manifest is reproducible in tests without a system clock @@ -45,9 +53,13 @@ pub struct BuildManifestRequest { pub struct DiagnosticsManifest { /// Additive technical provenance marker; v1 hosts may ignore it. pub redaction: &'static str, + /// Manifest format version. pub schema_version: u32, + /// Export request time copied from the request. pub generated_at_epoch_milliseconds: i64, + /// Environment facts copied from the request. pub environment: DiagnosticsEnvironmentInfo, + /// Staged files sorted by relative path. pub files: Vec, } diff --git a/rust/crates/core/src/diagnostics/plugin_operations.rs b/rust/crates/diagnostics/src/plugin_operations.rs similarity index 96% rename from rust/crates/core/src/diagnostics/plugin_operations.rs rename to rust/crates/diagnostics/src/plugin_operations.rs index 2bb8b912..9d1dc21b 100644 --- a/rust/crates/core/src/diagnostics/plugin_operations.rs +++ b/rust/crates/diagnostics/src/plugin_operations.rs @@ -227,7 +227,7 @@ pub fn record(input: DeclarativeOperation) -> Result<(), LogError> { /// Engine-side recording: a rejected or dropped fact becomes the queue's drop count. pub(crate) fn record_or_note_drop(input: DeclarativeOperation) { - #[cfg(test)] + #[cfg(any(test, feature = "test-support"))] test_capture::push(&input); if record(input).is_err() { core_events::note_dropped(); @@ -276,7 +276,7 @@ pub struct AttemptHandoff { /// Declarative attempt in flight inside a Core engine. #[derive(Debug)] -pub(crate) struct Attempt { +pub struct Attempt { contribution: &'static Contribution, plugin_id: String, operation_id: String, @@ -287,14 +287,11 @@ pub(crate) struct Attempt { /// Shared by every code path that can end one attempt. Whichever path reaches a /// terminal result first takes the attempt, so it is recorded at most once even /// when two paths race to end it. -pub(crate) type AttemptSlot = Arc>>; +pub type AttemptSlot = Arc>>; impl Attempt { /// Slot holding a handed-off attempt, timed from now. - pub(crate) fn slot( - contribution: &'static Contribution, - handoff: AttemptHandoff, - ) -> AttemptSlot { + pub fn slot(contribution: &'static Contribution, handoff: AttemptHandoff) -> AttemptSlot { Arc::new(Mutex::new(Some(Self { contribution, plugin_id: handoff.plugin_id, @@ -305,13 +302,13 @@ impl Attempt { } /// Slot for work that carries no declarative attempt; finishing it is a no-op. - pub(crate) fn none() -> AttemptSlot { + pub fn none() -> AttemptSlot { Arc::new(Mutex::new(None)) } } /// Records the attempt's terminal result unless another path already did. -pub(crate) fn finish(slot: &AttemptSlot, failure: Option) { +pub fn finish(slot: &AttemptSlot, failure: Option) { let Some(attempt) = slot.lock().unwrap_or_else(|e| e.into_inner()).take() else { return; }; @@ -326,15 +323,16 @@ pub(crate) fn finish(slot: &AttemptSlot, failure: Option) { } /// Ends the attempt without a result: the user cancelled or stopped it. -pub(crate) fn abandon(slot: &AttemptSlot) { +pub fn abandon(slot: &AttemptSlot) { slot.lock().unwrap_or_else(|e| e.into_inner()).take(); } /// Engine results as recorded, for engine tests. The process queue is shared and /// bounded, so a parallel test run can fill it; tests read their own facts back -/// by plugin ID instead. -#[cfg(test)] -pub(crate) mod test_capture { +/// by plugin ID instead. Other crates' tests reach it through the `test-support` +/// feature. +#[cfg(any(test, feature = "test-support"))] +pub mod test_capture { use super::DeclarativeOperation; use std::sync::Mutex; @@ -348,7 +346,7 @@ pub(crate) mod test_capture { } /// Removes and returns the facts recorded for `plugin_id`, in record order. - pub(crate) fn take(plugin_id: &str) -> Vec { + pub fn take(plugin_id: &str) -> Vec { let mut facts = FACTS.lock().unwrap_or_else(|e| e.into_inner()); let (taken, kept) = facts .drain(..) @@ -405,7 +403,7 @@ mod tests { assert_eq!(finished.event, "plugins.operation.finished"); assert_eq!(finished.fields["operation"], json!(row.operation)); assert_eq!(finished.op.as_deref(), Some("attempt-7")); - assert!(finished.fields.get("code").is_none()); + assert!(!finished.fields.contains_key("code")); for stage in STAGES { let result = prepare(json!({"pluginId":"io.example.contribution", "operation":row.operation, "outcome":"failed", "stage":stage, @@ -527,7 +525,7 @@ mod tests { #[test] fn diagnostics_host_plugin_operations_match_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../fixtures/diagnostics/engine-logging-v2.json" + "../../../fixtures/diagnostics/engine-logging-v2.json" )) .unwrap(); for case in fixture["hostOperations"].as_array().unwrap() { diff --git a/rust/crates/core/src/diagnostics/query_v2.rs b/rust/crates/diagnostics/src/query_v2.rs similarity index 100% rename from rust/crates/core/src/diagnostics/query_v2.rs rename to rust/crates/diagnostics/src/query_v2.rs diff --git a/rust/crates/core/src/diagnostics/record.rs b/rust/crates/diagnostics/src/record.rs similarity index 92% rename from rust/crates/core/src/diagnostics/record.rs rename to rust/crates/diagnostics/src/record.rs index 3ec41249..85c66460 100644 --- a/rust/crates/core/src/diagnostics/record.rs +++ b/rust/crates/diagnostics/src/record.rs @@ -649,7 +649,10 @@ fn batch_result(result: Result, channel: Option) -> En } } -pub(crate) fn prepare_catalog_event( +/// Checks one structured event against the catalog, fills its catalog level, +/// and returns the channel it belongs on. `input` supplies the channel for the +/// `log.dropped` and `log.repeated` system events. +pub fn prepare_catalog_event( event: &mut StructuredLogEvent, input: &Value, ) -> Result { @@ -661,10 +664,10 @@ pub(crate) fn prepare_catalog_event( &["count", "first", "last", "of"] }; if event.fields.len() != keys.len() - || !event + || event .fields .get("count") - .is_some_and(|v| v.as_u64().is_some()) + .is_none_or(|v| v.as_u64().is_none()) || keys .iter() .skip(1) @@ -786,12 +789,12 @@ pub fn dropped_event(ts: &str, seq: u64, count: u64) -> StructuredLogEvent { #[cfg(test)] mod tests { use super::*; - use crate::diagnostics::{channel, query_v2, retention}; + use crate::{channel, query_v2, retention}; use serde_json::json; fn fixture() -> Value { serde_json::from_str(include_str!( - "../../../../fixtures/diagnostics/logging-v2.json" + "../../../fixtures/diagnostics/logging-v2.json" )) .unwrap() } @@ -1209,90 +1212,6 @@ mod tests { assert!(decode_line(&line).is_ok()); } - #[test] - fn diagnostics_v2_commands_dispatch_with_stable_domain_errors() { - let cases = [ - ("diagnostics.encodeLogBatch", json!({"events":[]})), - ("diagnostics.queryLogsV2", json!({"sources":[]})), - ("diagnostics.parseLogFilter", json!({"expression":""})), - ( - "diagnostics.composeLogFilter", - json!({"expression":"","term":{"kind":"text","value":"test"}}), - ), - ( - "diagnostics.formatLogRecords", - json!({"lines":[],"format":"json"}), - ), - ( - "diagnostics.planLogRetention", - json!({"currentSession":"", "now":"2026-11-03T00:00:00Z"}), - ), - ( - "diagnostics.resolveLogChannel", - json!({"channel":"vcs/git"}), - ), - ]; - for (command, payload) in cases { - let response: Value = serde_json::from_str(&crate::runtime::execute_json( - &json!({"id":"fixture", "version":1, "command":command, "payload":payload}) - .to_string(), - )) - .unwrap(); - assert_eq!(response["ok"], true, "{response}"); - assert!(response["data"].get("error").is_none(), "{response}"); - } - let response: Value = serde_json::from_str(&crate::runtime::execute_json(&json!({"version":1,"command":"diagnostics.resolveLogChannel","payload":{"channel":"vcs/.."}}).to_string())).unwrap(); - assert_eq!(response["data"]["error"]["code"], -32033); - } - - #[test] - fn shared_window_commands_cover_dispatch_shapes_and_unique_errors() { - let fixture = fixture(); - let mut seen_codes = std::collections::BTreeSet::new(); - let mut seen_commands = std::collections::BTreeSet::new(); - for case in fixture["windowCommands"].as_array().unwrap() { - let mut payload = case["request"].clone(); - if let Some(count) = case["repeatCount"].as_u64() { - payload["lines"] = - json!(vec![case["repeatLine"].as_str().unwrap(); count as usize]); - } - let response: Value = serde_json::from_str(&crate::runtime::execute_json( - &json!({"version":1,"command":case["command"],"payload":payload}).to_string(), - )) - .unwrap(); - assert_eq!(response["ok"], true, "{response}"); - if let Some(code) = case["code"].as_i64() { - assert_eq!( - response["data"]["error"]["code"], code, - "{case}: {response}" - ); - assert!(fixture["errorCodes"][code.to_string()].is_string()); - seen_codes.insert(code); - } else { - // parse returns diagnostics directly; other pure v2 commands use the domain envelope. - let actual = if case["command"] == "diagnostics.parseLogFilter" - || case["command"] == "diagnostics.buildManifest" - { - &response["data"] - } else { - &response["data"]["result"] - }; - assert_eq!(actual, &case["expected"], "{case}: {response}"); - } - seen_commands.insert(case["command"].as_str().unwrap()); - } - for command in [ - "diagnostics.parseLogFilter", - "diagnostics.composeLogFilter", - "diagnostics.formatLogRecords", - ] { - assert!(seen_commands.contains(command)); - } - for code in [-32060, -32061, -32062, -32063] { - assert!(seen_codes.contains(&code)); - } - } - #[test] fn mandatory_overhead_and_non_scalar_fields_fail_without_losing_siblings() { let mut event = dropped_event("2026-11-03T00:00:00Z", 1, 1); diff --git a/rust/crates/core/src/diagnostics/redaction.rs b/rust/crates/diagnostics/src/redaction.rs similarity index 99% rename from rust/crates/core/src/diagnostics/redaction.rs rename to rust/crates/diagnostics/src/redaction.rs index c8fd0635..2ae704da 100644 --- a/rust/crates/core/src/diagnostics/redaction.rs +++ b/rust/crates/diagnostics/src/redaction.rs @@ -8,6 +8,7 @@ use std::sync::LazyLock; #[serde(rename_all = "camelCase")] /// Raw text a host wants scrubbed before it leaves the machine in a diagnostic bundle. pub struct RedactTextRequest { + /// Text to scrub. pub text: String, } @@ -15,6 +16,7 @@ pub struct RedactTextRequest { #[serde(rename_all = "camelCase")] /// Text with credentials, tokens, and home-directory paths replaced by stable placeholders. pub struct RedactTextResponse { + /// The scrubbed text. pub redacted: String, } diff --git a/rust/crates/core/src/diagnostics/retention.rs b/rust/crates/diagnostics/src/retention.rs similarity index 100% rename from rust/crates/core/src/diagnostics/retention.rs rename to rust/crates/diagnostics/src/retention.rs diff --git a/rust/crates/diff/src/lib.rs b/rust/crates/diff/src/lib.rs index c671ec54..48252283 100644 --- a/rust/crates/diff/src/lib.rs +++ b/rust/crates/diff/src/lib.rs @@ -348,13 +348,16 @@ fn inline_tokens(text: &str) -> Vec<&str> { tokens } +/// Half-open `[start, end)` UTF-16 column ranges of one line. +pub type ColumnRanges = Vec<[usize; 2]>; + /// The stretches in which two paired lines differ, compared word by word: for /// each side, half-open `[start, end)` UTF-16 column ranges, ascending and /// not touching. A deletion or insertion on one side has no range on the other. /// Lines that are equal give two empty lists; lines over `MAX_INLINE_CHARS` /// give `None`, and so do lines with nothing in common, which read better as a /// whole changed line than as one range covering it. -pub fn inline_changes(left: &str, right: &str) -> Option<(Vec<[usize; 2]>, Vec<[usize; 2]>)> { +pub fn inline_changes(left: &str, right: &str) -> Option<(ColumnRanges, ColumnRanges)> { if left.chars().count() > MAX_INLINE_CHARS || right.chars().count() > MAX_INLINE_CHARS { return None; } @@ -425,7 +428,7 @@ pub fn inline_changes(left: &str, right: &str) -> Option<(Vec<[usize; 2]>, Vec<[ } } } - if !shared && !(old.is_empty() && new.is_empty()) { + if !(shared || old.is_empty() && new.is_empty()) { return None; } Some((left_ranges, right_ranges)) diff --git a/rust/crates/document-uri/Cargo.toml b/rust/crates/document-uri/Cargo.toml new file mode 100644 index 00000000..6f7e27e9 --- /dev/null +++ b/rust/crates/document-uri/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "lithe-document-uri" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Lexical conversions between native filesystem paths and LSP document URIs that both platform hosts share" + +[dependencies] +lithe-protocol = { path = "../protocol" } +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0", features = ["preserve_order"] } +url = "2.5" diff --git a/rust/crates/document-uri/src/decode.rs b/rust/crates/document-uri/src/decode.rs new file mode 100644 index 00000000..6b395730 --- /dev/null +++ b/rust/crates/document-uri/src/decode.rs @@ -0,0 +1,110 @@ +//! Native paths and percent-decoded text named by LSP file URIs. + +/// Decodes a file URI using native drive/UNC syntax; opaque inputs retain +/// their percent-decoded spelling for existing non-file consumers. +pub fn file_path_from_uri(uri: &str) -> String { + file_path_for_uri(uri).unwrap_or_else(|| percent_decode(uri)) +} + +/// Native path of a `file:` URI; `None` for any other scheme, which callers +/// treat as a read-only location. +pub fn file_path_for_uri(uri: &str) -> Option { + let (scheme, _) = uri.split_once(':')?; + if !scheme.eq_ignore_ascii_case("file") { + return None; + } + // Workspace edits already decode percent escapes, UNC authorities and the + // leading slash of file:///C:/... without depending on the build host OS. + native_path(uri) +} + +/// Decodes `%XX` escapes in a URI component, keeping the input when the +/// decoded bytes are not UTF-8. +pub 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() { + 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(|_| value.to_string()) +} + +/// Native path named by an LSP document or workspace-edit key; `None` for a +/// non-file URI. Shared with navigation results and diagnostic placement. +/// Core's URI projection keeps the leading slash of `file:///C:/...`; strip it +/// so drive paths parse as Windows paths. +pub fn native_path(key: &str) -> Option { + let path = match key.split_once(':') { + // A one-letter "scheme" is a drive letter, not a URI scheme. + Some((scheme, rest)) + if scheme.len() > 1 + && scheme.starts_with(|c: char| c.is_ascii_alphabetic()) + && scheme + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) => + { + if !scheme.eq_ignore_ascii_case("file") { + return None; + } + let rest = rest.strip_prefix("//").unwrap_or(rest); + // `file:///p` leaves `/p`; `file://host/share` leaves `host/share`. + if rest.starts_with('/') { + percent_decode(rest) + } else { + format!("//{}", percent_decode(rest)) + } + } + _ => key.to_string(), + }; + let bytes = path.as_bytes(); + if bytes.len() >= 3 && bytes[0] == b'/' && bytes[1].is_ascii_alphabetic() && bytes[2] == b':' { + return Some(path[1..].to_string()); + } + Some(path) +} + +/// Value of one ASCII hexadecimal digit, either case. +pub 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::file_path_from_uri; + + #[test] + fn file_uri_paths_decode_spaces_and_utf8_characters() { + assert_eq!( + file_path_from_uri("file:///C:/work/Main.java"), + "C:/work/Main.java" + ); + assert_eq!( + file_path_from_uri("file:///C%3A/work/A%20B.java"), + "C:/work/A B.java" + ); + assert_eq!( + 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" + ); + } +} diff --git a/rust/crates/core/src/lsp/interface/document_uri.rs b/rust/crates/document-uri/src/encode.rs similarity index 76% rename from rust/crates/core/src/lsp/interface/document_uri.rs rename to rust/crates/document-uri/src/encode.rs index 20087a6f..c419a870 100644 --- a/rust/crates/core/src/lsp/interface/document_uri.rs +++ b/rust/crates/document-uri/src/encode.rs @@ -1,50 +1,51 @@ //! Platform-neutral conversion of absolute filesystem paths to LSP file URIs. -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::Deserialize; use serde_json::{json, Value}; use url::Url; /// Largest UTF-8 spelling of a Windows extended-length path (32,767 UTF-16 /// units); shared by native path and server document identity validation. -pub(crate) const MAX_PATH_BYTES: usize = 4 * 32_767; +pub const MAX_PATH_BYTES: usize = 4 * 32_767; #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct Request { path: String, - platform: Platform, + platform: DocumentPathPlatform, } /// Path syntax of a native absolute path; `auto` recognizes drive and UNC paths. #[derive(Clone, Copy, Debug, Deserialize)] #[serde(rename_all = "lowercase")] -pub(crate) enum Platform { +pub enum DocumentPathPlatform { + /// POSIX syntax: the path must start with `/`. Posix, + /// Windows syntax: drive letters, UNC shares and extended-length prefixes. Windows, + /// Windows when the path names a drive or a UNC share, POSIX otherwise. Auto, } -pub(crate) fn document_uri_command(payload: Value) -> Result { +/// `lsp.documentUri`: the canonical file URI of `path` under `platform`. +pub fn document_uri_command(payload: Value) -> Result { let request: Request = serde_json::from_value(payload).map_err(|_| invalid())?; Ok(json!({"uri": document_uri(&request.path, request.platform)?})) } -/// Public alias for planners that compare native paths through their URIs. -pub(crate) type DocumentPathPlatform = Platform; - -impl Platform { +impl DocumentPathPlatform { /// Canonical file URI of an absolute native path; see `lsp.documentUri`. - pub(crate) fn document_uri(self, path: &str) -> Result { + pub fn document_uri(self, path: &str) -> Result { document_uri(path, self) } /// True when `path` uses Windows syntax: drive or UNC, or `windows` explicitly. - pub(crate) fn is_windows_path(self, path: &str) -> bool { + pub fn is_windows_path(self, path: &str) -> bool { match self { - Platform::Windows => true, - Platform::Posix => false, - Platform::Auto => { + DocumentPathPlatform::Windows => true, + DocumentPathPlatform::Posix => false, + DocumentPathPlatform::Auto => { path.starts_with("\\\\") || path.starts_with("//") || path.as_bytes().get(1) == Some(&b':') @@ -53,29 +54,29 @@ impl Platform { } } -fn document_uri(path: &str, platform: Platform) -> Result { +fn document_uri(path: &str, platform: DocumentPathPlatform) -> Result { if path.len() > MAX_PATH_BYTES || path.contains('\0') { return Err(invalid()); } let platform = match platform { - Platform::Auto + DocumentPathPlatform::Auto if path.starts_with("\\\\") || path.starts_with("//") || (path.as_bytes().get(1) == Some(&b':')) => { - Platform::Windows + DocumentPathPlatform::Windows } - Platform::Auto => Platform::Posix, + DocumentPathPlatform::Auto => DocumentPathPlatform::Posix, platform => platform, }; let path = match platform { - Platform::Posix => { + DocumentPathPlatform::Posix => { if !path.starts_with('/') { return Err(invalid()); } path.to_owned() } - Platform::Windows => { + DocumentPathPlatform::Windows => { let path = path.replace('\\', "/"); if let Some(rest) = path.strip_prefix("//?/UNC/") { format!("//{rest}") @@ -85,12 +86,12 @@ fn document_uri(path: &str, platform: Platform) -> Result { path } } - Platform::Auto => unreachable!("auto platform is resolved above"), + DocumentPathPlatform::Auto => unreachable!("auto platform is resolved above"), }; let mut url = Url::parse("file:///").expect("constant file URI must parse"); let rest = match platform { - Platform::Posix => &path[1..], - Platform::Windows if path.starts_with("//") => { + DocumentPathPlatform::Posix => &path[1..], + DocumentPathPlatform::Windows if path.starts_with("//") => { let (host, rest) = path[2..].split_once('/').ok_or_else(invalid)?; if host.is_empty() || rest.is_empty() { return Err(invalid()); @@ -98,7 +99,7 @@ fn document_uri(path: &str, platform: Platform) -> Result { url.set_host(Some(host)).map_err(|_| invalid())?; rest } - Platform::Windows => { + DocumentPathPlatform::Windows => { let bytes = path.as_bytes(); if bytes.len() < 3 || !bytes[0].is_ascii_alphabetic() @@ -109,12 +110,12 @@ fn document_uri(path: &str, platform: Platform) -> Result { } path.as_str() } - Platform::Auto => unreachable!("auto platform is resolved above"), + DocumentPathPlatform::Auto => unreachable!("auto platform is resolved above"), }; // url::PathSegmentsMut ignores `..` rather than removing the preceding // component. Resolve dot segments before inserting and keep drive/share // roots intact. Empty trailing components retain a directory separator. - let root_components = usize::from(matches!(platform, Platform::Windows)); + let root_components = usize::from(matches!(platform, DocumentPathPlatform::Windows)); let mut normalized = Vec::new(); for segment in rest.split('/') { match segment { @@ -144,7 +145,7 @@ fn document_uri(path: &str, platform: Platform) -> Result { /// Accept a native path alongside existing string URIs without another IPC /// round trip. Only URI fields of named LSP operations are interpreted; opaque /// server payloads and virtual URIs remain untouched. -pub(crate) fn resolve_request_paths(command: &str, payload: &mut Value) -> Result<(), CoreError> { +pub fn resolve_request_paths(command: &str, payload: &mut Value) -> Result<(), CoreError> { match command { "lsp.startServer" => resolve_field(payload, "rootUri"), "lsp.request" => { @@ -182,7 +183,7 @@ pub(crate) fn resolve_request_paths(command: &str, payload: &mut Value) -> Resul } /// Resolve a named URI field at a command-specific boundary. -pub(crate) fn resolve_field(parent: &mut Value, key: &str) -> Result<(), CoreError> { +fn resolve_field(parent: &mut Value, key: &str) -> Result<(), CoreError> { let Some(value) = parent.get_mut(key) else { return Ok(()); }; diff --git a/rust/crates/document-uri/src/lib.rs b/rust/crates/document-uri/src/lib.rs new file mode 100644 index 00000000..8d2fc7c6 --- /dev/null +++ b/rust/crates/document-uri/src/lib.rs @@ -0,0 +1,15 @@ +//! Conversions between native filesystem paths and LSP document URIs. +//! +//! Paths are parsed lexically for the requested platform syntax, never through the build host's +//! filesystem, so macOS and Windows hosts get byte-identical URIs for the same input. + +mod decode; +mod encode; + +pub use decode::{file_path_for_uri, file_path_from_uri, hex_value, native_path, percent_decode}; +pub use encode::{ + document_uri_command, resolve_request_paths, DocumentPathPlatform, MAX_PATH_BYTES, +}; + +#[cfg(test)] +mod tests; diff --git a/rust/crates/document-uri/src/tests.rs b/rust/crates/document-uri/src/tests.rs new file mode 100644 index 00000000..ff485c7b --- /dev/null +++ b/rust/crates/document-uri/src/tests.rs @@ -0,0 +1,30 @@ +use super::*; +use serde_json::json; + +#[test] +fn document_paths_resolve_inside_lsp_requests_without_changing_virtual_uris() { + let mut request = json!({"uri":{"path":"C:\\work\\A#B.txt","platform":"auto"}, + "virtualUri":"untitled://contents/A#B", "text":"unchanged", + "diagnostics":[{"relatedInformation":[{"location":{"uri":{"path":"/work/A#B.txt","platform":"posix"}}}, + {"location":{"uri":"untitled://contents/A#B"}}]}]}); + resolve_request_paths("lsp.request", &mut request).unwrap(); + assert_eq!(request["uri"], "file:///C:/work/A%23B.txt"); + assert_eq!(request["virtualUri"], "untitled://contents/A#B"); + assert_eq!( + request["diagnostics"][0]["relatedInformation"][0]["location"]["uri"], + "file:///work/A%23B.txt" + ); + assert_eq!( + request["diagnostics"][0]["relatedInformation"][1]["location"]["uri"], + "untitled://contents/A#B" + ); + let mut changes = json!({"changes":[ + {"uri":{"path":"/work/A?.txt","platform":"posix"},"kind":"changed"}, + {"uri":"file:///already%20encoded","kind":"deleted"} + ]}); + resolve_request_paths("lsp.workspaceFilesChanged", &mut changes).unwrap(); + assert_eq!(changes["changes"][0]["uri"], "file:///work/A%3F.txt"); + assert_eq!(changes["changes"][1]["uri"], "file:///already%20encoded"); + let mut invalid = json!({"uri":{"path":"relative","platform":"posix"}}); + assert!(resolve_request_paths("lsp.syncDocument", &mut invalid).is_err()); +} diff --git a/rust/crates/editor/Cargo.toml b/rust/crates/editor/Cargo.toml new file mode 100644 index 00000000..091f9f7d --- /dev/null +++ b/rust/crates/editor/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "lithe-editor" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Deterministic editor commands: line edits and comment tokens, find, text edit plans, navigation input and history, preferences, recent files and recovery decisions" + +[dependencies] +lithe-protocol = { path = "../protocol" } +lithe-text = { path = "../text" } +regex = "1.11" +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0", features = ["preserve_order"] } diff --git a/rust/crates/core/src/editor/find.rs b/rust/crates/editor/src/find.rs similarity index 93% rename from rust/crates/core/src/editor/find.rs rename to rust/crates/editor/src/find.rs index 06e9664a..27d8b89d 100644 --- a/rust/crates/core/src/editor/find.rs +++ b/rust/crates/editor/src/find.rs @@ -3,7 +3,7 @@ //! The host owns the immutable document snapshot. Core borrows it for one worker request, //! keeps no text cache, and returns only byte ranges and requested replacements. -use crate::protocol::{cancellation, CoreError, ErrorCode}; +use lithe_protocol::{cancellation, CoreError, ErrorCode}; use regex::RegexBuilder; use serde::Deserialize; use serde_json::{json, Value}; @@ -48,7 +48,14 @@ fn invalid(reason: &str) -> CoreError { .with_details(reason) } -pub(crate) fn command(payload: Value, source: Option<&str>) -> Result { +/// Finds matches in the borrowed document `source` and, when a replacement is +/// requested, plans the edits (`editor.find`). +/// +/// Returns `{ matches, edits, selected, truncated }` with byte ranges into +/// `source`. Malformed requests, a missing document, oversized inputs and +/// ranges off character boundaries return `invalidRequest` with the reason in +/// the details. +pub fn command(payload: Value, source: Option<&str>) -> Result { let request: Request = serde_json::from_value(payload).map_err(|_| invalid("invalid_request"))?; let source = source.ok_or_else(|| invalid("missing_document"))?; diff --git a/rust/crates/editor/src/lib.rs b/rust/crates/editor/src/lib.rs new file mode 100644 index 00000000..e520a369 --- /dev/null +++ b/rust/crates/editor/src/lib.rs @@ -0,0 +1,20 @@ +//! Editor commands owned by Rust Core: deterministic line edits and the +//! comment-token table, find, plugin text edit plans, navigation input and +//! history, editor preferences, recent files and crash-recovery decisions. +//! +//! Hosts apply the returned replacements and states through their own text +//! engine. The `editor.*` JSON commands are dispatched by `lithe-core`. + +pub mod find; +pub mod line_edit; +pub mod navigation_history; +pub mod navigation_input; +pub mod preferences; +pub mod recent; +pub mod recovery; +pub mod text_edit_plan; + +pub use line_edit::{line_comment_token, line_edit, LineCommentTokenRequest, LineEditRequest}; + +#[cfg(test)] +mod tests; diff --git a/rust/crates/core/src/editor/line_edit.rs b/rust/crates/editor/src/line_edit.rs similarity index 97% rename from rust/crates/core/src/editor/line_edit.rs rename to rust/crates/editor/src/line_edit.rs index 19ddf90a..a09e486e 100644 --- a/rust/crates/core/src/editor/line_edit.rs +++ b/rust/crates/editor/src/line_edit.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; /// Line-level editing operations supported by `editor.lineEdit`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] @@ -34,10 +34,14 @@ pub enum LineEditOperation { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LineEditRequest { + /// Line operation to perform. pub operation: LineEditOperation, + /// Full document text the selection refers to. pub source: String, + /// Start of the selection; defaults to the start of the document. #[serde(default)] pub selection_start: usize, + /// Length of the selection; zero means a caret. #[serde(default)] pub selection_length: usize, /// Line comment token; required by `ToggleLineComment`. @@ -62,15 +66,21 @@ pub struct LineCommentTokenRequest { #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct LineEditOutcome { + /// Whether the operation changed the text. pub applied: bool, + /// Replacement text for the replaced range; present only when applied. #[serde(skip_serializing_if = "Option::is_none")] pub text: Option, + /// Start of the range `text` replaces; present only when applied. #[serde(skip_serializing_if = "Option::is_none")] pub replaced_start: Option, + /// Length of the range `text` replaces; present only when applied. #[serde(skip_serializing_if = "Option::is_none")] pub replaced_length: Option, + /// Start of the selection to restore; present only when applied. #[serde(skip_serializing_if = "Option::is_none")] pub selection_start: Option, + /// Length of the selection to restore; present only when applied. #[serde(skip_serializing_if = "Option::is_none")] pub selection_length: Option, } @@ -110,6 +120,7 @@ impl LineEditOutcome { #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct LineCommentToken { + /// Line comment token, such as `//` or `#`. pub token: Option, } diff --git a/rust/crates/core/src/editor/navigation_history.rs b/rust/crates/editor/src/navigation_history.rs similarity index 97% rename from rust/crates/core/src/editor/navigation_history.rs rename to rust/crates/editor/src/navigation_history.rs index 2342c71c..45be0f45 100644 --- a/rust/crates/core/src/editor/navigation_history.rs +++ b/rust/crates/editor/src/navigation_history.rs @@ -7,12 +7,12 @@ //! platform keeps the returned state and restores its previous value when //! opening the returned destination fails. -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use serde_json::Value; /// Maximum number of locations retained by each history direction. -pub(crate) const MAX_HISTORY_ENTRIES: usize = 100; +pub const MAX_HISTORY_ENTRIES: usize = 100; /// One editor location recorded in navigation history. /// @@ -85,7 +85,7 @@ struct Response { /// /// Returns `invalidRequest` for malformed payloads or empty document /// identities. Oversized stacks keep their newest entries. -pub(crate) fn reduce_command(payload: Value) -> Result { +pub fn reduce_command(payload: Value) -> Result { let request: Request = serde_json::from_value(payload).map_err(|error| { CoreError::new( ErrorCode::InvalidRequest, diff --git a/rust/crates/core/src/editor/navigation_input.rs b/rust/crates/editor/src/navigation_input.rs similarity index 82% rename from rust/crates/core/src/editor/navigation_input.rs rename to rust/crates/editor/src/navigation_input.rs index 09e97e91..02387ace 100644 --- a/rust/crates/core/src/editor/navigation_input.rs +++ b/rust/crates/editor/src/navigation_input.rs @@ -1,6 +1,6 @@ //! Parsing bounded, one-based Go to Line input into portable editor positions. -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::Deserialize; use serde_json::{json, Value}; @@ -14,7 +14,13 @@ struct Request { text: String, } -pub(crate) fn parse_command(payload: Value) -> Result { +/// Parses one-based `line` or `line:column` Go to Line input +/// (`editor.parseNavigationInput`). +/// +/// Returns `{ target }`, where `target` holds the zero-based `line` and +/// `column` and `selectsWholeLine`, or null when the input is not a valid +/// position. +pub fn parse_command(payload: Value) -> Result { let request: Request = serde_json::from_value(payload).map_err(|_| { CoreError::new( ErrorCode::InvalidRequest, diff --git a/rust/crates/core/src/editor/preferences.rs b/rust/crates/editor/src/preferences.rs similarity index 88% rename from rust/crates/core/src/editor/preferences.rs rename to rust/crates/editor/src/preferences.rs index 828ad1ca..bc9a0021 100644 --- a/rust/crates/core/src/editor/preferences.rs +++ b/rust/crates/editor/src/preferences.rs @@ -1,6 +1,6 @@ //! Defaults and bounded persisted options for native editor presentation. -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::Deserialize; use serde_json::{json, Value}; @@ -14,7 +14,9 @@ struct Stored { auto_save_delay_milliseconds: Option, } -pub(crate) fn resolve_command(payload: Value) -> Result { +/// Resolves stored editor preferences into effective values, filling in +/// defaults and clamping the auto-save delay (`editor.preferences.resolve`). +pub fn resolve_command(payload: Value) -> Result { let stored: Stored = serde_json::from_value(payload) .map_err(|_| CoreError::new(ErrorCode::InvalidRequest, "Invalid editor preferences"))?; let style = match stored.cursor_style.as_deref() { diff --git a/rust/crates/core/src/editor/recent.rs b/rust/crates/editor/src/recent.rs similarity index 95% rename from rust/crates/core/src/editor/recent.rs rename to rust/crates/editor/src/recent.rs index 552298cd..4c80fed9 100644 --- a/rust/crates/core/src/editor/recent.rs +++ b/rust/crates/editor/src/recent.rs @@ -1,6 +1,6 @@ //! Bounded recent-file and recent-edit history, independent of native storage and widgets. -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -51,7 +51,9 @@ fn valid_path(path: &str) -> bool { !path.is_empty() && path.len() <= 32_768 && !path.contains(['\0', '\r', '\n']) } -pub(crate) fn reduce_command(payload: Value) -> Result { +/// Applies one opened, edited or removed event to the recent-file list and +/// filters it by query (`editor.recent.reduce`). +pub fn reduce_command(payload: Value) -> Result { let mut request: Request = serde_json::from_value(payload) .map_err(|_| CoreError::new(ErrorCode::InvalidRequest, "Invalid recent-file request"))?; if request.entries.len() > MAX_ENTRIES || request.query.len() > 4096 { diff --git a/rust/crates/core/src/editor/recovery.rs b/rust/crates/editor/src/recovery.rs similarity index 91% rename from rust/crates/core/src/editor/recovery.rs rename to rust/crates/editor/src/recovery.rs index 30c6d246..472818e3 100644 --- a/rust/crates/core/src/editor/recovery.rs +++ b/rust/crates/editor/src/recovery.rs @@ -1,6 +1,6 @@ //! Recovery decisions and storage budgets; native adapters own atomic file I/O. -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::Deserialize; use serde_json::{json, Value}; @@ -20,7 +20,9 @@ struct Request { discarded: bool, } -pub(crate) fn decide_command(payload: Value) -> Result { +/// Decides what to do with one crash-recovery checkpoint and reports the +/// storage budgets (`editor.recovery.decide`). +pub fn decide_command(payload: Value) -> Result { let request: Request = serde_json::from_value(payload) .map_err(|_| CoreError::new(ErrorCode::InvalidRequest, "Invalid recovery decision"))?; if request.base_hash.len() > 128 || request.disk_hash.as_ref().is_some_and(|h| h.len() > 128) { diff --git a/rust/crates/core/src/editor/tests.rs b/rust/crates/editor/src/tests.rs similarity index 81% rename from rust/crates/core/src/editor/tests.rs rename to rust/crates/editor/src/tests.rs index a389ec04..b55602a0 100644 --- a/rust/crates/core/src/editor/tests.rs +++ b/rust/crates/editor/src/tests.rs @@ -6,138 +6,6 @@ use serde_json::{json, Value}; use super::line_edit::{line_comment_token, line_edit, LineCommentTokenRequest, LineEditRequest}; -#[test] -fn navigation_input_matches_shared_fixture() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../../fixtures/editor/navigation-input-v1.json" - )) - .unwrap(); - for case in fixture["cases"].as_array().unwrap() { - let response: Value = serde_json::from_str(&crate::execute_json( - &json!({"command":"editor.parseNavigationInput", "payload":{"text":case["input"]}}) - .to_string(), - )) - .unwrap(); - assert_eq!(response["ok"], true, "{}: {response}", case["name"]); - assert_eq!( - response["data"]["target"], case["target"], - "{}", - case["name"] - ); - } -} - -/// Plugin edit plans follow the shared fixture, and applying a plan's -/// document-separator text in reverse order yields the fixture's result. -#[test] -fn text_edit_plan_matches_shared_fixture() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../../fixtures/editor/text-edit-plan-v1.json" - )) - .unwrap(); - for case in fixture["cases"].as_array().unwrap() { - let mut payload = json!({"text": case["text"], "edits": case["edits"]}); - if let Some(positions) = case.get("positions") { - payload["positions"] = positions.clone(); - } - let response: Value = serde_json::from_str(&crate::execute_json( - &json!({"command":"editor.planTextEdits", "payload": payload}).to_string(), - )) - .unwrap(); - assert_eq!(response["ok"], true, "{}: {response}", case["name"]); - assert_eq!(response["data"], case["expected"], "{}", case["name"]); - if let Some(expected) = case["expectedText"].as_str() { - let mut units: Vec = case["text"].as_str().unwrap().encode_utf16().collect(); - for edit in response["data"]["edits"].as_array().unwrap().iter().rev() { - let offset = edit["utf16Offset"].as_u64().unwrap() as usize; - let length = edit["utf16Length"].as_u64().unwrap() as usize; - let replacement: Vec = edit["text"].as_str().unwrap().encode_utf16().collect(); - units.splice(offset..offset + length, replacement); - } - assert_eq!( - String::from_utf16(&units).unwrap(), - expected, - "{}", - case["name"] - ); - } - } -} - -#[test] -fn text_edit_plan_rejects_malformed_requests() { - for payload in [ - json!({"edits": []}), - json!({"text": "a", "edits": [{"range": {"start": {"line": -1, "column": 1}, "end": {"line": 1, "column": 1}}, "text": ""}]}), - json!({"text": "a", "unknown": true}), - ] { - let response: Value = serde_json::from_str(&crate::execute_json( - &json!({"command":"editor.planTextEdits", "payload": payload}).to_string(), - )) - .unwrap(); - assert_eq!(response["ok"], false, "{payload}"); - assert_eq!(response["error"]["code"], "invalid_request"); - } -} - -fn navigation_history(payload: Value) -> Value { - serde_json::from_str(&crate::execute_json( - &json!({"command":"editor.navigationHistory.reduce", "payload":payload}).to_string(), - )) - .unwrap() -} - -#[test] -fn navigation_history_matches_shared_fixture() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../../fixtures/editor/navigation-history-v1.json" - )) - .unwrap(); - assert_eq!( - fixture["maxEntries"], - super::navigation_history::MAX_HISTORY_ENTRIES - ); - for case in fixture["cases"].as_array().unwrap() { - let response = navigation_history(case["request"].clone()); - if let Some(code) = case.get("expectedError") { - assert_eq!(response["ok"], false, "{}: {response}", case["name"]); - assert_eq!(response["error"]["code"], *code, "{}", case["name"]); - continue; - } - assert_eq!(response["ok"], true, "{}: {response}", case["name"]); - assert_eq!(response["data"], case["expected"], "{}", case["name"]); - } -} - -// Both stacks are bounded so a long editing session cannot grow history (or -// the IPC payload that carries it) without limit; the oldest entries go first. -#[test] -fn navigation_history_keeps_only_the_newest_entries() { - let limit = super::navigation_history::MAX_HISTORY_ENTRIES as u64; - let back: Vec = (0..limit) - .map(|line| json!({"document":"a", "line":line, "column":0})) - .collect(); - let response = navigation_history(json!({ - "state": {"back": back, "forward": []}, - "event": {"type":"record", "departure":{"document":"b", "line":0, "column":0}} - })); - let back = response["data"]["state"]["back"].as_array().unwrap(); - assert_eq!(back.len() as u64, limit); - assert_eq!(back[0]["line"], 1); - assert_eq!(back[back.len() - 1]["document"], "b"); - - let oversized: Vec = (0..limit + 5) - .map(|line| json!({"document":"a", "line":line, "column":0})) - .collect(); - let response = navigation_history(json!({ - "state": {"back": [], "forward": oversized}, - "event": {"type":"back", "current":null} - })); - let forward = response["data"]["state"]["forward"].as_array().unwrap(); - assert_eq!(forward.len() as u64, limit); - assert_eq!(forward[0]["line"], 5); -} - fn edit( operation: &str, source: &str, diff --git a/rust/crates/core/src/editor/text_edit_plan.rs b/rust/crates/editor/src/text_edit_plan.rs similarity index 97% rename from rust/crates/core/src/editor/text_edit_plan.rs rename to rust/crates/editor/src/text_edit_plan.rs index 920eaaf0..0126edbe 100644 --- a/rust/crates/core/src/editor/text_edit_plan.rs +++ b/rust/crates/editor/src/text_edit_plan.rs @@ -5,11 +5,11 @@ //! line index, ordering and overlap rules as `lsp.applyTextEdits`, and returns //! UTF-16 offsets plus replacement text in the document's line ending. -use crate::lsp::{ +use lithe_protocol::{CoreError, ErrorCode}; +use lithe_text::{ normalize_line_breaks, plan_replacements, EditRejection, EditRejectionReason, PositionPolicy, RawTextEdit, TextLineIndex, }; -use crate::protocol::{CoreError, ErrorCode}; use serde::Deserialize; use serde_json::{json, Value}; @@ -76,7 +76,7 @@ fn reason_name(reason: EditRejectionReason) -> &'static str { /// with `reason`, the caller-order `editIndex`/`otherEditIndex`, or /// `positionIndex` for a failed extra position. An empty batch still reports /// the document's line ending. -pub(crate) fn plan_command(payload: Value) -> Result { +pub fn plan_command(payload: Value) -> Result { let request: Request = serde_json::from_value(payload).map_err(|error| { CoreError::new( ErrorCode::InvalidRequest, diff --git a/rust/crates/execution/Cargo.toml b/rust/crates/execution/Cargo.toml new file mode 100644 index 00000000..3124b192 --- /dev/null +++ b/rust/crates/execution/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "lithe-execution" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Run configurations for Lithe: generation, resolution, launch environments and project run discovery" + +[features] +# Lets lithe-core's tests seed the generated layer as if a host had generated it. +test-support = [] + +[dependencies] +lithe-diagnostics = { path = "../diagnostics" } +lithe-plugin-model = { path = "../plugin-model" } +lithe-protocol = { path = "../protocol" } +lithe-toolchain = { path = "../toolchain" } +lithe-workspace = { path = "../workspace" } +regex = "1.11" +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0", features = ["preserve_order"] } +serde_yaml_ng = "0.10.0" +sha2 = "0.10" +toml = { version = "1.1.4", default-features = false, features = ["parse", "serde"] } + diff --git a/rust/crates/core/src/execution/argument_text.rs b/rust/crates/execution/src/argument_text.rs similarity index 62% rename from rust/crates/core/src/execution/argument_text.rs rename to rust/crates/execution/src/argument_text.rs index be010c2e..f5aa1cf2 100644 --- a/rust/crates/core/src/execution/argument_text.rs +++ b/rust/crates/execution/src/argument_text.rs @@ -5,13 +5,13 @@ use serde::Deserialize; /// Argument vectors to format in order, with no shell expansion or execution. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -pub(crate) struct ArgumentTextRequest { +pub struct ArgumentTextRequest { /// Each vector becomes one editable text value in the response. pub argument_lists: Vec>, } /// Formats each vector using the same quoting and escaping accepted by the editor parser. -pub(crate) fn format_argument_text(request: ArgumentTextRequest) -> Vec { +pub fn format_argument_text(request: ArgumentTextRequest) -> Vec { request .argument_lists .iter() @@ -42,29 +42,10 @@ mod tests { use super::*; use serde_json::Value; - #[test] - fn argument_text_command_formats_a_batch_from_the_shared_fixture() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../../fixtures/run-configuration/argument-text.json" - )) - .unwrap(); - let cases = fixture["cases"].as_array().unwrap(); - let argument_lists: Vec<_> = cases.iter().map(|case| case["values"].clone()).collect(); - let expected: Vec<_> = cases.iter().map(|case| case["text"].clone()).collect(); - let request = serde_json::json!({ - "id": "argument-text", "command": "runConfig.argumentText", - "payload": {"argumentLists": argument_lists} - }); - let response: Value = - serde_json::from_str(&crate::execute_json(&request.to_string())).unwrap(); - assert_eq!(response["ok"], true, "{response}"); - assert_eq!(response["data"], Value::Array(expected)); - } - #[test] fn argument_text_matches_the_shared_roundtrip_examples() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../fixtures/run-configuration/argument-text.json" + "../../../fixtures/run-configuration/argument-text.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { diff --git a/rust/crates/execution/src/configuration.rs b/rust/crates/execution/src/configuration.rs new file mode 100644 index 00000000..8b4e4c54 --- /dev/null +++ b/rust/crates/execution/src/configuration.rs @@ -0,0 +1,89 @@ +//! Run-configuration schemas, layered overrides, and deterministic generation. + +mod project_toolchain; +pub use project_toolchain::{project_toolchain, ProjectToolchainRequest}; + +use super::generated_layer::{self, GeneratedLayer}; +use super::types::{Confidence, Execution, RunCategory}; +use lithe_protocol::{invalid_relative_path, CoreError, ErrorCode}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +mod document; +mod editing; +mod generation; +mod inputs; +mod launch; +mod layers; +mod merge; +mod requests; +mod requirements; +mod resolve; +mod validation; + +pub use document::*; +pub use editing::*; +pub use generation::*; +pub(crate) use inputs::*; +pub use launch::*; +pub(crate) use layers::*; +pub(crate) use merge::*; +pub use requests::*; +pub use requirements::*; +pub use resolve::*; +pub use validation::*; + +const VERSION: u32 = 2; + +const LEGACY_VERSION: u32 = 1; + +// Bumped when the detected layer changes; stale cached facts must regenerate. +const GENERATOR_REVISION: &str = "20"; + +/// Toolchain requirements and `project.json` are separate documents that happen +/// to live under `.lithe`. Their schema did not change with run-config v2, so +/// they keep their own version and must not be validated against `VERSION`. +const SIDECAR_VERSION: u32 = 1; + +#[cfg(test)] +mod incomplete_scan_tests { + use super::*; + + #[test] + fn unknown_inputs_retain_only_affected_configurations_and_hashes() { + let previous: RunConfigurationDocument = serde_json::from_value(json!({ + "version": 2, + "generator": {"fingerprint": "old", "inputs": { + "blocked/package.json": "old-blocked", "removed/package.json": "old-removed" + }}, + "configurations": [ + {"id":"blocked", "name":"Blocked", "provider":"npm.script", "source":"blocked/package.json"}, + {"id":"removed", "name":"Removed", "provider":"npm.script", "source":"removed/package.json"} + ] + })).unwrap(); + let mut configurations = Vec::new(); + let mut snapshot = ProjectInputs { + inputs: BTreeMap::from([("visible/package.json".into(), "new".into())]), + unreadable_paths: BTreeSet::from(["blocked".into()]), + }; + retain_unknown_inputs(&mut configurations, &mut snapshot, previous); + assert_eq!(configurations.len(), 1); + assert_eq!(configurations[0].id, "blocked"); + assert_eq!( + snapshot + .inputs + .get("blocked/package.json") + .map(String::as_str), + Some("old-blocked") + ); + assert!(!snapshot.inputs.contains_key("removed/package.json")); + assert!(!under_unknown( + "blocked-sibling/package.json", + &snapshot.unreadable_paths + )); + } +} diff --git a/rust/crates/execution/src/configuration/document.rs b/rust/crates/execution/src/configuration/document.rs new file mode 100644 index 00000000..4ae13a0c --- /dev/null +++ b/rust/crates/execution/src/configuration/document.rs @@ -0,0 +1,252 @@ +//! The stored run-configuration document, its migrations and toolchain requirements. + +use super::*; + +/// Persisted workspace identity for re-preparing a build-provider application. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct BuildProviderRunReference { + /// Plugin that declared the build system. + #[serde(rename = "pluginID")] + pub plugin_id: String, + /// Build system identifier within that plugin. + #[serde(rename = "buildSystemID")] + pub build_system_id: String, + /// Workspace-relative provider root, forward-slashed. + pub root: String, + /// Run declaration identifier within the build system. + pub run: String, + /// Opaque project identity reported by the build model. + #[serde(rename = "projectID")] + pub project_id: String, + /// Opaque target identity reported by the build model. + #[serde(rename = "targetID")] + pub target_id: String, + /// Omitted older references are applications; the current declaration must match at launch. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub execution: Option, + /// User arguments appended after the provider's own arguments. + #[serde(default)] + pub user_arguments: Vec, +} + +impl BuildProviderRunReference { + /// Stable generated identity excludes mutable display names, execution kind and arguments. + pub(crate) fn generated_id(&self) -> String { + use sha2::{Digest, Sha256}; + let selector = json!([ + self.plugin_id, + self.build_system_id, + self.root, + self.run, + self.project_id, + self.target_id + ]); + let bytes = serde_json::to_vec(&selector).expect("selector contains only strings"); + format!("build.provider:{:x}", Sha256::digest(bytes)) + } + + /// Persistence validates identities only; current model membership is checked at launch. + pub(crate) fn validate(&self) -> Result<(), CoreError> { + let identifier = |value: &str| { + !value.is_empty() + && value.len() <= 200 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte)) + }; + if self.execution == Some(Execution::Group) + || !identifier(&self.plugin_id) + || !identifier(&self.build_system_id) + || !identifier(&self.run) + || [&self.project_id, &self.target_id] + .iter() + .any(|id| id.is_empty() || id.len() > 2048 || id.contains('\0')) + || self.user_arguments.len() > 64 + || self + .user_arguments + .iter() + .any(|arg| arg.len() > 4096 || arg.contains('\0')) + || invalid_relative_path(&self.root) + || self.root.is_empty() + || self.root.contains('\\') + || self.root.contains('\0') + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid build provider run reference", + )); + } + Ok(()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +/// Versioned run-configuration document stored below `.lithe/run`. +pub struct RunConfigurationDocument { + /// Document schema version; v1 documents are migrated on read. + pub version: u32, + /// Generation record; present only in the generated layer. + #[serde(default)] + pub generator: Option, + /// Entries in document order; later layers override by `id`. + #[serde(default)] + pub configurations: Vec, +} + +/// Rewrites a v1 document in place into the v2 shape. +/// +/// Ids are preserved verbatim. The three-layer merge matches configurations by +/// id, so rewriting them would silently detach every override a user wrote in +/// the team or local layer -- a failure with no error message. +pub fn migrate_document_value(document: &mut Value) -> bool { + if document.get("version").and_then(Value::as_u64) != Some(LEGACY_VERSION as u64) { + return false; + } + if let Some(items) = document + .get_mut("configurations") + .and_then(Value::as_array_mut) + { + for item in items { + migrate_configuration_value(item); + } + } + document["version"] = json!(VERSION); + true +} + +fn migrate_configuration_value(item: &mut Value) { + let Some(object) = item.as_object_mut() else { + return; + }; + // v1 entries kept tool-specific launch fields at the top level. Core no + // longer interprets any of them; they stay inert because RunConfiguration + // ignores unknown keys, and the owning provider regenerates its payload. + let legacy_type = object + .remove("type") + .and_then(|value| value.as_str().map(str::to_string)) + .filter(|value| valid_provider(value)) + .unwrap_or_else(|| "legacy.unknown".to_string()); + if let Some(working_directory) = object.remove("workingDirectory") { + object.insert("cwd".to_string(), working_directory); + } + object.insert("provider".to_string(), json!(legacy_type)); + object.insert("execution".to_string(), json!("task")); + object.insert("confidence".to_string(), json!("native")); +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Fingerprint and inputs used to decide whether generated output is stale. +/// +/// `inputs` maps each workspace-relative input to `sha256:` of its bytes. +pub struct GeneratorMetadata { + /// Combined hash over `inputs` and the generator revision. + pub fingerprint: String, + /// Identity of generic run declarations and opaque targets, separate from + /// the file inputs so a provider answer alone can mark the layer stale. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_run_fingerprint: Option, + /// Unreadable inputs retained as unknown; a partial baseline cannot prove freshness. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + pub unreadable_paths: BTreeSet, + /// Workspace-relative input path mapped to `sha256:` of its bytes. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub inputs: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Debug adapter supported by a runnable configuration. +pub struct DebugCapability { + /// Stable adapter identifier declared by the provider that owns the entry. + pub adapter: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Ecosystem-neutral command, identity, and launch metadata for one runnable item. +pub struct RunConfiguration { + /// Stable identity the team and local layers use to override this entry. + pub id: String, + /// Display name shown in the Run list. + pub name: String, + /// Open namespaced discriminator, e.g. `npm.script`, `cargo.run`. + /// Deliberately not an enum: new ecosystems must not require a contract change. + pub provider: String, + /// How the entry behaves once started. + #[serde(default)] + pub execution: Execution, + /// Whether the entry runs this project or the infrastructure it depends on. + /// Omitted for project entries so existing documents stay byte-identical. + #[serde(default, skip_serializing_if = "RunCategory::is_project")] + pub category: RunCategory, + /// Executable to start; absent for groups and provider-prepared launches. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub command: Option, + /// Arguments passed to `command`, without shell interpretation. + #[serde(default)] + pub args: Vec, + /// Working directory relative to the workspace root. + #[serde(default = "dot")] + pub cwd: String, + /// Environment variables added for this entry. + #[serde(default)] + pub env: BTreeMap, + /// How the entry was discovered; higher confidence wins deduplication. + #[serde(default)] + pub confidence: Confidence, + /// Toolchain role mapped to the requirement identity it consumes, such as + /// `runtime` to `project-node`. + #[serde(default)] + pub toolchains: BTreeMap, + /// Debug adapter that can launch this entry, when one is declared. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub debug: Option, + /// Configuration identities started together when `execution` is `group`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub members: Vec, + /// Ecosystem-specific payload keyed by namespace (`npm`, `build`, ...). + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub extensions: BTreeMap, + /// Hidden from the resolved list; set by a user or when the working directory is missing. + #[serde(default)] + pub disabled: bool, + /// Workspace-relative manifest or source file that produced the configuration. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, +} + +fn dot() -> String { + ".".to_string() +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Versioned requirements written separately from run configurations. +pub struct ToolchainRequirementsDocument { + /// Document version the reader must accept before trusting any requirement. + pub version: u32, + /// Requirement per toolchain kind, keyed by the kind the caller asks about. + #[serde(default)] + pub toolchains: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Constraints the host uses when selecting one toolchain candidate. +pub struct ToolchainRequirement { + /// Toolchain role matched against [`ToolchainCandidate::kind`]. + #[serde(rename = "type")] + pub kind: String, + /// Lowest acceptable version; newer releases also satisfy it. + #[serde(default)] + pub minimum_version: Option, + /// Vendor preferred when several candidates satisfy the version. + #[serde(default)] + pub preferred_vendor: Option, + /// Exact version prefix; [`Self::minimum_version`] accepts newer releases. + #[serde(default)] + pub version: Option, +} diff --git a/rust/crates/execution/src/configuration/editing.rs b/rust/crates/execution/src/configuration/editing.rs new file mode 100644 index 00000000..09155b9f --- /dev/null +++ b/rust/crates/execution/src/configuration/editing.rs @@ -0,0 +1,392 @@ +//! Writing user changes back to the configuration documents. + +use super::*; + +/// Produces a Run-document replacement without materializing legacy defaults. +pub fn update_tool_preferences(request: UpdateToolPreferencesRequest) -> Result { + let mut document = request.local_document; + // A preference edit must not trigger legacy Run migration or silently + // narrow an unknown 64-bit version into a supported document version. + if !matches!(document["version"].as_u64(), Some(1 | 2)) { + return Err(CoreError::new( + ErrorCode::NotSupported, + "Unsupported run configuration version", + )); + } + configuration_ids(&document)?; + validate_tool_preferences(&document)?; + lithe_toolchain::preferences::validate(&request.preferences) + .map_err(|error| CoreError::new(ErrorCode::InvalidRequest, error.message))?; + let object = document + .as_object_mut() + .ok_or_else(|| CoreError::new(ErrorCode::InvalidRequest, "Invalid local Run document"))?; + let toolchain = object.entry("toolchain").or_insert_with(|| json!({})); + let toolchain = toolchain.as_object_mut().ok_or_else(|| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid local tool preferences") + })?; + toolchain.insert("preferences".into(), request.preferences); + Ok(json!({"document":serde_json::to_string_pretty(&document).expect("validated Run document")})) +} + +/// Persists editable configuration options in the requested ownership layer. +pub fn update_options(mut request: UpdateOptionsRequest) -> Result { + let root = existing_root(&request.root)?; + if let Some(toolchain) = request.toolchain.take() { + if request.scope != "local" { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Toolchain paths can only be saved in the local layer", + )); + } + let document = update_toolchain_document(&root, request.local_document, toolchain)?; + return Ok(json!({ + "document": serde_json::to_string_pretty(&document).expect("document should encode") + })); + } + let document = update_configuration_options(&root, request)?; + Ok(json!({ + "document": serde_json::to_string_pretty(&document).expect("document should encode") + })) +} + +/// Produces every document needed to save the run-configuration editor as one operation. +pub fn save_editor_changes(mut request: UpdateOptionsRequest) -> Result { + let root = existing_root(&request.root)?; + let toolchain = request.toolchain.take().ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "Run configuration editor changes require a project toolchain", + ) + })?; + let toolchain_document = + update_local_toolchains_document(&root, &toolchain.runtime_executable_paths)?; + let mut local_document = + update_toolchain_document(&root, request.local_document.take(), toolchain)?; + request.local_document = Some(local_document.clone()); + let scope = request.scope.clone(); + let options_document = update_configuration_options(&root, request)?; + let project_document = if scope == "project" { + Some(serde_json::to_string_pretty(&options_document).expect("document should encode")) + } else { + local_document = options_document; + None + }; + Ok(json!({ + "localDocument": serde_json::to_string_pretty(&local_document).expect("document should encode"), + "projectDocument": project_document, + "toolchainDocument": toolchain_document.map(|document| { + serde_json::to_string_pretty(&document).expect("document should encode") + }) + })) +} + +/// Replaces only the generic preferences. Other document-level toolchain +/// fields belong to whichever host or plugin wrote them, so a Run editor save +/// must not reset them. +fn update_toolchain_document( + root: &Path, + local_document: Option, + toolchain: ToolchainPaths, +) -> Result { + let mut document = local_layer_document(root, local_document)?; + validate_version_value(&document)?; + let mut saved = document["toolchain"] + .as_object() + .cloned() + .unwrap_or_default(); + if let Some(preferences) = toolchain.preferences { + saved.insert("preferences".to_string(), preferences); + } + document["toolchain"] = Value::Object(saved); + validate_tool_preferences(&document)?; + Ok(document) +} + +fn update_local_toolchains_document( + root: &Path, + runtime_executable_paths: &BTreeMap, +) -> Result, CoreError> { + if runtime_executable_paths.is_empty() { + return Ok(None); + } + let mut document = read_local_toolchains(root)? + .unwrap_or_else(|| json!({"version": SIDECAR_VERSION, "toolchains": {}})); + validate_sidecar_version_value(&document)?; + let toolchains = document + .get_mut("toolchains") + .and_then(Value::as_object_mut) + .ok_or_else(|| { + CoreError::new( + ErrorCode::ParseFailed, + "Local toolchains must contain a toolchains object", + ) + })?; + for (id, executable_path) in runtime_executable_paths { + if executable_path.trim().is_empty() { + toolchains.remove(id); + } else { + toolchains.insert(id.clone(), json!({ "executable": executable_path.trim() })); + } + } + Ok(Some(document)) +} + +fn update_configuration_options( + root: &Path, + request: UpdateOptionsRequest, +) -> Result { + let relative = scope_document(&request.scope)?; + let resolved = resolve(ResolveRequest { + team_document: None, + root: request.root.clone(), + toolchain_candidates: Vec::new(), + local_document: request.local_document.clone(), + })?; + let provider = resolved["configurations"] + .as_array() + .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") + })?; + if let Some(options) = &request.provider_run_options { + if provider != "build.provider" { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Provider run options require a build provider reference", + )); + } + options.validate()?; + } + let working_directory = if request.working_directory.trim().is_empty() { + None + } else { + Some(normalize_working_directory( + root, + request.working_directory.trim(), + )?) + }; + let mut document = if request.scope == "local" { + local_layer_document(root, request.local_document)? + } else { + read_document_value(root, relative)? + .unwrap_or_else(|| json!({"version": VERSION, "configurations": []})) + }; + validate_version_value(&document)?; + let configurations = document["configurations"] + .as_array_mut() + .ok_or_else(|| CoreError::new(ErrorCode::ParseFailed, "configurations must be an array"))?; + let mut patch = json!({ + "id": request.configuration_id, + "env": request.environment + }); + if let Some(working_directory) = working_directory.as_ref() { + patch["cwd"] = json!(working_directory); + } + patch["args"] = json!(split_arguments(&request.arguments)); + if provider == "build.provider" { + if let Some(options) = &request.provider_run_options { + patch["extensions"] = json!({"build-run":options}); + } + } + if let Some(existing) = configurations + .iter_mut() + .find(|value| value["id"] == patch["id"]) + { + let target = existing.as_object_mut().ok_or_else(|| { + CoreError::new( + ErrorCode::ParseFailed, + "Run configuration must be an object", + ) + })?; + if working_directory.is_none() { + target.remove("cwd"); + } + for (key, value) in patch.as_object_mut().expect("patch is an object") { + if key == "extensions" { + merge_extensions(target, value)?; + continue; + } + target.insert(key.clone(), value.take()); + } + } else { + configurations.push(patch); + } + configurations.sort_by(|left, right| { + left["id"] + .as_str() + .unwrap_or("") + .cmp(right["id"].as_str().unwrap_or("")) + }); + Ok(document) +} + +/// Only explicit provider references make an ungenerated workspace runnable. +pub(crate) fn has_saved_build_run(document: &Value) -> bool { + document["configurations"] + .as_array() + .is_some_and(|items| items.iter().any(crate::provider_runs::is_explicit)) +} + +/// Upserts a local reference while preserving unrelated settings and stable IDs. +fn create_build_provider_configuration( + root: &Path, + request: &CreateUserConfigurationRequest, +) -> Result { + if request.scope != "local" { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Build provider references must be saved locally", + )); + } + let reference = request.build.as_ref().ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "Missing build provider run reference", + ) + })?; + reference.validate()?; + normalize_project_directory(root, &reference.root)?; + let name = request.name.trim(); + if name.is_empty() || name.len() > 256 || name.contains('\0') { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid run configuration name", + )); + } + let mut document = read_document_value(root, "run/local.json")? + .unwrap_or_else(|| json!({"version":VERSION,"configurations":[]})); + validate_version_value(&document)?; + let mut ids: std::collections::BTreeSet = + configuration_ids(&document)?.into_keys().collect(); + ids.extend(detected_ids(root)); + if let Some(other) = read_document_value(root, "run/configurations.json")? { + validate_version_value(&other)?; + ids.extend(configuration_ids(&other)?.into_keys()); + } + let encoded = serde_json::to_value(reference).expect("reference encodes"); + let mut identity = encoded.clone(); + identity.as_object_mut().unwrap().remove("userArguments"); + identity.as_object_mut().unwrap().remove("execution"); + let items = document["configurations"] + .as_array_mut() + .ok_or_else(|| CoreError::new(ErrorCode::ParseFailed, "configurations must be an array"))?; + let existing = items.iter().position(|item| { + if item["provider"] != "build.provider" { + return false; + } + let mut saved = item["extensions"]["build"].clone(); + if let Some(object) = saved.as_object_mut() { + object.remove("userArguments"); + object.remove("execution"); + } + saved == identity + }); + let id = existing + .and_then(|index| items[index]["id"].as_str()) + .map(str::to_string) + .unwrap_or_else(|| unique_user_configuration_id(name, &ids)); + let mut configuration = existing + .map(|index| items[index].clone()) + .unwrap_or_else(|| json!({})); + let object = configuration.as_object_mut().ok_or_else(|| { + CoreError::new( + ErrorCode::ParseFailed, + "Run configuration must be an object", + ) + })?; + let managed = json!({"id":id,"name":name,"provider":"build.provider","execution":reference.execution.unwrap_or_default(), + "confidence":"declared"}); + object.entry("cwd").or_insert_with(|| json!(reference.root)); + object.extend( + managed + .as_object() + .expect("managed fields are an object") + .clone(), + ); + // Other namespaces may hold user/plugin metadata; replacing the whole + // entry would erase data unrelated to this selector update. + let extensions = object + .entry("extensions") + .or_insert_with(|| json!({})) + .as_object_mut() + .ok_or_else(|| { + CoreError::new( + ErrorCode::ParseFailed, + "Run configuration extensions must be an object", + ) + })?; + extensions.insert("build".to_string(), encoded); + let typed: RunConfiguration = serde_json::from_value(configuration.clone()) + .map_err(|_| CoreError::new(ErrorCode::ParseFailed, "Invalid saved run configuration"))?; + validate_configuration(&typed)?; + if let Some(index) = existing { + items[index] = configuration; + } else { + items.push(configuration); + } + items.sort_by(|a, b| a["id"].as_str().cmp(&b["id"].as_str())); + Ok( + json!({"id":id,"document":serde_json::to_string_pretty(&document).expect("document encodes")}), + ) +} + +/// Creates a user configuration while preserving stable IDs in existing layers. +/// +/// Only build provider references can be authored: every other runnable entry +/// is declared by the plugin that owns its launch semantics. +pub fn create_user_configuration( + request: CreateUserConfigurationRequest, +) -> Result { + let root = existing_root(&request.root)?; + if request.kind == "buildProvider" { + return create_build_provider_configuration(&root, &request); + } + Err(CoreError::new( + ErrorCode::NotSupported, + "Only build provider configurations can be created", + )) +} + +fn unique_user_configuration_id( + name: &str, + existing_ids: &std::collections::BTreeSet, +) -> String { + let mut slug = String::new(); + let mut separator = false; + for character in name.to_lowercase().chars() { + if character.is_alphanumeric() { + slug.push(character); + separator = false; + } else if !slug.is_empty() { + separator = true; + } + if separator && !slug.ends_with('-') { + slug.push('-'); + } + } + let slug = slug.trim_matches('-'); + let base = format!( + "user:{}", + if slug.is_empty() { + "configuration" + } else { + slug + } + ); + if !existing_ids.contains(&base) { + return base; + } + let mut suffix = 2; + loop { + let candidate = format!("{base}-{suffix}"); + if !existing_ids.contains(&candidate) { + return candidate; + } + suffix += 1; + } +} diff --git a/rust/crates/execution/src/configuration/generation.rs b/rust/crates/execution/src/configuration/generation.rs new file mode 100644 index 00000000..238ce1c8 --- /dev/null +++ b/rust/crates/execution/src/configuration/generation.rs @@ -0,0 +1,285 @@ +//! Inspecting a project and generating its detected configurations. + +use super::*; + +/// Validates configuration and sidecar documents without mutating the workspace. +pub fn inspect(request: InspectRequest) -> Result { + let root = existing_root(&request.root)?; + let layer = generated_layer::get(&root); + let generated = layer.as_ref().map(|layer| &layer.document); + let requirements = layer.as_ref().map(|layer| &layer.requirements); + let local_toolchains = read_local_toolchains(&root)?; + for relative in [ + "run/configurations.json", + "run/local.json", + "toolchains/local.json", + "project.json", + ] { + if relative == "run/local.json" { + if let Some(document) = request.local_document.as_ref() { + let mut migrated = document.clone(); + migrate_document_value(&mut migrated); + validate_version_value(&migrated)?; + configuration_ids(&migrated)?; + continue; + } + } + if let Some(document) = read_document_value(&root, relative)? { + if relative.starts_with("run/") { + validate_version_value(&document)?; + configuration_ids(&document)?; + } else { + validate_sidecar_version_value(&document)?; + if relative == "toolchains/local.json" + && document + .get("toolchains") + .and_then(Value::as_object) + .is_none() + { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Local toolchains must contain a toolchains object", + )); + } + } + } + } + let mut diagnostics = Vec::new(); + if request.check_fingerprint == Some(false) { + if let Some(metadata) = generated.and_then(|document| document.generator.as_ref()) { + if !metadata.unreadable_paths.is_empty() { + diagnostics.push(incomplete_scan_diagnostic(&metadata.unreadable_paths)); + } + } + } + let local = local_layer_document(&root, request.local_document.clone())?; + let team = read_document_value(&root, "run/configurations.json")? + .unwrap_or_else(|| json!({"version": VERSION, "configurations": []})); + if let Some(metadata) = generated + .and_then(|document| document.generator.as_ref()) + .filter(|_| request.check_fingerprint != Some(false)) + { + let current_inputs = project_inputs(&root)?; + if !current_inputs.unreadable_paths.is_empty() || !metadata.unreadable_paths.is_empty() { + let unknown = current_inputs + .unreadable_paths + .union(&metadata.unreadable_paths) + .cloned() + .collect(); + diagnostics.push(incomplete_scan_diagnostic(&unknown)); + } else if metadata.fingerprint != fingerprint_from_inputs(¤t_inputs.inputs) { + // Stored inputs that no longer reproduce the stored fingerprint were + // recorded by another generator revision, whose input rules (such + // as which manifests are hashed) may differ from today's. + let generator_changed = + fingerprint_from_inputs(&metadata.inputs) != metadata.fingerprint; + let message = if metadata.inputs.is_empty() { + "Project inputs changed after run configuration generation".to_string() + } else if generator_changed || metadata.inputs == current_inputs.inputs { + "Run configuration generator changed; regenerate configurations".to_string() + } else { + input_change_summary(&metadata.inputs, ¤t_inputs.inputs) + }; + diagnostics.push(json!({ + "code": "staleFingerprint", + "message": message + })); + } + } + if let Some(runs) = &request.provider_runs { + crate::provider_runs::validate(runs)?; + if let Some(document) = generated { + let expected = crate::provider_runs::fingerprint(runs); + if document + .generator + .as_ref() + .and_then(|metadata| metadata.provider_run_fingerprint.as_deref()) + != Some(expected.as_str()) + { + diagnostics.push(json!({"code":"staleProviderModel", "message":"Build provider model changed; regenerate Run configurations"})); + } + } + } + Ok(json!({ + "status": if generated.is_some() || has_saved_build_run(&local) || has_saved_build_run(&team) { "ready" } else { "missing" }, + // Saved provider tasks remain runnable before detection, but must not + // suppress discovery of the other entries after a process restart. + "requiresGeneration": generated.is_none(), + "generated": generated, + "toolchainRequirements": requirements, + "localToolchains": local_toolchains, + "toolchain": local.get("toolchain"), + "diagnostics": diagnostics, + "paths": { "configurations": ".lithe/run/configurations.json", "local": ".lithe/run/local.json" } + })) +} + +/// Detects runnable project entries and replaces the workspace's detected layer. +/// +/// The layer is held in memory (and in the host's cache directory when given), +/// never written into the workspace. +pub fn generate(request: GenerateRequest) -> Result { + let root = existing_root(&request.root)?; + let cache_directory = request.cache_directory.map(PathBuf::from); + let previous = previous_generation(&root, cache_directory.as_deref()); + let mut configurations = detected_configurations(&root)?; + if let Some(runs) = &request.provider_runs { + crate::provider_runs::validate(runs)?; + let claimed = configurations + .iter() + .map(|item| item.id.clone()) + .collect::>(); + configurations.extend( + crate::provider_runs::configurations(runs) + .into_iter() + .filter(|item| !claimed.contains(&item.id)), + ); + } + let mut snapshot = project_inputs(&root)?; + // Unknown subtrees cannot delete known configurations or baseline inputs. + // Invalid required documents still fail instead of being silently overwritten. + let authoritative_service_ids = request.provider_runs.as_ref().map(|_| { + configurations + .iter() + .filter(|item| item.provider == "build.provider") + .map(|item| item.id.clone()) + .collect::>() + }); + + if !snapshot.unreadable_paths.is_empty() { + if let Some(previous) = previous { + retain_unknown_inputs(&mut configurations, &mut snapshot, previous); + } + } + if let Some(ids) = authoritative_service_ids { + configurations.retain(|item| item.provider != "build.provider" || ids.contains(&item.id)); + } + disambiguate_configuration_names(&mut configurations); + let entry_count = configurations.len(); + let requirements = detect_requirements(&root, &configurations)?; + let generated = RunConfigurationDocument { + version: VERSION, + generator: Some(GeneratorMetadata { + provider_run_fingerprint: request + .provider_runs + .as_deref() + .map(crate::provider_runs::fingerprint), + fingerprint: fingerprint_from_inputs(&snapshot.inputs), + inputs: snapshot.inputs, + unreadable_paths: snapshot.unreadable_paths, + }), + configurations, + }; + let response = json!({ + "generated": generated, + "toolchainRequirements": requirements, + "entryCount": entry_count + }); + if let Some(cache_directory) = cache_directory.as_deref() { + generated_layer::cache_document(cache_directory, &root, &generated); + } + generated_layer::store( + &root, + GeneratedLayer { + document: generated, + requirements, + }, + ); + Ok(response) +} + +/// The detected layer the previous generation produced: this session's, else +/// the one cached by an earlier session, else the `.lithe/run/generated.json` +/// older Lithe versions wrote into the workspace (read, never written). +/// +/// Generation uses it only to keep entries under a subtree it cannot read: +/// replaying them keeps ids and user overrides attached until the subtree is +/// readable again. An unreadable or invalid previous document contributes +/// nothing, because this generation replaces it. +fn previous_generation( + root: &Path, + cache_directory: Option<&Path>, +) -> Option { + if let Some(layer) = generated_layer::get(root) { + return Some(layer.document.clone()); + } + let mut document = cache_directory + .and_then(|directory| generated_layer::cached_document(directory, root)) + .or_else(|| { + read_document_value(root, "run/generated.json") + .ok() + .flatten() + })?; + migrate_document_value(&mut document); + serde_json::from_value::(document) + .ok() + .filter(|document| validate_version(document.version).is_ok()) +} + +/// Qualifies display names that repeat across directories or modules. +/// +/// Ids already carry the directory, but the Run list shows only the name: three +/// Compose files each contributing `compose up` are otherwise +/// indistinguishable. The qualifier is the first +/// candidate that separates every entry in the group, so the shortest useful +/// label wins and unique names are never decorated. +fn disambiguate_configuration_names(configurations: &mut [RunConfiguration]) { + let mut groups: BTreeMap> = BTreeMap::new(); + for (index, configuration) in configurations.iter().enumerate() { + groups + .entry(configuration.name.to_lowercase()) + .or_default() + .push(index); + } + for indexes in groups.into_values().filter(|indexes| indexes.len() > 1) { + let candidates: [fn(&RunConfiguration) -> Option; 2] = [ + |configuration| Some(configuration.cwd.clone()).filter(|cwd| cwd != "."), + |configuration| configuration.source.clone(), + ]; + let Some(qualifier) = candidates.into_iter().find(|candidate| { + let values = indexes + .iter() + .map(|index| candidate(&configurations[*index])) + .collect::>>(); + values.is_some_and(|values| values.len() == indexes.len()) + }) else { + continue; + }; + for index in indexes { + if let Some(value) = qualifier(&configurations[index]) { + configurations[index].name = format!("{} ({value})", configurations[index].name); + } + } + } +} + +/// Translates detector output into the run-configuration contract. +/// +/// Nearly every detection is process-based: the detector resolved the command, +/// so there is no ecosystem-specific launch assembly. A process may still name +/// a runtime requirement for scoped compatibility diagnostics; a detection that +/// transfers executable ownership to toolchains carries no command. +fn detected_configurations(root: &Path) -> Result, CoreError> { + Ok(crate::detectors::detect_all(root)? + .into_iter() + .map(|item| RunConfiguration { + id: item.id(), + name: item.name, + provider: item.provider, + execution: item.execution, + category: item.category, + command: item.command, + args: item.args, + cwd: item.cwd, + env: item.env, + confidence: item.confidence, + toolchains: item.toolchains, + debug: None, + members: Vec::new(), + extensions: item.extensions, + disabled: false, + source: Some(item.source), + }) + .filter(|item| validate_configuration(item).is_ok()) + .collect()) +} diff --git a/rust/crates/execution/src/configuration/inputs.rs b/rust/crates/execution/src/configuration/inputs.rs new file mode 100644 index 00000000..b4c0fef7 --- /dev/null +++ b/rust/crates/execution/src/configuration/inputs.rs @@ -0,0 +1,209 @@ +//! Project inputs that fingerprint generated configurations. + +use super::*; + +/// Collects generation inputs; every input is hashed by content. +pub(crate) struct ProjectInputs { + pub(crate) inputs: BTreeMap, + pub(crate) unreadable_paths: BTreeSet, +} + +// Retention is independent of filesystem permissions so both hosts preserve the +// same last-known configuration when an optional subtree becomes unreadable. +pub(crate) fn retain_unknown_inputs( + configurations: &mut Vec, + snapshot: &mut ProjectInputs, + previous: RunConfigurationDocument, +) { + for configuration in previous.configurations { + let source = configuration.source.as_deref(); + if source.is_some_and(|path| under_unknown(path, &snapshot.unreadable_paths)) + && !configurations + .iter() + .any(|current| current.id == configuration.id) + { + configurations.push(configuration); + } + } + if let Some(metadata) = previous.generator { + for (path, hash) in metadata.inputs { + if under_unknown(&path, &snapshot.unreadable_paths) { + snapshot.inputs.entry(path).or_insert(hash); + } + } + } +} + +pub(crate) fn under_unknown(path: &str, unknown: &BTreeSet) -> bool { + unknown + .iter() + .any(|prefix| path == prefix || path.starts_with(&format!("{prefix}/"))) +} + +pub(crate) fn incomplete_scan_diagnostic(paths: &BTreeSet) -> Value { + json!({"code": "incompleteProjectScan", "message": format!( + "Project scan is incomplete; freshness and removals could not be verified. Unreadable inputs: {}", + paths.iter().cloned().collect::>().join(", "))}) +} + +pub(crate) fn project_inputs(root: &Path) -> Result { + let scan = crate::workspace_scan::scan_run_workspace(root, None)?; + let mut result = ProjectInputs { + inputs: BTreeMap::new(), + unreadable_paths: scan.unreadable_paths, + }; + for directory in scan.directories { + for name in directory.files { + let path = directory.path.join(name); + if !fingerprint_input(&path) { + continue; + } + let relative = path + .strip_prefix(root) + .expect("scan stays within root") + .to_string_lossy() + .replace('\\', "/"); + lithe_protocol::cancellation::check()?; + // The scan lists files through the workspace adapter, so content reads + // must use the same adapter or remote projects look permanently unreadable. + match lithe_workspace::fs::read(&path) { + Ok(bytes) => { + result + .inputs + .insert(relative, format!("sha256:{:x}", Sha256::digest(bytes))); + } + Err(_) => { + result.unreadable_paths.insert(relative); + } + } + } + } + Ok(result) +} + +pub(crate) fn fingerprint_from_inputs(inputs: &BTreeMap) -> String { + let mut digest = Sha256::new(); + // Detection changes invalidate persisted output even when project files are + // unchanged, so an application upgrade cannot keep launching a stale plan. + digest.update(GENERATOR_REVISION.as_bytes()); + digest.update([0]); + for (relative, content_hash) in inputs { + digest.update(relative.as_bytes()); + digest.update([0]); + digest.update(content_hash.as_bytes()); + digest.update([0]); + } + format!("sha256:{:x}", digest.finalize()) +} + +pub(crate) fn input_change_summary( + previous: &BTreeMap, + current: &BTreeMap, +) -> String { + let added = current + .keys() + .filter(|path| !previous.contains_key(*path)) + .count(); + let removed = previous + .keys() + .filter(|path| !current.contains_key(*path)) + .count(); + let changed = current + .iter() + .filter(|(path, hash)| previous.get(*path).is_some_and(|old| old != *hash)) + .count(); + format!("Project inputs changed: {added} added, {removed} removed, {changed} modified") +} + +fn fingerprint_input(path: &Path) -> bool { + matches!( + path.file_name().and_then(|name| name.to_str()), + Some( + "mise.toml" + | "package.json" + | "package-lock.json" + | "pnpm-lock.yaml" + | "yarn.lock" + | "bun.lock" + | "bun.lockb" + | "docker-compose.yml" + | "docker-compose.yaml" + | "compose.yml" + | "compose.yaml" + | "pyproject.toml" + | "manage.py" + | "Cargo.toml" + | "rust-toolchain" + | "rust-toolchain.toml" + | "go.mod" + | "Procfile" + | "Procfile.dev" + | "Procfile.local" + | "Makefile" + | "makefile" + | "GNUmakefile" + | "justfile" + | "Justfile" + | ".justfile" + ) + ) +} + +pub(crate) fn declared_node_version(root: &Path) -> Option { + highest_version( + project_manifest_paths(root, &["package.json"]) + .into_iter() + .filter_map(|path| lithe_workspace::fs::read_to_string(path).ok()) + .filter_map(|text| serde_json::from_str::(&text).ok()) + .filter_map(|document| { + document + .get("engines")? + .get("node")? + .as_str() + .and_then(first_numeric_version) + }) + .collect(), + ) +} + +fn first_numeric_version(value: &str) -> Option { + regex::Regex::new(r"[0-9]+(?:\.[0-9]+)+") + .ok()? + .find(value) + .map(|value| value.as_str().to_string()) +} + +fn highest_version(versions: Vec) -> Option { + versions + .into_iter() + .max_by(|left, right| version_parts(left).cmp(&version_parts(right))) +} + +fn project_manifest_paths(root: &Path, names: &[&str]) -> Vec { + // This heuristic only supplements declared toolchain requirements. Fingerprint + // scanning separately reports unknown subtrees to both product hosts. + let Ok(scan) = crate::workspace_scan::scan_run_workspace(root, None) else { + return Vec::new(); + }; + scan.directories + .into_iter() + .flat_map(|directory| { + directory + .files + .into_iter() + .filter(|name| names.contains(&name.as_str())) + .map(move |name| directory.path.join(name)) + }) + .collect() +} + +pub(crate) fn existing_root(value: &str) -> Result { + let path = PathBuf::from(value); + if !path.is_dir() { + return Err(CoreError::new( + ErrorCode::WorkspaceNotFound, + "Project root does not exist", + )); + } + Ok(path) +} diff --git a/rust/crates/execution/src/configuration/launch.rs b/rust/crates/execution/src/configuration/launch.rs new file mode 100644 index 00000000..9f1fe55d --- /dev/null +++ b/rust/crates/execution/src/configuration/launch.rs @@ -0,0 +1,258 @@ +//! Launch inputs and launch plans of a resolved configuration. + +use super::*; + +/// Extracts existing Run document values; framework goals and runtime semantics stay in providers. +pub fn provider_launch_inputs(request: ProviderLaunchInputsRequest) -> Result { + let root = existing_root(&request.root)?; + if invalid_relative_path(&request.provider_root) + || !project_directory_exists(&root, &request.provider_root) + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The provider instance leaves the workspace.", + )); + } + let explicit_cwd = configuration_override_has_key( + &root, + request.local_document.as_ref(), + &request.configuration_id, + "cwd", + )?; + let explicit_arguments = configuration_override_has_key( + &root, + request.local_document.as_ref(), + &request.configuration_id, + "args", + )?; + let resolved = resolve(ResolveRequest { + team_document: None, + root: request.root, + toolchain_candidates: Vec::new(), + local_document: request.local_document, + })?; + let config = resolved["configurations"] + .as_array() + .and_then(|items| { + items + .iter() + .find(|value| value["id"] == request.configuration_id) + }) + .ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "Run configuration is unavailable.", + ) + })?; + if config["provider"] == "build.provider" { + let reference: BuildProviderRunReference = + serde_json::from_value(config["extensions"]["build"].clone()).map_err(|_| { + CoreError::new( + ErrorCode::ParseFailed, + "Invalid build provider run reference", + ) + })?; + reference.validate()?; + if reference.root != request.provider_root || config["disabled"] == true { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Run configuration belongs to a different provider instance or is disabled.", + )); + } + let arguments = if explicit_arguments { + config["args"].clone() + } else { + json!(reference.user_arguments) + }; + let choices = crate::provider_run_options::BuildProviderRunOptions::from_extensions( + &serde_json::from_value(config["extensions"].clone()).map_err(|_| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid provider run options") + })?, + )?; + let mut options = json!({"execution":reference.execution.unwrap_or_default(),"runtimeArguments":choices.runtime_arguments, + "arguments":arguments,"environment":config["env"]}); + if !choices.entrypoint.is_empty() { + options["entrypoint"] = json!(choices.entrypoint); + } + // Saving an older selector materializes its root as cwd. Keep that + // default opaque for v4 applications; only an actual override is v10. + if explicit_cwd + && (config["cwd"] != reference.root + || request.configuration_id == reference.generated_id()) + { + options["workingDirectory"] = config["cwd"].clone(); + } + return Ok( + json!({"buildSystemID":reference.build_system_id,"provider":reference.run,"projectPath":reference.root, + "launchOptions":options,"optionOverrides":choices.options,"tools":choices.active_tools(),"reference":reference}), + ); + } + Err(CoreError::new( + ErrorCode::InvalidRequest, + "Run configuration has no provider-owned launch inputs.", + )) +} + +/// Resolves one configuration into the exact executable, arguments, and environment. +/// +/// Core only assembles process-based plans: a detector or provider either named +/// the command, or transferred executable ownership to a runtime toolchain. +/// Ecosystem launch semantics stay in the plugin that declared the run. +pub fn create_launch_plan(request: LaunchPlanRequest) -> Result { + existing_root(&request.root)?; + let resolved = resolve(ResolveRequest { + team_document: None, + root: request.root, + toolchain_candidates: Vec::new(), + local_document: request.local_document, + })?; + let config = resolved["configurations"] + .as_array() + .and_then(|items| items.iter().find(|v| v["id"] == request.configuration_id)) + .ok_or_else(|| { + CoreError::new(ErrorCode::InvalidRequest, "Run configuration was not found") + })?; + if config["disabled"].as_bool().unwrap_or(false) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Run configuration is disabled", + )); + } + if let Some(command) = config["command"].as_str().filter(|value| !value.is_empty()) { + return process_launch_plan(config, command); + } + if let Some(toolchain) = config["toolchains"]["runtime"] + .as_str() + .filter(|value| !value.is_empty()) + { + return toolchain_process_launch_plan(config, toolchain); + } + Err(CoreError::new( + ErrorCode::InvalidRequest, + "Run provider must declare a command or runtime toolchain", + ) + .with_details(config["provider"].as_str().unwrap_or(""))) +} + +/// Returns whether a user-owned layer explicitly sets one configuration key. +/// +/// Generated `cwd: "."` is the schema default and may inherit the provider +/// root. A project or local `cwd: "."`, however, is an intentional override +/// and must keep the workspace root. +fn configuration_override_has_key( + root: &Path, + provided_local: Option<&Value>, + configuration_id: &str, + key: &str, +) -> Result { + let team = read_document_value(root, "run/configurations.json")? + .unwrap_or_else(|| json!({"version": VERSION, "configurations": []})); + let local = local_layer_document(root, provided_local.cloned())?; + for document in [&team, &local] { + validate_version_value(document)?; + if document["configurations"] + .as_array() + .and_then(|items| items.iter().find(|item| item["id"] == configuration_id)) + .is_some_and(|item| item.get(key).is_some()) + { + return Ok(true); + } + } + Ok(false) +} + +/// Launch plan for configurations that name their own executable. +/// +/// `executable.command` is a bare program name resolved on PATH by the host, as +/// opposed to `executable.toolchain` which the host resolves from its toolchain +/// registry. The two are mutually exclusive and the host branches on which key +/// is present. No toolchain home variable is injected: the plan carries only +/// the configuration's own environment. +fn process_launch_plan(config: &Value, command: &str) -> Result { + if command.contains('/') || command.contains('\\') { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Run configuration command must be a bare program name", + ) + .with_details(command)); + } + let arguments = config["args"].as_array().cloned().unwrap_or_default(); + if arguments.iter().any(|value| !value.is_string()) { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Run configuration arguments must be strings", + )); + } + Ok(json!({ + "executable": { "command": command }, + "arguments": arguments, + "workingDirectory": config["cwd"].as_str().unwrap_or("."), + // `environment` is reserved for toolchain-derived values the host must + // resolve. Literal key/value pairs stay in `env` so the two never + // collide in one field. + "environment": {}, + "env": config["env"].as_object().cloned().unwrap_or_default() + })) +} + +/// Launch plan for a language provider whose executable is supplied by the +/// host Toolchain Registry. The shared contract uses the stable `runtime` key; +/// provider-specific SDK metadata stays in other toolchain/extension entries. +fn toolchain_process_launch_plan(config: &Value, toolchain: &str) -> Result { + let arguments = config["args"].as_array().cloned().unwrap_or_default(); + if arguments.iter().any(|value| !value.is_string()) { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Run configuration arguments must be strings", + )); + } + Ok(json!({ + "executable": { "toolchain": toolchain }, + "arguments": arguments, + "workingDirectory": config["cwd"].as_str().unwrap_or("."), + "environment": {}, + "env": config["env"].as_object().cloned().unwrap_or_default() + })) +} + +pub(crate) fn split_arguments(input: &str) -> Vec { + let mut result = Vec::new(); + let mut current = String::new(); + let mut started = false; + let mut quote = None; + let mut escaped = false; + for character in input.chars() { + // Quoting can deliberately start an empty argv item. + if !character.is_whitespace() || quote.is_some() || escaped { + started = true; + } + if escaped { + current.push(character); + escaped = false; + } else if character == '\\' && quote != Some('\'') { + escaped = true; + } else if matches!(character, '\'' | '"') { + if quote == Some(character) { + quote = None; + } else if quote.is_none() { + quote = Some(character); + } else { + current.push(character); + } + } else if character.is_whitespace() && quote.is_none() { + if started { + result.push(std::mem::take(&mut current)); + started = false; + } + } else { + current.push(character); + } + } + if escaped { + current.push('\\'); + } + if started { + result.push(current); + } + result +} diff --git a/rust/crates/execution/src/configuration/layers.rs b/rust/crates/execution/src/configuration/layers.rs new file mode 100644 index 00000000..373ebe40 --- /dev/null +++ b/rust/crates/execution/src/configuration/layers.rs @@ -0,0 +1,147 @@ +//! Reading the configuration layers and normalizing their directories. + +use super::*; + +pub(crate) fn local_layer_document( + root: &Path, + provided: Option, +) -> Result { + if let Some(mut document) = provided { + // A host-owned local layer may still carry the v1 shape, mirroring the + // migration applied to the on-disk document below. + migrate_document_value(&mut document); + validate_version_value(&document)?; + configuration_ids(&document)?; + return Ok(document); + } + Ok(read_document_value(root, "run/local.json")? + .unwrap_or_else(|| json!({"version": VERSION, "configurations": []}))) +} + +pub(crate) fn scope_document(scope: &str) -> Result<&'static str, CoreError> { + match scope { + "local" => Ok("run/local.json"), + "project" => Ok("run/configurations.json"), + _ => Err(CoreError::new( + ErrorCode::InvalidRequest, + "Run configuration scope must be local or project", + )), + } +} + +/// Validates the literal value of a `cwd` override before it is persisted. +/// +/// Resolution disables a configuration whose `cwd` is missing and hides it from +/// the run list, so a directory that does not exist must fail here, where the +/// editor can show the error, instead of making the saved entry disappear. +/// Values are not expanded: `${workspaceFolder}` style variables from other +/// editors would otherwise be stored as a literal directory name. +pub(crate) fn normalize_working_directory(root: &Path, value: &str) -> Result { + normalize_project_directory(root, value).map_err(|error| { + if value.contains('$') && !root.join(value).is_dir() { + CoreError::new( + ErrorCode::InvalidRequest, + "Working directory variables are not supported; use a path relative to the project", + ) + .with_details(value) + } else { + error + } + }) +} + +pub(crate) fn normalize_project_directory(root: &Path, value: &str) -> Result { + let candidate = Path::new(value); + if (!candidate.is_absolute() && invalid_relative_path(value)) + || candidate + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Project configuration paths must stay inside the project", + )); + } + let canonical_root = fs::canonicalize(root)?; + let target = if candidate.is_absolute() { + candidate.to_path_buf() + } else { + root.join(candidate) + }; + let canonical_target = fs::canonicalize(&target).map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Project configuration directory does not exist", + ) + .with_details(format!("{}: {error}", target.display())) + })?; + let relative = canonical_target + .strip_prefix(&canonical_root) + .map_err(|_| { + CoreError::new( + ErrorCode::InvalidRequest, + "Project configuration paths must stay inside the project", + ) + })?; + if relative.as_os_str().is_empty() { + Ok(".".to_string()) + } else { + Ok(relative.to_string_lossy().replace('\\', "/")) + } +} + +pub(crate) fn project_directory_exists(root: &Path, value: &str) -> bool { + if invalid_relative_path(value) { + return false; + } + let Ok(canonical_root) = fs::canonicalize(root) else { + return false; + }; + let Ok(canonical_target) = fs::canonicalize(root.join(value)) else { + return false; + }; + canonical_target.is_dir() && canonical_target.starts_with(canonical_root) +} + +pub(crate) fn read_document_value(root: &Path, relative: &str) -> Result, CoreError> { + let path = root.join(".lithe").join(relative); + if !path.exists() { + return Ok(None); + } + let text = fs::read_to_string(&path).map_err(|e| { + CoreError::new( + ErrorCode::PermissionDenied, + "Could not read run configuration", + ) + .with_details(e.to_string()) + })?; + let mut value: Value = serde_json::from_str(&text).map_err(|e| { + CoreError::new( + ErrorCode::ParseFailed, + format!("Configuration JSON is invalid: .lithe/{relative}"), + ) + .with_details(e.to_string()) + })?; + // Only run/* documents carry the run-configuration schema. project.json and + // the toolchain files share the .lithe root and a version field, but their + // schema is unrelated -- migrating them would bump a version they never own. + if relative.starts_with("run/") { + migrate_document_value(&mut value); + } + Ok(Some(value)) +} + +/// Ids of the configurations this session detected for `root`; a user +/// configuration must not reuse one. +pub(crate) fn detected_ids(root: &Path) -> BTreeSet { + generated_layer::get(root) + .map(|layer| { + layer + .document + .configurations + .iter() + .map(|configuration| configuration.id.clone()) + .collect() + }) + .unwrap_or_default() +} diff --git a/rust/crates/execution/src/configuration/merge.rs b/rust/crates/execution/src/configuration/merge.rs new file mode 100644 index 00000000..e16c0b93 --- /dev/null +++ b/rust/crates/execution/src/configuration/merge.rs @@ -0,0 +1,244 @@ +//! Merging the shared, local and detected layers of a configuration. + +use super::*; + +pub(crate) fn merge_values( + base: &Value, + team: &Value, + local: &Value, +) -> Result, CoreError> { + let mut result: BTreeMap = BTreeMap::new(); + let mut saved = BTreeMap::::new(); + let mut complete_ids = BTreeSet::new(); + for source in [team, local] { + for item in source["configurations"].as_array().into_iter().flatten() { + let Some(id) = item["id"].as_str() else { + continue; + }; + if crate::provider_runs::is_explicit(item) { + complete_ids.insert(id.to_string()); + } + if let Some(previous) = saved.get_mut(id) { + merge_configuration_value(previous, item)?; + } else { + saved.insert(id.to_string(), item.clone()); + } + } + } + let explicit_selectors = saved + .values() + .filter(|item| { + crate::provider_runs::is_explicit(item) + && item["id"] + .as_str() + .is_some_and(|id| complete_ids.contains(id)) + }) + .filter_map(crate::provider_runs::selector) + .collect::>(); + for (source_index, source) in [base, team, local].into_iter().enumerate() { + let Some(items) = source.get("configurations").and_then(Value::as_array) else { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "configurations must be an array", + )); + }; + for item in items { + let Some(id) = item.get("id").and_then(Value::as_str) else { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Run configuration id is required", + )); + }; + // A complete saved selector supersedes only its automatically generated + // counterpart. Distinct user configurations for that target remain distinct. + if source_index == 0 + && crate::provider_runs::selector(item) + .is_some_and(|selector| explicit_selectors.contains(&selector)) + { + continue; + } + if source_index > 0 + && !result.contains_key(id) + && !id.starts_with("user:") + && !crate::provider_runs::is_explicit(item) + { + continue; + } + if let Some(existing) = result.get_mut(id) { + merge_configuration_value(existing, item)?; + let target = existing + .as_object_mut() + .expect("merged configuration is an object"); + target.insert( + "source".to_string(), + json!(["generated", "project", "local"][source_index]), + ); + } else { + let mut value = item.clone(); + if let Some(object) = value.as_object_mut() { + object.insert( + "source".to_string(), + json!(["generated", "project", "local"][source_index]), + ); + } + result.insert(id.to_string(), value); + } + } + } + result + .into_values() + .map(|item| { + serde_json::from_value::(item).map_err(|e| { + CoreError::new(ErrorCode::ParseFailed, "Invalid merged run configuration") + .with_details(e.to_string()) + }) + }) + .collect() +} + +/// Applies one sparse configuration patch using the same rules for claim discovery and resolution. +fn merge_configuration_value(existing: &mut Value, item: &Value) -> Result<(), CoreError> { + let (Some(target), Some(patch)) = (existing.as_object_mut(), item.as_object()) else { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Run configuration must be an object", + )); + }; + for (key, value) in patch { + if key == "source" { + continue; + } + if key == "extensions" { + merge_extensions(target, &mut value.clone())?; + continue; + } + if key == "toolchains" { + let target_map = target + .entry(key.clone()) + .or_insert_with(|| json!({})) + .as_object_mut() + .ok_or_else(|| { + CoreError::new( + ErrorCode::ParseFailed, + "Run configuration toolchains must be an object", + ) + })?; + let patch_map = value.as_object().ok_or_else(|| { + CoreError::new( + ErrorCode::ParseFailed, + "Run configuration toolchains must be an object", + ) + })?; + for (toolchain_kind, toolchain_id) in patch_map { + target_map.insert(toolchain_kind.clone(), toolchain_id.clone()); + } + } else { + target.insert(key.clone(), value.clone()); + } + } + Ok(()) +} + +/// Reconciles runtime bindings from the effective command for generated v2 +/// documents written before detectors declared their consumption explicitly. +pub(crate) fn normalize_runtime_consumption(configurations: &mut [RunConfiguration]) { + for configuration in configurations { + let command = configuration + .command + .as_deref() + .unwrap_or("") + .to_ascii_lowercase(); + if matches!( + command.as_str(), + "npm" | "npm.cmd" | "pnpm" | "pnpm.cmd" | "yarn" | "yarn.cmd" + ) { + configuration + .toolchains + .entry("runtime".to_string()) + .or_insert_with(|| "project-node".to_string()); + } else if matches!(command.as_str(), "bun" | "bun.exe") + && configuration.toolchains.get("runtime").map(String::as_str) == Some("project-node") + { + configuration.toolchains.remove("runtime"); + } + } +} + +/// Deep-merges the `extensions` object one namespace at a time. +/// +/// A shallow insert would let a layer that touches a single `build-run` key +/// drop the whole `npm` namespace it never mentioned. +pub(crate) fn merge_extensions( + target: &mut serde_json::Map, + patch: &mut Value, +) -> Result<(), CoreError> { + let patch_map = patch.as_object().ok_or_else(|| { + CoreError::new( + ErrorCode::ParseFailed, + "Run configuration extensions must be an object", + ) + })?; + let target_map = target + .entry("extensions".to_string()) + .or_insert_with(|| json!({})) + .as_object_mut() + .ok_or_else(|| { + CoreError::new( + ErrorCode::ParseFailed, + "Run configuration extensions must be an object", + ) + })?; + for (namespace, value) in patch_map { + let slot = target_map + .entry(namespace.clone()) + .or_insert_with(|| json!({})); + match (slot.as_object_mut(), value.as_object()) { + (Some(existing), Some(incoming)) => { + for (key, item) in incoming { + if namespace == "build-run" && key == "tools" { + let tools = item.as_object().ok_or_else(|| { + CoreError::new( + ErrorCode::ParseFailed, + "Run tool selections must be an object", + ) + })?; + let slot = existing.entry(key.clone()).or_insert_with(|| json!({})); + let destination = slot.as_object_mut().ok_or_else(|| { + CoreError::new( + ErrorCode::ParseFailed, + "Run tool selections must be an object", + ) + })?; + destination.extend(tools.clone()); + continue; + } + existing.insert(key.clone(), item.clone()); + } + } + _ => { + *slot = value.clone(); + } + } + } + Ok(()) +} + +pub(crate) fn configuration_ids(document: &Value) -> Result, CoreError> { + let Some(items) = document.get("configurations").and_then(Value::as_array) else { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "configurations must be an array", + )); + }; + let mut result = BTreeMap::new(); + for item in items { + let Some(id) = item.get("id").and_then(Value::as_str) else { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Run configuration id is required", + )); + }; + result.insert(id.to_string(), ()); + } + Ok(result) +} diff --git a/rust/crates/core/src/execution/configuration/project_toolchain.rs b/rust/crates/execution/src/configuration/project_toolchain.rs similarity index 92% rename from rust/crates/core/src/execution/configuration/project_toolchain.rs rename to rust/crates/execution/src/configuration/project_toolchain.rs index 49ca2397..74f19038 100644 --- a/rust/crates/core/src/execution/configuration/project_toolchain.rs +++ b/rust/crates/execution/src/configuration/project_toolchain.rs @@ -4,7 +4,7 @@ //! It never inspects a build file or runs a tool. use super::{existing_root, local_layer_document, read_local_toolchains, validate_version_value}; -use crate::protocol::{invalid_relative_path, CoreError, ErrorCode}; +use lithe_protocol::{invalid_relative_path, CoreError, ErrorCode}; use serde::Deserialize; use serde_json::{json, Value}; use std::collections::BTreeMap; @@ -12,7 +12,7 @@ use std::collections::BTreeMap; /// Reads current tool selections even when no Run configuration was generated. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct ProjectToolchainRequest { +pub struct ProjectToolchainRequest { /// Absolute native workspace root, owned by the platform. pub(crate) root: String, /// Current Provider project root, relative to that workspace. @@ -27,7 +27,7 @@ pub(crate) struct ProjectToolchainRequest { } /// Returns saved paths, never resolved executables. -pub(crate) fn project_toolchain(request: ProjectToolchainRequest) -> Result { +pub fn project_toolchain(request: ProjectToolchainRequest) -> Result { if request.path.len() > 1024 || invalid_relative_path(&request.path) || request.path.contains('\\') @@ -59,7 +59,7 @@ pub(crate) fn project_toolchain(request: ProjectToolchainRequest) -> Result Result, + /// Host-owned local layer. When present, Core validates it instead of `.lithe/run/local.json`. + #[serde(default)] + pub local_document: Option, + /// Complete generic run selectors; omission preserves older callers. + #[serde(default)] + pub provider_runs: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Request to regenerate detected configurations from the current project tree. +pub struct GenerateRequest { + /// Canonical workspace root scanned for run entries. + pub root: String, + /// Complete generic run selectors; omission preserves older callers. + #[serde(default)] + pub provider_runs: Option>, + /// Application cache directory, outside the workspace. Generation records + /// its result there so the next session can list provider entries before + /// the providers answer again; omitted, only this process remembers it. + #[serde(default)] + pub cache_directory: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Request to merge configuration layers and resolve host toolchains. +pub struct ResolveRequest { + /// Canonical workspace root whose layers are merged. + pub root: String, + /// Executables the host discovered; requirements pick among these by role. + #[serde(default)] + pub toolchain_candidates: Vec, + /// Host-owned local layer. When present, Core uses it instead of `.lithe/run/local.json`. + #[serde(default)] + pub local_document: Option, + /// Optional prepared team layer for a host transaction preflight; never persisted by Core. + #[serde(default)] + pub team_document: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Host-discovered executable that may satisfy a configuration requirement. +pub struct ToolchainCandidate { + /// Host-stable candidate identifier referenced by resolved configurations. + pub id: String, + /// Toolchain role such as `node`, not a display label. + #[serde(rename = "type")] + pub kind: String, + /// Reported version, empty when the host could not read one. + #[serde(default)] + pub version: String, + /// Distribution vendor, empty when unknown. + #[serde(default)] + pub vendor: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Request to turn one resolved configuration into a process launch plan. +pub struct LaunchPlanRequest { + /// Canonical workspace root whose layers are merged before planning. + pub root: String, + /// Merged configuration identity to plan. + pub configuration_id: String, + /// Host-owned local layer. When present, Core uses it instead of `.lithe/run/local.json`. + #[serde(default)] + pub local_document: Option, +} + +/// Resolves saved Run choices without assembling a build tool's command line. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ProviderLaunchInputsRequest { + /// Canonical workspace owned by the native host. + pub root: String, + /// Stable identity resolved through the generated/team/local document layers. + pub configuration_id: String, + /// Current provider instance, supplied by the owning build model. + pub provider_root: String, + /// Optional host-owned replacement for the machine-local document. + #[serde(default)] + pub local_document: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Global machine toolchain stored at document level in the local layer. +pub struct ToolchainPaths { + /// Explicit executables for generic runtime toolchain IDs such as `project-node`. + #[serde(default)] + pub runtime_executable_paths: BTreeMap, + /// Generic consumer preferences in the same machine-local document. + /// Absent preserves saved choices; an empty v1 document resets them. + #[serde(default)] + pub preferences: Option, +} + +/// Replaces only generic preferences in a captured machine-local Run document. +/// Other toolchain fields are preserved untouched for their owners. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct UpdateToolPreferencesRequest { + /// Required captured local layer; this command never reads native files. + pub local_document: Value, + /// Inert v1 preferences already edited by the shared preference command. + pub preferences: Value, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Editable options applied to a team or machine-local configuration layer. +pub struct UpdateOptionsRequest { + /// Canonical workspace root that owns the edited layer. + pub root: String, + /// Persistence layer: `project` for shared configuration or `local` for this host. + pub scope: String, + /// Merged configuration identity to patch. + pub configuration_id: String, + /// Working directory relative to the workspace root; empty keeps the merged value. + #[serde(default)] + pub working_directory: String, + /// Common program arguments. `programArguments` remains a wire alias for + /// clients from before the generic run-options migration. + #[serde(default, alias = "programArguments")] + pub arguments: String, + /// Environment variables replacing the configuration's own entries. + #[serde(default)] + pub environment: BTreeMap, + /// Portable provider choices. Omission preserves the existing namespace. + #[serde( + default, + deserialize_with = "crate::provider_run_options::optional_request_options" + )] + pub provider_run_options: Option, + /// When present, `updateOptions` writes the document-level global toolchain + /// into the local layer instead of patching one configuration. + #[serde(default)] + pub toolchain: Option, + /// Host-owned local layer used when `scope` is `local` and when resolving first. + #[serde(default)] + pub local_document: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Request to add an explicitly user-authored run configuration. +pub struct CreateUserConfigurationRequest { + /// Canonical workspace root that receives the new entry. + pub root: String, + /// Persistence layer: `project` for shared configuration or `local` for this host. + pub scope: String, + /// Display name shown in the Run list. + pub name: String, + /// UI configuration kind mapped to a namespaced provider during creation. + #[serde(rename = "type")] + pub kind: String, + /// A typed selector, never a cached executable or resolved launch payload. + #[serde(default)] + pub build: Option, +} diff --git a/rust/crates/execution/src/configuration/requirements.rs b/rust/crates/execution/src/configuration/requirements.rs new file mode 100644 index 00000000..09605a6c --- /dev/null +++ b/rust/crates/execution/src/configuration/requirements.rs @@ -0,0 +1,196 @@ +//! Toolchain requirements of a project and their diagnostics. + +use super::*; + +/// Toolchain requirements of `root`: those of this session's latest +/// generation, else those derived from the project's own declarations, so a +/// runtime can be checked before the Run list has been generated. +pub fn read_requirements(root: &Path) -> Result, CoreError> { + if let Some(layer) = generated_layer::get(root) { + return Ok(Some(layer.requirements.clone())); + } + if !root.is_dir() { + return Ok(None); + } + detect_requirements(root, &[]).map(Some) +} + +pub(crate) fn read_local_toolchains(root: &Path) -> Result, CoreError> { + let Some(document) = read_document_value(root, "toolchains/local.json")? else { + return Ok(None); + }; + validate_sidecar_version_value(&document)?; + if document + .get("toolchains") + .and_then(Value::as_object) + .is_none() + { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Local toolchains must contain a toolchains object", + )); + } + Ok(Some(document)) +} + +/// Only roles a Run actually consumes become requirements; Core reads the +/// generic declarations it understands and leaves tool-specific version files to +/// the plugin that owns the toolchain. +pub(crate) fn detect_requirements( + root: &Path, + configurations: &[RunConfiguration], +) -> Result { + let mut toolchains = BTreeMap::new(); + let consumes = |toolchain: &str| { + configurations.iter().any(|configuration| { + configuration + .toolchains + .values() + .any(|candidate| candidate == toolchain) + }) + }; + if consumes("project-node") { + toolchains.insert( + "project-node".to_string(), + generic_requirement("node", declared_node_version(root)), + ); + } + Ok(ToolchainRequirementsDocument { + version: SIDECAR_VERSION, + toolchains, + }) +} + +fn generic_requirement(kind: &str, minimum_version: Option) -> ToolchainRequirement { + ToolchainRequirement { + kind: kind.to_string(), + minimum_version, + preferred_vendor: None, + version: None, + } +} + +pub(crate) fn toolchain_diagnostics( + root: &Path, + candidates: &[ToolchainCandidate], + configurations: &[RunConfiguration], +) -> Result, CoreError> { + let Some(requirements) = read_requirements(root)? else { + return Ok(Vec::new()); + }; + validate_sidecar_version(requirements.version)?; + let mut diagnostics = Vec::new(); + for (id, requirement) in requirements.toolchains { + let mut consumer_ids = configurations + .iter() + .filter(|configuration| { + configuration + .toolchains + .values() + .any(|toolchain| toolchain == &id) + }) + .map(|configuration| configuration.id.clone()) + .collect::>(); + consumer_ids.sort(); + consumer_ids.dedup(); + let Some(candidate) = candidates + .iter() + .find(|candidate| candidate.id == id && candidate.kind == requirement.kind) + else { + append_toolchain_diagnostics( + &mut diagnostics, + &consumer_ids, + json!({ + "code": "missingToolchain", + "toolchain": id, + "message": format!("No local {} toolchain is selected", requirement.kind) + }), + ); + continue; + }; + let required_version = requirement + .minimum_version + .as_deref() + .or(requirement.version.as_deref()); + if let Some(required) = required_version { + // `version` is an exact prefix match; only `minimumVersion` accepts + // newer releases. + let treat_as_minimum = requirement.minimum_version.is_some(); + if !version_satisfies(&candidate.version, required, treat_as_minimum) { + append_toolchain_diagnostics( + &mut diagnostics, + &consumer_ids, + json!({ + "code": "toolchainVersionMismatch", + "toolchain": id, + "message": format!( + "{} {} does not satisfy required version {}", + requirement.kind, candidate.version, required + ) + }), + ); + } + } + if let Some(vendor) = requirement.preferred_vendor.as_deref() { + if !candidate + .vendor + .to_lowercase() + .contains(&vendor.to_lowercase()) + { + append_toolchain_diagnostics( + &mut diagnostics, + &consumer_ids, + json!({ + "code": "toolchainVendorMismatch", + "toolchain": id, + "message": format!("Preferred {} vendor is {vendor}", requirement.kind) + }), + ); + } + } + } + Ok(diagnostics) +} + +fn append_toolchain_diagnostics( + diagnostics: &mut Vec, + consumer_ids: &[String], + diagnostic: Value, +) { + if consumer_ids.is_empty() { + return; + } + for configuration_id in consumer_ids { + let mut scoped = diagnostic.clone(); + scoped["id"] = json!(configuration_id); + diagnostics.push(scoped); + } +} + +/// Compares numeric version components. A minimum accepts any later version; +/// otherwise `required` must be a component prefix of `actual` (`20` matches +/// `20.11.1`). Tool-specific version schemes are normalized by their owner first. +pub fn version_satisfies(actual: &str, required: &str, minimum: bool) -> bool { + let actual_parts = version_parts(actual); + let required_parts = version_parts(required); + if actual_parts.is_empty() || required_parts.is_empty() { + return false; + } + if minimum { + actual_parts >= required_parts + } else { + actual_parts.starts_with(&required_parts) + } +} + +/// Splits a version into its numeric parts, ignoring separators and suffixes. +/// +/// An input with no digits yields an empty list, which callers treat as "not a +/// version" rather than as zero. +pub fn version_parts(value: &str) -> Vec { + value + .split(|character: char| !character.is_ascii_digit()) + .filter(|part| !part.is_empty()) + .filter_map(|part| part.parse().ok()) + .collect() +} diff --git a/rust/crates/execution/src/configuration/resolve.rs b/rust/crates/execution/src/configuration/resolve.rs new file mode 100644 index 00000000..3c0bd343 --- /dev/null +++ b/rust/crates/execution/src/configuration/resolve.rs @@ -0,0 +1,163 @@ +//! Resolving the configuration a launch uses. + +use super::*; + +/// Merges generated, team, and local layers and selects compatible toolchains. +pub fn resolve(request: ResolveRequest) -> Result { + let root = existing_root(&request.root)?; + let team = match request.team_document { + Some(mut document) => { + migrate_document_value(&mut document); + document + } + None => read_document_value(&root, "run/configurations.json")? + .unwrap_or_else(|| json!({"version": VERSION, "configurations": []})), + }; + let local = local_layer_document(&root, request.local_document)?; + let layer = generated_layer::get(&root); + let generated = match layer.as_ref() { + Some(layer) => serde_json::to_value(&layer.document).expect("run configurations encode"), + None if has_saved_build_run(&local) || has_saved_build_run(&team) => { + json!({"version": VERSION, "configurations": []}) + } + None => { + return Err(CoreError::new( + ErrorCode::WorkspaceNotFound, + "Run configuration has not been generated", + )) + } + }; + let local_toolchains = read_local_toolchains(&root)?; + let manifest = read_document_value(&root, "project.json")?; + validate_version_value(&generated)?; + validate_version_value(&team)?; + validate_version_value(&local)?; + if let Some(manifest) = manifest.as_ref() { + validate_sidecar_version_value(manifest)?; + } + configuration_ids(&generated)?; + let mut configurations = merge_values(&generated, &team, &local)?; + let resolved_ids = configurations + .iter() + .map(|item| item.id.as_str()) + .collect::>(); + let mut diagnostics = Vec::new(); + for source in [&team, &local] { + for id in configuration_ids(source)?.keys() { + if !resolved_ids.contains(id.as_str()) && !id.starts_with("user:") { + diagnostics.push(json!({ + "id": id, + "code": "orphanedOverride", + "message": "Override no longer matches an automatically generated configuration" + })); + } + } + } + normalize_runtime_consumption(&mut configurations); + let global_toolchain = local.get("toolchain").cloned(); + diagnostics.extend(toolchain_diagnostics( + &root, + &request.toolchain_candidates, + &configurations, + )?); + for configuration in &mut configurations { + validate_configuration(configuration)?; + if configuration.provider == "build.provider" { + // Materialize optional defaults in the shared output so native + // consumers receive the same shape as a newly saved selector. + let reference: BuildProviderRunReference = serde_json::from_value( + configuration.extensions["build"].clone(), + ) + .map_err(|_| { + CoreError::new( + ErrorCode::ParseFailed, + "Invalid build provider run reference", + ) + })?; + configuration.extensions.insert( + "build".to_string(), + serde_json::to_value(reference).expect("reference encodes"), + ); + } + if configuration.disabled { + diagnostics.push(json!({ + "id": configuration.id, + "code": "disabled", + "message": "Run configuration is disabled" + })); + continue; + } + if !project_directory_exists(&root, &configuration.cwd) { + configuration.disabled = true; + diagnostics.push(json!({ + "id": configuration.id, + "code": "missingWorkingDirectory", + "message": format!("Working directory does not exist: {}", configuration.cwd) + })); + } + } + configurations.retain(|configuration| !configuration.disabled); + let mut default_run_configuration = manifest + .as_ref() + .and_then(|value| value.get("defaultRunConfiguration")) + .and_then(Value::as_str) + .map(str::to_string); + if let Some(default_id) = default_run_configuration.as_deref() { + if !configurations + .iter() + .any(|configuration| configuration.id == default_id) + { + diagnostics.push(json!({ + "code": "missingDefaultConfiguration", + "message": format!("Default run configuration is unavailable: {default_id}") + })); + default_run_configuration = None; + } + } + if default_run_configuration.is_none() { + default_run_configuration = layer + .as_ref() + .and_then(|layer| detected_default(&layer.document, &configurations)); + } + Ok(json!({ + "version": VERSION, + "configurations": configurations, + "diagnostics": diagnostics, + "defaultRunConfiguration": default_run_configuration, + "toolchain": global_toolchain, + "localToolchains": local_toolchains + })) +} + +/// Prefer a detected service when the project names no default, otherwise +/// select the first available entry. +fn detected_default( + generated: &RunConfigurationDocument, + available: &[RunConfiguration], +) -> Option { + let by_id = available + .iter() + .map(|item| (item.id.as_str(), item)) + .collect::>(); + let mut by_selector = BTreeMap::new(); + for configuration in available { + if let Some(selector) = crate::provider_runs::selector( + &serde_json::to_value(configuration).expect("configuration encodes"), + ) { + by_selector.entry(selector).or_insert(configuration); + } + } + let detected = generated.configurations.iter().filter_map(|configuration| { + by_id.get(configuration.id.as_str()).copied().or_else(|| { + let selector = crate::provider_runs::selector( + &serde_json::to_value(configuration).expect("configuration encodes"), + )?; + by_selector.get(&selector).copied() + }) + }); + detected + .clone() + .find(|configuration| configuration.execution == Execution::Service) + .or_else(|| detected.clone().next()) + .map(|configuration| configuration.id.clone()) +} diff --git a/rust/crates/execution/src/configuration/validation.rs b/rust/crates/execution/src/configuration/validation.rs new file mode 100644 index 00000000..6211685e --- /dev/null +++ b/rust/crates/execution/src/configuration/validation.rs @@ -0,0 +1,123 @@ +//! Version and configuration validation. + +use super::*; + +pub(crate) fn validate_version_value(document: &Value) -> Result<(), CoreError> { + let version = document.get("version").and_then(Value::as_u64).unwrap_or(0) as u32; + validate_version(version)?; + validate_tool_preferences(document) +} + +pub(crate) fn validate_tool_preferences(document: &Value) -> Result<(), CoreError> { + if let Some(preferences) = document + .get("toolchain") + .and_then(|value| value.get("preferences")) + { + lithe_toolchain::preferences::validate(preferences) + .map_err(|error| CoreError::new(ErrorCode::InvalidRequest, error.message))?; + } + Ok(()) +} + +pub(crate) fn validate_version(version: u32) -> Result<(), CoreError> { + if version != VERSION { + return Err(CoreError::new( + ErrorCode::NotSupported, + "Unsupported run configuration version", + ) + .with_details(format!("expected {VERSION}, found {version}"))); + } + Ok(()) +} + +/// Rejects a sidecar document written for a version this Core does not read. +pub fn validate_sidecar_version(version: u32) -> Result<(), CoreError> { + if version != SIDECAR_VERSION { + return Err( + CoreError::new(ErrorCode::NotSupported, "Unsupported document version") + .with_details(format!("expected {SIDECAR_VERSION}, found {version}")), + ); + } + Ok(()) +} + +pub(crate) fn validate_sidecar_version_value(document: &Value) -> Result<(), CoreError> { + let version = document.get("version").and_then(Value::as_u64).unwrap_or(0) as u32; + validate_sidecar_version(version) +} + +pub(crate) fn validate_configuration(configuration: &RunConfiguration) -> Result<(), CoreError> { + if configuration.id.trim().is_empty() || configuration.name.trim().is_empty() { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Run configuration id and name are required", + )); + } + if !valid_provider(&configuration.provider) { + return Err(CoreError::new( + ErrorCode::NotSupported, + "Unsupported run configuration provider", + ) + .with_details(configuration.provider.clone())); + } + if configuration.provider == "build.provider" { + let reference: BuildProviderRunReference = serde_json::from_value( + configuration + .extensions + .get("build") + .cloned() + .unwrap_or(Value::Null), + ) + .map_err(|_| { + CoreError::new( + ErrorCode::ParseFailed, + "Invalid build provider run reference", + ) + })?; + reference.validate()?; + crate::provider_run_options::BuildProviderRunOptions::from_extensions( + &configuration.extensions, + )?; + if configuration.command.is_some() + || !configuration.toolchains.is_empty() + || configuration.debug.is_some() + || configuration.execution != reference.execution.unwrap_or_default() + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Build provider configurations cannot store commands, tool bindings or debug adapters", + )); + } + } + if invalid_relative_path(&configuration.cwd) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Run configuration contains an invalid project-relative path", + ) + .with_details(format!("workingDirectory: {}", configuration.cwd))); + } + Ok(()) +} + +#[allow(dead_code)] +fn valid_relative(value: &str) -> bool { + !invalid_relative_path(value) +} + +/// `namespace.name`, both segments lowercase kebab starting with a letter. +/// Shape is checked, membership is not: an unknown ecosystem must be able to +/// register a provider without touching this file. +pub(crate) fn valid_provider(value: &str) -> bool { + let Some((namespace, name)) = value.split_once('.') else { + return false; + }; + [namespace, name].iter().all(|segment| { + segment + .chars() + .next() + .is_some_and(|first| first.is_ascii_lowercase()) + && segment + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + }) +} diff --git a/rust/crates/core/src/execution/context_selection.rs b/rust/crates/execution/src/context_selection.rs similarity index 67% rename from rust/crates/core/src/execution/context_selection.rs rename to rust/crates/execution/src/context_selection.rs index ddafbe6e..2996df2c 100644 --- a/rust/crates/core/src/execution/context_selection.rs +++ b/rust/crates/execution/src/context_selection.rs @@ -21,7 +21,7 @@ pub(crate) struct ContextCandidate { /// Identity and ordered candidates for a context launch or editor action. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -pub(crate) struct ContextSelectionRequest { +pub struct ContextSelectionRequest { /// Source identity supplied by the platform's provider. source_path: String, /// Entry symbol supplied by the platform's provider. @@ -35,7 +35,7 @@ pub(crate) struct ContextSelectionRequest { } /// Returns the matching preferred or edited entry, preserving input order on ties. -pub(crate) fn select_context_configuration(request: ContextSelectionRequest) -> Option { +pub fn select_context_configuration(request: ContextSelectionRequest) -> Option { let path_key = |path: &str| { if request.case_insensitive_paths { path.replace('\\', "/").to_lowercase() @@ -59,30 +59,3 @@ pub(crate) fn select_context_configuration(request: ContextSelectionRequest) -> }) .map(|candidate| candidate.id.clone()) } - -#[cfg(test)] -mod tests { - use serde_json::Value; - - #[test] - fn context_selection_command_matches_shared_provider_neutral_cases() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../../fixtures/run-configuration/context-selection.json" - )) - .unwrap(); - for case in fixture["cases"].as_array().unwrap() { - let request = serde_json::json!({ - "id": "context-selection", "command": "runConfig.selectContextConfiguration", - "payload": case["request"] - }); - let response: Value = - serde_json::from_str(&crate::execute_json(&request.to_string())).unwrap(); - assert_eq!(response["ok"], true, "{}: {response}", case["name"]); - assert_eq!( - response["data"]["configurationId"], case["expected"], - "{}", - case["name"] - ); - } - } -} diff --git a/rust/crates/core/src/execution/detectors/cargo.rs b/rust/crates/execution/src/detectors/cargo.rs similarity index 100% rename from rust/crates/core/src/execution/detectors/cargo.rs rename to rust/crates/execution/src/detectors/cargo.rs diff --git a/rust/crates/core/src/execution/detectors/compose.rs b/rust/crates/execution/src/detectors/compose.rs similarity index 96% rename from rust/crates/core/src/execution/detectors/compose.rs rename to rust/crates/execution/src/detectors/compose.rs index ba18b456..4df9c0a8 100644 --- a/rust/crates/core/src/execution/detectors/compose.rs +++ b/rust/crates/execution/src/detectors/compose.rs @@ -47,7 +47,7 @@ pub fn detect(ctx: &DirectoryContext) -> Vec { "compose", serde_json::json!({ "file": file, "service": name }), ) - .as_infrastructure() + .into_infrastructure() }) .collect::>(); // The whole stack is usually what the user wants first, so offer it as a @@ -64,7 +64,7 @@ pub fn detect(ctx: &DirectoryContext) -> Vec { file, ) .with_extension("compose", serde_json::json!({ "file": file })) - .as_infrastructure(), + .into_infrastructure(), ); } detected diff --git a/rust/crates/core/src/execution/detectors/go.rs b/rust/crates/execution/src/detectors/go.rs similarity index 100% rename from rust/crates/core/src/execution/detectors/go.rs rename to rust/crates/execution/src/detectors/go.rs diff --git a/rust/crates/core/src/execution/detectors/make.rs b/rust/crates/execution/src/detectors/make.rs similarity index 100% rename from rust/crates/core/src/execution/detectors/make.rs rename to rust/crates/execution/src/detectors/make.rs diff --git a/rust/crates/core/src/execution/detectors/mod.rs b/rust/crates/execution/src/detectors/mod.rs similarity index 98% rename from rust/crates/core/src/execution/detectors/mod.rs rename to rust/crates/execution/src/detectors/mod.rs index 9ee3bb71..0bf4bdec 100644 --- a/rust/crates/core/src/execution/detectors/mod.rs +++ b/rust/crates/execution/src/detectors/mod.rs @@ -20,7 +20,7 @@ mod scan; mod shell; use super::types::{Confidence, Execution, RunCategory}; -use crate::protocol::CoreError; +use lithe_protocol::CoreError; use std::collections::BTreeMap; use std::path::Path; @@ -136,7 +136,7 @@ impl Detected { } /// Marks a detection as external infrastructure rather than project code. - pub fn as_infrastructure(mut self) -> Self { + pub fn into_infrastructure(mut self) -> Self { self.category = RunCategory::Infrastructure; self } diff --git a/rust/crates/core/src/execution/detectors/npm.rs b/rust/crates/execution/src/detectors/npm.rs similarity index 100% rename from rust/crates/core/src/execution/detectors/npm.rs rename to rust/crates/execution/src/detectors/npm.rs diff --git a/rust/crates/core/src/execution/detectors/procfile.rs b/rust/crates/execution/src/detectors/procfile.rs similarity index 100% rename from rust/crates/core/src/execution/detectors/procfile.rs rename to rust/crates/execution/src/detectors/procfile.rs diff --git a/rust/crates/core/src/execution/detectors/python.rs b/rust/crates/execution/src/detectors/python.rs similarity index 100% rename from rust/crates/core/src/execution/detectors/python.rs rename to rust/crates/execution/src/detectors/python.rs diff --git a/rust/crates/core/src/execution/detectors/scan.rs b/rust/crates/execution/src/detectors/scan.rs similarity index 95% rename from rust/crates/core/src/execution/detectors/scan.rs rename to rust/crates/execution/src/detectors/scan.rs index 872ce8cd..f02ddea5 100644 --- a/rust/crates/core/src/execution/detectors/scan.rs +++ b/rust/crates/execution/src/detectors/scan.rs @@ -1,7 +1,7 @@ //! Bounded workspace traversal shared by all run-configuration detectors. -use crate::protocol::{CoreError, ErrorCode}; -use crate::remote::workspace_io as fs; +use lithe_protocol::{CoreError, ErrorCode}; +use lithe_workspace::fs; use std::collections::BTreeSet; use std::path::{Path, PathBuf}; @@ -15,7 +15,9 @@ const MAX_DEPTH: usize = 6; /// directory. #[derive(Debug, Clone)] pub struct DirectoryContext { + /// Absolute project root the scan started from. pub root: PathBuf, + /// Absolute path of this directory. pub path: PathBuf, /// Project-relative, forward-slashed, `.` for the project root. pub relative: String, diff --git a/rust/crates/core/src/execution/detectors/shell.rs b/rust/crates/execution/src/detectors/shell.rs similarity index 100% rename from rust/crates/core/src/execution/detectors/shell.rs rename to rust/crates/execution/src/detectors/shell.rs diff --git a/rust/crates/core/src/execution/environment.rs b/rust/crates/execution/src/environment.rs similarity index 76% rename from rust/crates/core/src/execution/environment.rs rename to rust/crates/execution/src/environment.rs index 1e667648..ca2294f2 100644 --- a/rust/crates/core/src/execution/environment.rs +++ b/rust/crates/execution/src/environment.rs @@ -102,36 +102,6 @@ pub fn resolve_search_path(mut request: SearchPathRequest) -> Vec { mod tests { use super::*; - #[test] - fn search_path_command_follows_the_shared_order_and_identity_fixture() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../../fixtures/execution/search-path-v1.json" - )) - .unwrap(); - for case in fixture["cases"].as_array().unwrap() { - let request = serde_json::json!({"id":"search-path", "command":"execution.resolveSearchPath", "payload":case["request"]}); - let response: Value = - serde_json::from_str(&crate::execute_json(&request.to_string())).unwrap(); - assert_eq!(response["ok"], true, "{}: {response}", case["name"]); - assert_eq!(response["data"], case["expected"], "{}", case["name"]); - } - } - - #[test] - fn environment_command_follows_the_shared_precedence_fixture() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../../fixtures/execution/launch-environment-v1.json" - )) - .unwrap(); - for case in fixture["cases"].as_array().unwrap() { - let request = serde_json::json!({"id":"environment", "command":"execution.resolveEnvironment", "payload":case["request"]}); - let response: Value = - serde_json::from_str(&crate::execute_json(&request.to_string())).unwrap(); - assert_eq!(response["ok"], true, "{}: {response}", case["name"]); - assert_eq!(response["data"], case["expected"], "{}", case["name"]); - } - } - // A structured binding that reaches Core unresolved names no value, so it // must be dropped rather than leak an empty or stale variable. #[test] diff --git a/rust/crates/core/src/execution/generated_layer.rs b/rust/crates/execution/src/generated_layer.rs similarity index 95% rename from rust/crates/core/src/execution/generated_layer.rs rename to rust/crates/execution/src/generated_layer.rs index e21dae4b..bf394525 100644 --- a/rust/crates/core/src/execution/generated_layer.rs +++ b/rust/crates/execution/src/generated_layer.rs @@ -84,8 +84,8 @@ pub(super) fn cached_document(cache_directory: &Path, root: &Path) -> Option, requirements: Option) { +#[cfg(any(test, feature = "test-support"))] +pub fn seed(root: &Path, document: Option, requirements: Option) { let current = get(root); let document = match document { Some(mut document) => { @@ -140,14 +140,14 @@ pub(super) fn cache_document( .and_then(|()| fs::rename(&temporary, &path)); if let Err(error) = written { // Cache persistence is optional; WARN explains a cold next session. - crate::diagnostics::core_events::recovery_failed( + lithe_diagnostics::core_events::recovery_failed( "app.cache.write_failed", "generated-cache", &format!("{:?}", error.kind()), "io", ); // This boundary keeps the primary result; WARN exposes leftover staging. - let mut cleanup = crate::diagnostics::core_events::StorageCleanup::default(); + let mut cleanup = lithe_diagnostics::core_events::StorageCleanup::default(); cleanup.observe(fs::remove_file(&temporary)); cleanup.finish("generated-cache"); } diff --git a/rust/crates/core/src/execution/mod.rs b/rust/crates/execution/src/lib.rs similarity index 65% rename from rust/crates/core/src/execution/mod.rs rename to rust/crates/execution/src/lib.rs index 850d31ef..e12152d7 100644 --- a/rust/crates/core/src/execution/mod.rs +++ b/rust/crates/execution/src/lib.rs @@ -4,7 +4,7 @@ mod argument_text; mod configuration; mod context_selection; mod detectors; -pub(crate) mod generated_layer; +pub mod generated_layer; mod provider_run_options; mod provider_runs; mod title_action; @@ -16,13 +16,13 @@ pub use configuration::{ }; mod types; -pub(crate) use argument_text::{format_argument_text, ArgumentTextRequest}; -pub(crate) use configuration::*; -pub(crate) use context_selection::{select_context_configuration, ContextSelectionRequest}; -pub(crate) use detectors::{scan, DirectoryContext}; -pub(crate) use title_action::{decide_title_action, TitleActionRequest}; +pub use argument_text::{format_argument_text, ArgumentTextRequest}; +pub use configuration::*; +pub use context_selection::{select_context_configuration, ContextSelectionRequest}; +pub use detectors::{scan, DirectoryContext}; +pub use title_action::{decide_title_action, TitleActionRequest}; -pub(crate) mod workspace_scan; +pub mod workspace_scan; pub use workspace_scan::is_run_scan_excluded_directory; mod environment; diff --git a/rust/crates/core/src/execution/provider_run_options.rs b/rust/crates/execution/src/provider_run_options.rs similarity index 95% rename from rust/crates/core/src/execution/provider_run_options.rs rename to rust/crates/execution/src/provider_run_options.rs index 3290cc6b..1d70a407 100644 --- a/rust/crates/core/src/execution/provider_run_options.rs +++ b/rust/crates/execution/src/provider_run_options.rs @@ -1,8 +1,8 @@ //! Persists portable user choices separately from opaque provider target identities. //! Only the selected provider interprets runtime arguments, entrypoints and build options. -use crate::protocol::{CoreError, ErrorCode}; -pub use crate::toolchain::fixed::FixedToolchainSelection; +use lithe_protocol::{CoreError, ErrorCode}; +pub use lithe_toolchain::fixed::FixedToolchainSelection; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::BTreeMap; @@ -48,7 +48,7 @@ impl BuildProviderRunOptions { || serde_json::to_vec(&self.options).map_or(true, |bytes| bytes.len() > 65536) || self.tools.len() > 64 || self.tools.iter().any(|(id, value)| { - !crate::toolchain::definitions::tool_id(id) + !lithe_plugin_model::is_tool_id(id) || value.as_ref().is_some_and(|value| { serde_json::to_value(value) .ok() diff --git a/rust/crates/core/src/execution/provider_runs.rs b/rust/crates/execution/src/provider_runs.rs similarity index 97% rename from rust/crates/core/src/execution/provider_runs.rs rename to rust/crates/execution/src/provider_runs.rs index 3a87874a..4a04bf59 100644 --- a/rust/crates/core/src/execution/provider_runs.rs +++ b/rust/crates/execution/src/provider_runs.rs @@ -3,7 +3,7 @@ use super::configuration::{BuildProviderRunReference, RunConfiguration}; use super::types::{Confidence, RunCategory}; -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::{BTreeMap, BTreeSet}; @@ -34,7 +34,7 @@ pub(super) fn validate(runs: &[ProviderRunConfiguration]) -> Result<(), CoreErro if run.name.trim().is_empty() || run.name.len() > 4096 || run.name.chars().any(char::is_control) - || crate::protocol::invalid_relative_path(&run.project_path) + || lithe_protocol::invalid_relative_path(&run.project_path) || run.project_path.is_empty() || run.project_path.len() > 4096 || run.project_path.contains(['\\', '\0']) diff --git a/rust/crates/core/src/execution/title_action.rs b/rust/crates/execution/src/title_action.rs similarity index 95% rename from rust/crates/core/src/execution/title_action.rs rename to rust/crates/execution/src/title_action.rs index f02b7f17..71f83a42 100644 --- a/rust/crates/core/src/execution/title_action.rs +++ b/rust/crates/execution/src/title_action.rs @@ -48,7 +48,7 @@ pub(crate) enum TitleConfiguration { /// The facts one decision depends on. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct TitleActionRequest { +pub struct TitleActionRequest { pub(crate) action: TitleAction, pub(crate) trigger: TitleTrigger, /// The user chose Current File over the project's configurations. @@ -64,7 +64,7 @@ pub(crate) struct TitleActionRequest { /// What the host does with the press. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(tag = "kind", rename_all = "camelCase")] -pub(crate) enum TitleDecision { +pub enum TitleDecision { /// Act on the current file. CurrentFile, /// Keep the press until the configurations are read. @@ -86,7 +86,7 @@ pub(crate) enum TitleDecision { } /// Decides what one press does. -pub(crate) fn decide_title_action(request: TitleActionRequest) -> TitleDecision { +pub fn decide_title_action(request: TitleActionRequest) -> TitleDecision { if request.current_file_chosen { return TitleDecision::CurrentFile; } @@ -122,7 +122,7 @@ mod tests { #[test] fn title_actions_match_the_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../fixtures/run-configuration/title-action-v1.json" + "../../../fixtures/run-configuration/title-action-v1.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { diff --git a/rust/crates/core/src/execution/types.rs b/rust/crates/execution/src/types.rs similarity index 88% rename from rust/crates/core/src/execution/types.rs rename to rust/crates/execution/src/types.rs index c0b9c4c7..ddfaf27e 100644 --- a/rust/crates/core/src/execution/types.rs +++ b/rust/crates/execution/src/types.rs @@ -5,8 +5,10 @@ use serde::{Deserialize, Serialize}; /// How a configuration behaves once started. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] +#[derive(Default)] pub enum Execution { /// A user-facing process that may be interactive but is not a background service. + #[default] Application, /// A long-running process expected to keep serving until explicitly stopped. Service, @@ -16,30 +18,20 @@ pub enum Execution { 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")] +#[derive(Default)] pub enum Confidence { /// Inferred from naming or source-layout conventions rather than a declaration. Heuristic, /// Supported by a manifest, build plugin, dependency, or other project declaration. + #[default] Declared, /// Authored by Lithe itself and therefore stronger than detector evidence. Native, } -impl Default for Confidence { - fn default() -> Self { - Self::Declared - } -} - /// Whether a configuration runs the project itself or the external services it /// depends on. /// @@ -49,19 +41,15 @@ impl Default for Confidence { /// group infrastructure separately instead of dropping it. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] +#[derive(Default)] pub enum RunCategory { /// Code in this repository: a service, application, or build task. + #[default] Project, /// External dependencies the project runs against, such as Compose services. Infrastructure, } -impl Default for RunCategory { - fn default() -> Self { - Self::Project - } -} - impl RunCategory { /// Lets serialization omit the common case so generated documents stay small. pub fn is_project(&self) -> bool { diff --git a/rust/crates/core/src/execution/workspace_scan.rs b/rust/crates/execution/src/workspace_scan.rs similarity index 93% rename from rust/crates/core/src/execution/workspace_scan.rs rename to rust/crates/execution/src/workspace_scan.rs index 3cba2e14..b0edd616 100644 --- a/rust/crates/core/src/execution/workspace_scan.rs +++ b/rust/crates/execution/src/workspace_scan.rs @@ -3,8 +3,8 @@ //! Automatic discovery is best effort below the required workspace root. Declared //! build-system modules remain owned by their provider and bypass these defaults. -use crate::protocol::CoreError; -use crate::remote::workspace_io as fs; +use lithe_protocol::CoreError; +use lithe_workspace::fs; use std::collections::BTreeSet; use std::io; use std::ops::ControlFlow; @@ -59,20 +59,24 @@ pub fn is_run_scan_excluded_directory(name: &str) -> bool { } /// One directory's files, without following directory symlinks. -pub(crate) struct ScannedDirectory { +pub struct ScannedDirectory { + /// Absolute path of the directory. pub path: PathBuf, + /// Names of the regular files directly inside it. pub files: BTreeSet, } /// A partial scan must never be used as evidence of deletion or freshness. -pub(crate) struct WorkspaceScan { +pub struct WorkspaceScan { + /// Readable directories, sorted by path. pub directories: Vec, + /// Workspace-relative paths that could not be read; their contents are unknown. pub unreadable_paths: BTreeSet, } /// Traverses automatic discovery inputs. A depth limit belongs only to heuristic /// service detection; fingerprints and explicit host file lists use no depth limit. -pub(crate) fn scan_run_workspace( +pub fn scan_run_workspace( root: &Path, max_depth: Option, ) -> Result { @@ -89,8 +93,9 @@ fn read_directory(path: &Path) -> io::Result> { .collect() } -// Crate-visible so remote integration tests can drop the link between reads. -pub(crate) fn scan_with_reader( +/// Same traversal as [`scan_run_workspace`] with an injected directory reader, +/// so tests can fail or remove a directory between reads. +pub fn scan_with_reader( root: &Path, max_depth: Option, read: &impl Fn(&Path) -> io::Result>, @@ -119,7 +124,7 @@ fn visit_with_reader( let mut directory_count = 0; let mut pending = vec![(root.to_owned(), 0)]; while let Some((path, depth)) = pending.pop() { - crate::protocol::cancellation::check()?; + lithe_protocol::cancellation::check()?; if max_depth.is_some() && directory_count >= MAX_HEURISTIC_DIRECTORIES { unreadable_paths.insert(relative(root, &path)); unreadable_paths.extend(pending.iter().map(|(path, _)| relative(root, path))); @@ -141,7 +146,7 @@ fn visit_with_reader( let mut files = BTreeSet::new(); let mut children = Vec::new(); for (child, directory, symlink) in entries { - crate::protocol::cancellation::check()?; + lithe_protocol::cancellation::check()?; let Some(name) = child.file_name().and_then(|name| name.to_str()) else { unreadable_paths.insert(relative(root, &child)); continue; diff --git a/rust/crates/git-host/src/configuration.rs b/rust/crates/git-host/src/configuration.rs index 427b28ce..ca7b4f4f 100644 --- a/rust/crates/git-host/src/configuration.rs +++ b/rust/crates/git-host/src/configuration.rs @@ -116,7 +116,7 @@ pub fn version( } let started = Instant::now(); let output = crate::run( - &mut Command::new(program).arg("--version"), + Command::new(program).arg("--version"), None, || cancelled() || started.elapsed() >= Duration::from_secs(5), || {}, diff --git a/rust/crates/git/Cargo.toml b/rust/crates/git/Cargo.toml new file mode 100644 index 00000000..25dcf182 --- /dev/null +++ b/rust/crates/git/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "lithe-git" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Git inspection, mutation and history for Lithe Core, behind the shared command contract" + +[features] +# Lets lithe-core's git.* integration tests reach the diff block fixture entry points and the +# untracked directory summary threshold they compare against. +test-support = [] + +[dependencies] +lithe-ai = { path = "../ai" } +lithe-core-rules = { path = "../core-rules" } +lithe-diagnostics = { path = "../diagnostics" } +lithe-diff = { path = "../diff" } +lithe-git-host = { path = "../git-host" } +lithe-github = { path = "../github" } +lithe-protocol = { path = "../protocol" } +lithe-remote = { path = "../remote", default-features = false, features = ["client"] } +lithe-request-context = { path = "../request-context" } +lithe-workspace = { path = "../workspace" } +chrono = { version = "0.4", default-features = false, features = ["std", "clock"] } +rand = "0.8" +regex = "1.11" +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0", features = ["preserve_order"] } +sha2 = "0.10" diff --git a/rust/crates/core/src/git/authentication_failure.rs b/rust/crates/git/src/authentication_failure.rs similarity index 98% rename from rust/crates/core/src/git/authentication_failure.rs rename to rust/crates/git/src/authentication_failure.rs index 9a34be85..077f67db 100644 --- a/rust/crates/core/src/git/authentication_failure.rs +++ b/rust/crates/git/src/authentication_failure.rs @@ -8,7 +8,7 @@ //! matching stderr themselves. use super::GitCommandResponse; -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; /// Fixed C-locale fragments that mean the remote refused the credentials or /// none could be obtained. Compared case-sensitively against Git stderr. @@ -117,7 +117,7 @@ mod tests { #[test] fn shared_fixture_samples_classify_as_documented() { let fixture: serde_json::Value = serde_json::from_str(include_str!( - "../../../../fixtures/git/authentication-failure-v1.json" + "../../../fixtures/git/authentication-failure-v1.json" )) .unwrap(); for (key, expected) in [("failures", true), ("unrelated", false)] { diff --git a/rust/crates/core/src/git/branch_names.rs b/rust/crates/git/src/branch_names.rs similarity index 98% rename from rust/crates/core/src/git/branch_names.rs rename to rust/crates/git/src/branch_names.rs index e8118d1e..79173833 100644 --- a/rust/crates/core/src/git/branch_names.rs +++ b/rust/crates/git/src/branch_names.rs @@ -5,7 +5,7 @@ //! default live here instead of in either platform. The module is pure: it never //! starts Git and only reads the reference facts the caller already loaded. -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use std::collections::HashSet; @@ -106,7 +106,7 @@ pub struct GitBranchNamePreviewResponse { /// Builds the cleaned name forms and submit-time conflict for one dialog input. /// -/// `rawName` over [`MAX_RAW_NAME_CHARS`] characters is rejected with +/// `rawName` over 1024 characters is rejected with /// `invalid_request`. Everything else is a pure transformation of the request. pub fn preview( request: GitBranchNamePreviewRequest, @@ -264,7 +264,7 @@ fn validate_new_branch_name( require_name: bool, ) -> Option { if name.trim().is_empty() { - return require_name.then(|| GitBranchNameError { + return require_name.then_some(GitBranchNameError { code: GitBranchNameErrorCode::Empty, name: None, branch: None, @@ -369,7 +369,7 @@ mod tests { // algorithm moved into Core, so this test pins cross-platform parity. let fixture: CleanupParityFixture = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/src/tests/fixtures/branch-name-cleanup-parity-v1.json" + "/tests/fixtures/branch-name-cleanup-parity-v1.json" ))) .expect("branch cleanup parity fixture should be valid JSON"); assert!(!fixture.cases.is_empty()); diff --git a/rust/crates/git/src/branches.rs b/rust/crates/git/src/branches.rs new file mode 100644 index 00000000..08905aa0 --- /dev/null +++ b/rust/crates/git/src/branches.rs @@ -0,0 +1,556 @@ +//! Branch and tag creation, deletion and upstream configuration. + +use super::*; + +/// Reports whether `refs/tags/` already resolves, using `--verify` so +/// the probe matches the exact ref instead of any revision expression. +pub(crate) fn tag_exists(root: &str, name: &str) -> Result { + let probe = execute_git( + root, + &[ + "rev-parse".into(), + "--verify".into(), + "--quiet".into(), + format!("refs/tags/{name}"), + ], + None, + )?; + Ok(probe.exit_code == 0) +} + +/// Resolves a tag target revision to a commit and returns its object id. +/// Git allows tagging trees and blobs; the tag contract only promises commit +/// targets, so `^{commit}` both validates and yields the id the +/// mutation should point at. +pub(crate) fn resolved_commit_target( + root: &str, + target: &str, +) -> Result, CoreError> { + let probe = execute_git( + root, + &[ + "rev-parse".into(), + "--verify".into(), + "--quiet".into(), + format!("{target}^{{commit}}"), + ], + None, + )?; + Ok((probe.exit_code == 0).then(|| probe.stdout.trim().to_string())) +} + +/// Rejects tag-form combinations Git cannot represent. +/// +/// An explicitly lightweight tag cannot also be annotated or signed; the +/// message rules match the previous Windows host behavior, including allowing a +/// whitespace-only message that the lightweight form ignores. +pub(crate) fn validate_create_tag_form( + message: Option<&str>, + lightweight: Option, + signed: Option, +) -> Result<(), CoreError> { + if lightweight == Some(true) + && (signed == Some(true) || message.is_some_and(|message| !message.trim().is_empty())) + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Lightweight tags cannot include a message or signature", + )); + } + Ok(()) +} + +/// Builds the `git tag` argument vector for an already validated form and a +/// target resolved to a commit id. +/// +/// `lightweight: true` always adds `--no-sign`, so an explicit lightweight tag +/// stays independent of `tag.gpgSign`. `signed: true` adds `-s`; with a message +/// this becomes a signed annotated tag. Omitted fields keep the legacy +/// message-selects-annotated behavior. +pub(crate) fn create_tag_arguments( + name: &str, + target: &str, + message: Option<&str>, + lightweight: Option, + signed: Option, +) -> Vec { + if lightweight == Some(true) { + return vec![ + "tag".to_string(), + "--no-sign".to_string(), + name.to_string(), + target.to_string(), + ]; + } + let mut arguments = vec!["tag".to_string()]; + if signed == Some(true) { + arguments.push("-s".to_string()); + } + match message { + // An explicit message field (even an empty one) selects the annotated + // form. Verbatim cleanup keeps restored CRLF and trailing blank lines + // intact; UIs trim newly entered messages before send. + Some(message) => arguments.extend([ + "-a".to_string(), + "--cleanup=verbatim".to_string(), + name.to_string(), + "-m".to_string(), + message.to_string(), + target.to_string(), + ]), + None => arguments.extend([name.to_string(), target.to_string()]), + } + arguments +} + +/// Deletes one tag and returns a structured deletion record so the host can +/// offer a restore. The probes run before the deletion because `git tag -d` +/// diagnostics are localized prose that cannot be mapped to stable errors. +pub(crate) fn delete_tag(root: &str, value: Option<&str>) -> Result { + let name = validated_tag_name(value)?; + let reference = format!("refs/tags/{name}"); + let expected_object = resolve_ref_object(root, &reference)?.ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + format!("The tag '{name}' does not exist"), + ) + })?; + let object_type = execute_git( + root, + &["cat-file".into(), "-t".into(), expected_object.clone()], + None, + )?; + if object_type.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + format!("The tag '{name}' does not exist"), + )); + } + let is_annotated = object_type.stdout.trim() == "tag"; + let mut message = None; + if is_annotated { + let tag_object = execute_git( + root, + &["cat-file".into(), "tag".into(), expected_object.clone()], + None, + )?; + if tag_object.exit_code == 0 { + message = annotation_message_from_tag_object(&tag_object.stdout); + } + } + // Peel the ref to a commit so pre-existing tree/blob tags cannot produce + // a recovery record that violates the restore contract. + let peeled = execute_git( + root, + &[ + "rev-parse".into(), + "--verify".into(), + format!("{expected_object}^{{commit}}"), + ], + None, + )?; + if peeled.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + format!("Could not resolve tag target '{name}'"), + ) + .with_details(peeled.output)); + } + let mut result = delete_ref_if_unchanged(root, &reference, &expected_object)?; + if result.exit_code == 0 { + result.tag_deletion = Some(GitTagDeletionResponse { + name, + deleted_target: peeled.stdout.trim().to_string(), + kind: if is_annotated { + "annotated" + } else { + "lightweight" + } + .to_string(), + message, + }); + } + Ok(result) +} + +/// Deletes one local branch and returns a structured deletion record so the +/// host can offer a restore. The commit is resolved before the deletion +/// because `git branch -d` diagnostics are localized prose. +pub(crate) fn delete_branch(root: &str, branch: &str) -> Result { + let reference = format!("refs/heads/{branch}"); + let target = resolve_ref_object(root, &reference)?.ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + format!("The branch '{branch}' does not exist"), + ) + })?; + ensure_branch_is_safely_deletable(root, branch, &reference, &target)?; + let mut result = delete_ref_if_unchanged(root, &reference, &target)?; + if result.exit_code == 0 { + result.branch_deletion = Some(GitBranchDeletionResponse { + name: branch.to_string(), + deleted_target: target, + }); + if let Err(error) = remove_branch_config(root, branch) { + // The ref mutation already committed. Preserve recovery data and + // report configuration cleanup as a diagnosable partial success. + result.warnings.push(GitOperationWarning::new( + "branch_config_cleanup_failed", + &error.message, + error.details, + )); + } + } + Ok(result) +} + +/// Resolves an exact refname to its current unpeeled object id. +pub(crate) fn resolve_ref_object(root: &str, reference: &str) -> Result, CoreError> { + let probe = execute_git( + root, + &[ + "rev-parse".into(), + "--verify".into(), + "--quiet".into(), + reference.to_string(), + ], + None, + )?; + Ok((probe.exit_code == 0).then(|| probe.stdout.trim().to_string())) +} + +/// Deletes a ref only when it still points at the object observed by the +/// caller. `update-ref` performs the comparison and mutation under the same +/// ref lock, closing the probe-then-mutate race. +fn delete_ref_if_unchanged( + root: &str, + reference: &str, + expected_object: &str, +) -> Result { + let mut result = execute_git( + root, + &[ + "update-ref".into(), + "-d".into(), + reference.to_string(), + expected_object.to_string(), + ], + None, + )?; + if result.exit_code != 0 { + result.operation_error = Some( + CoreError::new( + ErrorCode::InvalidRequest, + format!("The Git reference '{reference}' changed before it could be deleted"), + ) + .with_details(result.output.clone()), + ); + } + Ok(result) +} + +/// Preserves `git branch -d` safety before the atomic ref mutation: a branch +/// must not be checked out in any worktree and must be merged into its valid +/// upstream, or into HEAD when it has no usable upstream. +fn ensure_branch_is_safely_deletable( + root: &str, + branch: &str, + reference: &str, + target: &str, +) -> Result<(), CoreError> { + let worktrees = execute_git( + root, + &["worktree".into(), "list".into(), "--porcelain".into()], + None, + )?; + if worktrees.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Could not inspect Git worktrees") + .with_details(worktrees.output), + ); + } + if worktrees + .stdout + .lines() + .any(|line| line == format!("branch {reference}")) + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + format!("The branch '{branch}' is checked out in a worktree"), + )); + } + + let upstream = execute_git( + root, + &[ + "for-each-ref".into(), + "--format=%(upstream)".into(), + reference.to_string(), + ], + None, + )?; + if upstream.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not inspect the branch upstream", + ) + .with_details(upstream.output)); + } + let upstream = upstream.stdout.trim(); + let merge_target = if !upstream.is_empty() && resolved_commit_target(root, upstream)?.is_some() + { + upstream + } else { + "HEAD" + }; + let merged = execute_git( + root, + &[ + "merge-base".into(), + "--is-ancestor".into(), + target.to_string(), + merge_target.to_string(), + ], + None, + )?; + match merged.exit_code { + 0 => Ok(()), + 1 => Err(CoreError::new( + ErrorCode::InvalidRequest, + format!("The branch '{branch}' is not fully merged"), + )), + _ => Err(CoreError::new( + ErrorCode::ProcessFailed, + format!("Could not verify whether branch '{branch}' is merged"), + ) + .with_details(merged.output)), + } +} + +/// Removes branch-local configuration after the ref has been deleted, matching +/// the metadata cleanup performed by `git branch -d`. +fn remove_branch_config(root: &str, branch: &str) -> Result<(), CoreError> { + let listing = capture_git_with_options( + root, + &[ + "config".into(), + "--name-only".into(), + "--get-regexp".into(), + "^branch\\.".into(), + ], + None, + false, + )?; + if listing.exit_code == 1 { + return Ok(()); + } + if listing.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not inspect branch configuration", + ) + .with_details(String::from_utf8_lossy(&listing.stderr))); + } + let prefix = format!("branch.{branch}."); + if !String::from_utf8_lossy(&listing.stdout) + .lines() + .any(|key| key.starts_with(&prefix)) + { + return Ok(()); + } + let cleanup = capture_git_with_options( + root, + &[ + "config".into(), + "--remove-section".into(), + format!("branch.{branch}"), + ], + None, + false, + )?; + if cleanup.exit_code == 0 { + return Ok(()); + } + Err(CoreError::new( + ErrorCode::ProcessFailed, + format!("Could not remove configuration for deleted branch '{branch}'"), + ) + .with_details(String::from_utf8_lossy(&cleanup.stderr))) +} + +/// Extracts the annotation message from a raw tag object byte-for-byte, so a +/// restored tag keeps the original message including CRLF line endings and +/// trailing newlines. Only the signature block is cut, by locating its first +/// line in the raw content; the signature belongs to the previous tagger and +/// a restored tag would be signed separately. +pub(crate) fn annotation_message_from_tag_object(raw: &str) -> Option { + let (_, message) = raw.split_once("\n\n")?; + let mut offset = 0; + for line in message.split_inclusive('\n') { + let without_eol = line.trim_end_matches(['\r', '\n']); + if without_eol.starts_with("-----BEGIN ") && without_eol.ends_with("SIGNATURE-----") { + return Some(message[..offset].to_string()); + } + offset += line.len(); + } + Some(message.to_string()) +} + +pub(crate) fn local_branch_name(reference: &str) -> Result { + let branch = reference + .strip_prefix("refs/heads/") + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "Only local branches support this Git operation", + ) + })?; + if !is_safe_pathspec(branch) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git branch name", + )); + } + Ok(branch.to_string()) +} + +pub(crate) fn current_branch(root: &str) -> Result { + let response = execute_git(root, &["branch".into(), "--show-current".into()], None)?; + if response.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not determine current branch", + ) + .with_details(response.output)); + } + let branch = response.output.trim(); + if branch.is_empty() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Git operation requires a checked out branch", + )); + } + Ok(branch.to_string()) +} + +pub(crate) fn optional_current_branch(root: &str) -> Result, CoreError> { + let response = execute_git_readonly(root, &["branch".into(), "--show-current".into()], None)?; + if response.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not determine current branch", + ) + .with_details(response.output)); + } + Ok(match response.output.trim() { + "" => None, + branch => Some(branch.to_string()), + }) +} + +pub(crate) fn is_current_reference(root: &str, reference: &str) -> Result { + let current = current_branch(root)?; + Ok(reference == current || reference == format!("refs/heads/{current}")) +} + +pub(crate) fn update_local_branch( + root: &str, + request: &GitWriteRequest, +) -> Result { + let reference = request + .git_reference + .as_ref() + .ok_or_else(invalid_git_reference) + .and_then(|reference| validated_git_reference(root, reference))?; + if reference.kind != "local" { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Branch update requires a local Git reference", + )); + } + if optional_current_branch(root)?.is_some_and(|current| { + reference.full_name == current || reference.full_name == format!("refs/heads/{current}") + }) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The current branch must be updated through pull", + )); + } + + let upstream = execute_git_readonly( + root, + &[ + "for-each-ref".into(), + "--format=%(upstream)".into(), + reference.full_name.clone(), + ], + None, + )?; + if upstream.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not inspect the branch upstream", + ) + .with_details(upstream.output)); + } + let upstream_reference = upstream.stdout.trim(); + if upstream_reference.is_empty() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The selected branch has no upstream", + )); + } + let (remote, remote_branch) = mutations::remote_branch_components(root, upstream_reference)?; + + // One atomic fetch refreshes the tracking ref and fast-forwards the selected + // local branch without changing HEAD. Git rejects non-fast-forward updates + // and branches checked out by any worktree. + execute_git( + root, + &[ + "fetch".into(), + "--atomic".into(), + "--no-tags".into(), + "--".into(), + remote, + format!("+refs/heads/{remote_branch}:{upstream_reference}"), + format!("refs/heads/{remote_branch}:{}", reference.full_name), + ], + None, + ) +} + +pub(crate) fn configure_branch_upstream( + root: &str, + local_branch: &str, + remote: &str, + remote_branch: &str, +) -> Result, CoreError> { + let remote_config = execute_git( + root, + &[ + "config".to_string(), + "--local".to_string(), + "--replace-all".to_string(), + format!("branch.{local_branch}.remote"), + remote.to_string(), + ], + None, + )?; + if remote_config.exit_code != 0 { + return Ok(Some(remote_config)); + } + let merge_config = execute_git( + root, + &[ + "config".to_string(), + "--local".to_string(), + "--replace-all".to_string(), + format!("branch.{local_branch}.merge"), + format!("refs/heads/{remote_branch}"), + ], + None, + )?; + Ok((merge_config.exit_code != 0).then_some(merge_config)) +} diff --git a/rust/crates/core/src/git/catalog.rs b/rust/crates/git/src/catalog.rs similarity index 97% rename from rust/crates/core/src/git/catalog.rs rename to rust/crates/git/src/catalog.rs index eb3e60cf..d5537e8b 100644 --- a/rust/crates/core/src/git/catalog.rs +++ b/rust/crates/git/src/catalog.rs @@ -1,6 +1,6 @@ //! Typed Git remote, tag and bounded checkout-metadata interpretation. -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::Deserialize; use serde_json::{json, Value}; use std::collections::BTreeMap; @@ -16,7 +16,7 @@ struct TextRequest { } /// Returns typed, deterministic metadata without exposing command output parsing to hosts. -pub(crate) fn command(operation: &str, payload: Value) -> Result { +pub fn command(operation: &str, payload: Value) -> Result { if matches!(operation, "checkout" | "gitdir") { let request: TextRequest = serde_json::from_value(payload).map_err(invalid_request)?; if request.text.len() > 4096 { diff --git a/rust/crates/core/src/git/change_targets.rs b/rust/crates/git/src/change_targets.rs similarity index 72% rename from rust/crates/core/src/git/change_targets.rs rename to rust/crates/git/src/change_targets.rs index 098a3665..e0121fa6 100644 --- a/rust/crates/core/src/git/change_targets.rs +++ b/rust/crates/git/src/change_targets.rs @@ -7,7 +7,7 @@ //! once the summary's files are gone. Hosts send the selection they captured; //! Core decides which entries remain operation targets. -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashSet; @@ -51,7 +51,7 @@ fn invalid(message: &str) -> CoreError { /// ancestor of its path. Everything else, including duplicates the host chose /// to send, keeps its position; acting on a summary already acts on the /// listed files below it. -pub(crate) fn command(payload: Value) -> Result { +pub fn command(payload: Value) -> Result { let request: Request = serde_json::from_value(payload).map_err(|error| { invalid("Invalid Git change target request").with_details(error.to_string()) })?; @@ -99,42 +99,3 @@ fn below_summary(target: &Target, summaries: &HashSet<(&str, &str)>) -> bool { prefix.len() < target.path.len() && summaries.contains(&(target.root.as_str(), prefix)) }) } - -#[cfg(test)] -mod tests { - use serde_json::{json, Value}; - - fn call(payload: Value) -> Value { - serde_json::from_str(&crate::execute_json( - &json!({"id":"targets", "command":"git.normalizeChangeTargets", "payload":payload}) - .to_string(), - )) - .unwrap() - } - - #[test] - fn shared_fixture_cases_keep_only_operation_targets() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../../fixtures/git/change-targets-v1.json" - )) - .unwrap(); - for case in fixture["cases"].as_array().unwrap() { - let response = call(case["request"].clone()); - assert_eq!(response["ok"], true, "{}: {response}", case["name"]); - assert_eq!( - response["data"]["indices"], case["indices"], - "{}", - case["name"] - ); - } - for case in fixture["invalid"].as_array().unwrap() { - let response = call(case["request"].clone()); - assert_eq!(response["ok"], false, "{}", case["name"]); - assert_eq!( - response["error"]["code"], "invalid_request", - "{}", - case["name"] - ); - } - } -} diff --git a/rust/crates/core/src/git/changelists.rs b/rust/crates/git/src/changelists.rs similarity index 98% rename from rust/crates/core/src/git/changelists.rs rename to rust/crates/git/src/changelists.rs index bf959513..c829ffd4 100644 --- a/rust/crates/core/src/git/changelists.rs +++ b/rust/crates/git/src/changelists.rs @@ -1,7 +1,7 @@ //! Pure local ChangeList state transitions and commit-scope projection. use super::workspace_commit::path_scope::{MembershipScope, PathScope}; -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::{BTreeMap, BTreeSet}; @@ -323,7 +323,7 @@ impl File { } /// Reduces one host-owned snapshot and projects list membership and commit scope. -pub(crate) fn changelists_command(payload: Value) -> Result { +pub fn changelists_command(payload: Value) -> Result { let mut request: Request = serde_json::from_value(payload).map_err(|_| invalid())?; request.state.validate()?; if request.files.len() > MAX_ITEMS || request.repositories.len() > MAX_ITEMS { @@ -353,7 +353,7 @@ mod tests { #[test] fn shared_fixture_matches_the_reducer() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../fixtures/git/changelists-reducer-v1.json" + "../../../fixtures/git/changelists-reducer-v1.json" )) .unwrap(); assert_eq!( diff --git a/rust/crates/git/src/checkout.rs b/rust/crates/git/src/checkout.rs new file mode 100644 index 00000000..6def4aeb --- /dev/null +++ b/rust/crates/git/src/checkout.rs @@ -0,0 +1,316 @@ +//! Checkout and switch of local and remote references, with an optional auto-stash. + +use super::*; + +/// Checks out `request.reference`, honouring the conflict-resolution strategy the user +/// picked in the checkout dialog. +/// +/// Three strategies, mirroring IntelliJ IDEA: +/// - default: plain switch. Git refuses when local changes would be overwritten. +/// - `force`: `--discard-changes`, throwing the local edits away. +/// - `auto_stash`: stash, switch, then restore the stash ("smart checkout"). +pub(crate) fn checkout( + root: &str, + request: GitWriteRequest, +) -> Result { + if request.reference_kind.as_deref() == Some("local") { + if let Some(reference) = request + .reference + .as_deref() + .filter(|value| !value.starts_with('-') && !value.chars().any(char::is_whitespace)) + { + if is_current_reference(root, reference)? { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The current branch is already checked out", + )); + } + } + } + if request.auto_stash { + // Reject local-only commits before creating a stash. Ordinary remote + // checkout aligns the branch; checkoutAndRebase retains its own policy. + let is_remote = request.git_reference.as_ref().map_or( + request.reference_kind.as_deref() == Some("remote"), + |reference| reference.kind == "remote", + ); + if is_remote { + let reference = checkout_request_reference(root, &request)?; + let (_, local_name) = mutations::remote_branch_components(root, &reference.full_name)?; + let local_ref = format!("refs/heads/{local_name}"); + if resolve_ref_object(root, &local_ref)?.is_some() { + remote_checkout_revisions(root, &local_ref, &reference.full_name)?; + } + } + return checkout_with_auto_stash(root, request); + } + switch_reference(root, &request) +} + +/// Checks out a local or remote branch, then rebases it onto the branch that +/// was current before the switch. A dirty tree is rejected before checkout so +/// the composite operation cannot leave the repository half-switched. +/// Stash, switch, restore. A failed switch leaves the stash untouched so the caller can +/// recover it, and a conflicting restore is reported as a failure rather than silently +/// leaving the entry behind. +fn checkout_with_auto_stash( + root: &str, + request: GitWriteRequest, +) -> Result { + let stash = execute_git( + root, + &[ + "stash".into(), + "push".into(), + "--include-untracked".into(), + "--message".into(), + AUTO_STASH_MESSAGE.into(), + ], + None, + )?; + if stash.exit_code != 0 { + return Ok(stash); + } + + let switched = switch_reference(root, &request)?; + if switched.exit_code != 0 { + // Leave the stash in place; the working tree is still on the original branch. + return Ok(switched); + } + + let Some(stash_reference) = find_stash_reference(root, AUTO_STASH_MESSAGE)? else { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Smart Checkout created a stash but could not locate it for restore.", + )); + }; + let mut restored = execute_git( + root, + &["stash".into(), "pop".into(), stash_reference.clone()], + None, + )?; + let conflicted_paths = conflicted_paths(root)?; + let entry_was_kept = stash_reference_exists(root, &stash_reference)?; + if entry_was_kept && (restored.exit_code == 0 || !conflicted_paths.is_empty()) { + restored.exit_code = 1; + restored.stash_restore = Some(GitStashRestoreResponse { + stash_reference, + conflicted_paths, + }); + } + Ok(restored) +} + +pub(crate) fn switch_reference( + root: &str, + request: &GitWriteRequest, +) -> Result { + let reference = checkout_request_reference(root, request)?; + switch_validated_reference( + root, + &reference, + request.force, + request.operation == "checkout", + ) +} + +fn switch_validated_reference( + root: &str, + reference: &ValidatedGitReference, + force: bool, + update_remote: bool, +) -> Result { + let mut base: Vec = vec!["switch".into()]; + if force { + base.push("--discard-changes".into()); + } + match reference.kind.as_str() { + "local" => { + if current_branch(root)? == reference.short_name { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The current branch is already checked out", + )); + } + // `git switch` rejects fully qualified refs ("refs/heads/foo"), so pass the + // short branch name. Tags still need the full ref for the --detach form. + base.push(reference.short_name.clone()); + execute_git(root, &base, None) + } + "tag" => { + base.push("--detach".into()); + base.push(reference.full_name.clone()); + execute_git(root, &base, None) + } + "remote" => { + let (_, local_name) = mutations::remote_branch_components(root, &reference.full_name)?; + let local_ref = format!("refs/heads/{local_name}"); + let existing = execute_git( + root, + &[ + "show-ref".into(), + "--verify".into(), + "--quiet".into(), + local_ref.clone(), + ], + None, + )?; + if existing.exit_code == 0 { + let upstream = execute_git_readonly( + root, + &[ + "for-each-ref".into(), + "--format=%(upstream)".into(), + "--count=1".into(), + local_ref.clone(), + ], + None, + )?; + if upstream.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not inspect the local branch upstream", + ) + .with_details(upstream.output)); + } + if upstream.stdout.trim() != reference.full_name { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "A same-named local branch tracks a different Git reference", + )); + } + if update_remote { + return checkout_updated_remote_branch( + root, + &local_name, + &local_ref, + &reference.full_name, + force, + ); + } + if current_branch(root)? == local_name { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The current branch is already checked out", + )); + } + base.push(local_name); + } else { + base.push("--track".into()); + base.push("-c".into()); + base.push(local_name); + base.push(reference.full_name.clone()); + } + execute_git(root, &base, None) + } + _ => Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git reference kind", + )), + } +} + +/// Pins the local and fetched remote commits and rejects any local-only history. +fn remote_checkout_revisions( + root: &str, + local_ref: &str, + remote_ref: &str, +) -> Result<(String, String), CoreError> { + let local_revision = resolve_commit_revision(root, local_ref)?; + let remote_revision = resolve_commit_revision(root, remote_ref)?; + let ancestor = execute_git_readonly( + root, + &[ + "merge-base".into(), + "--is-ancestor".into(), + local_revision.clone(), + remote_revision.clone(), + ], + None, + )?; + match ancestor.exit_code { + 0 => Ok((local_revision, remote_revision)), + 1 => Err(CoreError::new( + ErrorCode::InvalidRequest, + "The local branch has commits not included in the selected remote branch. Reconcile those commits before checking out the remote branch.", + )), + _ => Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not compare the local and remote branch commits", + ) + .with_details(ancestor.output)), + } +} + +/// Switches to an existing tracking branch and fast-forwards to a pinned remote +/// commit, including when that local branch is already current. +fn checkout_updated_remote_branch( + root: &str, + local_name: &str, + local_ref: &str, + remote_ref: &str, + force: bool, +) -> Result { + let (local_revision, remote_revision) = remote_checkout_revisions(root, local_ref, remote_ref)?; + if !force { + // Check against the final remote tree before switching to the old local + // tree, so a blocked update cannot leave the user on another branch. + let preflight = checkout_preflight(GitCheckoutPreflightRequest { + root: root.to_string(), + reference: Some(remote_revision.clone()), + git_reference: None, + })?; + if !preflight.blocking_paths.is_empty() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Local changes would be overwritten by checking out the remote branch", + ) + .with_details(preflight.blocking_paths.join("\n"))); + } + } + if force || current_branch(root)? != local_name { + let mut arguments = vec!["switch".into()]; + if force { + arguments.push("--discard-changes".into()); + } + arguments.push(local_name.to_string()); + let switched = execute_git(root, &arguments, None)?; + if switched.exit_code != 0 { + return Ok(switched); + } + } + // Post-checkout hooks and external clients may change HEAD. Verify the + // branch and its tip again; --ff-only also prevents losing local commits. + if current_branch(root)? != local_name + || resolve_commit_revision(root, "HEAD")? != local_revision + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The selected branch changed; review and retry", + )); + } + // `branch..mergeOptions=--squash` would make the merge exit 0 while + // only staging the remote diff. Override it for this call without touching + // the user's configuration, then confirm HEAD reached the pinned commit. + let merged = execute_git( + root, + &[ + "merge".into(), + "--ff-only".into(), + "--no-squash".into(), + remote_revision.clone(), + ], + None, + )?; + if merged.exit_code == 0 + && (current_branch(root)? != local_name + || resolve_commit_revision(root, "HEAD")? != remote_revision) + { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Git did not move the branch to the selected remote commit", + ) + .with_details(merged.output)); + } + Ok(merged) +} diff --git a/rust/crates/core/src/git/command_trace.rs b/rust/crates/git/src/command_trace.rs similarity index 97% rename from rust/crates/core/src/git/command_trace.rs rename to rust/crates/git/src/command_trace.rs index c6709f8e..23d00fbd 100644 --- a/rust/crates/core/src/git/command_trace.rs +++ b/rust/crates/git/src/command_trace.rs @@ -7,10 +7,10 @@ use super::console::Source; use super::{execution_events, execution_policy}; -use crate::diagnostics::core_events; -use crate::diagnostics::record::LogLevel; -use crate::diagnostics::request_trace; -use crate::protocol::{CoreError, ErrorCode}; +use lithe_diagnostics::core_events; +use lithe_diagnostics::record::LogLevel; +use lithe_protocol::{CoreError, ErrorCode}; +use lithe_request_context::{source_token, trace}; use serde_json::json; use std::collections::BTreeMap; use std::time::Instant; @@ -62,13 +62,13 @@ enum Outcome { impl Trace { /// Captures provenance on the request thread, before any reader thread exists. pub(super) fn begin(repository: &str, arguments: &[String], visible: bool) -> Self { - let context = request_trace::current(); + let context = trace::current(); let source = execution_policy::source(); Self { arguments: arguments.to_vec(), repository: repository.to_owned(), source, - remote: crate::remote::workspace::is_remote(), + remote: lithe_workspace::is_remote(), visible, op: context.op, started: Instant::now(), @@ -114,10 +114,7 @@ impl Trace { "arguments".to_string(), json!(format_arguments(&self.arguments)), ), - ( - "source".to_string(), - json!(request_trace::source_token(self.source)), - ), + ("source".to_string(), json!(source_token(self.source))), ( "duration_ms".to_string(), json!(self.started.elapsed().as_millis() as u64), diff --git a/rust/crates/core/src/git/commit_message_context.rs b/rust/crates/git/src/commit_message_context.rs similarity index 99% rename from rust/crates/core/src/git/commit_message_context.rs rename to rust/crates/git/src/commit_message_context.rs index ec2ba12a..a87229c3 100644 --- a/rust/crates/core/src/git/commit_message_context.rs +++ b/rust/crates/git/src/commit_message_context.rs @@ -2,7 +2,7 @@ use super::patch_exchange::{self, PatchExportRequest, PatchFile, PatchSource}; use super::{capture_git_with_options, commit_state, GitStatusRequest}; -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; @@ -336,7 +336,7 @@ fn collect(mode: Mode, repositories: &[Repository]) -> Result<(Vec, St } /// Captures bounded evidence or checks whether its complete Git state is current. -pub(crate) fn commit_message_context_command(value: Value) -> Result { +pub fn commit_message_context_command(value: Value) -> Result { let request: Request = serde_json::from_value(value).map_err(|_| invalid("AI_COMMIT_INVALID_OPTIONS"))?; let repositories = selected_repositories(request.repositories)?; diff --git a/rust/crates/core/src/git/commit_selection.rs b/rust/crates/git/src/commit_selection.rs similarity index 96% rename from rust/crates/core/src/git/commit_selection.rs rename to rust/crates/git/src/commit_selection.rs index dd9b41e5..b92c7507 100644 --- a/rust/crates/core/src/git/commit_selection.rs +++ b/rust/crates/git/src/commit_selection.rs @@ -5,7 +5,8 @@ use super::{ pathspec_batches, rewrite::RewriteLease, stage_paths, unstage_paths, validate_root, LS_FILES_PATHSPEC_BUDGET, }; -use crate::protocol::{CoreError, ErrorCode, GitChange}; +use crate::contracts::GitChange; +use lithe_protocol::{CoreError, ErrorCode}; use serde::Deserialize; use serde_json::{json, Value}; use std::collections::{BTreeMap, BTreeSet}; @@ -106,18 +107,18 @@ fn snapshot( { return Err(stale()); } - if item.status == "??" && item.path.ends_with('/') { - if indexed_paths + if item.status == "??" + && item.path.ends_with('/') + && (indexed_paths .range(item.path.as_str()..) .next() .is_some_and(|path| path.starts_with(&item.path)) || tracked_paths .range(item.path.clone()..) .next() - .is_some_and(|path| path.starts_with(&item.path)) - { - return Err(stale()); - } + .is_some_and(|path| path.starts_with(&item.path))) + { + return Err(stale()); } } let mut observed = BTreeSet::new(); @@ -321,7 +322,7 @@ fn verify_preserved_index( } /// Rechecks every repository, prepares each index under its Git lease, then verifies the result. -pub(crate) fn prepare_commit_selection_command(payload: Value) -> Result { +pub fn prepare_commit_selection_command(payload: Value) -> Result { let mut request: Request = serde_json::from_value(payload).map_err(|_| stale())?; if request.repositories.is_empty() || request.repositories.len() > MAX_REPOSITORIES @@ -378,7 +379,7 @@ pub(crate) fn prepare_commit_selection_command(payload: Value) -> Result Result Result Result, - pub branch: Option, + pub(crate) head: Option, + pub(crate) branch: Option, /// Git's NUL-delimited index entries, including object IDs and conflict stages. pub index_entries: String, - pub gitlinks: Vec, - pub staged_paths: Vec, - pub conflicted_paths: Vec, + pub(crate) gitlinks: Vec, + pub(crate) staged_paths: Vec, + pub(crate) conflicted_paths: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] /// An exact submodule commit recorded at a parent-relative path. pub struct GitCommitGitlink { - pub path: String, - pub revision: String, + pub(crate) path: String, + pub(crate) revision: String, } fn read(root: &str, arguments: &[&str]) -> Result { diff --git a/rust/crates/git/src/commits.rs b/rust/crates/git/src/commits.rs new file mode 100644 index 00000000..5a625f58 --- /dev/null +++ b/rust/crates/git/src/commits.rs @@ -0,0 +1,669 @@ +//! Commit lookups, commit comparisons and commits of selected paths through a +//! temporary index. + +use super::*; + +/// Owns an isolated Git administration directory for commits or read-only diffs. +pub(crate) struct TemporaryGitCommitContext { + pub(crate) directory: PathBuf, + pub(crate) index_path: PathBuf, + temporary_reference: Option, +} + +impl TemporaryGitCommitContext { + pub(crate) fn prepare(root: &str, head: Option<&str>) -> Result { + let common_directory = git_resolved_path( + root, + &["rev-parse", "--path-format=absolute", "--git-common-dir"], + "Git common directory", + )?; + Self::prepare_in(&common_directory, "lithe-commit", head) + } + + pub(crate) fn prepare_snapshot(head: Option<&str>) -> Result { + if let Some(directory) = + fs::remote_temp_directory("lithe-git-snapshot").map_err(line_history_io_error)? + { + let context = Self { + index_path: directory.join("index"), + directory, + temporary_reference: None, + }; + let contents = head + .map(|head| format!("{head}\n")) + .unwrap_or_else(|| "ref: refs/heads/lithe-snapshot\n".into()); + fs::write(context.directory.join("HEAD"), contents).map_err(line_history_io_error)?; + return Ok(context); + } + // Creating even an ignored child inside .git changes its parent directory + // on Windows. A read-only diff must not invalidate the Git watcher that + // requested it; keep its disposable index outside repository metadata. + Self::prepare_in(&std::env::temp_dir(), "lithe-git-snapshot", head) + } + + pub(crate) fn prepare_in( + parent: &Path, + prefix: &str, + head: Option<&str>, + ) -> Result { + let (directory, sequence) = loop { + let sequence = TEMPORARY_INDEX_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let candidate = parent.join(format!("{prefix}-{}-{sequence}", std::process::id())); + // Public POSIX temp roots must not expose tracked paths/object IDs. + // Set permissions at atomic creation, before writing HEAD or index. + match fs::create_dir_mode(&candidate, false, Some(0o700)) { + Ok(()) => break (candidate, sequence), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => { + return Err(CoreError::new( + ErrorCode::Unknown, + "Could not create an isolated Git commit context", + ) + .with_details(error.to_string())); + } + } + }; + let temporary_reference = head.is_none().then(|| { + format!( + "refs/lithe/selected-commit-{}-{sequence}", + std::process::id() + ) + }); + let head_contents = head.map(|head| format!("{head}\n")).unwrap_or_else(|| { + format!( + "ref: {}\n", + temporary_reference + .as_deref() + .expect("an unborn repository needs a temporary reference") + ) + }); + // Establish ownership before initializing HEAD so errors also remove + // the temporary directory through Drop. + let context = Self { + index_path: directory.join("index"), + directory, + temporary_reference, + }; + fs::write(context.directory.join("HEAD"), head_contents).map_err(|error| { + CoreError::new( + ErrorCode::Unknown, + "Could not initialize an isolated Git commit context", + ) + .with_details(error.to_string()) + })?; + Ok(context) + } + + pub(crate) fn environment(&self, root: &str, common_directory: &Path) -> Vec<(String, String)> { + vec![ + ( + "GIT_DIR".to_string(), + self.directory.to_string_lossy().into_owned(), + ), + ( + "GIT_COMMON_DIR".to_string(), + common_directory.to_string_lossy().into_owned(), + ), + ("GIT_WORK_TREE".to_string(), root.to_string()), + ( + "GIT_INDEX_FILE".to_string(), + self.index_path.to_string_lossy().into_owned(), + ), + ] + } + + fn cleanup_reference(&self, root: &str) -> Result<(), CoreError> { + let Some(reference) = self.temporary_reference.as_ref() else { + return Ok(()); + }; + let arguments = vec!["update-ref".into(), "-d".into(), reference.clone()]; + let removed = capture_git_with_options(root, &arguments, None, false)?; + if removed.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not remove temporary Git reference", + ) + .with_details(String::from_utf8_lossy(&removed.stderr))); + } + Ok(()) + } +} + +impl Drop for TemporaryGitCommitContext { + fn drop(&mut self) { + fs::cleanup_directory(&self.directory); + } +} + +pub(crate) fn read_commit_log( + root: &str, + selectors: Vec, + limit: usize, + failure_message: &str, +) -> Result<(Vec, bool), CoreError> { + let mut arguments = vec!["log".to_string()]; + arguments.extend(selectors); + arguments.extend([ + "--topo-order".to_string(), + "--decorate=short".to_string(), + "-n".to_string(), + (limit.saturating_add(1)).to_string(), + GIT_COMMIT_DATE_ARGUMENT.to_string(), + "--pretty=format:%H%x1f%h%x1f%P%x1f%an%x1f%ae%x1f%ad%x1f%s%x1f%D".to_string(), + ]); + let commit_output = readonly_command(GitCommandRequest { + root: root.to_string(), + arguments, + input: None, + })?; + if commit_output.exit_code != 0 { + return Err(CoreError::new(ErrorCode::ProcessFailed, failure_message) + .with_details(commit_output.output)); + } + + let all_commits = commit_output + .output + .lines() + .filter_map(parse_commit) + .collect::>(); + let has_more = all_commits.len() > limit; + Ok((all_commits.into_iter().take(limit).collect(), has_more)) +} + +/// Resolves one commit, its parent metadata, and the message body after the subject. +pub fn commit(request: GitCommitRequest) -> Result { + let root = validate_root(&request.root)?; + validate_revision(&request.commit)?; + let response = readonly_command(GitCommandRequest { + root, + arguments: vec![ + "show".to_string(), + "-s".to_string(), + // `log.showSignature` would otherwise print verification output ahead of + // the machine-parsed format. + "--no-show-signature".to_string(), + GIT_COMMIT_DATE_ARGUMENT.to_string(), + // The body is free-form multi-line text that may contain the unit + // separator, so it follows a NUL that Git never emits in the header fields. + "--pretty=format:%H%x1f%h%x1f%P%x1f%an%x1f%ae%x1f%ad%x1f%s%x1f%D%x00%b".to_string(), + request.commit, + ], + input: None, + })?; + if response.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Git commit lookup failed") + .with_details(response.output), + ); + } + parse_commit_lookup(&response.stdout) + .ok_or_else(|| CoreError::new(ErrorCode::ProcessFailed, "Git commit was not found")) +} + +/// Splits `git show` output produced by [`commit`] into header metadata and body. +pub(crate) fn parse_commit_lookup(output: &str) -> Option { + let (header, body) = output.split_once('\0')?; + let commit = parse_commit(header.trim_end_matches(['\r', '\n']))?; + Some(GitCommitLookupResponse { + commit, + // Git terminates `%b` with a line break; trailing blank lines carry no content. + body: body.trim_end().to_string(), + }) +} + +/// Lists workspace-relative files changed by one commit. +pub fn commit_files(request: GitCommitFilesRequest) -> Result { + let root = validate_root(&request.root)?; + validate_revision(&request.commit)?; + let response = readonly_command(GitCommandRequest { + root, + arguments: vec![ + "-c".to_string(), + "core.quotepath=false".to_string(), + "show".to_string(), + "--pretty=format:".to_string(), + "--name-status".to_string(), + "--find-renames".to_string(), + request.commit, + ], + input: None, + })?; + if response.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Git commit files failed") + .with_details(response.output), + ); + } + Ok(GitFilesResponse { + files: parse_name_status(&response.output), + }) +} + +/// Computes ahead/behind counts and changed files for a reference comparison. +pub fn comparison(request: GitComparisonRequest) -> Result { + let root = validate_root(&request.root)?; + if request.include_untracked + && (request.target_git_reference.is_some() + || request + .reference + .as_deref() + .is_some_and(|value| value.contains(".."))) + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Untracked files require a worktree comparison", + )); + } + let reference = typed_reference_range( + &root, + request.git_reference.as_ref(), + request.target_git_reference.as_ref(), + request.reference.as_deref(), + )? + .ok_or_else(|| CoreError::new(ErrorCode::InvalidRequest, "Missing Git reference"))?; + validate_revision(&reference)?; + let response = readonly_command(GitCommandRequest { + root: root.clone(), + arguments: vec![ + "-c".to_string(), + "core.quotepath=false".to_string(), + "diff".to_string(), + "--name-status".to_string(), + "--find-renames".to_string(), + reference, + "--".to_string(), + ], + input: None, + })?; + if response.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Git comparison failed") + .with_details(response.output), + ); + } + let files = parse_name_status(&response.output); + if !request.include_untracked { + return Ok(GitComparisonResponse { files }); + } + let snapshot = status(GitStatusRequest { + root, + include_index_only_changes: false, + repository_roots: Vec::new(), + summarize_untracked_directories: false, + })?; + let mut by_path: std::collections::BTreeMap<_, _> = files + .into_iter() + .map(|file| (file.path.clone(), file)) + .collect(); + for change in snapshot + .changes + .into_iter() + .filter(|change| change.untracked) + { + by_path + .entry(change.path.clone()) + .or_insert(GitFileResponse { + status: "A".into(), + path: change.path, + is_untracked: true, + }); + } + Ok(GitComparisonResponse { + files: by_path.into_values().collect(), + }) +} + +pub(crate) fn commit_selected_paths( + root: &str, + paths: Vec, + message: String, + amend: bool, + identity: Vec, +) -> Result { + let operation = operation_state(GitOperationStateRequest { + root: root.to_string(), + })?; + if !operation.kind.is_empty() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Finish or abort the current Git operation before committing selected files", + )); + } + + let branch = execute_git_readonly( + root, + &["symbolic-ref".into(), "--quiet".into(), "HEAD".into()], + None, + )?; + let branch_reference = branch.stdout.trim(); + if branch.exit_code != 0 || !branch_reference.starts_with("refs/heads/") { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Selected files can only be committed on a local branch", + )); + } + + let head = execute_git_readonly( + root, + &[ + "rev-parse".to_string(), + "--verify".to_string(), + "-q".to_string(), + "HEAD".to_string(), + ], + None, + )?; + if head.exit_code > 1 { + return Ok(head); + } + let original_head = (head.exit_code == 0).then(|| head.stdout.trim().to_string()); + if amend && original_head.is_none() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "An initial commit cannot be amended", + )); + } + + // Git's own commit command still runs hooks and signing, but its HEAD and + // index live in an isolated administration directory. The reviewed branch + // is updated only after the resulting snapshot passes validation. + let common_directory = git_resolved_path( + root, + &["rev-parse", "--path-format=absolute", "--git-common-dir"], + "Git common directory", + )?; + let temporary_context = TemporaryGitCommitContext::prepare(root, original_head.as_deref())?; + let environment = temporary_context.environment(root, &common_directory); + let pathspec_input = nul_pathspec_input(&paths); + let initialize_arguments = if let Some(head) = original_head.as_ref() { + vec!["read-tree".to_string(), head.clone()] + } else { + vec!["read-tree".to_string(), "--empty".to_string()] + }; + let initialized = + execute_git_with_environment(root, &initialize_arguments, None, false, &environment)?; + if initialized.exit_code != 0 { + return Ok(initialized); + } + let stage_arguments = vec![ + "add".to_string(), + "-A".to_string(), + "--pathspec-from-file=-".to_string(), + "--pathspec-file-nul".to_string(), + ]; + let staged = execute_git_with_environment( + root, + &stage_arguments, + Some(pathspec_input.clone()), + false, + &environment, + )?; + if staged.exit_code != 0 { + return Ok(staged); + } + + let allowed_paths = staged_changed_paths(root, &environment)?; + + let marker_paths = staged_conflict_marker_paths_with_environment(root, &environment, true)?; + if !marker_paths.is_empty() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Conflict markers remain in selected files", + ) + .with_details(marker_paths.join(", "))); + } + + let mut arguments = vec!["commit".into()]; + if amend { + arguments.push("--amend".into()); + } + arguments.extend(identity); + arguments.extend(["-m".into(), message]); + let committed = execute_git_with_environment(root, &arguments, None, false, &environment)?; + if committed.exit_code != 0 { + return Ok(committed); + } + + let committed_head = execute_git_with_environment( + root, + &["rev-parse".into(), "--verify".into(), "HEAD".into()], + None, + true, + &environment, + )?; + if committed_head.exit_code != 0 { + if let Err(error) = temporary_context.cleanup_reference(root) { + // Preserve the commit result; WARN reports a separate leaked reference. + lithe_diagnostics::core_events::core_failed( + "vcs.commit.cleanup_failed", + "reference", + &error, + ); + } + return Ok(committed_head); + } + let committed_head = committed_head.stdout.trim().to_string(); + let committed_paths = commit_changed_paths(root, original_head.as_deref(), &committed_head)?; + let unexpected_paths = committed_paths + .difference(&allowed_paths) + .cloned() + .collect::>(); + if !unexpected_paths.is_empty() { + if let Err(error) = temporary_context.cleanup_reference(root) { + // Preserve the commit result; WARN reports a separate leaked reference. + lithe_diagnostics::core_events::core_failed( + "vcs.commit.cleanup_failed", + "reference", + &error, + ); + } + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "A Git hook added files outside the selected commit", + ) + .with_details(unexpected_paths.join(", "))); + } + + let expected_old = match original_head.as_ref() { + Some(head) => head.clone(), + None => null_object_id(root)?, + }; + let updated = execute_git( + root, + &[ + "update-ref".into(), + "-m".into(), + if amend { + "commit (amend): selected files".into() + } else { + "commit: selected files".into() + }, + branch_reference.to_string(), + committed_head, + expected_old, + ], + None, + )?; + if updated.exit_code != 0 { + if let Err(error) = temporary_context.cleanup_reference(root) { + // Preserve the commit result; WARN reports a separate leaked reference. + lithe_diagnostics::core_events::core_failed( + "vcs.commit.cleanup_failed", + "reference", + &error, + ); + } + return Ok(updated); + } + + // Reconcile only committed paths in the real index. Unrelated staging that + // another Git process created while hooks ran remains intact. + let mut reconciled = execute_git( + root, + &[ + "reset".to_string(), + "-q".to_string(), + "HEAD".to_string(), + "--pathspec-from-file=-".to_string(), + "--pathspec-file-nul".to_string(), + ], + Some(pathspec_input), + )?; + if reconciled.exit_code != 0 { + reconciled.warnings.push(GitOperationWarning::new( + "git_index_reconcile_failed", + "The commit succeeded, but the Git index could not be reconciled", + Some(reconciled.output.clone()), + )); + reconciled.exit_code = 0; + } + if let Err(error) = temporary_context.cleanup_reference(root) { + reconciled.warnings.push(GitOperationWarning::new( + "git_temporary_reference_cleanup_failed", + "The commit succeeded, but its temporary Git reference could not be removed", + error.details, + )); + } + Ok(reconciled) +} + +fn staged_changed_paths( + root: &str, + environment: &[(String, String)], +) -> Result, CoreError> { + let changed = execute_git_with_environment( + root, + &[ + "diff".into(), + "--cached".into(), + "--name-only".into(), + "-z".into(), + ], + None, + true, + environment, + )?; + if changed.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Git staged diff failed") + .with_details(changed.output), + ); + } + Ok(changed + .stdout + .split('\0') + .filter(|path| !path.is_empty()) + .map(str::to_string) + .collect()) +} + +fn commit_changed_paths( + root: &str, + original_head: Option<&str>, + committed_head: &str, +) -> Result, CoreError> { + let arguments = if let Some(original_head) = original_head { + vec![ + "diff".into(), + "--name-only".into(), + "-z".into(), + original_head.into(), + committed_head.into(), + ] + } else { + vec![ + "diff-tree".into(), + "--root".into(), + "--no-commit-id".into(), + "--name-only".into(), + "-z".into(), + committed_head.into(), + ] + }; + let changed = execute_git_readonly(root, &arguments, None)?; + if changed.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not validate the selected commit", + ) + .with_details(changed.output)); + } + Ok(changed + .stdout + .split('\0') + .filter(|path| !path.is_empty()) + .map(str::to_string) + .collect()) +} + +fn null_object_id(root: &str) -> Result { + let format = execute_git_readonly( + root, + &["rev-parse".into(), "--show-object-format".into()], + None, + )?; + if format.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not determine Git object format", + ) + .with_details(format.output)); + } + Ok(match format.stdout.trim() { + "sha256" => "0".repeat(64), + _ => "0".repeat(40), + }) +} + +/// The `git commit` options for a chosen author and a sign-off. +pub(crate) fn commit_identity( + author: Option<&str>, + sign_off: bool, +) -> Result, CoreError> { + let mut arguments = Vec::new(); + if let Some(author) = author { + arguments.push(format!("--author={}", validated_author(author)?)); + } + if sign_off { + arguments.push("--signoff".into()); + } + Ok(arguments) +} + +/// An exact `Name ` identity. Git reads any other `--author` value as a pattern +/// that picks a matching earlier author, which is never what a typed author means. +fn validated_author(author: &str) -> Result<&str, CoreError> { + let invalid = || { + CoreError::new( + ErrorCode::GitCommitAuthorInvalid, + "The author must be a name followed by an email address in angle brackets", + ) + }; + let (name, email) = author + .strip_suffix('>') + .and_then(|rest| rest.rsplit_once(" <")) + .ok_or_else(invalid)?; + let name_valid = !name.trim().is_empty() + && name.trim() == name + && !name.contains(['<', '>']) + && !name.chars().any(char::is_control); + let email_valid = !email.is_empty() + && !email.contains(['<', '>']) + && !email.chars().any(|c| c.is_whitespace() || c.is_control()); + if name_valid && email_valid { + Ok(author) + } else { + Err(invalid()) + } +} + +pub(crate) fn required_text(value: Option<&str>, label: &str) -> Result { + let value = value.map(str::trim).filter(|value| !value.is_empty()); + match value { + Some(value) if !value.contains('\0') => Ok(value.to_string()), + _ => Err(CoreError::new( + ErrorCode::InvalidRequest, + format!("Missing or invalid Git {label}"), + )), + } +} diff --git a/rust/crates/core/src/git/configuration.rs b/rust/crates/git/src/configuration.rs similarity index 70% rename from rust/crates/core/src/git/configuration.rs rename to rust/crates/git/src/configuration.rs index 16e0a27a..6b379bb9 100644 --- a/rust/crates/core/src/git/configuration.rs +++ b/rust/crates/git/src/configuration.rs @@ -4,7 +4,7 @@ use super::{ capture_git_with_options, execute_git, execution_events, execution_policy, fetch, rewrite, validate_root, }; -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::BTreeSet; @@ -54,7 +54,7 @@ struct RemoteUrlRequest { /// Missing remote configuration is a successful lookup with a null URL. /// Real inspection failures still propagate through the request's error event. -pub(crate) fn remote_url(payload: Value) -> Result { +pub fn remote_url(payload: Value) -> Result { let request: RemoteUrlRequest = serde_json::from_value(payload).map_err(|error| { CoreError::new(ErrorCode::InvalidRequest, "Invalid Git remote URL request") .with_details(error.to_string()) @@ -421,7 +421,7 @@ pub(super) fn fetch_options( let text = std::str::from_utf8(&bytes).map_err(|_| { CoreError::new(ErrorCode::ParseFailed, "Git configuration is not UTF-8") })?; - if let Some(value) = text.split_terminator('\0').last() { + if let Some(value) = text.split_terminator('\0').next_back() { match *key { "lithe.fetch.prune" => { options.prune = match value { @@ -453,7 +453,7 @@ fn invalid_value() -> CoreError { } /// Reads provenance or edits one allowlisted field after an explicit user Save action. -pub(crate) fn dispatch(payload: Value, save: bool) -> Result { +pub fn dispatch(payload: Value, save: bool) -> Result { let request: Request = serde_json::from_value(payload).map_err(|_| { CoreError::new( ErrorCode::InvalidRequest, @@ -525,8 +525,8 @@ pub(crate) fn dispatch(payload: Value, save: bool) -> Result { /// can echo the effective Git before a project is open. The version probe is /// best effort: a binary that starts but cannot report a version still stays /// selected. -pub(crate) fn locate_executable() -> Result { - if let Some(host) = crate::remote::workspace::current() { +pub fn locate_executable() -> Result { + if let Some(host) = lithe_workspace::current() { let params = super::remote_process::parameters(&host, "/", &["--version".into()], false, true, &[])?; return Ok(json!({"executable": params.argv.first(), @@ -598,10 +598,9 @@ mod tests { use super::*; #[test] fn resolved_fetch_behavior_matches_shared_fixture() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../../fixtures/git/fetch-behavior-v1.json" - )) - .unwrap(); + let fixture: Value = + serde_json::from_str(include_str!("../../../fixtures/git/fetch-behavior-v1.json")) + .unwrap(); for case in fixture["cases"].as_array().unwrap() { let entries: Vec = serde_json::from_value(case["request"]["entries"].clone()).unwrap(); @@ -667,262 +666,3 @@ mod tests { .contains("unlabelled-fixture-secret")); } } - -#[cfg(test)] -mod inherited_tag_tests { - use serde_json::{json, Value}; - use std::fs; - use std::path::PathBuf; - use std::sync::atomic::{AtomicU64, Ordering}; - - static SEQUENCE: AtomicU64 = AtomicU64::new(0); - - struct Repository(PathBuf); - - impl Repository { - fn request(&self, command: &str, mut payload: Value) -> Value { - payload["root"] = json!(self.0); - // Core owns subprocess deadlines; the fixture never waits on an - // unbounded native child, including repository initialization. - serde_json::from_str(&crate::execute_json( - &json!({ - "timeoutMilliseconds": 5000, - "command": command, - "payload": payload, - }) - .to_string(), - )) - .unwrap() - } - } - - impl Drop for Repository { - fn drop(&mut self) { - if let Err(error) = fs::remove_dir_all(&self.0) { - eprintln!("Could not clean tag boolean repository: {error}"); - } - } - } - - // Real Git integration protects the inspection-to-presentation contract. - #[test] - fn inspection_normalizes_inherited_tag_booleans_without_changing_edit_values() { - let repo = Repository(std::env::temp_dir().join(format!( - "lithe-tag-booleans-{}-{}", - std::process::id(), - SEQUENCE.fetch_add(1, Ordering::Relaxed) - ))); - fs::create_dir_all(&repo.0).unwrap(); - let initialized = repo.request( - "git.command", - json!({ "arguments": ["init", "--template=", "-q", "-b", "main"] }), - ); - assert_eq!(initialized["ok"], true, "{initialized}"); - assert_eq!(initialized["data"]["exitCode"], 0, "{initialized}"); - - // Exercise Git's parser rather than duplicating its accepted spellings. - // Valueless and empty assignments are deliberately distinct booleans. - let cases = [ - ("yes", "pruneTags = yes", "true"), - ("on", "pruneTags = on", "true"), - ("one", "pruneTags = 1", "true"), - ("mixed", "pruneTags = TrUe", "true"), - ("valueless", "pruneTags", "true"), - ("no", "pruneTags = no", "false"), - ("off", "pruneTags = off", "false"), - ("zero", "pruneTags = 0", "false"), - ("false", "pruneTags = FaLsE", "false"), - ("empty", "pruneTags =", "false"), - ("invalid", "pruneTags = invalid", "invalid"), - ]; - let path = repo.0.join(".git/config"); - let mut config = fs::read_to_string(&path).unwrap(); - config.push_str("\n[fetch]\npruneTags = yes\n"); - for (name, configuration, _) in cases { - config.push_str(&format!("\n[remote \"{name}\"]\n{configuration}\n")); - } - fs::write(path, config).unwrap(); - - let response = repo.request("git.executionInspect", json!({})); - assert_eq!(response["ok"], true, "{response}"); - let entries = response["data"]["entries"].as_array().unwrap(); - for (name, _, expected) in cases { - let key = format!("remote.{name}.prunetags"); - let entry = entries - .iter() - .find(|entry| entry["key"] == key && entry["effective"] == true) - .unwrap(); - assert_eq!(entry["value"], expected, "{key}"); - assert_eq!(entry["scope"], "local", "{key}"); - assert!(entry["origin"].as_str().unwrap().ends_with(".git/config")); - } - let fetch = entries - .iter() - .find(|entry| entry["key"] == "fetch.prunetags" && entry["effective"] == true) - .unwrap(); - assert_eq!(fetch["value"], "true"); - let field = response["data"]["fields"] - .as_array() - .unwrap() - .iter() - .find(|field| field["key"] == "fetch.prunetags") - .unwrap(); - assert_eq!(field["configuredValues"], json!(["yes"])); - } - - #[test] - fn inspection_normalizes_skip_fetch_all_booleans() { - let repo = Repository(std::env::temp_dir().join(format!( - "lithe-skip-fetch-booleans-{}-{}", - std::process::id(), - SEQUENCE.fetch_add(1, Ordering::Relaxed) - ))); - fs::create_dir_all(&repo.0).unwrap(); - let initialized = repo.request( - "git.command", - json!({ "arguments": ["init", "--template=", "-q", "-b", "main"] }), - ); - assert_eq!(initialized["ok"], true, "{initialized}"); - assert_eq!(initialized["data"]["exitCode"], 0, "{initialized}"); - - let path = repo.0.join(".git/config"); - let mut config = fs::read_to_string(&path).unwrap(); - config.push_str("\n[remote \"yes\"]\nurl = /tmp/yes\nskipFetchAll = yes\n"); - config.push_str("\n[remote \"valueless\"]\nurl = /tmp/valueless\nskipFetchAll\n"); - config.push_str("\n[remote \"empty\"]\nurl = /tmp/empty\nskipFetchAll =\n"); - config.push_str("\n[remote \"no\"]\nurl = /tmp/no\nskipFetchAll = no\n"); - fs::write(path, config).unwrap(); - - let response = repo.request("git.executionInspect", json!({})); - assert_eq!(response["ok"], true, "{response}"); - let entries = response["data"]["entries"].as_array().unwrap(); - for (name, expected) in [ - ("yes", "true"), - ("valueless", "true"), - ("empty", "false"), - ("no", "false"), - ] { - let key = format!("remote.{name}.skipfetchall"); - let entry = entries - .iter() - .find(|entry| entry["key"] == key && entry["effective"] == true) - .unwrap(); - assert_eq!(entry["value"], expected, "{key}"); - } - } - - #[test] - fn inspection_matches_git_when_tags_are_forced_or_configuration_is_invalid() { - let repo = Repository(std::env::temp_dir().join(format!( - "lithe-fetch-effective-{}-{}", - std::process::id(), - SEQUENCE.fetch_add(1, Ordering::Relaxed) - ))); - fs::create_dir_all(&repo.0).unwrap(); - let git = |arguments: &[&str]| { - let response = repo.request("git.command", json!({"arguments": arguments})); - assert_eq!(response["ok"], true, "{response}"); - response["data"].clone() - }; - let ok = |arguments: &[&str]| { - let response = git(arguments); - assert_eq!(response["exitCode"], 0, "{arguments:?}: {response}"); - }; - ok(&["init", "--template=", "-q", "-b", "main"]); - ok(&[ - "-c", - "user.name=Fixture", - "-c", - "user.email=test@example.invalid", - "commit", - "--allow-empty", - "--no-gpg-sign", - "-qm", - "base", - ]); - ok(&["-c", "tag.gpgSign=false", "tag", "reachable"]); - ok(&[ - "-c", - "user.name=Fixture", - "-c", - "user.email=test@example.invalid", - "commit", - "--allow-empty", - "--no-gpg-sign", - "-qm", - "other", - ]); - ok(&["-c", "tag.gpgSign=false", "tag", "extra"]); - ok(&["reset", "--hard", "HEAD~1"]); - let remote = repo.0.join("remote.git"); - ok(&[ - "init", - "--bare", - "--template=", - "-q", - remote.to_str().unwrap(), - ]); - ok(&[ - "--git-dir", - remote.to_str().unwrap(), - "symbolic-ref", - "HEAD", - "refs/heads/main", - ]); - ok(&["remote", "add", "origin", remote.to_str().unwrap()]); - ok(&[ - "push", - "-q", - "origin", - "HEAD:refs/heads/main", - "refs/tags/*:refs/tags/*", - ]); - ok(&["tag", "-d", "reachable", "extra"]); - ok(&["config", "lithe.fetch.tags", "none"]); - ok(&["config", "lithe.fetch.prune", "false"]); - ok(&["config", "fetch.pruneTags", "true"]); - let inspect = || { - let result = repo.request("git.executionInspect", json!({})); - assert_eq!(result["ok"], true, "{result}"); - result["data"]["resolvedFetchBehavior"]["tags"].clone() - }; - let fetch = || git(&["fetch", "--dry-run", "--no-tags", "--no-prune", "origin"]); - assert_eq!(inspect()["value"], "all"); - let actual = fetch(); - assert_eq!(actual["exitCode"], 0, "{actual}"); - assert!( - actual["stderr"].as_str().unwrap().contains("extra"), - "{actual}" - ); - - ok(&["config", "--unset", "fetch.pruneTags"]); - ok(&[ - "config", - "--add", - "remote.origin.fetch", - "+refs/tags/*:refs/tags/*", - ]); - assert_eq!(inspect()["value"], "all"); - assert!(fetch()["stderr"].as_str().unwrap().contains("extra")); - ok(&["config", "--add", "remote.origin.fetch", "^refs/tags/extra"]); - assert_eq!(inspect()["value"], "custom"); - assert!(!fetch()["stderr"].as_str().unwrap().contains("extra")); - - ok(&["config", "--add", "remote.origin.fetch", ""]); - assert_eq!(inspect()["value"], "none"); - assert!(!fetch()["stderr"].as_str().unwrap().contains("extra")); - - ok(&["config", "remote.origin.pruneTags", "invalid"]); - let unknown = inspect(); - assert_eq!(unknown["value"], "unavailable"); - assert_eq!(unknown["reasonCode"], "invalidConfiguration"); - assert_ne!(fetch()["exitCode"], 0); - ok(&["config", "--unset", "remote.origin.pruneTags"]); - for key in ["fetch.prune", "remote.origin.prune", "remote.other.prune"] { - ok(&["config", key, "invalid"]); - assert_eq!(inspect()["reasonCode"], "invalidConfiguration", "{key}"); - assert_ne!(fetch()["exitCode"], 0, "{key}"); - ok(&["config", "--unset", key]); - } - } -} diff --git a/rust/crates/core/src/git/console/command.rs b/rust/crates/git/src/console/command.rs similarity index 97% rename from rust/crates/core/src/git/console/command.rs rename to rust/crates/git/src/console/command.rs index db441da1..ab804866 100644 --- a/rust/crates/core/src/git/console/command.rs +++ b/rust/crates/git/src/console/command.rs @@ -1,6 +1,6 @@ //! Conservative command compression: behavior-changing flags always remain visible. use super::types::{CommandFragment, Record}; -use crate::git::execution_policy::command_index; +use crate::execution_policy::command_index; pub(super) fn quote(value: &str) -> String { if !value.is_empty() diff --git a/rust/crates/core/src/git/console/mod.rs b/rust/crates/git/src/console/mod.rs similarity index 99% rename from rust/crates/core/src/git/console/mod.rs rename to rust/crates/git/src/console/mod.rs index ed52f909..f74bdb9c 100644 --- a/rust/crates/core/src/git/console/mod.rs +++ b/rust/crates/git/src/console/mod.rs @@ -4,7 +4,7 @@ mod output; #[cfg(test)] mod tests; mod types; -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use types::*; pub use types::{Request, Source}; diff --git a/rust/crates/core/src/git/console/output.rs b/rust/crates/git/src/console/output.rs similarity index 100% rename from rust/crates/core/src/git/console/output.rs rename to rust/crates/git/src/console/output.rs diff --git a/rust/crates/core/src/git/console/tests.rs b/rust/crates/git/src/console/tests.rs similarity index 99% rename from rust/crates/core/src/git/console/tests.rs rename to rust/crates/git/src/console/tests.rs index 59a28daa..c515d3d4 100644 --- a/rust/crates/core/src/git/console/tests.rs +++ b/rust/crates/git/src/console/tests.rs @@ -5,7 +5,7 @@ use serde_json::{json, Value}; #[test] fn presentation_matches_shared_console_cases_without_rewriting_retained_output() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../../fixtures/git/console-presentation-v1.json" + "../../../../fixtures/git/console-presentation-v1.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { diff --git a/rust/crates/core/src/git/console/types.rs b/rust/crates/git/src/console/types.rs similarity index 90% rename from rust/crates/core/src/git/console/types.rs rename to rust/crates/git/src/console/types.rs index 594e4740..0f15042f 100644 --- a/rust/crates/core/src/git/console/types.rs +++ b/rust/crates/git/src/console/types.rs @@ -1,18 +1,7 @@ //! Native console snapshots and deterministic, lossless disclosure ranges. use serde::{Deserialize, Serialize}; -/// Execution provenance is retained for diagnostics; display does not merge commands. -#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub enum Source { - /// A user deliberately invoked the operation. - User, - /// An automatic refresh or watcher invoked the query. - Background, - /// Provenance was not supplied; preserve each execution independently. - #[default] - Unknown, -} +pub use lithe_request_context::Source; /// One retained line, in native event order. Stderr alone does not imply failure. #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] @@ -26,9 +15,9 @@ pub struct Line { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct Request { - pub records: Vec, + pub(crate) records: Vec, #[serde(default)] - pub search: String, + pub(crate) search: String, /// Additional workspace roots disambiguate labels even before those repositories have output. #[serde(default)] pub repository_roots: Vec, @@ -103,7 +92,7 @@ impl Record { if self.state != "completed" || self.exit_code != Some(0) { return None; } - let fetch = crate::git::execution_policy::command_index(&self.arguments) + let fetch = crate::execution_policy::command_index(&self.arguments) .is_some_and(|i| self.arguments[i] == "fetch"); if fetch && self.remote_result.as_ref().is_some_and(|result| { diff --git a/rust/crates/core/src/protocol/contracts.rs b/rust/crates/git/src/contracts.rs similarity index 67% rename from rust/crates/core/src/protocol/contracts.rs rename to rust/crates/git/src/contracts.rs index abe16a0f..6ef523aa 100644 --- a/rust/crates/core/src/protocol/contracts.rs +++ b/rust/crates/git/src/contracts.rs @@ -1,55 +1,7 @@ -//! Serializable response models shared across every host boundary. +//! Serializable Git responses shared across every host boundary. use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -/// One file or directory in a deterministic workspace tree. -pub struct WorkspaceNode { - pub path: String, - pub name: String, - pub is_directory: bool, - /// Omitted for unloaded directories/files; an empty array is a known directory leaf. - #[serde(skip_serializing_if = "Option::is_none")] - pub children: Option>, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -/// Complete visible workspace tree and its flattened file paths. -pub struct WorkspaceSnapshotResponse { - /// Optional directories that could not be read; hosts must not infer deletions from this inventory. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub incomplete_directories: Vec, - pub root: WorkspaceNode, - pub files: Vec, - /// Linked worktrees checked out inside their own repository. They are left - /// out of `root` and `files`; hosts use them to ignore watcher events. - pub nested_worktrees: Vec, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -/// Visible children of the directories requested by `workspace.listDirectories`. -pub struct DirectoryListingResponse { - /// One entry per requested directory that still exists and is visible, - /// in request order. - pub directories: Vec, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -/// One directory's children, each without its own children. -pub struct DirectoryListing { - /// Normalized workspace-relative path; empty for the root. - pub path: String, - /// Directories first, then case-insensitive names, as in snapshots. - pub children: Vec, - /// Nested linked worktrees directly inside this directory, which - /// `children` leaves out. - pub nested_worktrees: Vec, -} - #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// One Git repository discovered for an opened workspace. @@ -64,144 +16,23 @@ pub struct WorkspaceRepositoryResponse { #[serde(rename_all = "camelCase")] /// Deterministically ordered Git repositories discovered below a workspace root. pub struct WorkspaceRepositoriesResponse { - pub repositories: Vec, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -/// File, content, or symbol match with optional source location. -pub struct SearchMatch { - /// Result category: file path, file content, or symbol. - pub kind: String, - pub path: String, - pub line: Option, - pub preview: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub symbol_name: Option, - /// Matching fragments in the displayed name, measured in UTF-16 code units. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub name_match_ranges: Vec, - /// Original content line, present only when requested by the host. - #[serde(skip_serializing_if = "Option::is_none")] - pub source_line: Option, - /// Content match ranges in UTF-16 units of `sourceLine`. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub content_match_ranges: Vec, - /// Name relevance computed before category truncation; absent for content search. - #[serde(skip_serializing_if = "Option::is_none")] - pub matching_degree: Option, - /// Git ignore status for a returned name; absent when Git cannot answer. - #[serde(skip_serializing_if = "Option::is_none")] - pub is_ignored: Option, -} - -/// One half-open matched fragment in a displayed file or symbol name. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct SearchNameRange { - pub start: usize, - pub length: usize, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -/// Bounded, deterministically ordered search matches. -pub struct SearchResponse { - pub matches: Vec, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -/// Preview of replacements occurring on one source line. -pub struct ReplacementMatch { - pub line: usize, - pub before: String, - pub after: String, - pub occurrence_count: usize, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -/// All preview matches and resulting text for one file. -pub struct ReplacementFile { - pub path: String, - pub matches: Vec, - pub replacement_text: String, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -/// Non-mutating replacement preview grouped by workspace-relative path. -pub struct ReplacementPreviewResponse { - pub files: Vec, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -/// Decoded contents read from one workspace-relative path. -pub struct FileReadResponse { - pub path: String, - pub text: String, - /// Stable id of the encoding used to decode `text`. - pub encoding: String, - /// A leading byte order mark was present and is not part of `text`. - pub has_bom: bool, - /// Invalid bytes were replaced with U+FFFD (explicit `encoding` only). - pub lossy: bool, - /// SHA-256 (lowercase hex) of the exact bytes read. - pub identity: String, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -/// Path and byte count produced by a successful file write. -pub struct FileWriteResponse { - pub path: String, - /// Encoded length including any byte order mark. - pub bytes_written: usize, - /// Stable id of the encoding the text was written with. - pub encoding: String, - /// A byte order mark was written. - pub has_bom: bool, - /// SHA-256 (lowercase hex) of the exact bytes written. - pub identity: String, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -/// Metadata for one retained local-history snapshot. -pub struct HistoryEntryResponse { - pub id: String, - pub timestamp: i64, - pub relative_path: String, - pub reason: String, - pub content_path: String, - pub byte_count: usize, - #[serde(skip_serializing_if = "Option::is_none")] - pub label: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -/// Local-history entries in deterministic newest-first order. -pub struct HistoryEntriesResponse { - pub entries: Vec, + pub(crate) repositories: Vec, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// One normalized index or working-tree change. pub struct GitChange { - pub path: String, + pub(crate) path: String, #[serde(skip_serializing_if = "Option::is_none")] - pub original_path: Option, + pub(crate) original_path: Option, /// Normalized porcelain status code for the path. pub status: String, - pub staged: bool, - pub worktree: bool, - pub untracked: bool, + pub(crate) staged: bool, + pub(crate) worktree: bool, + pub(crate) untracked: bool, #[serde(skip_serializing_if = "Option::is_none")] - pub submodule: Option, + pub(crate) submodule: Option, /// False for parent gitlinks with only uncommitted child content. pub can_toggle_staging: bool, /// Present only on a summarized untracked directory (`path` ends with `/`): @@ -215,36 +46,36 @@ pub struct GitChange { #[serde(rename_all = "camelCase")] /// Git porcelain v2 distinguishes a new gitlink target from child worktree dirt. pub struct GitSubmoduleStatus { - pub commit_changed: bool, - pub tracked_changes: bool, - pub untracked_changes: bool, + pub(crate) commit_changed: bool, + pub(crate) tracked_changes: bool, + pub(crate) untracked_changes: bool, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// Repository identity, branch divergence, and normalized file changes. pub struct GitStatusResponse { - pub repository_root: Option, - pub branch: Option, - pub ahead: usize, - pub behind: usize, - pub changes: Vec, + pub(crate) repository_root: Option, + pub(crate) branch: Option, + pub(crate) ahead: usize, + pub(crate) behind: usize, + pub(crate) changes: Vec, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// Immediate files and subdirectory summaries of one summarized untracked directory. pub struct GitUntrackedDirectoryResponse { - pub changes: Vec, + pub(crate) changes: Vec, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// Worktree-aware Git paths that a platform watcher should observe. pub struct GitWatchContextResponse { - pub repository_root: String, - pub git_directory: String, - pub git_common_directory: String, + pub(crate) repository_root: String, + pub(crate) git_directory: String, + pub(crate) git_common_directory: String, } #[derive(Debug, Clone, Serialize)] @@ -261,12 +92,12 @@ pub struct GitWorktreeResponse { pub is_current: bool, /// Whether this is the repository's primary worktree. pub is_primary: bool, - pub is_bare: bool, - pub is_detached: bool, - pub is_locked: bool, + pub(crate) is_bare: bool, + pub(crate) is_detached: bool, + pub(crate) is_locked: bool, /// Human-readable lock reason supplied to Git, when present. pub lock_reason: Option, - pub is_prunable: bool, + pub(crate) is_prunable: bool, /// Git's explanation for why the registration can be pruned. pub prune_reason: Option, } @@ -275,22 +106,22 @@ pub struct GitWorktreeResponse { #[serde(rename_all = "camelCase")] /// Deterministically ordered worktrees registered for one repository. pub struct GitWorktreesResponse { - pub worktrees: Vec, + pub(crate) worktrees: Vec, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// Local or remote Git reference in display-ready form. pub struct GitReferenceResponse { - pub full_name: String, - pub short_name: String, + pub(crate) full_name: String, + pub(crate) short_name: String, /// Reference category: local branch, remote branch, or tag. pub kind: String, /// Whether the reference resolves to a commit and therefore supports /// commit-only mutations such as restorable tag deletion. pub peels_to_commit: bool, - pub is_current: bool, - pub upstream_short_name: Option, + pub(crate) is_current: bool, + pub(crate) upstream_short_name: Option, /// Commits present only on this local branch compared with its upstream. pub ahead: usize, /// Commits present only on this local branch's upstream. @@ -301,11 +132,11 @@ pub struct GitReferenceResponse { #[serde(rename_all = "camelCase")] /// Commit metadata parsed from a machine-stable field format. pub struct GitCommitResponse { - pub hash: String, - pub short_hash: String, - pub parent_hashes: Vec, - pub author_name: String, - pub author_email: String, + pub(crate) hash: String, + pub(crate) short_hash: String, + pub(crate) parent_hashes: Vec, + pub(crate) author_name: String, + pub(crate) author_email: String, /// Author date as `YYYY/MM/DD HH:MM` in the author's own time zone. pub date: String, /// Author's UTC offset in minutes (east positive) for `date`; `null` when @@ -315,46 +146,46 @@ pub struct GitCommitResponse { /// Absolute author instant in Unix seconds when the date and offset are valid. #[serde(skip_serializing_if = "Option::is_none")] pub date_unix_seconds: Option, - pub subject: String, - pub decorations: String, + pub(crate) subject: String, + pub(crate) decorations: String, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// References and a bounded page of commit history. pub struct GitHistoryResponse { - pub references: Vec, + pub(crate) references: Vec, /// Up to five local branches ordered from most to least recently checked out. pub recent_references: Vec, - pub commits: Vec, - pub has_more: bool, - pub user_name: Option, - pub user_email: Option, + pub(crate) commits: Vec, + pub(crate) has_more: bool, + pub(crate) user_name: Option, + pub(crate) user_email: Option, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// Repository references and identity metadata loaded independently from commit pages. pub struct GitReferencesResponse { - pub references: Vec, + pub(crate) references: Vec, /// Up to five local branches ordered from most to least recently checked out. pub recent_references: Vec, - pub user_name: Option, - pub user_email: Option, + pub(crate) user_name: Option, + pub(crate) user_email: Option, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// One bounded page of commit history. pub struct GitHistoryPageResponse { - pub commits: Vec, + pub(crate) commits: Vec, /// Opaque cursor for the next page, or `None` when this page reaches the end. #[serde(skip_serializing_if = "Option::is_none")] pub next_cursor: Option, /// Deprecated offset emitted only for compatibility with offset-based requests. #[serde(skip_serializing_if = "Option::is_none")] pub next_offset: Option, - pub has_more: bool, + pub(crate) has_more: bool, } #[derive(Debug, Clone, Serialize)] @@ -427,7 +258,7 @@ pub struct GitPushTagResponse { #[serde(rename_all = "camelCase")] /// Exact lookup result for one commit. pub struct GitCommitLookupResponse { - pub commit: GitCommitResponse, + pub(crate) commit: GitCommitResponse, /// Message text after the subject paragraph, as Git's `%b` placeholder, /// with line breaks preserved and trailing whitespace removed. Empty when /// the message has only a subject. History pages omit it to stay compact. @@ -440,7 +271,7 @@ pub struct GitCommitLookupResponse { pub struct GitFileResponse { /// Normalized name-status code such as `A`, `M`, `D`, or `R`. pub status: String, - pub path: String, + pub(crate) path: String, /// True only for a worktree comparison entry absent from the index. #[serde(skip_serializing_if = "std::ops::Not::not")] pub is_untracked: bool, @@ -450,33 +281,33 @@ pub struct GitFileResponse { #[serde(rename_all = "camelCase")] /// Deterministically ordered changed paths. pub struct GitFilesResponse { - pub files: Vec, + pub(crate) files: Vec, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// Paths differing between a reference and the current checkout. pub struct GitComparisonResponse { - pub files: Vec, + pub(crate) files: Vec, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// One stash entry with its stable reference and parsed metadata. pub struct GitStashResponse { - pub reference: String, + pub(crate) reference: String, /// Immutable object identity; stash positions may change after another push. pub oid: String, - pub message: String, - pub branch: Option, - pub date: String, + pub(crate) message: String, + pub(crate) branch: Option, + pub(crate) date: String, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// Stash entries in Git's native newest-first order. pub struct GitStashesResponse { - pub stashes: Vec, + pub(crate) stashes: Vec, } /// Result of checking whether a checkout can proceed without losing local edits. @@ -487,14 +318,14 @@ pub struct GitStashesResponse { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct GitCheckoutPreflightResponse { - pub blocking_paths: Vec, + pub(crate) blocking_paths: Vec, } /// Staged files still holding conflict markers, which must never be committed. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct GitConflictMarkerResponse { - pub paths: Vec, + pub(crate) paths: Vec, } /// Whether a merge or rebase can start, and what stands in the way. @@ -506,8 +337,8 @@ pub struct GitConflictMarkerResponse { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct GitIntegrationPreflightResponse { - pub blocking_paths: Vec, - pub blocks_entirely: bool, + pub(crate) blocking_paths: Vec, + pub(crate) blocks_entirely: bool, } /// Whether a pull can fast-forward, and how far the two sides have drifted. @@ -519,11 +350,11 @@ pub struct GitIntegrationPreflightResponse { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct GitPullPreflightResponse { - pub upstream: Option, - pub ahead: usize, - pub behind: usize, - pub diverged: bool, - pub has_local_changes: bool, + pub(crate) upstream: Option, + pub(crate) ahead: usize, + pub(crate) behind: usize, + pub(crate) diverged: bool, + pub(crate) has_local_changes: bool, /// `rebase`, `merge`, or `ffOnly` when `pull.rebase` or `pull.ff` says so; /// absent when Git is not configured for a strategy. pub configured_strategy: Option<&'static str>, @@ -540,27 +371,27 @@ pub struct GitPullPreflightResponse { pub struct GitOperationStateResponse { /// Active operation name, or an empty string when no operation is in progress. pub kind: String, - pub reference: Option, - pub step: Option, - pub total: Option, - pub conflicted_paths: Vec, + pub(crate) reference: Option, + pub(crate) step: Option, + pub(crate) total: Option, + pub(crate) conflicted_paths: Vec, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// Line-level blame attribution normalized for editor gutters. pub struct GitBlameLineResponse { - pub line: usize, - pub commit_hash: String, - pub author_name: String, - pub author_time: i64, + pub(crate) line: usize, + pub(crate) commit_hash: String, + pub(crate) author_name: String, + pub(crate) author_time: i64, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// Blame attribution ordered by one-based source line. pub struct GitBlameResponse { - pub lines: Vec, + pub(crate) lines: Vec, } #[derive(Debug, Clone, Serialize)] @@ -592,14 +423,14 @@ pub struct GitLineHistoryRange { #[serde(rename_all = "camelCase")] /// One history entry that touched the traced lines, with positioning metadata. pub struct GitLineHistoryCommit { - pub hash: String, + pub(crate) hash: String, /// `commit`, or `merge-first-parent` for merges reported against the first /// parent only; branch-internal commits of a merge are not listed separately. pub kind: String, - pub author_name: String, + pub(crate) author_name: String, /// Authoring time as Unix seconds, matching `git.blame` attribution. pub author_time: i64, - pub subject: String, + pub(crate) subject: String, /// Workspace-relative path of the file at this entry. pub path_at_commit: String, /// Previous path when this entry renamed the file, otherwise absent. @@ -629,16 +460,16 @@ pub struct GitLineHistoryResponse { #[serde(rename_all = "camelCase")] /// One aligned side-by-side diff row. pub struct GitDiffRowResponse { - pub old_line: Option, - pub new_line: Option, - pub left: Option, + pub(crate) old_line: Option, + pub(crate) new_line: Option, + pub(crate) left: Option, /// Omitted for `context` and `information` rows, whose two sides always /// hold identical text; clients fall back to `left` in that case. #[serde(skip_serializing_if = "Option::is_none")] pub right: Option, /// Rendering category such as context, changed, insertion, deletion, or information. pub kind: String, - pub hunk_id: Option, + pub(crate) hunk_id: Option, /// Both sides look equal only after removing a trailing carriage return. #[serde(skip_serializing_if = "Option::is_none")] pub is_invisible_change: Option, @@ -655,19 +486,19 @@ pub struct GitDiffRowResponse { #[serde(rename_all = "camelCase")] /// One original-order display line from a unified diff. pub struct GitDiffUnifiedLineResponse { - pub line_type: String, - pub content: String, - pub old_line: Option, - pub new_line: Option, + pub(crate) line_type: String, + pub(crate) content: String, + pub(crate) old_line: Option, + pub(crate) new_line: Option, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// Independently applicable diff hunk and its original patch text. pub struct GitDiffHunkResponse { - pub id: String, - pub header: String, - pub patch: String, + pub(crate) id: String, + pub(crate) header: String, + pub(crate) patch: String, /// A rename's content-only patch preserves its file identity when applied. #[serde(skip_serializing_if = "Option::is_none")] pub content_patch: Option, @@ -684,30 +515,30 @@ pub struct GitDiffHunkResponse { #[serde(rename_all = "camelCase")] /// A saved file's exact patch and independently rendered document. pub struct GitDiffFileResponse { - pub path: String, + pub(crate) path: String, /// Original Git path for a rename or copy. pub original_path: Option, /// Git status used by the saved-changes list. pub status: String, - pub patch: String, - pub rows: Vec, - pub hunks: Vec, + pub(crate) patch: String, + pub(crate) rows: Vec, + pub(crate) hunks: Vec, /// Unified lines in Git patch order, with display-only CR normalization. pub lines: Vec, - pub is_binary: bool, + pub(crate) is_binary: bool, /// True if display lines omit CR, EOF markers or invalid UTF-8 bytes. pub has_lossy_line_endings: bool, - pub additions: Option, - pub deletions: Option, + pub(crate) additions: Option, + pub(crate) deletions: Option, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// Complete patch plus rendering rows and independently applicable hunks. pub struct GitDiffResponse { - pub patch: String, - pub rows: Vec, - pub hunks: Vec, + pub(crate) patch: String, + pub(crate) rows: Vec, + pub(crate) hunks: Vec, /// True when stdout contained invalid UTF-8 replaced by U+FFFD; the /// patch bytes are not a faithful representation of the file content. #[serde(default)] diff --git a/rust/crates/core/src/git/deleted_selection.rs b/rust/crates/git/src/deleted_selection.rs similarity index 93% rename from rust/crates/core/src/git/deleted_selection.rs rename to rust/crates/git/src/deleted_selection.rs index 82dc6de3..409a03d4 100644 --- a/rust/crates/core/src/git/deleted_selection.rs +++ b/rust/crates/git/src/deleted_selection.rs @@ -4,7 +4,7 @@ use super::{ execute_git_readonly_with_environment, is_safe_pathspec, parse_status, pathspec_batches, rewrite::RewriteLease, unstage_paths, validate_root, LS_FILES_PATHSPEC_BUDGET, }; -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::Deserialize; use serde_json::{json, Value}; use std::collections::BTreeSet; @@ -27,7 +27,7 @@ fn invalid() -> CoreError { } /// Read actual status and remove only explicitly selected, now missing staged additions. -pub(crate) fn reconcile_deleted_selection_command(payload: Value) -> Result { +pub fn reconcile_deleted_selection_command(payload: Value) -> Result { let request: Request = serde_json::from_value(payload).map_err(|_| invalid())?; if request.changes.len() > 10_000 { return Err(invalid()); @@ -61,7 +61,7 @@ pub(crate) fn reconcile_deleted_selection_command(payload: Value) -> Result = candidates.iter().cloned().collect(); let mut missing = BTreeSet::new(); for batch in pathspec_batches(&paths, LS_FILES_PATHSPEC_BUDGET) { - crate::protocol::cancellation::check()?; + lithe_protocol::cancellation::check()?; let mut args = [ "status", "--porcelain=v2", @@ -94,7 +94,7 @@ pub(crate) fn reconcile_deleted_selection_command(payload: Value) -> Result = missing.into_iter().collect(); if !paths.is_empty() { - crate::protocol::cancellation::check()?; + lithe_protocol::cancellation::check()?; let output = unstage_paths(&root, &paths)?; if output.exit_code != 0 { return Err(CoreError::new( diff --git a/rust/crates/git/src/diff.rs b/rust/crates/git/src/diff.rs new file mode 100644 index 00000000..0636c393 --- /dev/null +++ b/rust/crates/git/src/diff.rs @@ -0,0 +1,988 @@ +//! Diffs and patches: unified and structured diffs, worktree snapshots, patch parsing +//! and `git.apply`. + +use super::*; + +fn append_full_file_context_arguments(arguments: &mut Vec, context_lines: usize) { + if context_lines >= FULL_FILE_CONTEXT_LINES { + // Git xdiff combines nearby hunks using this value. Pin it to zero for + // whole-file reviews so user configuration cannot reintroduce the + // signed 32-bit overflow present in older Git for Windows releases. + arguments.push("--inter-hunk-context=0".into()); + } +} + +/// Builds a structured working-tree, staged, untracked, or commit diff. +pub fn diff(request: GitDiffRequest) -> Result { + let omit_file_patch = request.include_file_sections && request.omit_file_patch; + // A complete-file review prints one hunk per file. Staging that hunk would + // write the whole file, so Core splits it into Git-default hunks. + let complete_file = request.include_file_sections + && request.context_lines >= FULL_FILE_CONTEXT_LINES + && request.stash_reference.is_none(); + let mut response = diff_impl(request)?; + if let Some(files) = &mut response.files { + for file in files { + if complete_file && !file.is_binary && !file.hunks.is_empty() { + // Fail closed: an unsplittable section offers no writable hunk. + file.hunks = diff_blocks::stageable_hunks(&file.patch).unwrap_or_default(); + if file.status == "R" { + attach_rename_content_patches(&file.patch, &mut file.hunks); + } + } + if omit_file_patch { + file.patch = String::new(); + } + } + } + Ok(response) +} + +pub(crate) fn diff_impl(request: GitDiffRequest) -> Result { + if request.pathspecs.is_empty() || request.pathspecs.iter().any(|path| !is_safe_pathspec(path)) + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Git diff contains an invalid path", + )); + } + + if request.include_file_sections + && request.stash_reference.is_some() + && request.pathspecs != ["."] + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Per-file stash output requires the whole repository", + )); + } + let root = validate_root(&request.root)?; + if let Some(reference) = request.stash_reference.as_deref() { + let oid = resolve_commit_revision(&root, &validated_stash_reference(Some(reference))?)?; + let mut arguments = vec![ + "-c".into(), + "diff.noprefix=false".into(), + "-c".into(), + "diff.mnemonicprefix=false".into(), + "stash".into(), + "show".into(), + "--include-untracked".into(), + "--format=".into(), + "--binary".into(), + "--full-index".into(), + "--no-ext-diff".into(), + "--no-textconv".into(), + "--no-color".into(), + format!("--unified={}", request.context_lines), + oid, + ]; + if request.include_file_sections { + // Stash already has the safe prefix configuration at the front. + arguments.splice(6..6, ["--numstat".into(), "-z".into(), "--patch".into()]); + } + let output = capture_git_with_options(&root, &arguments, None, true)?; + if output.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Could not inspect saved stash") + .with_details(String::from_utf8_lossy(&output.stderr).to_string()), + ); + } + if request.include_file_sections { + return structured_numstat_output(output, true); + } + return Ok(structured_diff_from_output(output)); + } + if request.worktree_snapshot { + if request.reference.is_some() + || request.empty_tree_base + || request.git_reference.is_some() + || request.target_git_reference.is_some() + || request.commit.is_some() + || request.staged + || request.untracked + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Git worktree snapshot diff cannot combine other diff modes", + )); + } + return worktree_snapshot_diff(&root, &request); + } + let reference = if request.empty_tree_base { + if request.git_reference.is_some() + || request.target_git_reference.is_some() + || request.commit.is_some() + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Git empty-tree diff cannot combine reference forms", + )); + } + let target = request.reference.as_deref().ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "Git empty-tree diff requires a target reference", + ) + })?; + validate_revision(target)?; + Some(format!("{}..{target}", empty_tree_oid(&root)?)) + } else { + typed_reference_range( + &root, + request.git_reference.as_ref(), + request.target_git_reference.as_ref(), + request.reference.as_deref(), + )? + }; + if reference.is_some() && request.commit.is_some() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Git diff cannot combine a reference and a commit", + )); + } + + let include_untracked_with_reference = reference.is_some() && request.untracked; + // `diff --no-index` compares one file with another, so an untracked + // directory summary is diffed as the untracked files below it. + let expands_untracked_directories = reference.is_none() + && request.commit.is_none() + && request.untracked + && request.pathspecs.iter().any(|path| path.ends_with('/')); + let lists_untracked_paths = include_untracked_with_reference || expands_untracked_directories; + let mut arguments = if let Some(commit) = request.commit { + validate_revision(&commit)?; + vec![ + "show".to_string(), + "--format=".to_string(), + "--no-ext-diff".to_string(), + "--binary".to_string(), + format!("--unified={}", request.context_lines), + commit, + ] + } else if let Some(reference) = reference { + validate_revision(&reference)?; + vec![ + "diff".to_string(), + "--no-ext-diff".to_string(), + "--binary".to_string(), + format!("--unified={}", request.context_lines), + reference, + ] + } else { + let mut arguments = vec![ + "diff".to_string(), + "--no-ext-diff".to_string(), + "--binary".to_string(), + ]; + if request.untracked { + arguments.push("--no-index".to_string()); + } + arguments.push(format!("--unified={}", request.context_lines)); + if request.staged && !request.untracked { + arguments.push("--cached".to_string()); + } + arguments + }; + append_full_file_context_arguments(&mut arguments, request.context_lines); + if request.ignore_all_whitespace { + arguments.push("--ignore-all-space".to_string()); + } + arguments.push("--".to_string()); + if request.untracked && !include_untracked_with_reference { + arguments.push(null_device().to_string()); + } + arguments.extend(request.pathspecs.clone()); + + if request.include_file_sections { + configure_structured_diff(&mut arguments); + } + let root = validate_root(&request.root)?; + let mut output = if expands_untracked_directories { + GitProcessOutput { + stdout: Vec::new(), + stderr: Vec::new(), + exit_code: 0, + } + } else { + capture_git_with_options(&root, &arguments, None, true)? + }; + let mut sections = if request.include_file_sections { + Some(structured_numstat_output( + std::mem::replace( + &mut output, + GitProcessOutput { + stdout: Vec::new(), + stderr: Vec::new(), + exit_code: 0, + }, + ), + false, + )?) + } else { + None + }; + let mut section_bytes = sections.as_ref().map(structured_file_bytes).unwrap_or(0); + if lists_untracked_paths { + let untracked_paths = readonly_command(GitCommandRequest { + root: root.clone(), + arguments: vec![ + "ls-files".into(), + "--others".into(), + "--exclude-standard".into(), + "-z".into(), + "--".into(), + ] + .into_iter() + .chain(request.pathspecs.clone()) + .collect(), + input: None, + })?; + if untracked_paths.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Git untracked file lookup failed", + ) + .with_details(untracked_paths.output)); + } + for path in untracked_paths.stdout.split('\0') { + if path.is_empty() { + continue; + } + if !is_safe_pathspec(path) { + // Shelve cleans a whole summarized directory after saving this + // patch, so a file it cannot export must fail the diff rather + // than be dropped from the patch and then deleted. + if expands_untracked_directories { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Git diff cannot export an untracked file with an unsupported name", + )); + } + continue; + } + let mut untracked_arguments = vec![ + "diff".into(), + "--no-ext-diff".into(), + "--binary".into(), + "--no-index".into(), + format!("--unified={}", request.context_lines), + ]; + append_full_file_context_arguments(&mut untracked_arguments, request.context_lines); + if request.ignore_all_whitespace { + untracked_arguments.push("--ignore-all-space".into()); + } + untracked_arguments.extend(["--".into(), null_device().into(), path.into()]); + if request.include_file_sections { + configure_structured_diff(&mut untracked_arguments); + } + let untracked = capture_git_with_options(&root, &untracked_arguments, None, true)?; + if let Some(ref mut combined) = sections { + append_structured_files( + combined, + structured_numstat_output(untracked, false)?, + &mut section_bytes, + )?; + } else { + output.stdout.extend(untracked.stdout); + output.stderr.extend(untracked.stderr); + } + } + } + if let Some(sections) = sections { + Ok(sections) + } else { + Ok(structured_diff_from_output(output)) + } +} + +fn worktree_snapshot_diff( + root: &str, + request: &GitDiffRequest, +) -> Result { + let head = execute_git_readonly( + root, + &[ + "rev-parse".into(), + "--verify".into(), + "--quiet".into(), + "HEAD".into(), + ], + None, + )?; + if head.exit_code > 1 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Could not resolve Git HEAD") + .with_details(head.output), + ); + } + let head = (head.exit_code == 0).then(|| head.stdout.trim().to_string()); + let common_directory = git_resolved_path( + root, + &["rev-parse", "--path-format=absolute", "--git-common-dir"], + "Git common directory", + )?; + let temporary_context = TemporaryGitCommitContext::prepare_snapshot(head.as_deref())?; + let environment = temporary_context.environment(root, &common_directory); + let initialize_arguments = if let Some(head) = head.as_ref() { + vec!["read-tree".to_string(), head.clone()] + } else { + vec!["read-tree".to_string(), "--empty".to_string()] + }; + let initialized = + execute_git_with_environment(root, &initialize_arguments, None, true, &environment)?; + if initialized.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not prepare Git snapshot diff", + ) + .with_details(initialized.output)); + } + + let mut arguments = vec![ + "diff-files".to_string(), + "--no-ext-diff".to_string(), + "--binary".to_string(), + format!("--unified={}", request.context_lines), + ]; + append_full_file_context_arguments(&mut arguments, request.context_lines); + if request.ignore_all_whitespace { + arguments.push("--ignore-all-space".to_string()); + } + arguments.push("--".to_string()); + arguments.extend(request.pathspecs.clone()); + if request.include_file_sections { + configure_structured_diff(&mut arguments); + } + let mut output = capture_git_with_environment(root, &arguments, None, true, &environment)?; + if output.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Git snapshot diff failed") + .with_details(String::from_utf8_lossy(&output.stderr)), + ); + } + + let mut sections = if request.include_file_sections { + Some(structured_numstat_output( + std::mem::replace( + &mut output, + GitProcessOutput { + stdout: Vec::new(), + stderr: Vec::new(), + exit_code: 0, + }, + ), + false, + )?) + } else { + None + }; + let mut section_bytes = sections.as_ref().map(structured_file_bytes).unwrap_or(0); + let untracked_arguments = vec![ + "ls-files".into(), + "--others".into(), + "--exclude-standard".into(), + "-z".into(), + "--".into(), + ] + .into_iter() + .chain(request.pathspecs.clone()) + .collect::>(); + let untracked = + capture_git_with_environment(root, &untracked_arguments, None, true, &environment)?; + if untracked.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Git snapshot file lookup failed") + .with_details(String::from_utf8_lossy(&untracked.stderr)), + ); + } + for path in untracked + .stdout + .split(|byte| *byte == 0) + .filter(|path| !path.is_empty()) + { + let path = String::from_utf8_lossy(path).to_string(); + if !is_safe_pathspec(&path) { + continue; + } + let mut untracked_arguments = vec![ + "diff".into(), + "--no-ext-diff".into(), + "--binary".into(), + "--no-index".into(), + format!("--unified={}", request.context_lines), + ]; + append_full_file_context_arguments(&mut untracked_arguments, request.context_lines); + if request.ignore_all_whitespace { + untracked_arguments.push("--ignore-all-space".into()); + } + untracked_arguments.extend(["--".into(), null_device().into(), path]); + if request.include_file_sections { + configure_structured_diff(&mut untracked_arguments); + } + let added = + capture_git_with_environment(root, &untracked_arguments, None, true, &environment)?; + if let Some(ref mut combined) = sections { + append_structured_files( + combined, + structured_numstat_output(added, false)?, + &mut section_bytes, + )?; + } else { + output.stdout.extend(added.stdout); + output.stderr.extend(added.stderr); + } + } + if let Some(sections) = sections { + Ok(sections) + } else { + Ok(structured_diff_from_output(output)) + } +} + +// Request Git's NUL-delimited identities and its patch in the same process. +fn configure_structured_diff(arguments: &mut Vec) { + arguments.splice( + 1..1, + [ + "--numstat".into(), + "-z".into(), + "--patch".into(), + "--no-color".into(), + "--no-textconv".into(), + ], + ); + arguments.splice( + 0..0, + [ + "-c".into(), + "diff.noprefix=false".into(), + "-c".into(), + "diff.mnemonicprefix=false".into(), + ], + ); +} + +fn structured_file_bytes(response: &GitDiffResponse) -> usize { + response + .files + .as_ref() + .map(|files| files.iter().map(|file| file.patch.len()).sum()) + .unwrap_or(0) +} + +fn append_structured_files( + target: &mut GitDiffResponse, + source: GitDiffResponse, + bytes: &mut usize, +) -> Result<(), CoreError> { + *bytes = bytes + .checked_add(structured_file_bytes(&source)) + .ok_or_else(|| CoreError::new(ErrorCode::InvalidRequest, "Git diff exceeds 32 MiB"))?; + if *bytes > patch_exchange::MAX_PATCH_BYTES { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Git diff exceeds 32 MiB", + )); + } + target.has_decoding_loss |= source.has_decoding_loss; + if let (Some(target), Some(source)) = (&mut target.files, source.files) { + target.extend(source); + } + Ok(()) +} + +pub(crate) fn structured_diff_from_output(output: GitProcessOutput) -> GitDiffResponse { + // Diff is a machine-readable stdout protocol. Git diagnostics on stderr + // must never become synthetic file lines in the parsed patch. + let decoded = String::from_utf8_lossy(&output.stdout); + // A Cow::Owned means invalid UTF-8 bytes were replaced with U+FFFD; the + // patch cannot be used as byte-faithful write material. + let has_decoding_loss = matches!(decoded, std::borrow::Cow::Owned(_)); + let patch = decoded.to_string(); + let document = parse_diff(&patch); + GitDiffResponse { + patch, + rows: document.0, + hunks: document.1, + has_decoding_loss, + files: None, + } +} + +/// Adds a content-only patch to every hunk of a rename section, so applying +/// one hunk changes the target file's text without reversing the rename. +fn attach_rename_content_patches(section: &str, hunks: &mut [GitDiffHunkResponse]) { + let target = section + .lines() + .take_while(|line| !line.starts_with("@@")) + .find(|line| line.starts_with("+++ b/") || line.starts_with("+++ \"b/")); + let Some(target) = target else { + return; + }; + let old = target + .strip_prefix("+++ b/") + .map(|path| format!("--- a/{path}")) + .or_else(|| { + target + .strip_prefix("+++ \"b/") + .map(|path| format!("--- \"a/{path}")) + }); + let Some(old) = old else { + return; + }; + for hunk in hunks { + let marker = format!("\n{}", hunk.header); + if let Some(offset) = hunk.patch.find(&marker) { + hunk.content_patch = Some(format!("{old}\n{target}\n{}", &hunk.patch[offset + 1..])); + } + } +} + +// Git owns filename decoding. Pair its ordered NUL-delimited metadata with +// byte-preserving section boundaries, then parse each document once. +fn structured_patch_sections( + patch: &str, + identities: Vec, + has_decoding_loss: bool, +) -> Result { + let mut files = Vec::new(); + if !patch.is_empty() { + let mut starts = Vec::new(); + let mut offset = 0; + for line in patch.split_inclusive('\n') { + if line.starts_with("diff --git ") { + starts.push(offset); + } + offset += line.len(); + } + if starts.first() != Some(&0) || starts.len() != identities.len() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Patch sections do not match Git metadata", + )); + } + starts.push(patch.len()); + for (identity, range) in identities.into_iter().zip(starts.windows(2)) { + let section = &patch[range[0]..range[1]]; + let status = if section + .lines() + .any(|line| line.starts_with("new file mode ")) + { + "A" + } else if section + .lines() + .any(|line| line.starts_with("deleted file mode ")) + { + "D" + } else if identity.original_path.is_some() { + "R" + } else { + "M" + }; + let is_binary = identity.additions.is_none() && identity.deletions.is_none(); + let has_lossy_line_endings = has_decoding_loss + || section.split('\n').any(|line| { + line.ends_with('\r') || line.starts_with("\\ No newline at end of file") + }); + let (rows, mut hunks, lines) = parse_diff(section); + if status == "R" { + attach_rename_content_patches(section, &mut hunks); + } + files.push(GitDiffFileResponse { + path: identity.path, + original_path: identity.original_path, + status: status.into(), + patch: section.into(), + rows, + hunks, + lines, + is_binary, + has_lossy_line_endings, + additions: identity.additions, + deletions: identity.deletions, + }); + } + } else if !identities.is_empty() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Patch sections do not match Git metadata", + )); + } + Ok(GitDiffResponse { + patch: String::new(), + rows: Vec::new(), + hunks: Vec::new(), + has_decoding_loss, + files: Some(files), + }) +} + +// Git writes all `--numstat -z` records first, followed by one extra NUL and +// the patch. NUL cannot occur inside a Git path, including quoted path names. +pub(crate) fn structured_numstat_output( + output: GitProcessOutput, + require_utf8: bool, +) -> Result { + if output.stdout.is_empty() { + return structured_patch_sections("", Vec::new(), false); + } + let separator = output + .stdout + .windows(2) + .position(|bytes| bytes == [0, 0]) + .ok_or_else(|| { + CoreError::new(ErrorCode::InvalidRequest, "Git diff metadata is incomplete") + })?; + let identities = patch_exchange::parse_diff_statistics_in_order(&output.stdout[..=separator])?; + let bytes = &output.stdout[separator + 2..]; + if bytes.len() > patch_exchange::MAX_PATCH_BYTES { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Saved patch exceeds 32 MiB", + )); + } + let decoded = String::from_utf8_lossy(bytes); + let has_decoding_loss = matches!(decoded, std::borrow::Cow::Owned(_)); + if require_utf8 && has_decoding_loss { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Saved patch must contain valid UTF-8", + )); + } + structured_patch_sections(&decoded, identities, has_decoding_loss) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ParsePatchRequest { + root: String, + patch: String, +} + +/// Handles `git.parsePatch`: parses a saved patch into validated per-file documents. +pub fn parse_patch_command(payload: serde_json::Value) -> Result { + let request: ParsePatchRequest = serde_json::from_value(payload).map_err(|_| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid Git patch parse request") + })?; + let root = validate_root(&request.root)?; + if request.patch.len() > patch_exchange::MAX_PATCH_BYTES { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Saved patch exceeds 32 MiB", + )); + } + let identities = if request.patch.is_empty() { + Vec::new() + } else { + patch_exchange::ordered_patch_files(&root, &request.patch)? + }; + serde_json::to_value(structured_patch_sections( + &request.patch, + identities, + false, + )?) + .map_err(|_| CoreError::new(ErrorCode::Unknown, "Could not encode parsed Git patch")) +} + +/// Applies a validated patch using the requested index or working-tree mode. +pub fn apply(request: GitApplyRequest) -> Result { + let root = validate_root(&request.root)?; + let _lease = rewrite::RewriteLease::acquire(&root)?; + let arguments = match request.mode.as_str() { + "stage" => vec![ + "apply".to_string(), + "--cached".to_string(), + "--whitespace=nowarn".to_string(), + ], + "unstage" => vec![ + "apply".to_string(), + "--cached".to_string(), + "--reverse".to_string(), + "--whitespace=nowarn".to_string(), + ], + "discard" => { + // A hunk rollback rewrites worktree bytes; a clean/smudge filter or + // working-tree encoding could change bytes the patch never saw. + let mut paths = Vec::new(); + for file in patch_exchange::ordered_patch_files(&root, &request.patch)? { + paths.extend(file.original_path); + paths.push(file.path); + } + if !diff_blocks::worktree_attributes_safe(&root, &paths)? { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + diff_blocks::HUNK_ROLLBACK_UNSAFE, + )); + } + vec![ + "apply".to_string(), + "--reverse".to_string(), + "--whitespace=nowarn".to_string(), + ] + } + // Reconstruct a saved index snapshot and its worktree in one step. + // `--cached` alone would leave the worktree at HEAD, which makes a + // subsequent unstaged patch fail for files with both staged and + // unstaged edits. + "restoreIndex" => vec![ + "apply".to_string(), + "--index".to_string(), + "--whitespace=nowarn".to_string(), + ], + "worktree" => vec!["apply".to_string(), "--whitespace=nowarn".to_string()], + "restoreIndexCheck" => vec![ + "apply".to_string(), + "--cached".to_string(), + "--reverse".to_string(), + "--check".to_string(), + "--whitespace=nowarn".to_string(), + ], + "worktreeCheck" => vec![ + "apply".to_string(), + "--reverse".to_string(), + "--check".to_string(), + "--whitespace=nowarn".to_string(), + ], + _ => { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Unsupported Git patch mode", + )) + } + }; + command(GitCommandRequest { + root: request.root, + arguments, + input: Some(request.patch), + }) +} + +/// One removed or added line retained with its original side's line number. +struct DiffEntry { + number: usize, + text: String, +} + +/// Parsed patch hunk before it is aligned into side-by-side rows. +struct DiffHunkRecord { + id: String, + header: String, + lines: Vec, +} + +fn without_one_trailing_cr(value: &str) -> &str { + value.strip_suffix('\r').unwrap_or(value) +} + +fn flush_diff_changes( + rows: &mut Vec, + removed: &mut Vec, + added: &mut Vec, + hunk_id: Option<&str>, +) { + let removed_text: Vec<&str> = removed.iter().map(|entry| entry.text.as_str()).collect(); + let added_text: Vec<&str> = added.iter().map(|entry| entry.text.as_str()).collect(); + for (left_index, right_index) in lithe_diff::pair_lines(&removed_text, &added_text) { + let left = left_index.map(|index| &removed[index]); + let right = right_index.map(|index| &added[index]); + let kind = match (left.is_some(), right.is_some()) { + (true, true) => "changed", + (true, false) => "removal", + (false, true) => "addition", + (false, false) => continue, + }; + let is_invisible_change = left.zip(right).and_then(|(left, right)| { + (without_one_trailing_cr(&left.text) == without_one_trailing_cr(&right.text)) + .then_some(true) + }); + let (left_changes, right_changes) = match left.zip(right) { + Some((left, right)) if is_invisible_change.is_none() => { + match lithe_diff::inline_changes( + without_one_trailing_cr(&left.text), + without_one_trailing_cr(&right.text), + ) { + Some((left, right)) => (Some(left), Some(right)), + None => (None, None), + } + } + _ => (None, None), + }; + rows.push(GitDiffRowResponse { + old_line: left.map(|entry| entry.number), + new_line: right.map(|entry| entry.number), + left: left.map(|entry| entry.text.clone()), + right: right.map(|entry| entry.text.clone()), + kind: kind.to_string(), + hunk_id: hunk_id.map(String::from), + is_invisible_change, + left_changes, + right_changes, + }); + } + removed.clear(); + added.clear(); +} + +fn parse_hunk_header(header: &str) -> Option<(usize, usize)> { + let mut columns = header.split_whitespace(); + if columns.next()? != "@@" { + return None; + } + let old_range = columns.next()?.strip_prefix('-')?; + let new_range = columns.next()?.strip_prefix('+')?; + let old_line = old_range.split(',').next()?.parse().ok()?; + let new_line = new_range.split(',').next()?.parse().ok()?; + Some((old_line, new_line)) +} + +pub(crate) fn parse_diff( + patch: &str, +) -> ( + Vec, + Vec, + Vec, +) { + let has_trailing_newline = patch.ends_with('\n'); + let lines = patch.strip_suffix('\n').unwrap_or(patch).split('\n'); + + let mut rows = Vec::new(); + let mut unified = Vec::new(); + let mut old_line = 0; + let mut new_line = 0; + let mut removed = Vec::new(); + let mut added = Vec::new(); + let mut current_hunk_id: Option = None; + let mut current_hunk_header = String::new(); + let mut current_hunk_lines: Vec = Vec::new(); + let mut file_header_lines: Vec = Vec::new(); + let mut hunk_records = Vec::new(); + let mut hunk_index = 0; + + for line in lines { + if line.starts_with("@@") { + if let Some(hunk_id) = current_hunk_id.take() { + flush_diff_changes(&mut rows, &mut removed, &mut added, Some(&hunk_id)); + hunk_records.push(DiffHunkRecord { + id: hunk_id, + header: std::mem::take(&mut current_hunk_header), + lines: std::mem::take(&mut current_hunk_lines), + }); + } + + let hunk_id = format!("hunk-{hunk_index}"); + hunk_index += 1; + current_hunk_id = Some(hunk_id.clone()); + current_hunk_header = line.to_string(); + current_hunk_lines = file_header_lines.clone(); + current_hunk_lines.push(line.to_string()); + if let Some((old, new)) = parse_hunk_header(line) { + old_line = old; + new_line = new; + } + unified.push(GitDiffUnifiedLineResponse { + line_type: "header".into(), + content: without_one_trailing_cr(line).into(), + old_line: None, + new_line: None, + }); + rows.push(GitDiffRowResponse { + old_line: None, + new_line: None, + left: Some(line.to_string()), + right: None, + kind: "information".to_string(), + hunk_id: Some(hunk_id), + is_invisible_change: None, + left_changes: None, + right_changes: None, + }); + } else if line.starts_with("diff --git") && current_hunk_id.is_some() { + let hunk_id = current_hunk_id.take().expect("hunk should exist"); + flush_diff_changes(&mut rows, &mut removed, &mut added, Some(&hunk_id)); + hunk_records.push(DiffHunkRecord { + id: hunk_id, + header: std::mem::take(&mut current_hunk_header), + lines: std::mem::take(&mut current_hunk_lines), + }); + file_header_lines = vec![line.to_string()]; + } else if current_hunk_id.is_none() { + file_header_lines.push(line.to_string()); + } else if let Some(rest) = line.strip_prefix('-') { + unified.push(GitDiffUnifiedLineResponse { + line_type: "removed".into(), + content: without_one_trailing_cr(rest).into(), + old_line: Some(old_line), + new_line: None, + }); + current_hunk_lines.push(line.to_string()); + removed.push(DiffEntry { + number: old_line, + text: rest.to_string(), + }); + old_line += 1; + } else if let Some(rest) = line.strip_prefix('+') { + unified.push(GitDiffUnifiedLineResponse { + line_type: "added".into(), + content: without_one_trailing_cr(rest).into(), + old_line: None, + new_line: Some(new_line), + }); + current_hunk_lines.push(line.to_string()); + added.push(DiffEntry { + number: new_line, + text: rest.to_string(), + }); + new_line += 1; + } else if let Some(rest) = line.strip_prefix(' ') { + unified.push(GitDiffUnifiedLineResponse { + line_type: "context".into(), + content: without_one_trailing_cr(rest).into(), + old_line: Some(old_line), + new_line: Some(new_line), + }); + let hunk_id = current_hunk_id.as_deref(); + flush_diff_changes(&mut rows, &mut removed, &mut added, hunk_id); + current_hunk_lines.push(line.to_string()); + rows.push(GitDiffRowResponse { + old_line: Some(old_line), + new_line: Some(new_line), + left: Some(rest.to_string()), + right: None, + kind: "context".to_string(), + hunk_id: current_hunk_id.clone(), + is_invisible_change: None, + left_changes: None, + right_changes: None, + }); + old_line += 1; + new_line += 1; + } else { + current_hunk_lines.push(line.to_string()); + } + } + + if let Some(hunk_id) = current_hunk_id.take() { + flush_diff_changes(&mut rows, &mut removed, &mut added, Some(&hunk_id)); + hunk_records.push(DiffHunkRecord { + id: hunk_id, + header: current_hunk_header, + lines: current_hunk_lines, + }); + } + + let hunks = hunk_records + .into_iter() + .map(|record| { + let patch = record.lines.join("\n") + if has_trailing_newline { "\n" } else { "" }; + GitDiffHunkResponse { + id: record.id, + header: record.header, + patch, + content_patch: None, + line_start: None, + line_end: None, + } + }) + .collect(); + (rows, hunks, unified) +} diff --git a/rust/crates/core/src/git/diff_blocks.rs b/rust/crates/git/src/diff_blocks.rs similarity index 96% rename from rust/crates/core/src/git/diff_blocks.rs rename to rust/crates/git/src/diff_blocks.rs index ca38720f..b1f6d999 100644 --- a/rust/crates/core/src/git/diff_blocks.rs +++ b/rust/crates/git/src/diff_blocks.rs @@ -12,7 +12,8 @@ use super::{ command, diff_impl, execute_git_readonly, is_safe_pathspec, rewrite::RewriteLease, validate_root, GitCommandRequest, GitDiffRequest, FULL_FILE_CONTEXT_LINES, }; -use crate::protocol::{CoreError, ErrorCode, GitDiffFileResponse, GitDiffHunkResponse}; +use crate::contracts::{GitDiffFileResponse, GitDiffHunkResponse}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; @@ -133,23 +134,24 @@ impl SingleHunkDocument { old_start, new_start, }; - (document.old().len() == old_count && document.new().len() == new_count).then_some(document) + (document.old_lines().len() == old_count && document.new_lines().len() == new_count) + .then_some(document) } /// Parses a section whose hunk spans both complete files. fn parse_complete(section: &str) -> Option { let document = Self::parse(section)?; let expected = |count: usize| usize::from(count > 0); - (document.old_start == expected(document.old().len()) - && document.new_start == expected(document.new().len())) + (document.old_start == expected(document.old_lines().len()) + && document.new_start == expected(document.new_lines().len())) .then_some(document) } - fn old(&self) -> Vec { + fn old_lines(&self) -> Vec { self.side(Operation::Removed) } - fn new(&self) -> Vec { + fn new_lines(&self) -> Vec { self.side(Operation::Added) } @@ -506,14 +508,14 @@ pub(super) fn plan_blocks( None => None, }; let runs = visible.runs(); - let (head_lines, worktree_lines) = (visible.old(), visible.new()); + let (head_lines, worktree_lines) = (visible.old_lines(), visible.new_lines()); if matches!(worktree.status, "A" | "D") { let [run] = runs.as_slice() else { return None; }; let checked = indexed .as_ref() - .is_some_and(|indexed| indexed.new() == worktree_lines); + .is_some_and(|indexed| indexed.new_lines() == worktree_lines); // Whole-file inclusion stages the file through `git add`, which keeps // its bytes even when the patch text could not be decoded faithfully. return Some(vec![whole_file_block( @@ -523,7 +525,7 @@ pub(super) fn plan_blocks( )]); } let (index_head, index_lines, index_runs) = match &indexed { - Some(indexed) => (indexed.old(), indexed.new(), indexed.runs()), + Some(indexed) => (indexed.old_lines(), indexed.new_lines(), indexed.runs()), None => (head_lines.clone(), head_lines.clone(), Vec::new()), }; if index_head != head_lines { @@ -789,7 +791,7 @@ fn section_value(file: Option, decoding_loss: bool) -> Valu } /// Plans commit-review blocks for one file (`git.planDiffBlocks`). -pub(crate) fn plan_diff_blocks_command(payload: Value) -> Result { +pub fn plan_diff_blocks_command(payload: Value) -> Result { let target: BlockTarget = serde_json::from_value(payload).map_err(|_| invalid("Invalid Git diff block request"))?; let root = validate_root(&target.root)?; @@ -820,7 +822,7 @@ struct ApplyBlockRequest { /// Applies one planned block action (`git.applyDiffBlock`). The plan is rebuilt /// under the write lease; a different token means the file, the index or the /// attributes changed after the user saw the block, so nothing is written. -pub(crate) fn apply_diff_block_command(payload: Value) -> Result { +pub fn apply_diff_block_command(payload: Value) -> Result { let request: ApplyBlockRequest = serde_json::from_value(payload).map_err(|_| invalid("Invalid Git diff block request"))?; let root = validate_root(&request.target.root)?; @@ -916,7 +918,7 @@ fn displayed(lines: &[RawLine], start: usize, end: usize, expected: &[String]) - /// what it compared; Core re-reads the index -> worktree patch under the lease, /// confirms both sides still show those lines and stages them with the /// original bytes, so a moved index or an unsaved edit is never staged. -pub(crate) fn stage_gutter_change_command(payload: Value) -> Result { +pub fn stage_gutter_change_command(payload: Value) -> Result { let request: GutterChangeRequest = serde_json::from_value(payload) .map_err(|_| invalid("Invalid Git gutter change request"))?; if !safe_path(&request.path) { @@ -936,7 +938,7 @@ pub(crate) fn stage_gutter_change_command(payload: Value) -> Result Result, writable: bool, @@ -1002,7 +1004,7 @@ pub(crate) mod fixture_support { } /// Splits a complete-file section into Git-default hunks. - pub(crate) fn hunks(section: &str) -> Option> { + pub fn hunks(section: &str) -> Option> { stageable_hunks(section) } } diff --git a/rust/crates/core/src/git/execution_events.rs b/rust/crates/git/src/execution_events.rs similarity index 60% rename from rust/crates/core/src/git/execution_events.rs rename to rust/crates/git/src/execution_events.rs index c31b947f..76ffc900 100644 --- a/rust/crates/core/src/git/execution_events.rs +++ b/rust/crates/git/src/execution_events.rs @@ -1,52 +1,25 @@ -//! Request-scoped Git diagnostics and bounded, incremental output decoding. +//! Git process invocations on the request's event channel: the `started`, +//! `output` and `finished` events of each traced child, with bounded, +//! incremental output decoding. //! -//! Events contain sanitized diagnostics, never command input or environment. -//! Native process ownership belongs to the `lithe-git-host` adapter. -//! -//! Hosts keep a console journal of these events. The journal's record states -//! and retention budget are part of this contract: `requestStarted` carries -//! [`JOURNAL_RETENTION`], `finished` carries the record's final state, and -//! `requestFinished` names the records that never confirmed an exit and -//! whether a failure needs a record of its own. Hosts store, lock, and -//! throttle; they do not derive states or redact again. +//! The channel itself, its request boundary events and redaction live in +//! `lithe_request_context::events`. Native process ownership belongs to the +//! `lithe-git-host` adapter. -use crate::protocol::CoreError; +use lithe_protocol::CoreError; +use lithe_request_context::events::{deliver, EventSink}; +pub(crate) use lithe_request_context::events::{emit, has_sink, redact}; use serde::Serialize; -use std::cell::{Cell, RefCell}; -use std::sync::{Arc, OnceLock}; +use std::cell::Cell; use std::time::Instant; const MAX_LINE_BYTES: usize = 16 * 1024; const MAX_DIAGNOSTIC_BYTES: usize = 512 * 1024; const MAX_OUTPUT_EVENTS: usize = 4096; -/// Console journal retention shared by every host, in host string length units. -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct JournalRetention { - /// Records kept across all requests; the oldest are dropped first. - pub(crate) max_records: usize, - /// Output kept per record; earlier lines are dropped first. - pub(crate) max_record_output_characters: usize, - /// Output lines kept per record, including empty lines. - pub(crate) max_record_output_lines: usize, - /// Output kept across all records before the oldest record is dropped. - pub(crate) max_total_output_characters: usize, -} - -/// One budget for both products, chosen for the lower resident memory. -pub(crate) const JOURNAL_RETENTION: JournalRetention = JournalRetention { - max_records: 200, - max_record_output_characters: 32_768, - max_record_output_lines: 2_000, - max_total_output_characters: 1_048_576, -}; -/// A serialized event consumer, invoked synchronously during its owning request. -pub type EventSink = Arc; - #[derive(Serialize)] #[serde(rename_all = "camelCase")] -/// One ordered diagnostic event within a Core request. +/// One ordered diagnostic event of a Git invocation. struct Event<'a> { operation_id: &'a str, #[serde(flatten)] @@ -61,18 +34,6 @@ struct Event<'a> { )] /// Process start is evidence of a spawned child; failure may have no exit code. enum Kind<'a> { - RequestStarted { - retention: JournalRetention, - }, - RequestFinished { - error: Option, - /// Records of this request still running; hosts mark them `unconfirmed`. - unconfirmed_invocations: Vec, - /// No Git process of this request failed or is still running: none started, or - /// each one succeeded, such as the queries before a refusal. The failure is shown - /// as its own record instead of on an executed command. - failure_record: bool, - }, Started { source: super::console::Source, executable: Option, @@ -106,127 +67,6 @@ enum Kind<'a> { }, } -/// Per-request identity and sequence shared by nested Git invocations. -struct Context { - sink: EventSink, - operation_id: String, - next_invocation: u64, - /// Transfer summaries remain attached to the transfer despite subsequent inspection queries. - last_transfer: Option, - /// Processes of the current request that finished without succeeding. - failed_invocations: usize, - /// Started processes of the current request without a `finished` event. - open_invocations: std::collections::BTreeSet, -} -thread_local! { static CURRENT: RefCell> = const { RefCell::new(None) }; } - -/// Restores the caller's observer after a nested request or callback returns. -struct ObserverScope(Option); -impl ObserverScope { - fn begin(context: Option) -> Self { - Self(CURRENT.with(|current| current.replace(context))) - } -} -impl Drop for ObserverScope { - fn drop(&mut self) { - CURRENT.with(|current| current.replace(self.0.take())); - } -} - -/// Whether the current request has an incremental event consumer. -pub(crate) fn has_sink() -> bool { - CURRENT.with(|current| current.borrow().is_some()) -} - -/// Installs a consumer only for the duration of this synchronous request. -pub(crate) fn with_sink(sink: EventSink, operation: impl FnOnce() -> T) -> T { - let _scope = ObserverScope::begin(Some(Context { - sink, - operation_id: String::new(), - next_invocation: 0, - last_transfer: None, - failed_invocations: 0, - open_invocations: Default::default(), - })); - operation() -} - -fn deliver(sink: &EventSink, event: &str) { - // A host may synchronously call git.authRespond from this callback. That - // request must not inherit or overwrite the observed operation's identity. - let _scope = ObserverScope::begin(None); - sink(event); -} - -/// Called after cancellation registration so immediate cancellation cannot race startup. -pub(crate) fn request_started(operation_id: Option<&str>) { - let context = CURRENT.with(|current| { - let mut current = current.borrow_mut(); - let context = current.as_mut()?; - context.operation_id = operation_id.unwrap_or_default().to_string(); - context.failed_invocations = 0; - context.open_invocations.clear(); - Some((context.sink.clone(), context.operation_id.clone())) - }); - if let Some((sink, operation_id)) = context { - send( - &sink, - &operation_id, - Kind::RequestStarted { - retention: JOURNAL_RETENTION, - }, - ); - } -} - -/// A final event shares the same ordered channel, including preflight failures. -pub(crate) fn request_finished(response: &str) { - let context = CURRENT.with(|current| { - current.borrow_mut().as_mut().map(|context| { - ( - context.sink.clone(), - context.operation_id.clone(), - context.failed_invocations, - std::mem::take(&mut context.open_invocations), - ) - }) - }); - let Some((sink, operation_id, failed, open)) = context else { - return; - }; - let value: serde_json::Value = serde_json::from_str(response).unwrap_or_default(); - let raw_error = value - .get("error") - .filter(|value| !value.is_null()) - .or_else(|| { - value - .pointer("/data/operationError") - .filter(|value| !value.is_null()) - }); - let error = raw_error.map(|error| serde_json::json!({ - "code": error.get("code"), - "message": redact(error.get("message").and_then(|value| value.as_str()).unwrap_or("Git operation failed")), - "details": error.get("details").and_then(|value| value.as_str()).map(redact), - })); - let error = error.or_else(|| value.pointer("/data/exitCode").and_then(|value| value.as_i64()).filter(|code| *code != 0).map(|code| serde_json::json!({ "code": "process_failed", "message": format!("Git exited with code {code}") }))); - // A request cancelled without a failed command was superseded; it has nothing - // to report. Any other failure that no command reported needs its own record. - let failure_record = failed == 0 - && open.is_empty() - && error - .as_ref() - .is_some_and(|error| error["code"] != "cancelled"); - send( - &sink, - &operation_id, - Kind::RequestFinished { - error, - unconfirmed_invocations: open.into_iter().collect(), - failure_record, - }, - ); -} - /// A traced invocation. The byte decoders are independent for stdout/stderr. pub(super) struct Invocation { sink: EventSink, @@ -268,25 +108,20 @@ impl Invocation { }) } pub(super) fn current() -> Option { - CURRENT.with(|current| { - let mut current = current.borrow_mut(); - let context = current.as_mut()?; - context.next_invocation += 1; - Some(Self { - sink: context.sink.clone(), - operation_id: context.operation_id.clone(), - id: context.next_invocation, - clock: Instant::now(), - output_bytes: 0, - output_events: 0, - omitted: false, - stdout: LineDecoder::default(), - stderr: LineDecoder::default(), - pending_progress: None, - redact_configuration: Cell::new(false), - configuration_marker_sent: false, - query: false, - }) + lithe_request_context::events::next_invocation().map(|channel| Self { + sink: channel.sink, + operation_id: channel.operation_id, + id: channel.id, + clock: Instant::now(), + output_bytes: 0, + output_events: 0, + omitted: false, + stdout: LineDecoder::default(), + stderr: LineDecoder::default(), + pending_progress: None, + redact_configuration: Cell::new(false), + configuration_marker_sent: false, + query: false, }) } pub(super) fn started(&self, root: &str, arguments: &[String]) { @@ -327,23 +162,13 @@ impl Invocation { || key.ends_with(".pushRemote"))) }); self.redact_configuration.set(config && !safe_config); - if super::execution_policy::command_index(arguments).is_some_and(|index| { + let transfer = super::execution_policy::command_index(arguments).is_some_and(|index| { matches!( arguments[index].as_str(), "fetch" | "push" | "pull" | "clone" ) - }) { - CURRENT.with(|current| { - if let Some(context) = current.borrow_mut().as_mut() { - context.last_transfer = Some(self.id); - } - }); - } - CURRENT.with(|current| { - if let Some(context) = current.borrow_mut().as_mut() { - context.open_invocations.insert(self.id); - } }); + lithe_request_context::events::invocation_started(self.id, transfer); let arguments = arguments .iter() .map(|argument| redact(argument)) @@ -373,12 +198,25 @@ impl Invocation { }, ); } + fn output_target(&mut self) -> OutputTarget<'_> { + OutputTarget { + sink: &self.sink, + operation_id: &self.operation_id, + invocation_id: self.id, + pending: &mut self.pending_progress, + } + } pub(super) fn output(&mut self, stream: lithe_git_host::Stream, bytes: &[u8]) { if self.redact_configuration.get() && matches!(stream, lithe_git_host::Stream::Stdout) { if !bytes.is_empty() && !self.configuration_marker_sent { self.configuration_marker_sent = true; - emit_output(&self.sink, &self.operation_id, self.id, &mut self.pending_progress, - "stdout", "[Git configuration values redacted; inspect Git settings for supported values]".into(), false, false); + self.output_target().emit( + "stdout", + "[Git configuration values redacted; inspect Git settings for supported values]" + .into(), + false, + false, + ); } return; } @@ -390,11 +228,7 @@ impl Invocation { self.omitted = true; self.stdout = LineDecoder::default(); self.stderr = LineDecoder::default(); - emit_output( - &self.sink, - &self.operation_id, - self.id, - &mut self.pending_progress, + self.output_target().emit( "stderr", "[Further Git output omitted: diagnostic limit]".into(), false, @@ -406,12 +240,14 @@ impl Invocation { lithe_git_host::Stream::Stdout => (&mut self.stdout, "stdout"), lithe_git_host::Stream::Stderr => (&mut self.stderr, "stderr"), }; - let sink = &self.sink; - let operation_id = &self.operation_id; - let id = self.id; + let mut target = OutputTarget { + sink: &self.sink, + operation_id: &self.operation_id, + invocation_id: self.id, + pending: &mut self.pending_progress, + }; let output_events = &mut self.output_events; let omitted = &mut self.omitted; - let pending_progress = &mut self.pending_progress; decoder.push(bytes, &mut |text, progress, truncated| { if *omitted { return; @@ -419,11 +255,7 @@ impl Invocation { *output_events += 1; if *output_events > MAX_OUTPUT_EVENTS { *omitted = true; - emit_output( - sink, - operation_id, - id, - pending_progress, + target.emit( name, "[Further Git output omitted: diagnostic limit]".into(), false, @@ -431,16 +263,7 @@ impl Invocation { ); return; } - emit_output( - sink, - operation_id, - id, - pending_progress, - name, - text, - progress, - truncated, - ); + target.emit(name, text, progress, truncated); }); } #[cfg(test)] @@ -457,18 +280,15 @@ impl Invocation { self.stdout = LineDecoder::default(); self.stderr = LineDecoder::default(); } + let mut target = OutputTarget { + sink: &self.sink, + operation_id: &self.operation_id, + invocation_id: self.id, + pending: &mut self.pending_progress, + }; for (name, decoder) in [("stdout", &mut self.stdout), ("stderr", &mut self.stderr)] { decoder.flush(&mut |text, progress, truncated| { - emit_output( - &self.sink, - &self.operation_id, - self.id, - &mut self.pending_progress, - name, - text, - progress, - truncated, - ) + target.emit(name, text, progress, truncated) }); } if let Some(pending) = self.pending_progress.take() { @@ -491,12 +311,7 @@ impl Invocation { details: error.details.as_ref().map(|value| redact(value)), }); let failed = error.is_some() || !(exit_code == Some(0) || expected_exit); - CURRENT.with(|current| { - if let Some(context) = current.borrow_mut().as_mut() { - context.open_invocations.remove(&self.id); - context.failed_invocations += usize::from(failed); - } - }); + lithe_request_context::events::invocation_finished(self.id, failed); send( &self.sink, &self.operation_id, @@ -523,54 +338,56 @@ struct PendingProgress { text: String, stage: String, } -fn emit_output( - sink: &EventSink, - operation_id: &str, - id: u64, - pending: &mut Option, - stream: &'static str, - text: String, - progress: bool, - truncated: bool, -) { - let stage = super::progress::parse(&text) - .map(|p| p.stage.to_string()) - .or_else(|| text.split_once(':').map(|(prefix, _)| prefix.to_string())) - .unwrap_or_default(); - if let Some(previous) = pending.take() { - let changed_stage = previous.stage != stage || previous.stream != stream; - if (changed_stage || (!progress && stage.is_empty())) && previous.text != text { - send( - sink, - operation_id, - Kind::Output { - invocation_id: id, - stream: previous.stream, - text: previous.text, - progress: false, - truncated: false, - }, - ); +/// Where one invocation's output goes: the operation's event sink, and the +/// progress line still waiting for its final form. +struct OutputTarget<'a> { + sink: &'a EventSink, + operation_id: &'a str, + invocation_id: u64, + pending: &'a mut Option, +} + +impl OutputTarget<'_> { + fn emit(&mut self, stream: &'static str, text: String, progress: bool, truncated: bool) { + let stage = super::progress::parse(&text) + .map(|p| p.stage.to_string()) + .or_else(|| text.split_once(':').map(|(prefix, _)| prefix.to_string())) + .unwrap_or_default(); + if let Some(previous) = self.pending.take() { + let changed_stage = previous.stage != stage || previous.stream != stream; + if (changed_stage || (!progress && stage.is_empty())) && previous.text != text { + send( + self.sink, + self.operation_id, + Kind::Output { + invocation_id: self.invocation_id, + stream: previous.stream, + text: previous.text, + progress: false, + truncated: false, + }, + ); + } } + if progress && !truncated { + *self.pending = Some(PendingProgress { + stream, + text: text.clone(), + stage, + }); + } + send( + self.sink, + self.operation_id, + Kind::Output { + invocation_id: self.invocation_id, + stream, + text, + progress, + truncated, + }, + ); } - if progress && !truncated { - *pending = Some(PendingProgress { - stream, - text: text.clone(), - stage, - }); - } - send( - sink, - operation_id, - Kind::Output { - invocation_id: id, - stream, - text, - progress, - truncated, - }, - ); } fn send(sink: &EventSink, operation_id: &str, kind: Kind<'_>) { @@ -584,50 +401,6 @@ fn send(sink: &EventSink, operation_id: &str, kind: Kind<'_>) { } } -/// Sends structured native diagnostics on the same ordered request channel. -pub(crate) fn emit(mut value: serde_json::Value) { - let context = CURRENT.with(|current| { - current.borrow().as_ref().map(|context| { - ( - context.sink.clone(), - context.operation_id.clone(), - context.last_transfer, - ) - }) - }); - if let Some((sink, operation_id, transfer)) = context { - if value["type"] == "remoteResult" { - value["invocationId"] = transfer.into(); - } - // Structured results can carry native error text; it is redacted here - // like every other event so hosts never redact a second time. - if let Some(error) = value - .get_mut("error") - .and_then(|error| error.as_object_mut()) - { - for key in ["message", "details"] { - if let Some(text) = error.get(key).and_then(|text| text.as_str()) { - let redacted = redact(text); - error.insert(key.into(), redacted.into()); - } - } - } - value["operationId"] = operation_id.into(); - deliver(&sink, &value.to_string()); - } -} - -/// Redacts complete records, including credentials split across native chunks. -pub(crate) fn redact(text: &str) -> String { - static URL_AUTH: OnceLock = OnceLock::new(); - let pattern = URL_AUTH.get_or_init(|| { - regex::Regex::new(r"(?i)(https?|ssh)://[^\s/@]+(?::[^\s/@]*)?@") - .expect("valid URL redaction") - }); - let text = pattern.replace_all(text, "$1://redacted@").into_owned(); - crate::diagnostics::redact_text(crate::diagnostics::RedactTextRequest { text }).redacted -} - #[derive(Default)] /// Complete-line decoding avoids exposing a secret before its delimiter arrives. /// Overlong lines are discarded in full rather than publishing a secret prefix. @@ -679,6 +452,8 @@ impl LineDecoder { #[cfg(test)] mod tests { use super::*; + use lithe_request_context::events::{request_finished, request_started, with_sink}; + use std::sync::Arc; /// A query's nonzero exit that answers it is expected; the same exit of a visible /// command, or an exit Git means as failure, is not. @@ -708,83 +483,6 @@ mod tests { assert_eq!(lines, ["? first.txt", "? second"]); } - #[test] - fn callback_auth_reply_preserves_outer_events_and_cancellation() { - let events = Arc::new(std::sync::Mutex::new(Vec::::new())); - let captured = events.clone(); - let response = crate::execute_json_with_events( - r#"{"id":"callback-cancellation","command":"core.ping","timeoutMilliseconds":1000}"#, - Arc::new(move |event| { - let event: serde_json::Value = serde_json::from_str(event).unwrap(); - captured.lock().unwrap().push(event.clone()); - if event["type"] == "requestStarted" - && event["operationId"] == "callback-cancellation" - { - // Like the C AskPass host, synchronously reply through Core - // before requesting cancellation of the observed operation. - let reply: serde_json::Value = serde_json::from_str(&crate::execute_json( - r#"{"id":"callback-auth-reply","command":"git.authRespond","payload":{"requestId":"expired-fixture","answer":null}}"#, - )).unwrap(); - assert_eq!(reply["ok"], true); - assert_eq!(reply["data"]["accepted"], false); - assert!(crate::cancel_operation("callback-cancellation")); - } - }), - ); - let response: serde_json::Value = serde_json::from_str(&response).unwrap(); - assert_eq!(response["error"]["code"], "cancelled", "{response}"); - let events = events.lock().unwrap(); - assert_eq!(events.len(), 2, "{events:?}"); - assert_eq!(events[0]["type"], "requestStarted"); - assert_eq!(events[1]["type"], "requestFinished"); - assert!(events - .iter() - .all(|event| event["operationId"] == "callback-cancellation")); - assert!(!crate::cancel_operation("callback-cancellation")); - } - - #[test] - fn callback_can_observe_a_nested_request_without_mixing_channels() { - let outer = Arc::new(std::sync::Mutex::new(Vec::::new())); - let inner = Arc::new(std::sync::Mutex::new(Vec::::new())); - let outer_capture = outer.clone(); - let inner_capture = inner.clone(); - let response = crate::execute_json_with_events( - r#"{"id":"outer-observer","command":"core.ping"}"#, - Arc::new(move |event| { - let event: serde_json::Value = serde_json::from_str(event).unwrap(); - outer_capture.lock().unwrap().push(event.clone()); - if event["type"] == "requestStarted" { - let captured = inner_capture.clone(); - let response = crate::execute_json_with_events( - r#"{"id":"inner-observer","command":"core.ping"}"#, - Arc::new(move |event| { - captured - .lock() - .unwrap() - .push(serde_json::from_str(event).unwrap()) - }), - ); - assert_eq!( - serde_json::from_str::(&response).unwrap()["ok"], - true - ); - } - }), - ); - assert_eq!( - serde_json::from_str::(&response).unwrap()["ok"], - true - ); - for (events, id) in [(outer, "outer-observer"), (inner, "inner-observer")] { - let events = events.lock().unwrap(); - assert_eq!(events.len(), 2); - assert_eq!(events[0]["type"], "requestStarted"); - assert_eq!(events[1]["type"], "requestFinished"); - assert!(events.iter().all(|event| event["operationId"] == id)); - } - } - #[test] fn emitted_events_match_the_shared_fixture_and_observer_scope_is_released() { let captured = Arc::new(std::sync::Mutex::new(Vec::::new())); @@ -820,7 +518,7 @@ mod tests { } } let fixture: serde_json::Value = serde_json::from_str(include_str!( - "../../../../fixtures/git/execution-events-v1.json" + "../../../fixtures/git/execution-events-v1.json" )) .unwrap(); assert_eq!(serde_json::json!(events), fixture["events"]); @@ -871,11 +569,11 @@ mod tests { #[test] fn reported_queries_are_marked_apart_from_visible_commands() { - let options = crate::git::execution_policy::GitExecutionOptions { + let options = crate::execution_policy::GitExecutionOptions { report_queries: true, ..Default::default() }; - let _policy = crate::git::execution_policy::Scope::begin(Some(options)).unwrap(); + let _policy = crate::execution_policy::Scope::begin(Some(options)).unwrap(); let events = capture_request(|| { request_started(Some("marked")); let query = Invocation::reported(false).unwrap(); diff --git a/rust/crates/core/src/git/execution_policy.rs b/rust/crates/git/src/execution_policy.rs similarity index 94% rename from rust/crates/core/src/git/execution_policy.rs rename to rust/crates/git/src/execution_policy.rs index 523d335b..fcc1d07d 100644 --- a/rust/crates/core/src/git/execution_policy.rs +++ b/rust/crates/git/src/execution_policy.rs @@ -1,7 +1,7 @@ //! Request-scoped execution choices and deterministic temporary configuration. use super::fetch::GitFetchOptions; -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use std::cell::RefCell; @@ -44,9 +44,11 @@ impl Default for GitExecutionOptions { thread_local! { static OPTIONS: RefCell = RefCell::new(GitExecutionOptions::default()); } /// Restores the previous policy even if a nested request fails. -pub(crate) struct Scope(GitExecutionOptions); +pub struct Scope(GitExecutionOptions); impl Scope { - pub(crate) fn begin(options: Option) -> Result { + /// Installs `options` (or the defaults) for the current thread until the scope drops. + /// Rejects a relative executable path and invalid fetch defaults. + pub fn begin(options: Option) -> Result { let options = options.unwrap_or_default(); if options.executable.as_ref().is_some_and(|path| { !std::path::Path::new(path).is_absolute() || path.contains(['\0', '\r', '\n']) @@ -76,7 +78,7 @@ pub(super) fn current() -> GitExecutionOptions { /// envelope's when the policy names none. pub(super) fn source() -> super::console::Source { match current().source { - super::console::Source::Unknown => crate::diagnostics::request_trace::current().source, + super::console::Source::Unknown => lithe_request_context::trace::current().source, source => source, } } @@ -174,7 +176,7 @@ mod tests { #[test] fn console_projection_preserves_raw_arguments_and_folds_only_global_options() { let fixture: serde_json::Value = serde_json::from_str(include_str!( - "../../../../fixtures/git/console-command-v1.json" + "../../../fixtures/git/console-command-v1.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { @@ -216,7 +218,7 @@ mod tests { fn named_defaults_are_scoped_and_do_not_duplicate_transfer_progress() { let before = current(); let fixture: serde_json::Value = serde_json::from_str(include_str!( - "../../../../fixtures/git/execution-policy-v1.json" + "../../../fixtures/git/execution-policy-v1.json" )) .unwrap(); assert_eq!( diff --git a/rust/crates/core/src/git/fetch.rs b/rust/crates/git/src/fetch.rs similarity index 72% rename from rust/crates/core/src/git/fetch.rs rename to rust/crates/git/src/fetch.rs index 87b6f43a..c0054991 100644 --- a/rust/crates/core/src/git/fetch.rs +++ b/rust/crates/git/src/fetch.rs @@ -1,6 +1,6 @@ //! Deterministic Fetch options and the exact command shared by preview and execution. -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; /// Per-operation submodule policy; inheritance preserves the user's Git configuration. @@ -34,8 +34,8 @@ pub struct GitFetchOptions { pub remote: Option, /// Remove stale remote-tracking references; false explicitly overrides fetch.prune. pub prune: bool, - pub submodules: GitFetchSubmodules, - pub tags: GitFetchTags, + pub(crate) submodules: GitFetchSubmodules, + pub(crate) tags: GitFetchTags, } impl Default for GitFetchOptions { @@ -54,7 +54,7 @@ impl Default for GitFetchOptions { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct GitFetchPlanRequest { #[serde(default)] - pub options: GitFetchOptions, + pub(crate) options: GitFetchOptions, /// Omit for a pure legacy argument preview. pub root: Option, } @@ -171,29 +171,14 @@ pub(super) fn arguments(options: &GitFetchOptions) -> Result, CoreEr #[cfg(test)] mod tests { - use serde_json::{json, Value}; - - fn fixture() -> Value { - serde_json::from_str(include_str!("../../../../fixtures/git/fetch-plan-v1.json")).unwrap() - } - - fn request(payload: Value) -> Value { - serde_json::from_str(&crate::execute_json( - &json!({ - "id": "fetch-plan-test", "command": "git.fetchPlan", "payload": payload - }) - .to_string(), - )) - .unwrap() - } + use serde_json::Value; + /// The arguments the executor passes to `git fetch` are the ones `git.fetchPlan` reports. #[test] - fn fetch_plans_match_shared_choices_without_accessing_a_repository() { - for case in fixture()["cases"].as_array().unwrap() { - let response = request(json!({"options": case["options"]})); - assert_eq!(response["ok"], true, "{response}"); - assert_eq!(response["data"]["options"], case["expectedOptions"]); - assert_eq!(response["data"]["arguments"], case["arguments"]); + fn fetch_arguments_match_the_shared_fixture() { + let fixture: Value = + serde_json::from_str(include_str!("../../../fixtures/git/fetch-plan-v1.json")).unwrap(); + for case in fixture["cases"].as_array().unwrap() { let options = serde_json::from_value(case["expectedOptions"].clone()).unwrap(); assert_eq!( serde_json::to_value(super::arguments(&options).unwrap()).unwrap(), @@ -201,38 +186,4 @@ mod tests { ); } } - - #[test] - fn fetch_rejects_sources_and_arguments_in_the_remote_name_field() { - for remote in fixture()["invalidRemotes"].as_array().unwrap() { - let response = request(json!({"options": {"remote": remote}})); - assert_eq!( - response["error"]["code"], "invalid_request", - "{remote}: {response}" - ); - } - } - - #[test] - fn fetch_rejects_unknown_settings_instead_of_silently_ignoring_them() { - for options in [ - json!({"prune": "false"}), - json!({"submodules": "sometimes"}), - json!({"arguments": ["--force"]}), - json!({"credentialHelper": ""}), - ] { - assert_eq!( - request(json!({"options": options}))["error"]["code"], - "invalid_request" - ); - } - // Misplaced options must fail before repository lookup or any mutation. - let response: Value = serde_json::from_str(&crate::execute_json(&json!({ - "command": "git.write", "payload": {"root": "", "operation": "commit", "fetchOptions": {}} - }).to_string())).unwrap(); - assert_eq!( - response["error"]["message"], - "Fetch options require a fetch operation" - ); - } } diff --git a/rust/crates/core/src/git/fetch_execution.rs b/rust/crates/git/src/fetch_execution.rs similarity index 98% rename from rust/crates/core/src/git/fetch_execution.rs rename to rust/crates/git/src/fetch_execution.rs index 7b8740ba..caa1d461 100644 --- a/rust/crates/core/src/git/fetch_execution.rs +++ b/rust/crates/git/src/fetch_execution.rs @@ -3,7 +3,7 @@ use super::{ capture_git_with_options, configuration, execute_git, execution_events, fetch, read_git_config_value, GitCommandResponse, }; -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde_json::json; use std::collections::BTreeMap; @@ -122,7 +122,7 @@ pub(super) fn execute( let mut last = None; let mut failures = Vec::new(); for remote in remotes { - crate::protocol::cancellation::check()?; + lithe_protocol::cancellation::check()?; options.remote = Some(remote.clone()); let before = references(root)?; let result = execute_git(root, &fetch::arguments(&options)?, None); diff --git a/rust/crates/core/src/git/file_context.rs b/rust/crates/git/src/file_context.rs similarity index 89% rename from rust/crates/core/src/git/file_context.rs rename to rust/crates/git/src/file_context.rs index a4211017..11a38b3e 100644 --- a/rust/crates/core/src/git/file_context.rs +++ b/rust/crates/git/src/file_context.rs @@ -1,19 +1,21 @@ //! Resolve a file to its actual Git checkout, independently of UI selection. -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use std::path::{Component, Path, PathBuf}; #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -pub(crate) struct Request { +/// A path, absolute or relative to `root`, to route to its owning checkout. +pub struct Request { root: String, path: String, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] -pub(crate) struct Context { +/// The checkout that owns a path, and the path relative to that checkout. +pub struct Context { root: String, path: String, ownership: &'static str, @@ -56,7 +58,7 @@ fn resolved_parent(path: &Path) -> Result { /// Resolve the deepest actual Git checkout and the file's path inside it. /// Missing directories use their nearest existing ancestor; discovery failures /// never silently fall back to the currently selected repository. -pub(crate) fn resolve(request: Request) -> Result, CoreError> { +pub fn resolve(request: Request) -> Result, CoreError> { if request.path.is_empty() || request.path.contains('\0') { return Err(CoreError::new( ErrorCode::InvalidRequest, @@ -225,9 +227,9 @@ mod tests { let repos = Repositories::new(); let first = repos.root("first"); let second = repos.root("second"); - symlink(&repos.0.join("second"), repos.0.join("first/linkdir")).unwrap(); + symlink(repos.0.join("second"), repos.0.join("first/linkdir")).unwrap(); symlink( - &repos.0.join("second/target"), + repos.0.join("second/target"), repos.0.join("first/linkfile"), ) .unwrap(); @@ -246,25 +248,13 @@ mod tests { } #[test] - fn rejects_ambiguous_parent_segments_and_routes_json() { + fn rejects_ambiguous_parent_segments() { let repos = Repositories::new(); let root = repos.root("first"); assert!(resolve(Request { - root: root.clone(), + root, path: "missing/../file".into() }) .is_err()); - let response: serde_json::Value = serde_json::from_str(&crate::execute_json( - &serde_json::json!({ - "id":"resolve", "command":"git.resolveFileContext", - "payload":{"root":root,"path":"nested/file.txt"} - }) - .to_string(), - )) - .unwrap(); - assert_eq!(response["ok"], true, "{response}"); - assert_eq!(response["data"]["root"], repos.root("first/nested")); - assert_eq!(response["data"]["path"], "file.txt"); - assert_eq!(response["data"]["ownership"], "git"); } } diff --git a/rust/crates/core/src/git/github_checkout.rs b/rust/crates/git/src/github_checkout.rs similarity index 97% rename from rust/crates/core/src/git/github_checkout.rs rename to rust/crates/git/src/github_checkout.rs index 947b100b..c569f564 100644 --- a/rust/crates/core/src/git/github_checkout.rs +++ b/rust/crates/git/src/github_checkout.rs @@ -16,9 +16,9 @@ static ORIGIN_FINGERPRINT_SALT: OnceLock<[u8; 32]> = OnceLock::new(); #[serde(rename_all = "camelCase")] /// Pull-request identity for a repository-local checkout preview. pub struct PullRequestCheckoutPreviewRequest { - pub root: String, - pub number: u64, - pub head_ref: String, + pub(crate) root: String, + pub(crate) number: u64, + pub(crate) head_ref: String, /// Repository whose pull request was displayed; absent only for generic Core callers. pub expected_repository: Option, } @@ -27,11 +27,11 @@ pub struct PullRequestCheckoutPreviewRequest { #[serde(rename_all = "camelCase")] /// The exact checkout identity reviewed by the user. pub struct PullRequestCheckoutRequest { - pub root: String, - pub number: u64, - pub head_ref: String, - pub expected_head: String, - pub expected_branch: Option, + pub(crate) root: String, + pub(crate) number: u64, + pub(crate) head_ref: String, + pub(crate) expected_head: String, + pub(crate) expected_branch: Option, /// Opaque digest of the effective origin fetch URL, never the URL itself. pub expected_origin_fingerprint: String, /// Repository whose pull request was displayed, if known by the caller. diff --git a/rust/crates/core/src/git/graph.rs b/rust/crates/git/src/graph.rs similarity index 99% rename from rust/crates/core/src/git/graph.rs rename to rust/crates/git/src/graph.rs index ba15a983..879b6681 100644 --- a/rust/crates/core/src/git/graph.rs +++ b/rust/crates/git/src/graph.rs @@ -22,8 +22,8 @@ //! `NaturalComparator`), Apache-2.0, Copyright 2000-2024 JetBrains s.r.o. and //! contributors. See `macos/Resources/GitGraph/NOTICE.txt`. -use crate::project::file_name_order::compare_reference_names; -use crate::protocol::{CoreError, ErrorCode}; +use lithe_core_rules::file_names::compare_reference_names; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::cmp::Ordering; @@ -239,7 +239,7 @@ pub struct GraphProjection { /// scoped `commits`, optional `repositoryCommits` that fix the permanent layout, /// optional `visibleHashes` for a filtered graph, `remoteNames`, `displayMode` /// (`compact` or `expanded`), and an optional `currentBranch`. -pub(crate) fn command(payload: Value) -> Result { +pub fn command(payload: Value) -> Result { let request: Request = serde_json::from_value(payload).map_err(|error| { CoreError::new(ErrorCode::InvalidRequest, "Invalid Git graph request") .with_details(error.to_string()) diff --git a/rust/crates/core/src/git/graph_tests.rs b/rust/crates/git/src/graph_tests.rs similarity index 92% rename from rust/crates/core/src/git/graph_tests.rs rename to rust/crates/git/src/graph_tests.rs index b5d47287..268e9a4e 100644 --- a/rust/crates/core/src/git/graph_tests.rs +++ b/rust/crates/git/src/graph_tests.rs @@ -1,8 +1,8 @@ -//! Regressions for `git.graphProjection`: the shared fixture, pinned IntelliJ oracles, -//! and deterministic scale checks. +//! Regressions for `git.graphProjection`: pinned IntelliJ oracles, request bounds, +//! and deterministic scale checks. `lithe-core` checks the shared fixture. use super::*; -use serde_json::{json, Value}; +use serde_json::json; use std::collections::BTreeSet; use std::fs; use std::path::PathBuf; @@ -47,13 +47,6 @@ fn filtered(commits: Vec, visible: &[&str]) -> GraphProjection { project(&request, COMPACT) } -fn call(payload: Value) -> Value { - serde_json::from_str(&crate::execute_json( - &json!({"id":"graph", "command":"git.graphProjection", "payload":payload}).to_string(), - )) - .unwrap() -} - fn root_path(relative: &str) -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../..") @@ -64,13 +57,6 @@ fn read_fixture(relative: &str) -> String { fs::read_to_string(root_path(relative)).unwrap_or_else(|error| panic!("{relative}: {error}")) } -fn shared_fixture() -> Value { - serde_json::from_str(include_str!( - "../../../../fixtures/git/graph-projection-v1.json" - )) - .unwrap() -} - /// Every non-terminal half-edge must meet the matching half in the neighbouring row /// with its identity, positions, color, and style intact. fn assert_continuity(projection: &GraphProjection) { @@ -118,63 +104,14 @@ fn parent_hashes(row: &GraphRow) -> Vec<&str> { .collect() } -// MARK: - shared fixture - -#[test] -fn shared_fixture_projects_the_exact_response() { - let fixture = shared_fixture(); - let cases = fixture["cases"].as_array().unwrap(); - assert!(cases.len() >= 20, "the fixture must keep its scenarios"); - for case in cases { - let response = call(case["request"].clone()); - assert_eq!(response["ok"], true, "{}: {response}", case["name"]); - assert_eq!(response["data"], case["response"], "{}", case["name"]); - } -} - -#[test] -fn shared_fixture_keeps_a_named_branch_color_across_pages() { - let fixture = shared_fixture(); - let cases = fixture["cases"].as_array().unwrap(); - for sequence in fixture["stability"].as_array().unwrap() { - let expected = &sequence["expectedColorId"]; - assert!(!expected.is_null()); - for step in sequence["steps"].as_array().unwrap() { - let case = cases - .iter() - .find(|case| case["name"] == step["case"]) - .unwrap(); - let response = call(case["request"].clone()); - let row = response["data"]["rows"] - .as_array() - .unwrap() - .iter() - .find(|row| row["commitHash"] == step["hash"]) - .unwrap(); - assert_eq!( - &row["colorId"], expected, - "{} / {}", - sequence["name"], step["case"] - ); - } - } -} +// MARK: - request bounds #[test] -fn shared_fixture_rejects_invalid_requests() { - for case in shared_fixture()["invalid"].as_array().unwrap() { - let response = call(case["request"].clone()); - assert_eq!(response["ok"], false, "{}", case["name"]); - assert_eq!( - response["error"]["code"], "invalid_request", - "{}", - case["name"] - ); - } +fn rejects_more_commits_than_one_projection_holds() { let too_many = (0..=MAX_COMMITS) .map(|row| json!({"hash": row.to_string()})) .collect::>(); - assert_eq!(call(json!({"commits": too_many}))["ok"], false); + assert!(command(json!({"commits": too_many})).is_err()); } // MARK: - IntelliJ oracles diff --git a/rust/crates/core/src/git/history.rs b/rust/crates/git/src/history.rs similarity index 96% rename from rust/crates/core/src/git/history.rs rename to rust/crates/git/src/history.rs index 866d59e0..3db756d4 100644 --- a/rust/crates/core/src/git/history.rs +++ b/rust/crates/git/src/history.rs @@ -5,10 +5,11 @@ use super::{ parse_reference, readonly_command, validate_root, GitCommandRequest, GIT_COMMIT_DATE_ARGUMENT, INTERNAL_REF_PREFIX, }; -use crate::protocol::{ - cancellation, CoreError, ErrorCode, GitCommitResponse, GitHistoryCursorCloseResponse, - GitHistoryPageResponse, GitHistoryResponse, GitReferenceResponse, GitReferencesResponse, +use crate::contracts::{ + GitCommitResponse, GitHistoryCursorCloseResponse, GitHistoryPageResponse, GitHistoryResponse, + GitReferenceResponse, GitReferencesResponse, }; +use lithe_protocol::{cancellation, CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use std::io::{BufRead, BufReader, Read}; use std::process::{Child, ChildStderr, ChildStdout, ExitStatus, Stdio}; @@ -45,38 +46,38 @@ static NEXT_HISTORY_CURSOR: AtomicU64 = AtomicU64::new(1); #[serde(rename_all = "camelCase")] /// Backward-compatible request for references and the first bounded history page. pub struct GitHistoryRequest { - pub root: String, + pub(crate) root: String, #[serde(default)] - pub reference: Option, + pub(crate) reference: Option, #[serde(default = "default_history_limit")] - pub limit: usize, + pub(crate) limit: usize, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] /// Request for repository references and identity metadata. pub struct GitReferencesRequest { - pub root: String, + pub(crate) root: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] /// Request for one bounded page of commit history from an optional reference. pub struct GitHistoryPageRequest { - pub root: String, + pub(crate) root: String, #[serde(default)] - pub reference: Option, + pub(crate) reference: Option, /// Traversal order is fixed for the lifetime of a cursor. Omission keeps /// existing clients on topology order; macOS opts into IDEA's date order. #[serde(default)] pub order: HistoryOrder, #[serde(default)] - pub cursor: Option, + pub(crate) cursor: Option, /// Deprecated compatibility field; new callers omit it and continue with `cursor`. #[serde(default)] pub offset: Option, #[serde(default = "default_history_limit")] - pub limit: usize, + pub(crate) limit: usize, } /// Git-native ordering policies that both preserve child-before-parent order. @@ -103,17 +104,17 @@ impl HistoryOrder { #[serde(rename_all = "camelCase")] /// Request to release an incremental history cursor that will not be consumed further. pub struct GitHistoryCursorCloseRequest { - pub root: String, - pub cursor: String, + pub(crate) root: String, + pub(crate) cursor: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] /// Request to locate one commit while appending bounded pages to a loaded window. pub struct GitHistoryLocateRequest { - pub root: String, + pub(crate) root: String, /// Full reference to traverse; omission means all references, like - /// [`GitHistoryPageRequest::reference`]. + /// `git.historyPage`. #[serde(default)] pub reference: Option, /// Traversal order bound to the cursor; omission keeps topology order. @@ -179,7 +180,7 @@ pub fn history(request: GitHistoryRequest) -> Result Result) { cursor, }) { // The failed page already ended its stream; report cleanup only. - crate::diagnostics::core_events::core_failed( - "vcs.history.cleanup_failed", - "cursor", - &error, - ); + lithe_diagnostics::core_events::core_failed("vcs.history.cleanup_failed", "cursor", &error); } } @@ -637,7 +634,7 @@ pub fn close_history_cursor( .is_some_and(|session| { session.root != root || session.host_key - != crate::remote::workspace::identity_key(std::path::Path::new(&root)) + != lithe_workspace::identity_key(std::path::Path::new(&root)) }) { return Err(invalid_history_cursor(&request.cursor)); @@ -755,7 +752,7 @@ impl HistorySession { "git-history-cursor-{}", NEXT_HISTORY_CURSOR.fetch_add(1, Ordering::Relaxed) ), - host_key: crate::remote::workspace::identity_key(std::path::Path::new(&root)), + host_key: lithe_workspace::identity_key(std::path::Path::new(&root)), root, reference, order, @@ -786,7 +783,7 @@ impl HistorySession { let mut bytes = Vec::new(); if let Err(error) = stderr.read_to_end(&mut bytes) { // One reader completion, not per chunk: WARN explains partial diagnostics. - crate::diagnostics::core_events::recovery_failed( + lithe_diagnostics::core_events::recovery_failed( "vcs.history.read_failed", "stderr", &format!("{:?}", error.kind()), @@ -915,7 +912,7 @@ impl HistorySession { if let Err(error) = child.kill() { // An already reaped child is expected; other cleanup failures are distinct. if !matches!(child.try_wait(), Ok(Some(_))) { - crate::diagnostics::core_events::recovery_failed( + lithe_diagnostics::core_events::recovery_failed( "vcs.history.cleanup_failed", "kill", &format!("{:?}", error.kind()), @@ -943,7 +940,7 @@ impl HistorySession { }; if let Some(reader) = self.stdout_reader.take() { if reader.join().is_err() { - crate::diagnostics::core_events::recovery_failed( + lithe_diagnostics::core_events::recovery_failed( "vcs.history.cleanup_failed", "stdout-reader", "reader_panicked", @@ -957,7 +954,7 @@ impl HistorySession { .and_then(|reader| match reader.join() { Ok(bytes) => Some(bytes), Err(_) => { - crate::diagnostics::core_events::recovery_failed( + lithe_diagnostics::core_events::recovery_failed( "vcs.history.cleanup_failed", "stderr-reader", "reader_panicked", @@ -985,7 +982,7 @@ impl HistorySession { fn stop(mut self) { if let Err(error) = self.finish_process(true) { // This owner stops the cursor without failing the completed page. - crate::diagnostics::core_events::core_failed( + lithe_diagnostics::core_events::core_failed( "vcs.history.cleanup_failed", "wait", &error, @@ -998,7 +995,7 @@ impl Drop for HistorySession { fn drop(&mut self) { if let Err(error) = self.finish_process(true) { // This owner stops the cursor without failing the completed page. - crate::diagnostics::core_events::core_failed( + lithe_diagnostics::core_events::core_failed( "vcs.history.cleanup_failed", "wait", &error, @@ -1325,7 +1322,7 @@ mod tests { reserve_history_session_slot_from, take_expired_history_sessions, HistorySession, HistorySessionRegistry, MAX_HISTORY_SESSIONS, }; - use crate::protocol::ErrorCode; + use lithe_protocol::ErrorCode; use std::time::{Duration, Instant}; #[test] @@ -1361,8 +1358,8 @@ mod tests { // Plumbing commands have no hooks, signing, or child helpers. Use the // production cancellation watchdog with a local deadline for each one. let git = |arguments: &[&str], environment: &[(String, String)]| { - let _deadline = crate::protocol::cancellation::Scope::begin(None, Some(3_000)); - let response = crate::git::execute_git_with_environment( + let _deadline = lithe_protocol::cancellation::Scope::begin(None, Some(3_000)); + let response = crate::execute_git_with_environment( &root, &arguments.iter().map(|s| s.to_string()).collect::>(), None, @@ -1413,14 +1410,8 @@ mod tests { let page = |mut payload: Value| -> Value { payload["root"] = json!(root); - serde_json::from_str(&crate::execute_json( - &json!({ - "id": "date-order-regression", "command": "git.historyPage", - "timeoutMilliseconds": 3_000, "payload": payload - }) - .to_string(), - )) - .expect("JSON response") + let _deadline = lithe_protocol::cancellation::Scope::begin(None, Some(3_000)); + crate::test_envelope(crate::test_request(payload).and_then(super::history_page)) }; let subjects = |response: &Value| -> Vec { assert_eq!(response["ok"], true, "{response}"); @@ -1432,7 +1423,7 @@ mod tests { .collect() }; let request: Value = serde_json::from_str(include_str!( - "../../../../fixtures/git/history-page-date-request-v1.json" + "../../../fixtures/git/history-page-date-request-v1.json" )) .expect("portable date-order request"); let first = page(request.clone()); @@ -1477,10 +1468,9 @@ mod tests { use serde_json::Value; use std::collections::BTreeSet; - let fixture: Value = serde_json::from_str(include_str!( - "../../../../fixtures/git/history-locate-v1.json" - )) - .expect("history locate fixture should be valid JSON"); + let fixture: Value = + serde_json::from_str(include_str!("../../../fixtures/git/history-locate-v1.json")) + .expect("history locate fixture should be valid JSON"); let minimal: super::GitHistoryLocateRequest = serde_json::from_value(fixture["minimalRequest"].clone()) diff --git a/rust/crates/git/src/history_edits.rs b/rust/crates/git/src/history_edits.rs new file mode 100644 index 00000000..1df1e490 --- /dev/null +++ b/rust/crates/git/src/history_edits.rs @@ -0,0 +1,431 @@ +//! Commit-list edits behind the history actions: edited, deleted and squashed commits +//! replayed onto a new head. + +use super::*; + +/// Immutable commit fields needed to rebuild a linear history without invoking +/// an editor or losing author and committer attribution. +#[derive(Clone)] +pub(crate) struct RewriteCommit { + /// Original commit object used to derive the patch during history replay. + hash: String, + tree: String, + pub(crate) parents: Vec, + author_name: String, + author_email: String, + author_date: String, + committer_name: String, + committer_email: String, + committer_date: String, + pub(crate) message: String, +} + +/// Current local branch context and its first-parent chain, ordered from HEAD +/// toward the root commit. +pub(crate) struct HistoryRewriteContext { + pub(crate) branch_reference: String, + pub(crate) original_head: String, + pub(crate) first_parent_chain: Vec, + pub(crate) published_commits: HashSet, +} + +pub(crate) fn edited_history_head( + root: &str, + context: &HistoryRewriteContext, + revision: &str, + message: &str, +) -> Result { + let target = resolve_commit_revision(root, revision)?; + let target_index = history_commit_index(context, &target)?; + let commits = checked_rewrite_range(root, context, target_index)?; + let mut parent = commits[0].parents.first().cloned(); + + for (index, commit) in commits.iter().enumerate() { + let commit_message = if index == 0 { message } else { &commit.message }; + parent = Some(write_commit_tree( + root, + commit, + parent.as_deref(), + commit_message, + )?); + } + + Ok(parent.expect("rewrite range contains a commit")) +} + +pub(crate) fn deleted_history_head( + root: &str, + context: &HistoryRewriteContext, + revision: &str, +) -> Result { + let target = resolve_commit_revision(root, revision)?; + let target_index = history_commit_index(context, &target)?; + let commits = checked_rewrite_range(root, context, target_index)?; + let target_commit = &commits[0]; + let parent = target_commit.parents.first().ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "The root commit cannot be deleted", + ) + })?; + + let mut rewritten_head = parent.clone(); + for commit in commits.iter().skip(1) { + let mut replayed_commit = commit.clone(); + replayed_commit.tree = replay_commit_tree(root, commit, &rewritten_head)?; + rewritten_head = write_commit_tree( + root, + &replayed_commit, + Some(&rewritten_head), + &commit.message, + )?; + } + + Ok(rewritten_head) +} + +pub(crate) fn squashed_history_head( + root: &str, + context: &HistoryRewriteContext, + revisions: &[String], + message: &str, +) -> Result { + if revisions.len() < 2 { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Select at least two commits to squash", + )); + } + + let mut selected = Vec::with_capacity(revisions.len()); + for revision in revisions { + validate_revision(revision)?; + selected.push(resolve_commit_revision(root, revision)?); + } + selected.sort(); + selected.dedup(); + if selected.len() != revisions.len() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Select distinct commits to squash", + )); + } + + let mut selected_indices = selected + .iter() + .map(|commit| history_commit_index(context, commit)) + .collect::, _>>()?; + selected_indices.sort_unstable(); + let newest_index = selected_indices[0]; + let oldest_index = *selected_indices + .last() + .expect("at least two commits were selected"); + if oldest_index - newest_index + 1 != selected_indices.len() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Only a contiguous range of commits can be squashed", + )); + } + + let commits = checked_rewrite_range(root, context, oldest_index)?; + let selected_count = oldest_index - newest_index + 1; + let oldest = &commits[0]; + let newest = &commits[selected_count - 1]; + let mut squashed = oldest.clone(); + squashed.tree.clone_from(&newest.tree); + squashed.committer_name.clone_from(&newest.committer_name); + squashed.committer_email.clone_from(&newest.committer_email); + squashed.committer_date.clone_from(&newest.committer_date); + + let mut parent = Some(write_commit_tree( + root, + &squashed, + oldest.parents.first().map(String::as_str), + message, + )?); + for commit in commits.iter().skip(selected_count) { + parent = Some(write_commit_tree( + root, + commit, + parent.as_deref(), + &commit.message, + )?); + } + + Ok(parent.expect("squash produces a commit")) +} + +pub(crate) fn resolve_commit_revision(root: &str, revision: &str) -> Result { + let response = execute_git_readonly( + root, + &[ + "rev-parse".into(), + "--verify".into(), + "--quiet".into(), + "--end-of-options".into(), + format!("{revision}^{{commit}}"), + ], + None, + )?; + let commit = response.output.trim(); + if response.exit_code != 0 || commit.is_empty() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The selected Git commit does not exist", + )); + } + Ok(commit.to_string()) +} + +fn history_commit_index(context: &HistoryRewriteContext, commit: &str) -> Result { + context + .first_parent_chain + .iter() + .position(|candidate| candidate == commit) + .ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "Only commits on the current branch first-parent history can be rewritten", + ) + }) +} + +fn checked_rewrite_range( + root: &str, + context: &HistoryRewriteContext, + oldest_index: usize, +) -> Result, CoreError> { + let hashes = &context.first_parent_chain[..=oldest_index]; + if hashes + .iter() + .any(|commit| context.published_commits.contains(commit)) + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Commits published to a remote cannot be rewritten", + )); + } + + let mut commits = hashes + .iter() + .rev() + .map(|hash| read_rewrite_commit(root, hash)) + .collect::, _>>()?; + if commits.iter().any(|commit| commit.parents.len() > 1) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "A history range containing merge commits cannot be rewritten", + )); + } + commits.shrink_to_fit(); + Ok(commits) +} + +pub(crate) fn read_rewrite_commit(root: &str, hash: &str) -> Result { + let format = "%T%x00%P%x00%an%x00%ae%x00%aI%x00%cn%x00%ce%x00%cI%x00%B%x00"; + let response = execute_git_readonly( + root, + &[ + "show".into(), + "--no-patch".into(), + "--no-show-signature".into(), + format!("--format={format}"), + hash.to_string(), + ], + None, + )?; + if response.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not read a commit selected for history rewrite", + ) + .with_details(response.output)); + } + + let output = response + .output + .strip_suffix("\0\n") + .or_else(|| response.output.strip_suffix('\0')) + .unwrap_or(&response.output); + let fields = output.splitn(9, '\0').collect::>(); + if fields.len() != 9 { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Could not decode a commit selected for history rewrite", + )); + } + Ok(RewriteCommit { + hash: hash.to_string(), + tree: fields[0].to_string(), + parents: fields[1].split_whitespace().map(String::from).collect(), + author_name: fields[2].to_string(), + author_email: fields[3].to_string(), + author_date: fields[4].to_string(), + committer_name: fields[5].to_string(), + committer_email: fields[6].to_string(), + committer_date: fields[7].to_string(), + message: fields[8].to_string(), + }) +} + +fn replay_commit_tree( + root: &str, + commit: &RewriteCommit, + rewritten_parent: &str, +) -> Result { + let original_parent = commit.parents.first().ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "A root commit cannot be replayed after deleting its parent", + ) + })?; + let patch_arguments = vec![ + "diff-tree".to_string(), + "--binary".to_string(), + "--full-index".to_string(), + "--no-ext-diff".to_string(), + "--no-renames".to_string(), + "--no-commit-id".to_string(), + "-p".to_string(), + original_parent.clone(), + commit.hash.clone(), + ]; + let patch = execute_git_readonly(root, &patch_arguments, None)?; + if patch.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not read changes after the deleted commit", + ) + .with_details(patch.output)); + } + + let common_directory = git_resolved_path( + root, + &["rev-parse", "--path-format=absolute", "--git-common-dir"], + "Git common directory", + )?; + let temporary_context = TemporaryGitCommitContext::prepare(root, Some(rewritten_parent))?; + let environment = temporary_context.environment(root, &common_directory); + let initialized = execute_git_with_environment( + root, + &["read-tree".to_string(), rewritten_parent.to_string()], + None, + false, + &environment, + )?; + if initialized.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not initialize history replay", + ) + .with_details(initialized.output)); + } + + if !patch.stdout.is_empty() { + let applied = execute_git_with_environment( + root, + &[ + "apply".to_string(), + "--cached".to_string(), + "--whitespace=nowarn".to_string(), + "-".to_string(), + ], + Some(patch.stdout), + false, + &environment, + )?; + if applied.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not replay changes after the deleted commit", + ) + .with_details(applied.output)); + } + } + + let tree = + execute_git_with_environment(root, &["write-tree".to_string()], None, false, &environment)?; + if tree.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not write replayed Git tree", + ) + .with_details(tree.output)); + } + let tree_hash = tree.stdout.trim(); + if tree_hash.is_empty() { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Git did not return a replayed tree identifier", + )); + } + Ok(tree_hash.to_string()) +} + +fn write_commit_tree( + root: &str, + commit: &RewriteCommit, + parent: Option<&str>, + message: &str, +) -> Result { + let mut arguments = vec![ + "-c".into(), + "commit.gpgSign=false".into(), + "commit-tree".into(), + commit.tree.clone(), + ]; + if let Some(parent) = parent { + arguments.extend(["-p".into(), parent.to_string()]); + } + arguments.extend(["-F".into(), "-".into()]); + let environment = vec![ + ("GIT_AUTHOR_NAME".into(), commit.author_name.clone()), + ("GIT_AUTHOR_EMAIL".into(), commit.author_email.clone()), + ("GIT_AUTHOR_DATE".into(), commit.author_date.clone()), + ("GIT_COMMITTER_NAME".into(), commit.committer_name.clone()), + ("GIT_COMMITTER_EMAIL".into(), commit.committer_email.clone()), + ("GIT_COMMITTER_DATE".into(), commit.committer_date.clone()), + ]; + let output = capture_git_with_environment( + root, + &arguments, + Some(message.to_string()), + false, + &environment, + )?; + if output.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not rebuild Git commit history", + ) + .with_details(output.into_command_response(&arguments).output)); + } + let hash = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if hash.is_empty() { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Git did not return a rebuilt commit identifier", + )); + } + Ok(hash) +} + +pub(crate) fn update_history_reference( + root: &str, + context: &HistoryRewriteContext, + new_head: &str, + reflog_message: &str, +) -> Result { + execute_git( + root, + &[ + "update-ref".into(), + "-m".into(), + reflog_message.into(), + context.branch_reference.clone(), + new_head.into(), + context.original_head.clone(), + ], + None, + ) +} diff --git a/rust/crates/core/src/git/history_query.rs b/rust/crates/git/src/history_query.rs similarity index 93% rename from rust/crates/core/src/git/history_query.rs rename to rust/crates/git/src/history_query.rs index e5853a8f..4c7a1327 100644 --- a/rust/crates/core/src/git/history_query.rs +++ b/rust/crates/git/src/history_query.rs @@ -1,7 +1,7 @@ //! Shared commit-date conversion and bounded history date filtering. -use crate::protocol::{CoreError, ErrorCode}; use chrono::{DateTime, FixedOffset, NaiveDateTime, TimeZone}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::Deserialize; use serde_json::{json, Value}; use std::collections::HashSet; @@ -45,7 +45,8 @@ pub(super) fn commit_timestamp(date: &str, offset: Option) -> Option { .map(|date| date.timestamp()) } -pub(crate) fn query_command(payload: Value) -> Result { +/// Handles `git.historyQuery`: filters commit dates against explicit absolute bounds. +pub fn query_command(payload: Value) -> Result { let request: Request = serde_json::from_value(payload).map_err(|_| invalid())?; if request.entries.len() > MAX_ENTRIES || request diff --git a/rust/crates/core/src/git/history_window.rs b/rust/crates/git/src/history_window.rs similarity index 83% rename from rust/crates/core/src/git/history_window.rs rename to rust/crates/git/src/history_window.rs index 5d8b5976..6a534240 100644 --- a/rust/crates/core/src/git/history_window.rs +++ b/rust/crates/git/src/history_window.rs @@ -6,7 +6,7 @@ //! anchor when those commits are still loaded. Hosts own the commits, cursors, //! and pixel scroll anchors; they send commit identities and apply the plan. -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::{HashMap, HashSet}; @@ -92,7 +92,7 @@ fn invalid(message: &str) -> CoreError { } /// Handles `git.historyWindow`. -pub(crate) fn command(payload: Value) -> Result { +pub fn command(payload: Value) -> Result { let request: Request = serde_json::from_value(payload).map_err(|error| { invalid("Invalid Git history window request").with_details(error.to_string()) })?; @@ -229,46 +229,3 @@ fn reconcile( anchor, } } - -#[cfg(test)] -mod tests { - use serde_json::{json, Value}; - - fn call(payload: Value) -> Value { - serde_json::from_str(&crate::execute_json( - &json!({"id":"window", "command":"git.historyWindow", "payload":payload}).to_string(), - )) - .unwrap() - } - - #[test] - fn shared_fixture_windows_keep_depth_and_selection() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../../fixtures/git/history-window-v1.json" - )) - .unwrap(); - for case in fixture["cases"].as_array().unwrap() { - let response = call(case["request"].clone()); - assert_eq!(response["ok"], true, "{}: {response}", case["name"]); - assert_eq!(response["data"], case["response"], "{}", case["name"]); - } - for case in fixture["invalid"].as_array().unwrap() { - assert_eq!( - call(case["request"].clone())["ok"], - false, - "{}", - case["name"] - ); - } - } - - #[test] - fn merge_never_grows_past_the_window_bound() { - let window = (0..4_999) - .map(|index| format!("w{index}")) - .collect::>(); - let response = call(json!({"operation":"mergePage", "window":window, - "page":["a","b","c"], "pageHasMore":true})); - assert_eq!(response["data"], json!({"append":[0], "hasMore":false})); - } -} diff --git a/rust/crates/git/src/ignore.rs b/rust/crates/git/src/ignore.rs new file mode 100644 index 00000000..949ca4e8 --- /dev/null +++ b/rust/crates/git/src/ignore.rs @@ -0,0 +1,107 @@ +//! Appending patterns to `.gitignore` and `.git/info/exclude`. + +use super::*; + +/// Chooses whether ignore patterns belong to the shared repository file or the +/// current checkout's local Git metadata. +pub(crate) enum GitIgnoreTarget { + Repository, + LocalExclude, +} + +pub(crate) fn append_git_ignore_patterns( + root: &str, + paths: &[String], + target: GitIgnoreTarget, +) -> Result { + let patterns = git_ignore_patterns(paths)?; + let target_path = match target { + GitIgnoreTarget::Repository => repository_root(root)?.join(".gitignore"), + GitIgnoreTarget::LocalExclude => git_path(root, "info/exclude")?, + }; + let existing = read_git_ignore_bytes(&target_path)?; + let existing_text = String::from_utf8_lossy(&existing); + let additions = patterns + .into_iter() + .filter(|pattern| !existing_text.lines().any(|line| line == pattern)) + .collect::>(); + if additions.is_empty() { + return Ok(successful_git_result()); + } + + if let Some(parent) = target_path.parent() { + fs::create_dir_all(parent).map_err(|error| git_ignore_io_error("create", error))?; + } + let mut appended = String::new(); + if !existing.is_empty() && !existing.ends_with(b"\n") { + appended.push('\n'); + } + for pattern in additions { + appended.push_str(&pattern); + appended.push('\n'); + } + fs::append(&target_path, appended.as_bytes()) + .map_err(|error| git_ignore_io_error("write", error))?; + Ok(successful_git_result()) +} + +fn read_git_ignore_bytes(path: &Path) -> Result, CoreError> { + match fs::read(path) { + Ok(content) => Ok(content), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()), + Err(error) => Err(git_ignore_io_error("read", error)), + } +} + +fn git_ignore_patterns(paths: &[String]) -> Result, CoreError> { + let mut patterns = Vec::with_capacity(paths.len()); + for path in paths { + if path.contains(['\0', '\n', '\r']) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Git ignore operation contains an invalid path", + )); + } + let normalized = path.replace('\\', "/"); + let is_directory = normalized.ends_with('/'); + let normalized = normalized.trim_end_matches('/'); + if !is_safe_pathspec(normalized) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Git ignore operation contains an invalid path", + )); + } + + let mut escaped = String::with_capacity(normalized.len() + 2); + escaped.push('/'); + for character in normalized.chars() { + if matches!(character, '*' | '?' | '[' | ']' | '#' | '!' | ' ') { + escaped.push('\\'); + } + escaped.push(character); + } + if is_directory { + escaped.push('/'); + } + patterns.push(escaped); + } + patterns.sort(); + patterns.dedup(); + if patterns.is_empty() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Git ignore operation contains an invalid path", + )); + } + Ok(patterns) +} + +fn git_ignore_io_error(action: &str, error: std::io::Error) -> CoreError { + let code = if error.kind() == std::io::ErrorKind::PermissionDenied { + ErrorCode::PermissionDenied + } else { + ErrorCode::Unknown + }; + CoreError::new(code, format!("Could not {action} Git ignore file")) + .with_details(error.to_string()) +} diff --git a/rust/crates/git/src/lib.rs b/rust/crates/git/src/lib.rs new file mode 100644 index 00000000..47ededc7 --- /dev/null +++ b/rust/crates/git/src/lib.rs @@ -0,0 +1,222 @@ +//! Deterministic Git inspection and mutation behind the shared command contract. +//! +//! `lithe-core` routes every `git.*` command here. Repository access goes through +//! the request's workspace host (`lithe-workspace`), so the same code serves local +//! and remote workspaces; process tracing, cancellation and the console journal come +//! from `lithe-request-context` and `lithe-protocol`. [`contracts`] holds the +//! serialized responses, which `lithe-core` re-exports unchanged. + +// The initial projection/routing IR is deliberately not command- or host-facing +// until both native products can consume the same versioned contract. +mod authentication_failure; +mod branch_names; +pub mod catalog; +mod change_targets; +mod changelists; +mod command_trace; +mod commit_message_context; +mod commit_selection; +mod commit_state; +pub mod contracts; +mod deleted_selection; +mod diff_blocks; +pub mod workspace_commit; +pub mod workspace_status; +pub use commit_state::{inspect as commit_state, GitCommitGitlink, GitCommitState}; + +mod branches; +mod checkout; +mod commits; +pub mod configuration; +pub mod console; +mod diff; +pub(crate) mod execution_events; +pub mod execution_policy; +mod fetch; +mod fetch_execution; +pub mod file_context; +mod github_checkout; +mod graph; +mod history; +mod history_edits; +mod history_query; +mod history_window; +mod ignore; +mod line_history; +mod metadata_watch; +mod mutations; +mod parse; +mod patch_exchange; +mod preflight; +mod process; +mod progress; +mod push; +mod rebase_session; +mod reference_tree; +mod references; +mod remote_history; +mod remote_process; +mod repository; +mod repository_colors; +mod requests; +mod rewrite; +mod rollback; +mod setup; +mod shelf; +mod stash; +mod status; +mod untracked_directories; +mod update_plan; +mod worktrees; +mod write; + +// The response and reference types stay owned by the module; callers consume +// them through the command contract instead of naming more shared types. +pub use branch_names::{preview as branch_name_preview, GitBranchNamePreviewRequest}; +pub use fetch::{plan as fetch_plan, GitFetchOptions, GitFetchPlanRequest}; +pub use untracked_directories::{untracked_directory, GitUntrackedDirectoryRequest}; + +pub use setup::{ + configure_identity, initialize as initialize_repository, inspect as repository_setup, + GitConfigureIdentityRequest, GitSetupRequest, +}; + +pub use change_targets::command as normalize_change_targets_command; +pub use changelists::changelists_command; +pub use commit_message_context::commit_message_context_command; +pub use commit_selection::prepare_commit_selection_command; +pub use deleted_selection::reconcile_deleted_selection_command; +pub use diff_blocks::{ + apply_diff_block_command, plan_diff_blocks_command, stage_gutter_change_command, +}; +pub use github_checkout::{ + checkout as checkout_pull_request, preview as checkout_pull_request_preview, + PullRequestCheckoutPreviewRequest, PullRequestCheckoutRequest, +}; +pub use graph::command as graph_projection_command; +pub use history::{ + close_history_cursor, history, history_locate, history_page, references, + GitHistoryCursorCloseRequest, GitHistoryLocateRequest, GitHistoryPageRequest, + GitHistoryRequest, GitReferencesRequest, +}; +pub use history_query::query_command as history_query_command; +pub use history_window::command as history_window_command; +pub use metadata_watch::classify_command as metadata_classification_command; +pub use metadata_watch::{ + classify_git_metadata_change, GitMetadataChangeKind, GitMetadataEventClassification, + GitMetadataWatchContext, +}; +pub use patch_exchange::{ + apply_reviewed as apply_patch_reviewed, export as export_patch, preview as preview_patch, + PatchApplyRequest, PatchExportRequest, PatchPreviewRequest, +}; +pub use rebase_session::plan_preview_command as rebase_plan_preview_command; +pub use rebase_session::{ + control as rebase_control, preview as rebase_preview, session as rebase_session, + start as rebase_start, GitRebaseControlRequest, GitRebasePreviewRequest, + GitRebaseSessionRequest, GitRebaseStartRequest, +}; +pub use reference_tree::command as reference_tree_command; +pub use repository_colors::assign_command as repository_color_assignments_command; +pub use rollback::{rollback_plan_command, rollback_prepare_command}; +pub use shelf::shelf_command; +pub use update_plan::command as update_plan_command; + +/// Internals the `git.*` integration tests in `lithe-core` assert against. +#[cfg(feature = "test-support")] +pub mod test_support { + pub use crate::diff_blocks::fixture_support as diff_blocks; + pub use crate::untracked_directories::UNTRACKED_DIRECTORY_SUMMARY_THRESHOLD; +} + +pub use rewrite::history_action_capabilities_command; +pub use rewrite::{ + history_rewrite_preview, GitHistoryRewriteExpectation, GitHistoryRewritePreviewRequest, + GitHistoryRewriteResult, +}; + +use branches::*; +use checkout::*; +pub use commits::*; +pub use diff::*; +use history_edits::*; +use ignore::*; +pub use line_history::*; +use parse::*; +pub use preflight::*; +pub use process::*; +pub use push::*; +use references::*; +pub use repository::*; +pub use requests::*; +pub use stash::*; +pub use status::*; +pub use worktrees::*; +pub use write::*; + +use crate::contracts::{ + GitBlameLineResponse, GitBlameResponse, GitChange, GitCheckoutPreflightResponse, + GitCommitLookupResponse, GitCommitResponse, GitComparisonResponse, GitConflictMarkerResponse, + GitDiffFileResponse, GitDiffHunkResponse, GitDiffResponse, GitDiffRowResponse, + GitDiffUnifiedLineResponse, GitFileResponse, GitFilesResponse, GitIntegrationPreflightResponse, + GitLineHistoryCommit, GitLineHistoryRange, GitLineHistoryResponse, GitLineHistoryUnmappedLine, + GitOperationStateResponse, GitPullPreflightResponse, GitPushPreviewResponse, + GitPushTagResponse, GitPushTargetKind, GitReferenceResponse, GitStashResponse, + GitStashesResponse, GitStatusResponse, GitWatchContextResponse, GitWorktreeResponse, + GitWorktreesResponse, WorkspaceRepositoriesResponse, WorkspaceRepositoryResponse, +}; +use lithe_protocol::{CoreError, ErrorCode}; +use lithe_workspace::fs; +use serde::{Deserialize, Serialize}; +use std::cell::RefCell; +use std::collections::{HashSet, VecDeque}; +use std::io::Read; +#[cfg(target_os = "windows")] +use std::os::windows::process::CommandExt; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::thread; +use std::time::Duration; + +const DEFAULT_PUSH_PREVIEW_LIMIT: usize = 500; + +const INTERNAL_REF_PREFIX: &str = "refs/lithe/"; + +/// Commit date argument for every `parse_commit` producer. The trailing `%z` +/// carries the author's UTC offset, which `parse_commit` splits off so the +/// displayed `date` keeps its `%Y/%m/%d %H:%M` shape. +const GIT_COMMIT_DATE_ARGUMENT: &str = "--date=format:%Y/%m/%d %H:%M %z"; + +const DEFAULT_REPOSITORY_SCAN_MAX_DIRECTORIES: usize = usize::MAX; + +const DEFAULT_REPOSITORY_SCAN_MAX_DEPTH: usize = usize::MAX; + +const FULL_FILE_CONTEXT_LINES: usize = 1_073_741_823; + +static TEMPORARY_INDEX_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +static AUTO_STASH_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +/// Decodes a test payload the way `lithe-core` decodes a typed `git.*` request. +#[cfg(test)] +pub(crate) fn test_request( + payload: serde_json::Value, +) -> Result { + serde_json::from_value(payload).map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid Git request") + .with_details(error.to_string()) + }) +} + +/// The `ok`/`data`/`error` envelope `lithe-core` wraps around a `git.*` result. +#[cfg(test)] +pub(crate) fn test_envelope(result: Result) -> serde_json::Value { + match result { + Ok(data) => serde_json::json!({"ok": true, "data": data}), + Err(error) => serde_json::json!({"ok": false, "error": error}), + } +} + +#[cfg(test)] +mod tests; diff --git a/rust/crates/git/src/line_history.rs b/rust/crates/git/src/line_history.rs new file mode 100644 index 00000000..cbbd371f --- /dev/null +++ b/rust/crates/git/src/line_history.rs @@ -0,0 +1,759 @@ +//! Per-line authorship: `git.blame` and the line history that follows a range +//! through earlier revisions. + +use super::*; + +/// Largest editor snapshot accepted by `git.lineHistory`; larger documents must +/// use committed state instead of an unsaved buffer. +const LINE_HISTORY_MAX_SNAPSHOT_BYTES: usize = 8 * 1024 * 1024; + +/// Default entry limit for `git.lineHistory` before truncation is reported. +const LINE_HISTORY_DEFAULT_LIMIT: usize = 50; + +/// Hard ceiling for a client-requested `git.lineHistory` limit. +const LINE_HISTORY_MAX_LIMIT: usize = 200; + +/// Returns line attribution parsed from Git's machine-readable porcelain form. +pub fn blame(request: GitBlameRequest) -> Result { + if !is_safe_pathspec(&request.path) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Git blame contains an invalid path", + )); + } + let response = readonly_command(GitCommandRequest { + root: request.root, + arguments: vec![ + "blame".to_string(), + "--line-porcelain".to_string(), + "--".to_string(), + request.path, + ], + input: None, + })?; + if response.exit_code != 0 { + return Err(CoreError::new(ErrorCode::ProcessFailed, "Git blame failed") + .with_details(response.output)); + } + + let mut lines = Vec::new(); + let mut commit_hash = String::new(); + let mut author_name = "Unknown".to_string(); + let mut author_time = 0; + let mut final_line = 0; + for line in response.output.lines() { + let columns = line.split_whitespace().collect::>(); + if columns.len() >= 3 && columns[0].len() == 40 { + if let Ok(parsed_line) = columns[2].parse::() { + commit_hash = columns[0].to_string(); + final_line = parsed_line; + } + } else if let Some(value) = line.strip_prefix("author ") { + author_name = value.to_string(); + } else if let Some(value) = line.strip_prefix("author-time ") { + author_time = value.parse::().unwrap_or_default(); + } else if line.starts_with('\t') && final_line > 0 { + lines.push(GitBlameLineResponse { + line: final_line, + commit_hash: commit_hash.clone(), + author_name: author_name.clone(), + author_time, + }); + final_line += 1; + } + } + Ok(GitBlameResponse { lines }) +} + +/// Normalizes CRLF and bare CR newlines to LF. Line numbers are preserved, +/// so mapping stays valid while both comparison sides share one newline +/// semantics regardless of repository autocrlf settings. +fn normalize_line_history_newlines(content: &str) -> String { + if content.contains('\r') { + content.replace("\r\n", "\n").replace('\r', "\n") + } else { + content.to_string() + } +} + +/// Counts lines the way editors do: a trailing newline starts one final, +/// empty line that Rust's `lines()` does not yield. +fn line_history_line_count(content: &str) -> usize { + content.lines().count() + usize::from(content.ends_with('\n')) +} + +/// Removes one line-history scratch directory on every exit path. +struct ScopedLineHistoryDirectory(std::path::PathBuf); + +impl Drop for ScopedLineHistoryDirectory { + fn drop(&mut self) { + fs::cleanup_directory(&self.0); + } +} + +/// Creates an isolated scratch directory under the platform temp root. The +/// snapshot may hold private source text, so POSIX permissions are applied at +/// atomic creation exactly like the Git commit context directories. +fn create_line_history_scratch() -> Result { + if let Some(directory) = + fs::remote_temp_directory("lithe-line-history").map_err(line_history_io_error)? + { + return Ok(ScopedLineHistoryDirectory(directory)); + } + let prefix = format!("lithe-line-history-{}", std::process::id()); + let mut attempt = 0u128; + loop { + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|value| value.as_nanos()) + .unwrap_or_default() + + attempt; + let candidate = std::env::temp_dir().join(format!("{prefix}-{stamp}")); + let builder = std::fs::DirBuilder::new(); + #[cfg(unix)] + let builder = { + use std::os::unix::fs::DirBuilderExt; + let mut builder = builder; + builder.mode(0o700); + builder + }; + match builder.create(&candidate) { + Ok(()) => return Ok(ScopedLineHistoryDirectory(candidate)), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + attempt += 1; + continue; + } + Err(error) => { + return Err(CoreError::new( + ErrorCode::Unknown, + "Could not create a line-history scratch directory", + ) + .with_details(error.to_string())); + } + } + } +} + +pub(crate) fn line_history_io_error(error: std::io::Error) -> CoreError { + CoreError::new( + ErrorCode::Unknown, + "Could not stage the line-history snapshot", + ) + .with_details(error.to_string()) +} + +/// Parses one `@@ -a[,n] +b[,m] @@` header into raw hunk coordinates. A zero +/// count marks an empty side, whose start is the line count before the hunk. +fn parse_line_history_hunk_header(line: &str) -> Option<(usize, usize, usize, usize)> { + let rest = line.strip_prefix("@@ -")?; + let (old, rest) = rest.split_once(' ')?; + let rest = rest.strip_prefix('+')?; + let (new, _) = rest.split_once(" @@")?; + let parse = |text: &str| -> Option<(usize, usize)> { + match text.split_once(',') { + Some((start, count)) => Some((start.parse().ok()?, count.parse().ok()?)), + None => Some((text.parse().ok()?, 1)), + } + }; + let (old_start, old_count) = parse(old)?; + let (new_start, new_count) = parse(new)?; + Some((old_start, old_count, new_start, new_count)) +} + +fn parse_line_history_hunks(diff: &str) -> Vec<(usize, usize, usize, usize)> { + diff.lines() + .filter_map(parse_line_history_hunk_header) + .collect() +} + +/// Inclusive end of one hunk side; an empty side collapses to its position. +fn line_history_endpoint(start: usize, count: usize) -> usize { + if count == 0 { + start + } else { + start + count - 1 + } +} + +/// Maps snapshot lines onto pinned-revision lines through `-U0` hunks, using +/// the unified-format rule that a zero-count side starts between lines. +/// Returns matched `(snapshot_line, revision_line)` pairs plus unmapped lines +/// flagged as provably inserted (`true`) or unreliably rewritten (`false`). +/// Matched `(snapshot_line, revision_line)` pairs, and the unmapped snapshot +/// lines flagged as inserted (`true`) or rewritten (`false`). +type SnapshotLineMap = (Vec<(usize, usize)>, Vec<(usize, bool)>); + +fn map_snapshot_lines( + snapshot_line_count: usize, + hunks: &[(usize, usize, usize, usize)], +) -> SnapshotLineMap { + let mut mapped = Vec::new(); + let mut unmapped = Vec::new(); + let mut snapshot_line = 1usize; + let mut revision_line = 1usize; + for &(old_start, old_count, new_start, new_count) in hunks { + let first_new = if new_count == 0 { + new_start + 1 + } else { + new_start + }; + while snapshot_line < first_new && snapshot_line <= snapshot_line_count { + mapped.push((snapshot_line, revision_line)); + snapshot_line += 1; + revision_line += 1; + } + if new_count > 0 { + let replaced_end = new_start + new_count - 1; + while snapshot_line <= replaced_end && snapshot_line <= snapshot_line_count { + unmapped.push((snapshot_line, old_count == 0)); + snapshot_line += 1; + } + } + if old_count > 0 { + // Jump to the first revision line after the replaced block; pure + // insertions leave the incremental positions aligned already. + revision_line = old_start + old_count; + } + } + while snapshot_line <= snapshot_line_count { + mapped.push((snapshot_line, revision_line)); + snapshot_line += 1; + revision_line += 1; + } + (mapped, unmapped) +} + +/// Recovers merge relevance from the ordinary first-parent diff, keeping only +/// hunks that actually contain one of the traced line contents. Anchoring by +/// content survives commits that `-L` omitted because they shifted lines or +/// renamed the file after the merge. +/// Translates one set of commit-side line numbers onto the parent side of the +/// same commit through its `-U0` hunks. Lines inside a replacement map onto +/// the parent-side span; lines inserted by the commit disappear; untouched +/// lines shift by the hunk deltas. +fn line_history_translate_lines( + lines: &std::collections::BTreeSet, + hunks: &[(usize, usize, usize, usize)], +) -> std::collections::BTreeSet { + let mut translated = std::collections::BTreeSet::new(); + for &line in lines { + let mut handled = false; + let mut delta = 0i64; + for &(old_start, old_count, new_start, new_count) in hunks { + let new_end = new_start + new_count.saturating_sub(1); + if new_count > 0 && line >= new_start && line <= new_end { + if old_count > 0 { + // A rewrite maps the new line onto the whole parent span: + // when counts differ, the line may descend from any of the + // replaced lines (squashing three lines into one keeps the + // merge range of the middle line reachable), so relevance + // intersection stays sound while remaining a superset. + for old_line in old_start..=old_start + old_count - 1 { + translated.insert(old_line); + } + } + handled = true; + break; + } + // A zero-count new side marks the position after the boundary + // row: that row itself keeps its number, later rows shift. + let after_hunk = if new_count == 0 { + line > new_start + } else { + line >= new_start + new_count + }; + if after_hunk { + delta += old_count as i64 - new_count as i64; + } + } + if !handled { + let shifted = line as i64 + delta; + if shifted >= 1 { + translated.insert(shifted as usize); + } + } + } + translated +} + +/// The ranges a merge touched, the path at the merge, and the path it was +/// renamed from. +type MergeRanges = (Vec, String, Option); + +/// Recovers merge relevance by replaying the first-parent chain between the +/// pinned base and the merge's first parent. Text anchoring cannot disambiguate +/// duplicate or blank lines, so every touched commit's diff translates the +/// traced base intervals - and renames translate the path - into the merge's +/// coordinate system before its first-parent diff is filtered. +fn line_history_merge_ranges( + root: &str, + base_commit: &str, + base_intervals: &[(usize, usize)], + base_path: &str, + compare_base: &str, + hash: &str, +) -> Result, CoreError> { + let mut lines: std::collections::BTreeSet = std::collections::BTreeSet::new(); + for (start, end) in base_intervals { + for line in *start..=*end { + lines.insert(line); + } + } + let mut path = base_path.to_string(); + // Walk backwards from the pinned base towards the merge's first parent, + // restarting the listing whenever a rename changes the path. + let mut walk_end = base_commit.to_string(); + loop { + let listing = readonly_command(GitCommandRequest { + root: root.to_string(), + arguments: vec![ + "log".to_string(), + "--first-parent".to_string(), + "--format=%H".to_string(), + // Exclude the merge itself: only commits after it may shift + // coordinates, which must stay in the merge's own frame. + format!("{hash}..{walk_end}"), + "--".to_string(), + path.clone(), + ], + input: None, + })?; + if listing.exit_code != 0 { + return Ok(None); + } + let touched: Vec<&str> = listing + .output + .lines() + .filter(|line| !line.trim().is_empty()) + .collect(); + if touched.is_empty() { + break; + } + let mut renamed_at: Option = None; + for commit in &touched { + let parent = readonly_command(GitCommandRequest { + root: root.to_string(), + arguments: vec!["rev-parse".to_string(), format!("{commit}^")], + input: None, + }); + // Root commits compare against the empty tree, matching git.diff. + let base_revision = match parent { + Ok(response) if response.exit_code == 0 => response.output.trim().to_string(), + _ => empty_tree_oid(root)?.to_string(), + }; + // The diff runs without a pathspec: restricting it to the walked + // path would clip renames into synthetic "new file" patches and + // destroy both the rename pairing and the line translation. + let diff = readonly_command(GitCommandRequest { + root: root.to_string(), + arguments: vec![ + "diff".to_string(), + "--unified=0".to_string(), + "--no-ext-diff".to_string(), + "--no-textconv".to_string(), + "--find-renames".to_string(), + base_revision.clone(), + commit.to_string(), + ], + input: None, + })?; + if diff.exit_code != 0 { + continue; + } + let mut hunks: Vec<(usize, usize, usize, usize)> = Vec::new(); + let mut rename_target: Option = None; + // Collect hunks only from the segment whose new side is the + // walked path; other files in the commit are irrelevant here. + let mut segment_matches = false; + for line in diff.output.lines() { + if let Some(rest) = line.strip_prefix("diff --git a/") { + if let Some((old, new)) = rest.split_once(" b/") { + segment_matches = new == path; + if segment_matches && old != path { + rename_target = Some(old.to_string()); + } + } + continue; + } + if segment_matches { + if let Some(hunk) = parse_line_history_hunk_header(line) { + hunks.push(hunk); + } + } + } + lines = line_history_translate_lines(&lines, &hunks); + if let Some(old_name) = rename_target { + path = old_name; + // Advance the walk past the rename itself, or a back-and-forth + // rename (a -> b -> a) would rescan the same commits forever + // and run the query into its timeout. + walk_end = base_revision.clone(); + renamed_at = Some(base_revision); + break; + } + } + if renamed_at.is_none() { + break; + } + } + if lines.is_empty() { + return Ok(Some((Vec::new(), path, None))); + } + // The supplement diff runs without a pathspec, exactly like the replay: + // restricting it to the walked path would clip a rename committed by the + // merge itself into a synthetic whole-file addition and lose the + // before-content along with precise ranges. + let diff = readonly_command(GitCommandRequest { + root: root.to_string(), + arguments: vec![ + "diff".to_string(), + "--unified=0".to_string(), + "--no-ext-diff".to_string(), + "--no-textconv".to_string(), + "--find-renames".to_string(), + compare_base.to_string(), + hash.to_string(), + ], + input: None, + })?; + if diff.exit_code != 0 { + return Ok(None); + } + let mut ranges = Vec::new(); + let mut previous_path: Option = None; + let mut segment_matches = false; + for line in diff.output.lines() { + if let Some(rest) = line.strip_prefix("diff --git a/") { + if let Some((old, new)) = rest.split_once(" b/") { + segment_matches = new == path; + if segment_matches && old != path { + previous_path = Some(old.to_string()); + } + } + continue; + } + if !segment_matches { + continue; + } + if let Some((old_start, old_count, new_start, new_count)) = + parse_line_history_hunk_header(line) + { + let new_end = line_history_endpoint(new_start, new_count); + let intersects = lines + .iter() + .any(|line| *line >= new_start && *line <= new_end); + if intersects { + ranges.push(GitLineHistoryRange { + old_start, + old_end: line_history_endpoint(old_start, old_count), + new_start, + new_end, + }); + } + } + } + Ok(Some((ranges, path, previous_path))) +} + +/// Traces which commits touched one one-based line range of an editor +/// snapshot. The snapshot is mapped onto a pinned HEAD revision through an +/// isolated temp comparison, so unsaved edits stay answerable without ever +/// writing inside the workspace or repository metadata. +pub fn line_history(request: GitLineHistoryRequest) -> Result { + if !is_safe_pathspec(&request.path) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Git line history contains an invalid path", + )); + } + if request.start_line == 0 || request.end_line < request.start_line { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Git line history requires an ascending one-based line range", + )); + } + if request.content.len() > LINE_HISTORY_MAX_SNAPSHOT_BYTES { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Git line history snapshot exceeds the size budget", + ) + .with_details(format!("{} bytes", request.content.len()))); + } + let limit = request + .limit + .unwrap_or(LINE_HISTORY_DEFAULT_LIMIT) + .clamp(1, LINE_HISTORY_MAX_LIMIT); + let snapshot = normalize_line_history_newlines(&request.content); + // Range validation uses the editor's line count, where a trailing newline + // starts one final empty line; the mapping walk uses the physical line + // count because that final line has no counterpart in any diff. + let editor_line_count = line_history_line_count(&snapshot); + let snapshot_line_count = snapshot.lines().count(); + if request.end_line > editor_line_count.max(1) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Git line history range exceeds the snapshot", + )); + } + + // Pin one revision so the snapshot mapping and the history walk cannot + // observe different HEADs when a commit lands mid-query. + let base_commit = match readonly_command(GitCommandRequest { + root: request.root.clone(), + arguments: vec!["rev-parse".to_string(), "HEAD".to_string()], + input: None, + }) { + Ok(response) if response.exit_code == 0 => response.output.trim().to_string(), + // Only a repository without commits has no line history; every other + // failure (invalid root, timeout, cancellation) must surface. + Ok(response) if response.output.contains("unknown revision") => String::new(), + Ok(response) => { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Git line history could not resolve HEAD", + ) + .with_details(response.output)) + } + Err(error) => return Err(error), + }; + let unmapped_new_lines = || { + (request.start_line..=request.end_line) + .map(|line| GitLineHistoryUnmappedLine { + line, + kind: "new".to_string(), + }) + .collect::>() + }; + if base_commit.is_empty() { + // A repository without commits has no line history at all. + return Ok(GitLineHistoryResponse { + base_commit, + unmapped_lines: unmapped_new_lines(), + commits: Vec::new(), + truncated: false, + }); + } + + let head_content = readonly_command(GitCommandRequest { + root: request.root.clone(), + arguments: vec![ + "show".to_string(), + format!("{base_commit}:{}", request.path), + ], + input: None, + }); + let head_content = match head_content { + Ok(response) if response.exit_code == 0 => response.output, + // Only a path absent from the pinned revision counts as new; other + // failures must propagate rather than fabricate "new" lines. + Ok(response) if response.output.contains("does not exist") => { + return Ok(GitLineHistoryResponse { + base_commit, + unmapped_lines: unmapped_new_lines(), + commits: Vec::new(), + truncated: false, + }) + } + Ok(response) => { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Git line history could not read the pinned revision", + ) + .with_details(response.output)) + } + Err(error) => return Err(error), + }; + + let scratch = create_line_history_scratch()?; + let head_path = scratch.0.join("head.txt"); + let snapshot_path = scratch.0.join("snapshot.txt"); + let head_content = normalize_line_history_newlines(&head_content); + fs::write(&head_path, &head_content).map_err(line_history_io_error)?; + fs::write(&snapshot_path, &snapshot).map_err(line_history_io_error)?; + + // Exit code 1 only reports differences between the two scratch files; + // anything above that is a genuine failure. Driver overrides keep the + // hunk coordinates raw and deterministic on every machine. + let diff = readonly_command(GitCommandRequest { + root: request.root.clone(), + arguments: vec![ + "diff".to_string(), + "--no-index".to_string(), + "--unified=0".to_string(), + "--no-ext-diff".to_string(), + "--no-textconv".to_string(), + head_path.to_string_lossy().to_string(), + snapshot_path.to_string_lossy().to_string(), + ], + input: None, + })?; + if diff.exit_code > 1 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Git line history mapping failed") + .with_details(diff.output), + ); + } + let hunks = parse_line_history_hunks(&diff.output); + let (mapped, unmapped) = map_snapshot_lines(snapshot_line_count, &hunks); + + let requested = request.start_line..=request.end_line; + let mut intervals: Vec<(usize, usize)> = Vec::new(); + for (snapshot_line, revision_line) in mapped { + if !requested.contains(&snapshot_line) { + continue; + } + match intervals.last_mut() { + // Split whenever revision lines are not strictly consecutive, so a + // line deleted between two queried lines never widens the range. + Some((_, end)) if *end + 1 == revision_line => *end = revision_line, + _ => intervals.push((revision_line, revision_line)), + } + } + let mut unmapped_lines = unmapped + .into_iter() + .filter(|(line, _)| requested.contains(line)) + .map(|(line, inserted)| GitLineHistoryUnmappedLine { + line, + kind: if inserted { "new" } else { "unreliable" }.to_string(), + }) + .collect::>(); + // The editor's trailing virtual line exists only for display; it never + // maps onto committed content and is reported as newly added. + for line in requested.clone().filter(|line| *line > snapshot_line_count) { + unmapped_lines.push(GitLineHistoryUnmappedLine { + line, + kind: "new".to_string(), + }); + } + unmapped_lines.sort_unstable_by_key(|entry| entry.line); + if intervals.is_empty() { + return Ok(GitLineHistoryResponse { + base_commit, + unmapped_lines, + commits: Vec::new(), + truncated: false, + }); + } + + // One process traces every interval; `-L` emits only hunks intersecting + // the traced lines, and `--first-parent` pins the merge semantics. + let mut arguments = vec!["log".to_string()]; + for (start, end) in &intervals { + arguments.push(format!("-L{start},{end}:{}", request.path)); + } + arguments.push("--first-parent".to_string()); + arguments.push("--patch".to_string()); + // Zero context keeps every emitted hunk exactly the touched lines; default + // three-line context would report whole-file ranges on small documents. + arguments.push("--unified=0".to_string()); + arguments.push("--no-color".to_string()); + arguments.push("--no-ext-diff".to_string()); + arguments.push("--no-textconv".to_string()); + arguments.push(format!("--max-count={}", limit + 1)); + arguments.push("--pretty=format:%x1e%H%x1f%an%x1f%at%x1f%P%x1f%s".to_string()); + arguments.push(base_commit.clone()); + let log = readonly_command(GitCommandRequest { + root: request.root.clone(), + arguments, + input: None, + })?; + if log.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Git line history query failed") + .with_details(log.output), + ); + } + + let mut commits: Vec = Vec::new(); + for record in log.output.split('\x1e') { + let record = record.trim_start_matches('\n'); + if record.trim().is_empty() { + continue; + } + let (header, body) = record.split_once('\n').unwrap_or((record, "")); + let fields: Vec<&str> = header.split('\x1f').collect(); + if fields.len() < 5 { + continue; + } + let hash = fields[0].to_string(); + let parent_hashes = fields[3].split_whitespace().collect::>(); + let is_merge = parent_hashes.len() > 1; + let compare_base = parent_hashes + .first() + .map(|value| value.to_string()) + .unwrap_or_default(); + // Paths with spaces are not representable in this minimal `diff --git` + // header parse; traced files are expected to use ordinary names. + let mut path_at_commit = request.path.clone(); + let mut previous_path = None; + let mut ranges = Vec::new(); + for line in body.lines() { + if let Some(rest) = line.strip_prefix("diff --git a/") { + if let Some((old, new)) = rest.split_once(" b/") { + path_at_commit = new.to_string(); + if old != new { + previous_path = Some(old.to_string()); + } + } + } else if let Some((old_start, old_count, new_start, new_count)) = + parse_line_history_hunk_header(line) + { + ranges.push(GitLineHistoryRange { + old_start, + old_end: line_history_endpoint(old_start, old_count), + new_start, + new_end: line_history_endpoint(new_start, new_count), + }); + } + } + if is_merge && !compare_base.is_empty() { + // `-L` emits merge headers without hunks, so neither the path nor + // the ranges come from the log output. Replay the first-parent + // chain from the pinned base to the merge's parent, translating + // the base intervals and path through every touched commit's diff; + // text matching could not disambiguate duplicate or blank lines. + if let Some((supplement, resolved_path, renamed_from)) = line_history_merge_ranges( + &request.root, + &base_commit, + &intervals, + &request.path, + &compare_base, + &hash, + )? { + ranges = supplement; + if resolved_path != path_at_commit { + path_at_commit = resolved_path; + previous_path = renamed_from; + } else if previous_path.is_none() { + // The rename happened inside the merge itself; -L already + // reported the new path, but the pairing is still news. + previous_path = renamed_from; + } + } + } + commits.push(GitLineHistoryCommit { + hash, + kind: if is_merge { + "merge-first-parent" + } else { + "commit" + } + .to_string(), + author_name: fields[1].to_string(), + author_time: fields[2].parse::().unwrap_or_default(), + subject: fields[4].to_string(), + path_at_commit, + previous_path, + compare_base, + relevant_ranges: ranges, + }); + } + let truncated = commits.len() > limit; + commits.truncate(limit); + Ok(GitLineHistoryResponse { + base_commit, + unmapped_lines, + commits, + truncated, + }) +} diff --git a/rust/crates/core/src/git/metadata_watch.rs b/rust/crates/git/src/metadata_watch.rs similarity index 98% rename from rust/crates/core/src/git/metadata_watch.rs rename to rust/crates/git/src/metadata_watch.rs index 06441441..3e88dd62 100644 --- a/rust/crates/core/src/git/metadata_watch.rs +++ b/rust/crates/git/src/metadata_watch.rs @@ -1,6 +1,6 @@ //! Classifies Git watcher paths without filesystem access or platform events. -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::path::{Path, PathBuf}; @@ -136,7 +136,7 @@ struct Request { } /// JSON boundary used once per bounded watcher batch or page. -pub(crate) fn classify_command(payload: Value) -> Result { +pub fn classify_command(payload: Value) -> Result { let request: Request = serde_json::from_value(payload).map_err(|_| invalid())?; let valid_path = |path: &Path| !path.as_os_str().is_empty() && path.as_os_str().len() <= MAX_PATH_BYTES; diff --git a/rust/crates/core/src/git/mutations.rs b/rust/crates/git/src/mutations.rs similarity index 98% rename from rust/crates/core/src/git/mutations.rs rename to rust/crates/git/src/mutations.rs index db047278..82602c4a 100644 --- a/rust/crates/core/src/git/mutations.rs +++ b/rust/crates/git/src/mutations.rs @@ -1,7 +1,7 @@ //! Shared Git mutation helpers kept outside the command facade. use super::{capture_git_with_options, is_safe_pathspec}; -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use super::{ current_branch, execute_git, switch_reference, write_request_reference, GitCommandResponse, diff --git a/rust/crates/git/src/parse.rs b/rust/crates/git/src/parse.rs new file mode 100644 index 00000000..630d0787 --- /dev/null +++ b/rust/crates/git/src/parse.rs @@ -0,0 +1,158 @@ +//! Parsers for Git's machine-readable reference, commit, name-status and stash output. + +use super::*; + +pub(crate) fn parse_reference(line: &str) -> Option { + let columns = line.split('\t').collect::>(); + if columns.len() < 8 || columns[1].ends_with("/HEAD") { + return None; + } + let kind = if columns[0].starts_with("refs/heads/") { + "local" + } else if columns[0].starts_with("refs/remotes/") { + "remote" + } else { + "tag" + }; + let short_name = match kind { + "local" => columns[0].strip_prefix("refs/heads/"), + "remote" => columns[0].strip_prefix("refs/remotes/"), + "tag" => columns[0].strip_prefix("refs/tags/"), + _ => None, + }?; + let upstream_short_name = (!columns[3].is_empty()).then(|| columns[3].to_string()); + let (ahead, behind) = if kind == "local" && upstream_short_name.is_some() { + parse_tracking_counts(columns.get(5).copied().unwrap_or_default()) + } else { + (0, 0) + }; + Some(GitReferenceResponse { + full_name: columns[0].to_string(), + // `%(refname:short)` deliberately adds `heads/` or `tags/` when + // namespaces collide. The typed contract keeps identity in + // `fullName` and always exposes the namespace-relative short name. + short_name: short_name.to_string(), + kind: kind.to_string(), + peels_to_commit: kind != "tag" || columns[6] == "commit" || columns[7] == "commit", + is_current: columns[2].trim() == "*", + upstream_short_name, + ahead, + behind, + }) +} + +fn parse_tracking_counts(value: &str) -> (usize, usize) { + let mut ahead = 0; + let mut behind = 0; + for component in value.split(',').map(str::trim) { + if let Some(value) = component.strip_prefix("ahead ") { + ahead = value.parse().unwrap_or(0); + } else if let Some(value) = component.strip_prefix("behind ") { + behind = value.parse().unwrap_or(0); + } + } + (ahead, behind) +} + +pub(crate) fn parse_commit(line: &str) -> Option { + let columns = line.split('\u{1f}').collect::>(); + if columns.len() < 8 { + return None; + } + let (date, date_utc_offset_minutes) = split_commit_date_offset(columns[5]); + Some(GitCommitResponse { + hash: columns[0].to_string(), + short_hash: columns[1].to_string(), + parent_hashes: columns[2].split_whitespace().map(String::from).collect(), + author_name: columns[3].to_string(), + author_email: columns[4].to_string(), + date: date.to_string(), + date_utc_offset_minutes, + date_unix_seconds: history_query::commit_timestamp(date, date_utc_offset_minutes), + subject: columns[6].to_string(), + decorations: columns[7].to_string(), + }) +} + +/// Splits a `GIT_COMMIT_DATE_ARGUMENT` value into the displayed local date and +/// the author's UTC offset in minutes. Values without a valid `±HHMM` suffix are +/// kept whole with no offset so unexpected Git output stays visible. +fn split_commit_date_offset(value: &str) -> (&str, Option) { + let Some((date, zone)) = value.rsplit_once(' ') else { + return (value, None); + }; + let bytes = zone.as_bytes(); + if bytes.len() != 5 + || !matches!(bytes[0], b'+' | b'-') + || !zone[1..].bytes().all(|b| b.is_ascii_digit()) + { + return (value, None); + } + let hours: i32 = zone[1..3].parse().unwrap_or(0); + let minutes: i32 = zone[3..5].parse().unwrap_or(0); + if hours > 23 || minutes > 59 { + return (value, None); + } + let sign = if bytes[0] == b'-' { -1 } else { 1 }; + (date, Some(sign * (hours * 60 + minutes))) +} + +pub(crate) fn parse_name_status(output: &str) -> Vec { + output + .lines() + .filter_map(|line| { + let columns = line.split('\t').collect::>(); + if columns.len() < 2 || columns.last().is_some_and(|path| path.is_empty()) { + return None; + } + Some(GitFileResponse { + status: columns[0].to_string(), + path: columns.last().unwrap_or(&"").to_string(), + is_untracked: false, + }) + }) + .collect() +} + +pub(crate) fn parse_stash(line: &str) -> Option { + let columns = line.split('\u{1f}').collect::>(); + if columns.len() < 3 { + return None; + } + let reference = columns[0].trim(); + if reference.is_empty() { + return None; + } + let subject = columns[1].trim(); + let lower = subject.to_ascii_lowercase(); + let marker = lower.find("on ").or_else(|| lower.find(" on ")); + let branch = marker.and_then(|index| { + let raw = &subject[index + 3..]; + raw.split_once(':') + .or_else(|| raw.split_once(',')) + .map(|(branch, _)| branch.trim().to_string()) + .filter(|branch| !branch.is_empty()) + }); + let message = marker + .and_then(|index| { + subject[index + 3..] + .split_once(':') + .map(|(_, message)| message) + }) + .or_else(|| subject.split_once(':').map(|(_, message)| message)) + .unwrap_or(subject) + .trim() + .to_string(); + Some(GitStashResponse { + reference: reference.to_string(), + oid: columns + .get(3) + .copied() + .unwrap_or_default() + .trim() + .to_string(), + message, + branch, + date: columns[2].trim().to_string(), + }) +} diff --git a/rust/crates/core/src/git/patch_exchange.rs b/rust/crates/git/src/patch_exchange.rs similarity index 98% rename from rust/crates/core/src/git/patch_exchange.rs rename to rust/crates/git/src/patch_exchange.rs index 4f95f482..720d8203 100644 --- a/rust/crates/core/src/git/patch_exchange.rs +++ b/rust/crates/git/src/patch_exchange.rs @@ -5,7 +5,7 @@ use super::{ operation_state, repository_root, resolve_commit_revision, validate_root, CoreError, ErrorCode, GitCommandResponse, GitOperationStateRequest, TemporaryGitCommitContext, }; -use crate::remote::workspace_io as fs; +use lithe_workspace::fs; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::BTreeSet; @@ -32,13 +32,13 @@ pub enum PatchSource { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PatchExportRequest { - pub root: String, - pub source: PatchSource, + pub(crate) root: String, + pub(crate) source: PatchSource, /// Repository-relative literal paths; empty selects the entire repository. #[serde(default)] pub paths: Vec, - pub base_revision: Option, - pub target_revision: Option, + pub(crate) base_revision: Option, + pub(crate) target_revision: Option, /// Enumerate counts and rename paths without decoding or transporting patch contents. #[serde(default)] pub metadata_only: bool, @@ -60,9 +60,9 @@ pub enum PatchTarget { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PatchPreviewRequest { - pub root: String, - pub patch: String, - pub target: PatchTarget, + pub(crate) root: String, + pub(crate) patch: String, + pub(crate) target: PatchTarget, /// Reverse a saved patch when cleaning a durably captured shelf. Defaults to forward. #[serde(default)] pub reverse: bool, @@ -75,13 +75,13 @@ pub struct PatchPreviewRequest { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PatchApplyRequest { - pub root: String, - pub patch: String, - pub target: PatchTarget, + pub(crate) root: String, + pub(crate) patch: String, + pub(crate) target: PatchTarget, /// Reverse a saved patch when cleaning a durably captured shelf. Defaults to forward. #[serde(default)] pub reverse: bool, - pub expected_state: String, + pub(crate) expected_state: String, } /// One path reported by Git's NUL-delimited patch statistics. @@ -819,7 +819,7 @@ fn state_token( .collect::, _>>()?; paths.sort(); for path in paths { - crate::protocol::cancellation::check()?; + lithe_protocol::cancellation::check()?; add_hash_field(&mut hash, path.as_bytes()); hash_working_file(&mut hash, &Path::new(root).join(path))?; } @@ -880,7 +880,7 @@ fn hash_working_file(hash: &mut Sha256, path: &Path) -> Result<(), CoreError> { let mut file = fs::File::open(path).map_err(patch_io_error)?; let mut buffer = [0; 65536]; loop { - crate::protocol::cancellation::check()?; + lithe_protocol::cancellation::check()?; let length = file.read(&mut buffer).map_err(patch_io_error)?; if length == 0 { break; diff --git a/rust/crates/git/src/preflight.rs b/rust/crates/git/src/preflight.rs new file mode 100644 index 00000000..014b495b --- /dev/null +++ b/rust/crates/git/src/preflight.rs @@ -0,0 +1,665 @@ +//! Read-only checks before checkout, merge, rebase and pull, and the repository's +//! in-progress operation state. + +use super::*; + +/// Reports which files would block a checkout of `reference`. +/// +/// A file blocks the switch when it has uncommitted changes *and* its content +/// differs between HEAD and the target ref. Files dirty in only one of those two +/// senses are carried across by Git without complaint. +pub fn checkout_preflight( + request: GitCheckoutPreflightRequest, +) -> Result { + let root = validate_root(&request.root)?; + let reference = request_reference( + &root, + request.git_reference.as_ref(), + request.reference.as_deref(), + )?; + + let dirty = readonly_command(GitCommandRequest { + root: root.clone(), + arguments: vec![ + "diff".to_string(), + "HEAD".to_string(), + "--name-only".to_string(), + ], + input: None, + })?; + if dirty.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Git diff failed").with_details(dirty.output) + ); + } + let dirty_paths = dirty + .output + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>(); + + // Untracked files block a checkout too, whenever the target branch tracks the same + // path: git refuses rather than overwrite them. These never appear in `diff HEAD`. + let untracked = readonly_command(GitCommandRequest { + root: root.clone(), + arguments: vec![ + "ls-files".to_string(), + "--others".to_string(), + "--exclude-standard".to_string(), + ], + input: None, + })?; + if untracked.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Git ls-files failed") + .with_details(untracked.output), + ); + } + let untracked_paths = untracked + .output + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>(); + + if dirty_paths.is_empty() && untracked_paths.is_empty() { + return Ok(GitCheckoutPreflightResponse { + blocking_paths: Vec::new(), + }); + } + + let mut blocking_paths = Vec::new(); + if !untracked_paths.is_empty() { + let tracked_on_target = readonly_command(GitCommandRequest { + root: root.clone(), + arguments: vec![ + "ls-tree".to_string(), + "-r".to_string(), + "--name-only".to_string(), + reference.clone(), + ], + input: None, + })?; + if tracked_on_target.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Git ls-tree failed") + .with_details(tracked_on_target.output), + ); + } + blocking_paths.extend( + tracked_on_target + .output + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && untracked_paths.contains(line)) + .map(str::to_string), + ); + } + + if dirty_paths.is_empty() { + blocking_paths.sort(); + blocking_paths.dedup(); + return Ok(GitCheckoutPreflightResponse { blocking_paths }); + } + + let divergent = readonly_command(GitCommandRequest { + root, + arguments: vec![ + "diff".to_string(), + "HEAD".to_string(), + reference, + "--name-only".to_string(), + ], + input: None, + })?; + if divergent.exit_code != 0 { + return Err(CoreError::new(ErrorCode::ProcessFailed, "Git diff failed") + .with_details(divergent.output)); + } + + blocking_paths.extend( + divergent + .output + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && dirty_paths.contains(line)) + .map(str::to_string), + ); + blocking_paths.sort(); + blocking_paths.dedup(); + Ok(GitCheckoutPreflightResponse { blocking_paths }) +} + +/// Lists staged files that still contain conflict markers. +/// +/// Only `<<<<<<< ` and friends with their trailing space are matched: a bare +/// `=======` is also a Markdown heading underline, and matching it flags ordinary +/// documentation. `|||||||` covers the diff3 conflict style. Git skips binary +/// files itself, so a blob containing those bytes cannot trip this. +pub fn conflict_marker_paths( + request: GitConflictMarkerRequest, +) -> Result { + let root = validate_root(&request.root)?; + let paths = staged_conflict_marker_paths(&root)?; + Ok(GitConflictMarkerResponse { paths }) +} + +pub(crate) fn staged_conflict_marker_paths(root: &str) -> Result, CoreError> { + staged_conflict_marker_paths_with_environment(root, &[], false) +} + +pub(crate) fn staged_conflict_marker_paths_with_environment( + root: &str, + environment: &[(String, String)], + changed_only: bool, +) -> Result, CoreError> { + // The alternate index starts at HEAD, so its staged diff is the authoritative + // expansion of directory and glob pathspecs without repeating a long path list. + let changed_paths = if changed_only { + let changed = execute_git_with_environment( + root, + &[ + "diff".to_string(), + "--cached".to_string(), + "--no-ext-diff".to_string(), + "--name-only".to_string(), + "-z".to_string(), + ], + None, + true, + environment, + )?; + if changed.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Git staged diff failed") + .with_details(changed.output), + ); + } + Some( + changed + .stdout + .split('\0') + .filter(|path| !path.is_empty()) + .map(str::to_string) + .collect::>(), + ) + } else { + None + }; + let arguments = vec![ + "grep".to_string(), + "--cached".to_string(), + "-l".to_string(), + "-z".to_string(), + "-E".to_string(), + r"^(<<<<<<<|>>>>>>>|\|\|\|\|\|\|\|) ".to_string(), + ]; + let found = execute_git_with_environment(root, &arguments, None, true, environment)?; + // `git grep` exits 1 when nothing matches, which is not a failure here. + if found.exit_code > 1 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Git grep failed").with_details(found.output) + ); + } + + let mut paths: Vec = found + .stdout + .split('\0') + .filter(|path| { + !path.is_empty() + && changed_paths + .as_ref() + .is_none_or(|changed| changed.contains(*path)) + }) + .map(str::to_string) + .collect(); + paths.sort(); + paths.dedup(); + Ok(paths) +} + +/// How an operation decides whether a dirty working tree is in its way. +enum IntegrationShape { + /// Refuses only over files differing between the merge base and the target. + MergeBase, + /// Refuses over any uncommitted change, however unrelated. + AnyDirty, + /// Refuses only over files the single replayed commit touches. + SingleCommit, +} + +/// Reports what would stop a merge, rebase, cherry-pick, or revert from starting. +/// +/// The rules differ and were each checked against Git directly: merge, cherry-pick +/// and revert refuse only when a dirty file overlaps what they would write, while a +/// rebase refuses on any uncommitted change, staged or not, however unrelated. +/// Matching Git's stderr is not an option since it is localized. +pub fn integration_preflight( + request: GitIntegrationPreflightRequest, +) -> Result { + let root = validate_root(&request.root)?; + let reference = request_reference( + &root, + request.git_reference.as_ref(), + request.reference.as_deref(), + )?; + let shape = match request.operation.as_str() { + "merge" => IntegrationShape::MergeBase, + "rebase" => IntegrationShape::AnyDirty, + // Verified against Git: both refuse only when a dirty file overlaps what the + // commit touches, exactly like a merge. The overlap set differs though, since + // they replay one commit rather than joining two branches. + "cherryPick" | "revert" => IntegrationShape::SingleCommit, + other => { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + format!("Unknown integration operation '{other}'"), + )) + } + }; + + // Tracked files differing from HEAD, staged or not: `diff HEAD` covers both. + let dirty = readonly_command(GitCommandRequest { + root: root.clone(), + arguments: vec![ + "diff".to_string(), + "HEAD".to_string(), + "--name-only".to_string(), + ], + input: None, + })?; + if dirty.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Git diff failed").with_details(dirty.output) + ); + } + let dirty_paths: Vec = dirty + .output + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(str::to_string) + .collect(); + + if dirty_paths.is_empty() { + return Ok(GitIntegrationPreflightResponse { + blocking_paths: Vec::new(), + blocks_entirely: false, + }); + } + + // A rebase stops for any uncommitted change, so every dirty path is blocking + // and there is no overlap to compute. + if matches!(shape, IntegrationShape::AnyDirty) { + let mut blocking_paths = dirty_paths; + blocking_paths.sort(); + blocking_paths.dedup(); + return Ok(GitIntegrationPreflightResponse { + blocking_paths, + blocks_entirely: true, + }); + } + + // Everything else refuses only over files it would write. For a merge those are + // the files differing since the merge base; for a single replayed commit they + // are the files that commit changed against its own parent. + let written = match shape { + IntegrationShape::MergeBase => { + let base = readonly_command(GitCommandRequest { + root: root.clone(), + arguments: vec![ + "merge-base".to_string(), + "HEAD".to_string(), + reference.clone(), + ], + input: None, + })?; + if base.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Git merge-base failed") + .with_details(base.output), + ); + } + readonly_command(GitCommandRequest { + root, + arguments: vec![ + "diff".to_string(), + base.output.trim().to_string(), + reference, + "--name-only".to_string(), + ], + input: None, + })? + } + // `diff-tree` against the commit itself; the extra flags make a merge commit + // and the root commit behave rather than print nothing. + IntegrationShape::SingleCommit => readonly_command(GitCommandRequest { + root, + arguments: vec![ + "diff-tree".to_string(), + "-r".to_string(), + "-m".to_string(), + "--root".to_string(), + "--name-only".to_string(), + "--no-commit-id".to_string(), + reference, + ], + input: None, + })?, + IntegrationShape::AnyDirty => unreachable!("handled above"), + }; + if written.exit_code != 0 { + return Err(CoreError::new(ErrorCode::ProcessFailed, "Git diff failed") + .with_details(written.output)); + } + let written_paths = written + .output + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>(); + + let mut blocking_paths: Vec = dirty_paths + .into_iter() + .filter(|path| written_paths.contains(path.as_str())) + .collect(); + blocking_paths.sort(); + blocking_paths.dedup(); + Ok(GitIntegrationPreflightResponse { + blocking_paths, + blocks_entirely: false, + }) +} + +/// Reports whether a pull can fast-forward, so the UI can ask before it fails. +/// +/// Git's own refusal for a divergent pull is a multi-line hint block that is +/// localized, so the counts are computed here instead. Fetching first would make +/// the numbers fresher, but this stays read-only: the caller decides when to hit +/// the network. +pub fn pull_preflight( + request: GitPullPreflightRequest, +) -> Result { + let root = validate_root(&request.root)?; + + let upstream = readonly_command(GitCommandRequest { + root: root.clone(), + arguments: vec![ + "rev-parse".to_string(), + "--abbrev-ref".to_string(), + "--symbolic-full-name".to_string(), + "@{upstream}".to_string(), + ], + input: None, + })?; + // A non-zero exit here means "no upstream configured", not a failure. + if upstream.exit_code != 0 { + return Ok(GitPullPreflightResponse { + upstream: None, + ahead: 0, + behind: 0, + diverged: false, + has_local_changes: false, + configured_strategy: None, + }); + } + let upstream = upstream.output.trim().to_string(); + + let counts = readonly_command(GitCommandRequest { + root: root.clone(), + arguments: vec![ + "rev-list".to_string(), + "--left-right".to_string(), + "--count".to_string(), + format!("{upstream}...HEAD"), + ], + input: None, + })?; + if counts.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Git rev-list failed") + .with_details(counts.output), + ); + } + // `--left-right --count` prints "\t" for `upstream...HEAD`. + let mut fields = counts.output.split_whitespace(); + let behind = fields.next().and_then(|v| v.parse().ok()).unwrap_or(0); + let ahead = fields.next().and_then(|v| v.parse().ok()).unwrap_or(0); + + let dirty = readonly_command(GitCommandRequest { + root: root.clone(), + arguments: vec![ + "status".to_string(), + "--porcelain".to_string(), + "--untracked-files=no".to_string(), + ], + input: None, + })?; + if dirty.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Git status failed") + .with_details(dirty.output), + ); + } + + Ok(GitPullPreflightResponse { + upstream: Some(upstream), + ahead, + behind, + diverged: ahead > 0 && behind > 0, + has_local_changes: !dirty.output.trim().is_empty(), + configured_strategy: configured_pull_strategy(&root)?, + }) +} + +/// The pull strategy Git is explicitly configured for, or `None` when neither +/// `pull.rebase` nor `pull.ff` says anything. `git.updatePlan` falls back to +/// fast-forward only in that case. +fn configured_pull_strategy(root: &str) -> Result, CoreError> { + let rebase = read_git_config_value(root, "pull.rebase")?; + let fast_forward = read_git_config_value(root, "pull.ff")?; + let enabled = |value: &Option, accepted: &[&str]| { + value + .as_deref() + .is_some_and(|value| accepted.contains(&value.to_ascii_lowercase().as_str())) + }; + Ok( + if enabled( + &rebase, + &["true", "yes", "on", "1", "interactive", "merges"], + ) { + Some("rebase") + } else if enabled(&fast_forward, &["only"]) { + Some("ffOnly") + } else if enabled(&fast_forward, &["false", "no", "off", "0"]) + || enabled(&rebase, &["false", "no", "off", "0"]) + { + Some("merge") + } else { + None + }, + ) +} + +/// Resolves the Git directory once, honoring worktrees and submodules where +/// `.git` is a file pointing elsewhere rather than a directory. +pub(crate) fn git_directory(root: &str) -> Result, CoreError> { + let response = execute_git_readonly( + root, + &["rev-parse".to_string(), "--absolute-git-dir".to_string()], + None, + )?; + if response.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Git rev-parse failed") + .with_details(response.output), + ); + } + let raw = response.output.trim(); + if raw.is_empty() { + return Ok(None); + } + Ok(Some(PathBuf::from(raw))) +} + +/// Reads a single-line numeric counter written by an in-progress rebase. +fn rebase_counter(directory: &std::path::Path, name: &str) -> Option { + fs::read_to_string(directory.join(name)) + .ok()? + .trim() + .parse() + .ok() +} + +/// Reports whichever sequential operation Git has left half-finished. +/// +/// Detection reads the marker files Git itself writes, so an operation stopped by +/// a conflict is reported the same way whether the user started it in Lithe or on +/// the command line. +pub fn operation_state( + request: GitOperationStateRequest, +) -> Result { + let root = validate_root(&request.root)?; + + let mut kind = String::new(); + let mut reference = None; + let mut step = None; + let mut total = None; + + // Order matters: a conflicted rebase can carry a REVERT_HEAD from the commit + // it is replaying, so the more specific rebase directories are checked first. + if let Some(git_directory) = git_directory(&root)? { + let rebase_merge = git_directory.join("rebase-merge"); + let rebase_apply = git_directory.join("rebase-apply"); + let operation_directory = if fs::try_exists(&rebase_merge)? { + Some(rebase_merge) + } else if fs::try_exists(&rebase_apply)? { + Some(rebase_apply) + } else { + None + }; + if let Some(directory) = operation_directory { + kind = "rebase".to_string(); + step = rebase_counter(&directory, "msgnum"); + total = rebase_counter(&directory, "end"); + reference = fs::read_to_string(directory.join("onto")) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + } else if fs::try_exists(git_directory.join("MERGE_HEAD"))? { + kind = "merge".to_string(); + } else if fs::try_exists(git_directory.join("CHERRY_PICK_HEAD"))? { + kind = "cherryPick".to_string(); + } else if fs::try_exists(git_directory.join("REVERT_HEAD"))? { + kind = "revert".to_string(); + } + } + + let status = execute_git_readonly( + &root, + &["status".to_string(), "--porcelain".to_string()], + None, + )?; + if status.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Git status failed") + .with_details(status.output), + ); + } + + let mut conflicted_paths = status + .output + .lines() + .filter(|line| line.len() > 3 && is_conflicted_status(&line[..2])) + .map(|line| line[3..].trim().to_string()) + .filter(|path| !path.is_empty()) + .collect::>(); + conflicted_paths.sort(); + conflicted_paths.dedup(); + + Ok(GitOperationStateResponse { + kind, + reference, + step, + total, + conflicted_paths, + }) +} + +/// Continues, aborts, or skips whichever operation is currently in progress. +/// +/// The subcommand depends on what Git left behind, so the state is read first +/// rather than trusted from the caller: the UI's view of it may be a refresh +/// behind, and issuing `rebase --continue` during a merge would just fail. +pub(crate) fn resolve_operation( + root: &str, + operation: &str, +) -> Result { + if let Some(response) = rebase_session::resolve_owned_operation(root, operation)? { + return Ok(response); + } + let state = operation_state(GitOperationStateRequest { + root: root.to_string(), + })?; + if state.kind.is_empty() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "No Git operation is in progress", + )); + } + + let subcommand = match state.kind.as_str() { + "rebase" => "rebase", + "merge" => "merge", + "cherryPick" => "cherry-pick", + "revert" => "revert", + _ => { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Unsupported Git operation state", + )) + } + }; + + let action = match operation { + "operationContinue" => "--continue", + "operationAbort" => "--abort", + _ => "--skip", + }; + + // Only a rebase can skip a step; merge and cherry-pick have no equivalent. + if action == "--skip" && subcommand != "rebase" { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Only a rebase can skip a step", + )); + } + + if action == "--continue" && !state.conflicted_paths.is_empty() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Resolve the conflicted files before continuing", + ) + .with_details(state.conflicted_paths.join("\n"))); + } + + // `--continue` opens an editor for the commit message by default, which would + // hang a process launched from the GUI with no terminal attached. Pointing the + // editor at `true` makes Git accept the message it already prepared and exit. + // `merge --continue` rejects extra arguments, so this must stay a config + // override rather than a `--no-edit` flag. + let arguments = vec![ + "-c".to_string(), + "core.editor=true".to_string(), + subcommand.to_string(), + action.to_string(), + ]; + + execute_git(root, &arguments, None) +} + +/// The porcelain status pairs Git uses for an unresolved merge conflict. +fn is_conflicted_status(code: &str) -> bool { + matches!(code, "UU" | "AA" | "DD" | "DU" | "UD" | "AU" | "UA") +} diff --git a/rust/crates/git/src/process.rs b/rust/crates/git/src/process.rs new file mode 100644 index 00000000..aa28c42a --- /dev/null +++ b/rust/crates/git/src/process.rs @@ -0,0 +1,616 @@ +//! Git subprocesses: local and remote invocation, captured streams, the per-operation +//! invocation trace and the generic `git.command` entry points. + +use super::*; + +/// Raw Git process streams kept separate for machine-readable consumers. +pub(crate) struct GitProcessOutput { + pub(crate) stdout: Vec, + pub(crate) stderr: Vec, + pub(crate) exit_code: i32, +} + +impl GitProcessOutput { + pub(crate) fn into_command_response(self, arguments: &[String]) -> GitCommandResponse { + let stdout = String::from_utf8_lossy(&self.stdout).to_string(); + let stderr = String::from_utf8_lossy(&self.stderr).to_string(); + let invocation = GitCommandInvocation { + arguments: arguments.to_vec(), + stdout: stdout.clone(), + stderr: stderr.clone(), + exit_code: self.exit_code, + }; + let output = format!("{stdout}{stderr}"); + GitCommandResponse { + arguments: arguments.to_vec(), + output, + stdout, + stderr, + exit_code: self.exit_code, + invocations: vec![invocation], + operation_error: None, + stash_restore: None, + tag_deletion: None, + branch_deletion: None, + history_rewrite: None, + outcome_authoritative: false, + warnings: Vec::new(), + } + } +} + +thread_local! { + static GIT_INVOCATION_TRACE: RefCell>> = const { + RefCell::new(None) + }; +} + +pub(crate) fn with_git_invocation_trace( + operation: impl FnOnce() -> Result, +) -> Result { + let previous = GIT_INVOCATION_TRACE.with(|trace| trace.replace(Some(Vec::new()))); + let result = operation(); + let invocations = GIT_INVOCATION_TRACE + .with(|trace| trace.replace(previous)) + .unwrap_or_default(); + + match result { + Ok(mut response) => { + response.invocations = invocations; + synchronize_final_invocation(&mut response); + Ok(response) + } + Err(error) if !invocations.is_empty() => { + // A composite Git operation may complete subprocesses before a + // follow-up probe fails. Preserve those diagnostics as command data + // instead of replacing them with an empty error result. + let mut response = failed_git_result(error); + response.invocations = invocations; + synchronize_final_invocation(&mut response); + Ok(response) + } + Err(error) => Err(error), + } +} + +pub(crate) fn synchronize_final_invocation(response: &mut GitCommandResponse) { + // Recovery creation and post-mutation cleanup are traced subprocesses, but + // must not replace the authoritative history mutation's success or failure. + if response.history_rewrite.is_some() || response.outcome_authoritative { + return; + } + if response.exit_code == 0 && !response.warnings.is_empty() { + // The mutation already succeeded and a later reconciliation step only + // produced a warning. Keep the authoritative success summary while the + // failed follow-up remains available in `invocations` and `warnings`. + return; + } + let Some(final_invocation) = response.invocations.last() else { + return; + }; + response.arguments = final_invocation.arguments.clone(); + response.stdout = final_invocation.stdout.clone(); + response.stderr = final_invocation.stderr.clone(); + response.output = format!("{}{}", response.stdout, response.stderr); + response.exit_code = final_invocation.exit_code; +} + +pub(crate) fn record_git_invocation(response: &GitCommandResponse) { + let Some(invocation) = response.invocations.first().cloned() else { + return; + }; + GIT_INVOCATION_TRACE.with(|trace| { + if let Some(invocations) = trace.borrow_mut().as_mut() { + invocations.push(invocation); + } + }); +} + +/// Executes an argument-based Git command after validating the workspace root. +pub fn command(request: GitCommandRequest) -> Result { + with_git_invocation_trace(|| { + let root = validate_root(&request.root)?; + // Raw compatibility commands can also mutate refs and configuration. + // Share the typed writers' fail-fast lease instead of waiting on a mutex + // that cannot observe the request's cancellation or deadline. + let _lease = rewrite::RewriteLease::acquire(&root)?; + execute_git(&root, &request.arguments, request.input) + }) +} + +pub(crate) fn readonly_command( + request: GitCommandRequest, +) -> Result { + let root = validate_root(&request.root)?; + execute_git_readonly(&root, &request.arguments, request.input) +} + +/// Resolves the containing repository root without participating in write coordination. +pub fn discover_repository_root( + request: GitRepositoryRootRequest, +) -> Result, CoreError> { + let root = validate_root(&request.root)?; + Ok(discover_containing_repository(Path::new(&root))? + .map(|path| path.to_string_lossy().replace('\\', "/"))) +} + +/// Runs one Git invocation the user sees in the console, under the request's +/// execution policy and cancellation scope. +pub fn execute_git( + root: &str, + arguments: &[String], + input: Option, +) -> Result { + execute_git_with_options(root, arguments, input, false) +} + +/// Runs one internal read-only Git query with optional locks disabled. It never +/// prompts, and reaches the console only when the request sets `reportQueries`. +pub fn execute_git_readonly( + root: &str, + arguments: &[String], + input: Option, +) -> Result { + execute_git_readonly_with_environment(root, arguments, input, &[]) +} + +pub(crate) fn execute_git_readonly_with_environment( + root: &str, + arguments: &[String], + input: Option, + environment: &[(String, String)], +) -> Result { + // Internal queries follow IDEA's silent handler policy. Keep their result for + // the owning workflow without filling the user's console with plumbing output. + let arguments = execution_policy::arguments(arguments); + let response = capture_git_with_environment(root, &arguments, input, true, environment)? + .into_command_response(&arguments); + record_git_invocation(&response); + Ok(response) +} + +fn execute_git_with_options( + root: &str, + arguments: &[String], + input: Option, + disable_optional_locks: bool, +) -> Result { + execute_git_with_environment(root, arguments, input, disable_optional_locks, &[]) +} + +pub(crate) fn execute_git_with_environment( + root: &str, + arguments: &[String], + input: Option, + disable_optional_locks: bool, + environment: &[(String, String)], +) -> Result { + let arguments = execution_policy::arguments(arguments); + let mut retry_scope = None; + for attempt in 0..3 { + let output = capture_git_process( + root, + &arguments, + input.clone(), + disable_optional_locks, + environment, + true, + )?; + let response = output.into_command_response(&arguments); + record_git_invocation(&response); + let authentication_failed = [ + "Authentication failed", + "could not read Username", + "could not read Password", + "Permission denied (publickey", + ] + .iter() + .any(|marker| response.stderr.contains(marker)); + let transfer = execution_policy::command_index(&arguments).is_some_and(|index| { + matches!( + arguments[index].as_str(), + "fetch" | "pull" | "push" | "clone" + ) + }); + if response.exit_code == 0 + || !authentication_failed + || !transfer + || !execution_policy::current().interactive + || attempt == 2 + { + return Ok(response); + } + let confirmation = + lithe_git_host::authentication::Confirmation::new().map_err(|error| { + CoreError::new( + ErrorCode::ProcessFailed, + "Could not request Git authentication retry", + ) + .with_details(error.to_string()) + })?; + execution_events::emit( + serde_json::json!({ "type": "authentication", "requestId": confirmation.request_id, + "prompt": "Git authentication failed. Retry with credentials entered in Lithe, bypassing the credential helper for this retry?", + "secret": false, "retry": true, "attempt": attempt + 2, "workingDirectory": root }), + ); + let retry = confirmation + .wait(|| lithe_protocol::cancellation::check().is_err()) + .map_err(|_| CoreError::new(ErrorCode::TimedOut, "Git authentication retry expired"))?; + lithe_protocol::cancellation::check()?; + if !retry { + return Ok(response); + } + drop(retry_scope.take()); + let mut options = execution_policy::current(); + options.use_credential_helper = false; + retry_scope = Some(execution_policy::Scope::begin(Some(options))?); + } + unreachable!("bounded Git attempts always return a response") +} + +pub(crate) fn capture_git_with_options( + root: &str, + arguments: &[String], + input: Option, + disable_optional_locks: bool, +) -> Result { + capture_git_with_environment(root, arguments, input, disable_optional_locks, &[]) +} + +pub(crate) fn capture_git_with_environment( + root: &str, + arguments: &[String], + input: Option, + disable_optional_locks: bool, + environment: &[(String, String)], +) -> Result { + capture_git_process( + root, + arguments, + input, + disable_optional_locks, + environment, + false, + ) +} + +pub(crate) fn capture_git_process( + root: &str, + arguments: &[String], + input: Option, + disable_optional_locks: bool, + environment: &[(String, String)], + visible: bool, +) -> Result { + validate_windows_git_path(Path::new(root)).map_err(git_path_error)?; + lithe_protocol::cancellation::check()?; + let mut trace = command_trace::Trace::begin(root, arguments, visible); + let result = match remote_process::capture( + root, + arguments, + input.as_deref(), + disable_optional_locks, + environment, + visible, + &mut trace, + ) { + Some(result) => result, + None => capture_local_git_process( + root, + arguments, + input, + disable_optional_locks, + environment, + visible, + &mut trace, + ), + }; + trace.finish(result.as_ref().err()); + result +} + +fn capture_local_git_process( + root: &str, + arguments: &[String], + input: Option, + disable_optional_locks: bool, + environment: &[(String, String)], + visible: bool, + trace: &mut command_trace::Trace, +) -> Result { + let mut process = git_process(); + let version = lithe_git_host::configuration::version(process.get_program(), || { + lithe_protocol::cancellation::check().is_err() + }); + lithe_protocol::cancellation::check()?; + execution_policy::validate_version(&version.map_err(|error| { + CoreError::new( + ErrorCode::ProcessStartFailed, + "Could not inspect Git executable", + ) + .with_details(error.to_string()) + })?)?; + if visible && execution_policy::current().interactive && !execution_events::has_sink() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Interactive Git requires an execution event receiver", + )); + } + process + .args(arguments) + .current_dir(root) + .envs(environment.iter().map(|(key, value)| (key, value))); + if disable_optional_locks { + process.env("GIT_OPTIONAL_LOCKS", "0"); + } + if execution_policy::command_index(arguments).is_some_and(|index| arguments[index] != "config") + { + lithe_git_host::configuration::configure( + &mut process, + &execution_policy::temporary_config(), + ) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Could not apply temporary Git configuration", + ) + .with_details(error.to_string()) + })?; + } + let invocation = RefCell::new(execution_events::Invocation::reported(visible)); + let mut authentication = if visible && execution_policy::current().interactive { + let session = lithe_git_host::authentication::Session::new().map_err(|error| { + CoreError::new( + ErrorCode::ProcessStartFailed, + "Could not prepare Git authentication", + ) + .with_details(error.to_string()) + })?; + session.configure(&mut process).map_err(|error| { + CoreError::new( + ErrorCode::ProcessStartFailed, + "Could not configure Git authentication", + ) + .with_details(error.to_string()) + })?; + Some(session) + } else { + None + }; + let mut cancellation_error = None; + let output = lithe_git_host::run( + &mut process, + input.as_deref().map(str::as_bytes), + || { + cancellation_error = lithe_protocol::cancellation::check().err(); + if cancellation_error.is_none() { + if let Some(session) = &mut authentication { + match session.poll() { + Ok(challenges) => { + for challenge in challenges { + execution_events::emit( + serde_json::json!({ "type": "authentication", "requestId": challenge.request_id, + "prompt": execution_events::redact(&challenge.prompt), "secret": challenge.secret, + "attempt": challenge.attempt, "workingDirectory": root }), + ); + } + } + Err(error) => { + cancellation_error = Some( + CoreError::new( + ErrorCode::ProcessFailed, + "Git authentication transport failed", + ) + .with_details(error.to_string()), + ) + } + } + } + } + cancellation_error.is_some() + }, + || { + if let Some(invocation) = invocation.borrow().as_ref() { + invocation.started(root, arguments); + } + }, + |stream, bytes| { + if let Some(invocation) = invocation.borrow_mut().as_mut() { + invocation.output(stream, bytes); + } + }, + ); + let error = output.failure.map(|failure| match failure { + lithe_git_host::Failure::Start(error) => { + CoreError::new(ErrorCode::ProcessStartFailed, "Could not start Git") + .with_details(error.to_string()) + } + lithe_git_host::Failure::Io(error) => { + CoreError::new(ErrorCode::ProcessFailed, "Could not read Git output") + .with_details(error.to_string()) + } + lithe_git_host::Failure::Cancelled => cancellation_error + .unwrap_or_else(|| CoreError::new(ErrorCode::Cancelled, "Operation was cancelled")), + lithe_git_host::Failure::OutputLimit => CoreError::new( + ErrorCode::ProcessFailed, + "Git output exceeded the capture limit", + ), + lithe_git_host::Failure::Cleanup => CoreError::new( + ErrorCode::ProcessFailed, + "Git process cleanup did not complete within its deadline", + ), + }); + let exit_code = output + .status + .as_ref() + .and_then(std::process::ExitStatus::code); + trace.observe(exit_code, &output.stderr); + if let Some(invocation) = invocation.into_inner() { + // A query that answers with a nonzero exit, such as a missing config key, keeps + // its actual code without presenting a normal result as failure. + let expected_exit = error.is_none() + && execution_events::Invocation::answered(visible, arguments, exit_code); + invocation.finished_with_expected_exit(exit_code, error.as_ref(), expected_exit); + } + if let Some(error) = error { + return Err(error); + } + Ok(GitProcessOutput { + stdout: output.stdout, + stderr: output.stderr, + exit_code: exit_code.unwrap_or(1), + }) +} + +pub(crate) fn git_process() -> Command { + let options = execution_policy::current(); + let executable = lithe_git_host::configuration::executable(options.executable.as_deref()) + .unwrap_or_else(|| options.executable.unwrap_or_else(|| "git".into()).into()); + #[cfg(target_os = "windows")] + { + let mut process = Command::new(executable); + process.creation_flags(git_process_creation_flags()); + process + } + + #[cfg(not(target_os = "windows"))] + { + Command::new(executable) + } +} + +#[cfg(target_os = "windows")] +pub(crate) fn git_process_creation_flags() -> u32 { + // Git runs as an IDE background task; attaching a console can briefly open + // the user's default terminal whenever status or repository data refreshes. + const CREATE_NO_WINDOW: u32 = 0x08000000; + CREATE_NO_WINDOW +} + +pub(crate) fn successful_git_result() -> GitCommandResponse { + GitCommandResponse { + arguments: Vec::new(), + output: String::new(), + stdout: String::new(), + stderr: String::new(), + exit_code: 0, + invocations: Vec::new(), + operation_error: None, + stash_restore: None, + tag_deletion: None, + branch_deletion: None, + history_rewrite: None, + outcome_authoritative: false, + warnings: Vec::new(), + } +} + +pub(crate) fn failed_git_result(error: CoreError) -> GitCommandResponse { + GitCommandResponse { + arguments: Vec::new(), + output: String::new(), + stdout: String::new(), + stderr: String::new(), + exit_code: 1, + invocations: Vec::new(), + operation_error: Some(error), + stash_restore: None, + tag_deletion: None, + branch_deletion: None, + history_rewrite: None, + outcome_authoritative: false, + warnings: Vec::new(), + } +} + +pub(crate) fn read_git_config_value(root: &str, key: &str) -> Result, CoreError> { + // This is an internal preflight, not a user command. A missing optional + // value returns 1 normally and must not appear as a failed console action. + let arguments = ["config".into(), "--get".into(), key.to_string()]; + let configured = + capture_git_with_options(root, &arguments, None, true)?.into_command_response(&arguments); + if configured.exit_code == 1 { + return Ok(None); + } + if configured.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Could not read Git configuration") + .with_details(configured.output), + ); + } + let value = configured.output.trim(); + if value.is_empty() { + return Ok(None); + } + validate_push_component(value)?; + Ok(Some(value.to_string())) +} + +pub(crate) fn run_git( + directory: &Path, + arguments: &[&str], +) -> Result { + let arguments = arguments + .iter() + .map(|value| value.to_string()) + .collect::>(); + if lithe_workspace::is_remote() { + let output = capture_git_process( + &directory.to_string_lossy(), + &arguments, + None, + true, + &[], + false, + )?; + #[cfg(unix)] + let status = { + use std::os::unix::process::ExitStatusExt; + std::process::ExitStatus::from_raw(output.exit_code << 8) + }; + #[cfg(windows)] + let status = { + use std::os::windows::process::ExitStatusExt; + std::process::ExitStatus::from_raw(output.exit_code as u32) + }; + return Ok(std::process::Output { + status, + stdout: output.stdout, + stderr: output.stderr, + }); + } + // Status and path discovery are read-only from Lithe's point of view. Git + // may otherwise refresh its optional index data while answering a query, + // which emits `.git/index` events into the native watcher and can trigger + // another status refresh. + let root = directory.to_string_lossy(); + let mut trace = command_trace::Trace::begin(&root, &arguments, false); + let mut invocation = execution_events::Invocation::reported(false); + if let Some(invocation) = &invocation { + invocation.started(&root, &arguments); + } + let result = git_process() + .env("GIT_OPTIONAL_LOCKS", "0") + .args(&arguments) + .current_dir(directory) + .output() + .map_err(|error| { + CoreError::new(ErrorCode::ProcessStartFailed, "Could not start Git") + .with_details(error.to_string()) + }); + let exit_code = result.as_ref().ok().and_then(|output| output.status.code()); + if let Ok(output) = &result { + trace.observe(exit_code, &output.stderr); + if let Some(invocation) = &mut invocation { + invocation.output(lithe_git_host::Stream::Stdout, &output.stdout); + invocation.output(lithe_git_host::Stream::Stderr, &output.stderr); + } + } + if let Some(invocation) = invocation { + let expected_exit = + result.is_ok() && execution_events::Invocation::answered(false, &arguments, exit_code); + invocation.finished_with_expected_exit(exit_code, result.as_ref().err(), expected_exit); + } + trace.finish(result.as_ref().err()); + result +} diff --git a/rust/crates/core/src/git/progress.rs b/rust/crates/git/src/progress.rs similarity index 97% rename from rust/crates/core/src/git/progress.rs rename to rust/crates/git/src/progress.rs index bfcd6881..5d8b2493 100644 --- a/rust/crates/core/src/git/progress.rs +++ b/rust/crates/git/src/progress.rs @@ -49,7 +49,7 @@ mod tests { #[test] fn progress_matches_shared_phases_without_treating_errors_as_percentages() { let fixture: serde_json::Value = serde_json::from_str(include_str!( - "../../../../fixtures/git/execution-policy-v1.json" + "../../../fixtures/git/execution-policy-v1.json" )) .unwrap(); for case in fixture["progress"].as_array().unwrap() { diff --git a/rust/crates/git/src/push.rs b/rust/crates/git/src/push.rs new file mode 100644 index 00000000..824c0d99 --- /dev/null +++ b/rust/crates/git/src/push.rs @@ -0,0 +1,601 @@ +//! Push previews, push target resolution and expectation checks, and publishing a branch. + +use super::*; + +#[derive(Clone)] +struct PushTarget { + local_branch: String, + remote: String, + /// Remote branch the push updates. For a divergent upstream this follows the + /// confirmed choice, so previews, expectation checks, leases and refspecs all + /// describe the same branch. + remote_branch: String, + /// Relationship between the local branch and its upstream; independent of the + /// confirmed choice. + target_kind: PushTargetKind, + /// Whether the destination branch does not exist on the remote yet. + creates_remote_branch: bool, + upstream: Option, + /// Branch name of a divergent upstream on `remote`; `None` for every other kind. + divergent_upstream_branch: Option, + comparison_reference: Option, +} + +/// Resolves the remote destination and commits that a subsequent push will use. +pub fn push_preview(request: GitPushPreviewRequest) -> Result { + let root = validate_root(&request.root)?; + let reference = if let Some(reference) = request.git_reference.as_ref() { + let reference = validated_git_reference(&root, reference)?; + if reference.kind != "local" { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Only local branches can be pushed", + )); + } + Some(reference.full_name) + } else { + request + .reference + .as_deref() + .map(|reference| validated_reference(Some(reference))) + .transpose()? + }; + let target = resolve_push_target(&root, reference.as_deref(), request.push_target)?; + let limit = request.limit.clamp(1, 5_000); + let local_reference = format!("refs/heads/{}", target.local_branch); + let local_head = resolve_commit_revision(&root, &local_reference)?; + let tags = resolve_push_tags( + &root, + request.push_tags.as_deref().unwrap_or("none"), + &local_head, + )?; + let remote_tracking_oid = target + .comparison_reference + .as_deref() + .map(|reference| resolve_commit_revision(&root, reference)) + .transpose()?; + let selectors = if let Some(comparison_reference) = target.comparison_reference.as_ref() { + vec![format!("{comparison_reference}..{local_reference}")] + } else { + // A branch without an upstream may still be based on another remote branch. + // Excluding every commit reachable from the selected remote keeps the preview + // focused on commits that publication would introduce. + vec![ + local_reference, + "--not".to_string(), + format!("--remotes={}", target.remote), + ] + }; + let (commits, has_more) = read_commit_log(&root, selectors, limit, "Git push preview failed")?; + + Ok(GitPushPreviewResponse { + local_branch: target.local_branch, + local_head, + remote: target.remote, + remote_branch: target.remote_branch, + target_kind: target.target_kind.as_contract(), + creates_remote_branch: target.creates_remote_branch, + remote_tracking_oid, + upstream: target.upstream, + tags, + commits, + has_more, + }) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +/// Destination the user explicitly confirmed for a push. +/// +/// Only a divergent upstream requires this: the usual same-named destination stays +/// implicit so routine pushes keep their current interaction cost. +pub enum PushTargetChoiceRequest { + /// Publish the local branch to the same-named remote branch. + Publish, + /// Keep using the existing upstream branch, even though its name differs. + Upstream, +} + +/// Rejection for a divergent upstream that no caller confirmed. The message names +/// the command that unblocks a client without a destination picker, because the +/// native macOS push does not offer one yet. +pub(crate) fn push_destination_needs_confirmation( + local_branch: &str, + remote: &str, + upstream: &str, +) -> CoreError { + CoreError::new( + ErrorCode::InvalidRequest, + format!( + "Push destination needs confirmation: '{local_branch}' tracks '{upstream}', which has a different name. \ + Run `git push -u {remote} {local_branch}` to publish it under its own name, \ + or confirm the destination in a client that offers the choice." + ), + ) +} + +pub(crate) fn push( + root: &str, + reference: Option<&str>, + force: bool, + push_tags: Option<&str>, + expected_push: Option<&GitPushExpectationRequest>, + check_submodules: bool, + push_target: Option, +) -> Result { + let tag_scope = push_tags.unwrap_or("none"); + let tag_argument = match tag_scope { + "none" => None, + "all" => Some("--tags"), + "reachable" => Some("--follow-tags"), + _ => { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Unsupported Git push tag scope", + )) + } + }; + // Issue #1272: the effective destination is resolved once, with the caller's + // confirmed choice, by the same function the preview uses. A reviewed push is + // therefore compared against exactly the branch the user was shown. + let target = resolve_push_target(root, reference, push_target)?; + if target.target_kind == PushTargetKind::Divergent && push_target.is_none() { + return Err(push_destination_needs_confirmation( + &target.local_branch, + &target.remote, + target.upstream.as_deref().unwrap_or_default(), + )); + } + if let Some(expected_push) = expected_push { + validate_push_expectation(root, &target, tag_scope, expected_push)?; + } + // Only an upstream that was missing or deliberately replaced is repointed after + // a successful push. Confirming "upstream" keeps the foreign branch name and + // leaves the user's tracking configuration untouched. + let should_set_upstream = match target.target_kind { + PushTargetKind::Tracking => false, + PushTargetKind::Publish => target.upstream.is_none(), + PushTargetKind::Divergent => push_target == Some(PushTargetChoiceRequest::Publish), + }; + let mut arguments = vec!["push".to_string()]; + if check_submodules { + arguments.push("--recurse-submodules=check".into()); + } + if force { + // Bind reviewed pushes to the observed remote OID so an unseen remote update is rejected. + arguments.push(match expected_push { + Some(expected) => format!( + "--force-with-lease=refs/heads/{}:{}", + target.remote_branch, + expected.remote_tracking_oid.as_deref().unwrap_or_default() + ), + None => "--force-with-lease".into(), + }); + } + if expected_push.is_none() { + if let Some(tag_argument) = tag_argument { + arguments.push(tag_argument.into()); + } + } + let source = expected_push + .map(|expected| expected.local_head.clone()) + .unwrap_or_else(|| format!("refs/heads/{}", target.local_branch)); + let remote = target.remote; + let remote_branch = target.remote_branch; + let local_branch = target.local_branch; + arguments.extend([ + remote.clone(), + format!("{source}:refs/heads/{remote_branch}"), + ]); + if let Some(expected) = expected_push { + arguments.extend( + expected + .tags + .iter() + .map(|tag| format!("{}:{}", tag.object_id, tag.full_name)), + ); + } + let pushed = execute_git(root, &arguments, None)?; + if pushed.exit_code != 0 || !should_set_upstream { + return Ok(pushed); + } + + if let Some(configuration) = + configure_branch_upstream(root, &local_branch, &remote, &remote_branch)? + { + return Ok(push_with_upstream_warning(pushed, configuration)); + } + Ok(pushed) +} + +fn push_with_upstream_warning( + mut pushed: GitCommandResponse, + configuration: GitCommandResponse, +) -> GitCommandResponse { + pushed.warnings.push(GitOperationWarning::new( + "git_upstream_configuration_failed", + "The push succeeded, but the branch upstream could not be configured", + Some(configuration.output), + )); + pushed +} + +fn validate_push_expectation( + root: &str, + target: &PushTarget, + tag_scope: &str, + expected: &GitPushExpectationRequest, +) -> Result<(), CoreError> { + let local_reference = format!("refs/heads/{}", target.local_branch); + let local_head = resolve_commit_revision(root, &local_reference)?; + let remote_tracking_oid = target + .comparison_reference + .as_deref() + .map(|reference| resolve_commit_revision(root, reference)) + .transpose()?; + let tags = resolve_push_tags(root, tag_scope, &local_head)?; + let expected_tags = expected + .tags + .iter() + .map(|tag| (&tag.full_name, &tag.object_id)) + .collect::>(); + let actual_tags = tags + .iter() + .map(|tag| (&tag.full_name, &tag.object_id)) + .collect::>(); + // A reviewed push records the destination relationship too, so a branch that + // started tracking `main` after the preview cannot slip through the check. + // `targetKind` is compared only when the caller supplied it: a caller that + // predates target kinds keeps its previous behaviour instead of being rejected + // as stale. + let target_kind_changed = expected + .target_kind + .as_ref() + .is_some_and(|kind| *kind != target.target_kind.as_contract()); + if expected.local_branch != target.local_branch + || expected.local_head != local_head + || expected.remote != target.remote + || expected.remote_branch != target.remote_branch + || expected.remote_tracking_oid != remote_tracking_oid + || target_kind_changed + || expected_tags != actual_tags + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Git push preview is stale; refresh and try again.", + )); + } + Ok(()) +} + +fn resolve_push_tags( + root: &str, + scope: &str, + local_head: &str, +) -> Result, CoreError> { + if scope == "none" { + return Ok(Vec::new()); + } + if !matches!(scope, "all" | "reachable") { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Unsupported Git push tag scope", + )); + } + let listed = execute_git_readonly( + root, + &[ + "for-each-ref".into(), + "--sort=refname".into(), + "--format=%(refname)%09%(objectname)%09%(objecttype)%09%(*objectname)".into(), + "refs/tags".into(), + ], + None, + )?; + if listed.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Could not inspect Git tags") + .with_details(listed.output), + ); + } + + let mut tags = Vec::new(); + for line in listed.stdout.lines() { + let columns = line.split('\t').collect::>(); + if columns.len() != 4 || !columns[0].starts_with("refs/tags/") || columns[1].is_empty() { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Could not decode Git tag metadata", + )); + } + if scope == "reachable" { + if columns[2] != "tag" || columns[3].is_empty() { + continue; + } + let reachable = execute_git_readonly( + root, + &[ + "merge-base".into(), + "--is-ancestor".into(), + columns[3].into(), + local_head.into(), + ], + None, + )?; + if reachable.exit_code == 1 { + continue; + } + if reachable.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not inspect Git tag reachability", + ) + .with_details(reachable.output)); + } + } + tags.push(GitPushTagResponse { + full_name: columns[0].to_string(), + object_id: columns[1].to_string(), + }); + } + Ok(tags) +} + +/// Resolves the single destination used by both `pushPreview` and `push`. +/// +/// `choice` only matters for a divergent upstream. Keeping the preview and the push +/// on this one function is what lets a reviewed push compare the exact remote +/// branch, tracking OID and lease the user was shown. +fn resolve_push_target( + root: &str, + reference: Option<&str>, + choice: Option, +) -> Result { + let local_branch = match reference { + Some(reference) => local_branch_name(&validated_reference(Some(reference))?)?, + None => current_branch(root)?, + }; + let upstream_lookup = execute_git_readonly( + root, + &[ + "rev-parse".into(), + "--abbrev-ref".into(), + format!("{local_branch}@{{upstream}}"), + ], + None, + )?; + let (upstream, upstream_components) = if upstream_lookup.exit_code == 0 { + let upstream = upstream_lookup.output.trim().to_string(); + let components = + mutations::remote_branch_components(root, &format!("refs/remotes/{upstream}"))?; + (Some(upstream), Some(components)) + } else { + (None, None) + }; + + let branch_push_remote = + read_git_config_value(root, &format!("branch.{local_branch}.pushRemote"))?; + let default_push_remote = read_git_config_value(root, "remote.pushDefault")?; + let branch_remote = read_git_config_value(root, &format!("branch.{local_branch}.remote"))? + .filter(|remote| remote != "."); + let remote = branch_push_remote + .or(default_push_remote) + .or_else(|| { + upstream_components + .as_ref() + .map(|(remote, _)| remote.clone()) + }) + .or(branch_remote) + .map(Ok) + .unwrap_or_else(|| default_push_remote_name(root))?; + validate_push_component(&remote)?; + + // The upstream only identifies a destination when it lives on the remote this + // push actually targets. A `pushRemote`/`remote.pushDefault` override therefore + // falls back to the same-named branch on the selected remote. + let upstream_branch = upstream_components + .as_ref() + .filter(|(upstream_remote, _)| upstream_remote == &remote) + .map(|(_, branch)| branch.clone()); + let (target_kind, remote_branch, divergent_upstream_branch) = match upstream_branch { + // Issue #1272: a divergent upstream means pushing would update a different + // branch, so it is never the implicit destination. The same-named branch is + // reported while the choice is pending only so a client can describe it; + // `push` refuses to use it until a choice is confirmed. + Some(branch) if branch != local_branch => { + let remote_branch = match choice { + Some(PushTargetChoiceRequest::Upstream) => branch.clone(), + Some(PushTargetChoiceRequest::Publish) | None => local_branch.clone(), + }; + (PushTargetKind::Divergent, remote_branch, Some(branch)) + } + Some(branch) => (PushTargetKind::Tracking, branch, None), + None => (PushTargetKind::Publish, local_branch.clone(), None), + }; + let target_reference = format!("refs/remotes/{remote}/{remote_branch}"); + let comparison_reference = if reference_exists(root, &target_reference)? { + Some(target_reference) + } else { + None + }; + + Ok(PushTarget { + local_branch, + remote, + remote_branch, + target_kind, + creates_remote_branch: comparison_reference.is_none(), + upstream, + divergent_upstream_branch, + comparison_reference, + }) +} + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +/// A branch whose upstream has a different name and therefore needs the user's +/// choice before anything is pushed. +pub struct DivergentPushDestination { + /// Short name of the local branch being pushed. + pub local_branch: String, + /// Remote that both choices target; the upstream lives on it. + pub remote: String, + /// Upstream short name such as `origin/release`. + pub upstream: String, + /// Branch name of the upstream on `remote`, which may itself contain slashes. + pub upstream_branch: String, +} + +impl DivergentPushDestination { + /// Remote branch that a confirmed `choice` updates. + pub(crate) fn remote_branch_for(&self, choice: PushTargetChoiceRequest) -> &str { + match choice { + PushTargetChoiceRequest::Publish => &self.local_branch, + PushTargetChoiceRequest::Upstream => &self.upstream_branch, + } + } +} + +/// Reports whether pushing `reference` needs a destination choice, using the same +/// resolution as `push` so a plan can ask before it commits anything. +pub(crate) fn divergent_push_destination( + root: &str, + reference: &str, +) -> Result, CoreError> { + let target = resolve_push_target(root, Some(reference), None)?; + Ok( + match ( + target.target_kind, + target.upstream, + target.divergent_upstream_branch, + ) { + (PushTargetKind::Divergent, Some(upstream), Some(upstream_branch)) => { + Some(DivergentPushDestination { + local_branch: target.local_branch, + remote: target.remote, + upstream, + upstream_branch, + }) + } + _ => None, + }, + ) +} + +/// Push destination relationship, mirroring [`GitPushTargetKind`] inside Core. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PushTargetKind { + /// No upstream on the selected remote; publish to the same-named branch. + Publish, + /// Upstream and local branch share a name. + Tracking, + /// Upstream branch name differs from the local branch name. + Divergent, +} + +impl PushTargetKind { + /// Contract representation returned to callers. + fn as_contract(self) -> GitPushTargetKind { + match self { + Self::Publish => GitPushTargetKind::Publish, + Self::Tracking => GitPushTargetKind::Tracking, + Self::Divergent => GitPushTargetKind::Divergent, + } + } +} + +fn reference_exists(root: &str, reference: &str) -> Result { + let result = execute_git_readonly( + root, + &[ + "show-ref".into(), + "--verify".into(), + "--quiet".into(), + reference.to_string(), + ], + None, + )?; + if result.exit_code > 1 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not inspect Git push destination", + ) + .with_details(result.output)); + } + Ok(result.exit_code == 0) +} + +fn default_push_remote_name(root: &str) -> Result { + let remotes = execute_git_readonly(root, &["remote".into()], None)?; + if remotes.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Could not list Git remotes") + .with_details(remotes.output), + ); + } + let remote = remotes + .output + .lines() + .map(str::trim) + .find(|remote| *remote == "origin") + .or_else(|| { + remotes + .output + .lines() + .map(str::trim) + .find(|remote| !remote.is_empty()) + }) + .ok_or_else(|| CoreError::new(ErrorCode::InvalidRequest, "No Git remote is configured"))?; + validate_push_component(remote)?; + Ok(remote.to_string()) +} + +pub(crate) fn validate_push_component(value: &str) -> Result<(), CoreError> { + if value.is_empty() + || value.starts_with('-') + || value.contains(['\0', '\n', '\r']) + || value.chars().any(char::is_whitespace) + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git push destination", + )); + } + Ok(()) +} + +pub(crate) fn publish_branch( + root: &str, + name: Option<&str>, +) -> Result { + let name = validated_branch_name(root, name)?; + match optional_current_branch(root)? { + Some(current) if current != name => { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Publish the currently checked out branch", + )); + } + Some(_) => {} + None => { + let created = execute_git( + root, + &["switch".into(), "-c".into(), name.clone(), "HEAD".into()], + None, + )?; + if created.exit_code != 0 { + return Ok(created); + } + } + } + execute_git( + root, + &[ + "push".into(), + "--set-upstream".into(), + "origin".into(), + name, + ], + None, + ) +} diff --git a/rust/crates/core/src/git/rebase_session.rs b/rust/crates/git/src/rebase_session.rs similarity index 98% rename from rust/crates/core/src/git/rebase_session.rs rename to rust/crates/git/src/rebase_session.rs index c1136286..e90790e9 100644 --- a/rust/crates/core/src/git/rebase_session.rs +++ b/rust/crates/git/src/rebase_session.rs @@ -2,7 +2,7 @@ use super::rewrite::{GitHistoryRewriteBlocker, GitHistoryRewriteCommit, RewriteLease}; use super::*; -use crate::remote::workspace_io as fs; +use lithe_workspace::fs; use std::collections::{BTreeMap, BTreeSet}; const SESSION_DIRECTORY: &str = "lithe-rebase-session"; @@ -31,8 +31,8 @@ const MESSAGE_EDITOR: &str = r#"sh -c 'set -eu; target=$1; row=$(tail -n 1 "$LIT #[serde(rename_all = "camelCase")] /// Unchanged base commit; only its successors through HEAD are rewritten. pub struct GitRebasePreviewRequest { - pub root: String, - pub revision: String, + pub(crate) root: String, + pub(crate) revision: String, } #[derive(Debug, Serialize)] @@ -65,8 +65,9 @@ pub struct GitRebaseStep { #[serde(rename_all = "camelCase")] /// Complete reviewed range, with each commit represented exactly once. pub struct GitRebaseStartRequest { - pub root: String, - pub expected_state: GitHistoryRewriteExpectation, + pub(crate) root: String, + pub(crate) expected_state: GitHistoryRewriteExpectation, + /// The todo, in the order Git replays it. pub steps: Vec, } @@ -74,6 +75,7 @@ pub struct GitRebaseStartRequest { #[serde(rename_all = "camelCase")] /// Query the last Lithe-owned session in this checkout's Git directory. pub struct GitRebaseSessionRequest { + /// Repository whose Git directory holds the session. pub root: String, } @@ -81,7 +83,9 @@ pub struct GitRebaseSessionRequest { #[serde(rename_all = "camelCase")] /// Resume an identified session, keeping process cancellation separate from abort. pub struct GitRebaseControlRequest { + /// Repository whose Git directory holds the session. pub root: String, + /// Session the host reviewed; a different recorded session is refused. pub session_id: String, /// `continue`, `skip`, or `abort`. pub action: String, @@ -408,9 +412,7 @@ struct PlanPreviewResponse { /// /// Problems the user can fix in the editor are returned as an `issue`; a draft /// that does not cover the reviewed range exactly once is a host error. -pub(crate) fn plan_preview_command( - payload: serde_json::Value, -) -> Result { +pub fn plan_preview_command(payload: serde_json::Value) -> Result { let request: PlanPreviewRequest = serde_json::from_value(payload).map_err(|error| { invalid("Invalid interactive rebase plan").with_details(error.to_string()) })?; @@ -853,7 +855,7 @@ fn finish_mutation( // Normal inspection keeps the request budget; only an ended request needs // a separate bounded scope to report surviving state without relaunching Git. let refreshed = - crate::protocol::cancellation::with_mutation_inspection(Duration::from_secs(2), || { + lithe_protocol::cancellation::with_mutation_inspection(Duration::from_secs(2), || { if !native_owned(root, &record)? { record.status = if command.operation_error.is_some() { "interrupted" @@ -875,7 +877,7 @@ fn finish_mutation( Some(error.message), )); } - crate::protocol::cancellation::check()?; + lithe_protocol::cancellation::check()?; Ok::<_, CoreError>(projected) }); let session = match refreshed { @@ -937,7 +939,7 @@ pub fn start(request: GitRebaseStartRequest) -> Result Value { - serde_json::from_str(&crate::execute_json( - &json!({"id":"plan", "command":"git.rebasePlanPreview", - "payload":{"commits":commits, "steps":steps}}) - .to_string(), + crate::test_envelope(super::plan_preview_command( + json!({"commits": commits, "steps": steps}), )) - .unwrap() } /// Both editors present and submit exactly these values; a drift here is @@ -1239,7 +1238,7 @@ mod plan_preview_tests { #[test] fn shared_fixture_drafts_match_start_semantics() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../fixtures/git/rebase-plan-preview-v1.json" + "../../../fixtures/git/rebase-plan-preview-v1.json" )) .unwrap(); let commits = &fixture["commits"]; @@ -1257,7 +1256,7 @@ mod plan_preview_tests { #[test] fn issue_free_preview_is_accepted_by_the_start_planner() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../fixtures/git/rebase-plan-preview-v1.json" + "../../../fixtures/git/rebase-plan-preview-v1.json" )) .unwrap(); let commits = fixture["commits"] diff --git a/rust/crates/core/src/git/reference_tree.rs b/rust/crates/git/src/reference_tree.rs similarity index 82% rename from rust/crates/core/src/git/reference_tree.rs rename to rust/crates/git/src/reference_tree.rs index 0ab20a8f..b5ae64e5 100644 --- a/rust/crates/core/src/git/reference_tree.rs +++ b/rust/crates/git/src/reference_tree.rs @@ -5,8 +5,8 @@ //! indentation, row identities, and virtualization stay in the views; Core only //! decides the grouping and the sibling order. -use crate::project::file_name_order::FileNameKey; -use crate::protocol::{CoreError, ErrorCode}; +use lithe_core_rules::file_names::FileNameKey; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; @@ -62,7 +62,7 @@ struct Response { /// then references before pure groups, then IDEA's natural name order /// (`v2` before `v10`, case ignored until it is the only difference). Empty /// segments are ignored; a repeated short name keeps the last reference. -pub(crate) fn command(payload: Value) -> Result { +pub fn command(payload: Value) -> Result { let request: Request = serde_json::from_value(payload).map_err(|error| { CoreError::new( ErrorCode::InvalidRequest, @@ -150,34 +150,3 @@ impl Builder { nodes.into_iter().map(|(_, node)| node).collect() } } - -#[cfg(test)] -mod tests { - use serde_json::{json, Value}; - - fn call(payload: Value) -> Value { - serde_json::from_str(&crate::execute_json( - &json!({"id":"tree", "command":"git.referenceTree", "payload":payload}).to_string(), - )) - .unwrap() - } - - #[test] - fn shared_fixture_trees_have_one_grouping_and_order() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../../fixtures/git/reference-tree-v1.json" - )) - .unwrap(); - for case in fixture["cases"].as_array().unwrap() { - let response = call(case["request"].clone()); - assert_eq!(response["ok"], true, "{}: {response}", case["name"]); - assert_eq!(response["data"]["nodes"], case["nodes"], "{}", case["name"]); - } - } - - #[test] - fn rejects_unknown_fields() { - let response = call(json!({"references":[{"name":"main","kind":"local"}]})); - assert_eq!(response["ok"], false); - } -} diff --git a/rust/crates/git/src/references.rs b/rust/crates/git/src/references.rs new file mode 100644 index 00000000..68e1a878 --- /dev/null +++ b/rust/crates/git/src/references.rs @@ -0,0 +1,282 @@ +//! Validation of user-supplied revisions, references and branch, tag and stash names. + +use super::*; + +#[derive(Clone)] +pub(crate) struct ValidatedGitReference { + pub(crate) full_name: String, + pub(crate) short_name: String, + pub(crate) kind: String, +} + +pub(crate) fn invalid_git_reference() -> CoreError { + CoreError::new(ErrorCode::InvalidRequest, "Invalid Git reference") +} + +pub(crate) fn validated_git_reference( + root: &str, + reference: &GitReferenceRequest, +) -> Result { + if reference.full_name.contains(['\0', '\n', '\r']) + || reference.short_name.contains(['\0', '\n', '\r']) + || reference.full_name.chars().any(char::is_whitespace) + || reference.short_name.trim() != reference.short_name + { + return Err(invalid_git_reference()); + } + + let prefix = match reference.kind.as_str() { + "local" => "refs/heads/", + "remote" => "refs/remotes/", + "tag" => "refs/tags/", + _ => return Err(invalid_git_reference()), + }; + let expected_short_name = reference + .full_name + .strip_prefix(prefix) + .filter(|value| !value.is_empty()) + .ok_or_else(invalid_git_reference)?; + if expected_short_name != reference.short_name { + return Err(invalid_git_reference()); + } + + let checked = execute_git_readonly( + root, + &["check-ref-format".into(), reference.full_name.clone()], + None, + )?; + if checked.exit_code != 0 { + return Err(invalid_git_reference()); + } + if reference.kind == "remote" { + let (_, branch) = mutations::remote_branch_components(root, &reference.full_name)?; + if branch == "HEAD" { + return Err(invalid_git_reference()); + } + } + + Ok(ValidatedGitReference { + full_name: reference.full_name.clone(), + short_name: reference.short_name.clone(), + kind: reference.kind.clone(), + }) +} + +fn legacy_checkout_reference( + root: &str, + reference: &str, + kind: &str, +) -> Result { + let (full_name, short_name) = match kind { + "local" => { + let short_name = reference.strip_prefix("refs/heads/").unwrap_or(reference); + (format!("refs/heads/{short_name}"), short_name.to_string()) + } + "remote" => { + let short_name = reference + .strip_prefix("refs/remotes/") + .ok_or_else(invalid_git_reference)?; + (reference.to_string(), short_name.to_string()) + } + "tag" => { + let short_name = reference.strip_prefix("refs/tags/").unwrap_or(reference); + (format!("refs/tags/{short_name}"), short_name.to_string()) + } + _ => return Err(invalid_git_reference()), + }; + validated_git_reference( + root, + &GitReferenceRequest { + full_name, + short_name, + kind: kind.to_string(), + }, + ) +} + +pub(crate) fn optional_write_request_reference( + root: &str, + request: &GitWriteRequest, +) -> Result, CoreError> { + if let Some(reference) = request.git_reference.as_ref() { + return validated_git_reference(root, reference).map(|value| Some(value.full_name)); + } + request + .reference + .as_deref() + .map(|reference| validated_reference(Some(reference))) + .transpose() +} + +pub(crate) fn write_request_reference( + root: &str, + request: &GitWriteRequest, +) -> Result { + // Branch restore records carry the deleted commit object ID rather than a + // live ref. Accept that exact revision only for createBranch; every other + // mutation continues to require a typed, existing Git reference. + if request.operation == "createBranch" { + if let Some(reference) = request.git_reference.as_ref() { + if reference.full_name == reference.short_name { + if let Ok(revision) = validated_revision(Some(&reference.full_name)) { + return Ok(revision); + } + } + } + } + optional_write_request_reference(root, request)? + .ok_or_else(|| CoreError::new(ErrorCode::InvalidRequest, "Missing Git reference")) +} + +pub(crate) fn checkout_request_reference( + root: &str, + request: &GitWriteRequest, +) -> Result { + if let Some(reference) = request.git_reference.as_ref() { + return validated_git_reference(root, reference); + } + let reference = validated_reference(request.reference.as_deref())?; + let kind = required_text(request.reference_kind.as_deref(), "reference kind")?; + legacy_checkout_reference(root, &reference, &kind) +} + +pub(crate) fn request_reference( + root: &str, + typed: Option<&GitReferenceRequest>, + legacy: Option<&str>, +) -> Result { + if let Some(reference) = typed { + return validated_git_reference(root, reference).map(|value| value.full_name); + } + validated_reference(legacy) +} + +pub(crate) fn typed_reference_range( + root: &str, + base: Option<&GitReferenceRequest>, + target: Option<&GitReferenceRequest>, + legacy: Option<&str>, +) -> Result, CoreError> { + match (base, target) { + (Some(base), Some(target)) => { + if legacy.is_some() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Git comparison cannot combine typed and legacy references", + )); + } + let base = validated_git_reference(root, base)?; + let target = validated_git_reference(root, target)?; + Ok(Some(format!("{}..{}", base.full_name, target.full_name))) + } + (None, Some(_)) => Err(CoreError::new( + ErrorCode::InvalidRequest, + "Git comparison target requires a base reference", + )), + (Some(base), None) => { + if legacy.is_some() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Git comparison cannot combine typed and legacy references", + )); + } + Ok(Some(validated_git_reference(root, base)?.full_name)) + } + (None, None) => legacy + .map(|value| validated_reference(Some(value))) + .transpose(), + } +} + +pub(crate) fn validated_revision(value: Option<&str>) -> Result { + let value = required_text(value, "revision")?; + validate_revision(&value)?; + Ok(value) +} + +pub(crate) fn validated_reference(value: Option<&str>) -> Result { + let value = required_text(value, "reference")?; + if value.starts_with('-') || value.chars().any(char::is_whitespace) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git reference", + )); + } + Ok(value) +} + +pub(crate) fn validated_stash_reference(value: Option<&str>) -> Result { + let value = required_text(value, "stash reference")?; + if value.starts_with('-') || value.contains(char::is_whitespace) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git stash reference", + )); + } + Ok(value) +} + +pub(crate) fn validated_branch_name(root: &str, value: Option<&str>) -> Result { + let value = required_text(value, "branch name")?; + let validation = execute_git( + root, + &["check-ref-format".into(), "--branch".into(), value.clone()], + None, + )?; + if validation.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::InvalidRequest, "Invalid Git branch name") + .with_details(validation.output), + ); + } + Ok(value) +} + +/// Validates a tag name against the same refname rules `git check-ref-format` +/// enforces, so an invalid name fails before any subprocess with a stable +/// message instead of depending on localized `git tag` output. +pub(crate) fn validated_tag_name(value: Option<&str>) -> Result { + let value = required_text(value, "tag name")?; + if is_invalid_tag_name(&value) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git tag name", + )); + } + Ok(value) +} + +/// Refname rules from `git check-ref-format` plus command-line safety guards +/// (no leading dash) shared by every Git mutation argument. +fn is_invalid_tag_name(value: &str) -> bool { + if value.starts_with('-') + || value == "@" + || value.starts_with('/') + || value.ends_with('/') + || value.ends_with('.') + || value.contains("..") + || value.contains("@{") + || value.contains("//") + { + return true; + } + if value.chars().any(|character| { + character.is_control() + || matches!(character, ' ' | '~' | '^' | ':' | '?' | '*' | '[' | '\\') + }) { + return true; + } + value + .split('/') + .any(|component| component.starts_with('.') || component.ends_with(".lock")) +} + +pub(crate) fn validate_revision(value: &str) -> Result<(), CoreError> { + if value.trim().is_empty() || value.starts_with('-') || value.contains('\0') { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git revision", + )); + } + Ok(()) +} diff --git a/rust/crates/core/src/git/remote_history.rs b/rust/crates/git/src/remote_history.rs similarity index 96% rename from rust/crates/core/src/git/remote_history.rs rename to rust/crates/git/src/remote_history.rs index 5a3f9b2b..b43f43e9 100644 --- a/rust/crates/core/src/git/remote_history.rs +++ b/rust/crates/git/src/remote_history.rs @@ -1,9 +1,9 @@ //! Incremental Git history on the workspace host, without local reader threads. -use crate::protocol::{cancellation, CoreError, ErrorCode}; -use crate::remote::workspace::{self, Host}; +use lithe_protocol::{cancellation, CoreError, ErrorCode}; use lithe_remote::client::session::Event; use lithe_remote::protocol::methods::{self, OutputStream}; +use lithe_workspace::{self as workspace, Host}; use serde_json::json; use std::collections::VecDeque; use std::sync::mpsc::{Receiver, RecvTimeoutError}; @@ -196,11 +196,11 @@ impl Drop for RemoteHistory { // A finished log process is already pruned by the host, which is the // expected missing case. Drop owns other cleanup failures; WARN keeps // the page result. - if !crate::diagnostics::core_events::remote_resource_already_gone(&error) { - crate::diagnostics::core_events::recovery_failed( + if !error.is_resource_already_gone() { + lithe_diagnostics::core_events::recovery_failed( "vcs.history.cleanup_failed", "remote-kill", - &crate::diagnostics::core_events::client_failure_category(&error), + &error.failure_category(), "remote", ); } diff --git a/rust/crates/core/src/git/remote_process.rs b/rust/crates/git/src/remote_process.rs similarity index 98% rename from rust/crates/core/src/git/remote_process.rs rename to rust/crates/git/src/remote_process.rs index f5d6301f..ceb6c3eb 100644 --- a/rust/crates/core/src/git/remote_process.rs +++ b/rust/crates/git/src/remote_process.rs @@ -1,9 +1,9 @@ //! Executes the existing Git engine's invocations on its request-scoped host. use super::{command_trace, execution_events, execution_policy, GitProcessOutput}; -use crate::protocol::{CoreError, ErrorCode}; -use crate::remote::{workspace, workspace_host}; +use lithe_protocol::{CoreError, ErrorCode}; use lithe_remote::protocol::methods::{self, EnvironmentSource, OutputStream, SpawnParams}; +use lithe_workspace::{self as workspace, host as workspace_host}; use std::collections::BTreeMap; use std::path::Path; use std::time::Duration; @@ -121,7 +121,7 @@ pub(super) fn parameters( serde_json::json!({}), Duration::from_secs(60), ); - crate::protocol::cancellation::check()?; + lithe_protocol::cancellation::check()?; let resolved: methods::EnvironmentResolveResult = serde_json::from_value(result.map_err(|error| { CoreError::new( @@ -241,7 +241,7 @@ fn remote_git_executable( serde_json::json!({"path": candidate}), Duration::from_secs(30), ); - crate::protocol::cancellation::check()?; + lithe_protocol::cancellation::check()?; let metadata = match result { Ok(value) => { serde_json::from_value::(value).map_err(|error| { diff --git a/rust/crates/git/src/repository.rs b/rust/crates/git/src/repository.rs new file mode 100644 index 00000000..a0b05136 --- /dev/null +++ b/rust/crates/git/src/repository.rs @@ -0,0 +1,433 @@ +//! Repository discovery and path validation: workspace repository scans, root and +//! `.git` resolution, and the pathspec checks every command applies. + +use super::*; + +/// Discovers Git repositories for an opened workspace using shared traversal rules. +pub fn workspace_repositories( + request: WorkspaceRepositoriesRequest, +) -> Result { + let workspace_root = PathBuf::from(validate_root(&request.root)?); + let max_directories = request.max_directories; + let max_depth = request.max_depth; + let mut discovered_repositories = HashSet::new(); + let containing_repository = discover_containing_repository(&workspace_root)?; + + if let Some(repository) = &containing_repository { + discovered_repositories.insert(repository.clone()); + } + + let mut queue = VecDeque::from([(workspace_root.clone(), 0usize)]); + let mut visited_directories = HashSet::new(); + + while let Some((directory, depth)) = queue.pop_front() { + lithe_protocol::cancellation::check()?; + if visited_directories.len() >= max_directories { + break; + } + + let canonical_directory = match canonicalize_simplified(&directory) { + Ok(path) => path, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => return Err(repository_scan_error(error)), + }; + if !canonical_directory.starts_with(&workspace_root) { + continue; + } + if !visited_directories.insert(canonical_directory.clone()) { + continue; + } + + let entries = fs::read_dir(&canonical_directory).map_err(repository_scan_error)?; + let mut child_directories = Vec::new(); + for entry in entries { + lithe_protocol::cancellation::check()?; + let entry = entry.map_err(repository_scan_error)?; + let name = entry.file_name().to_string_lossy().to_string(); + if name == ".git" { + // Only openable repositories join the workspace list; a dangling gitfile + // entry must not poison status and reference loads for sibling roots. + if git_entry_opens_as_repository(&entry.path()) { + discovered_repositories.insert(canonical_directory.clone()); + } + continue; + } + if !repository_scan_directory_is_visible(&name) { + continue; + } + let file_type = entry.file_type().map_err(repository_scan_error)?; + if file_type.is_dir() { + child_directories.push(entry.path()); + } + } + child_directories.sort_by(|left, right| { + path_sort_key(left) + .cmp(&path_sort_key(right)) + .then_with(|| left.cmp(right)) + }); + + if depth >= max_depth { + continue; + } + for child_directory in child_directories { + queue.push_back((child_directory, depth + 1)); + } + } + + let mut repositories = discovered_repositories + .into_iter() + .collect::>(); + sort_workspace_repository_paths(&mut repositories, &workspace_root); + if let Some(repository) = containing_repository { + repositories.retain(|path| path != &repository); + repositories.insert(0, repository); + } + + Ok(WorkspaceRepositoriesResponse { + repositories: repositories + .into_iter() + .map(|path| WorkspaceRepositoryResponse { + path: path.to_string_lossy().replace('\\', "/"), + }) + .collect(), + }) +} + +fn repository_scan_directory_is_visible(name: &str) -> bool { + // These intentional checkout containers are hidden in the file tree but + // remain discoverable in Git. Apply exclusions to their children as usual. + if name.eq_ignore_ascii_case(".worktree") || name.eq_ignore_ascii_case(".worktrees") { + return true; + } + // Filter descendants, never the explicitly opened root or its containing + // repository. Git ignore rules govern tracked files, not repository ownership. + !name.starts_with('.') + && !lithe_core_rules::visibility::BUILT_IN_HIDDEN_DIRECTORIES + .iter() + .any(|hidden| name.eq_ignore_ascii_case(hidden)) +} + +/// Rejects Windows names that cannot round-trip through ordinary path identifiers. +/// POSIX names keep their native meaning; Windows validates relative paths too. +pub(crate) fn validate_windows_git_path(path: &Path) -> std::io::Result<()> { + let text = path.to_string_lossy().replace('\\', "/"); + let windows_path = cfg!(windows) + || text.starts_with("//") + || (text.as_bytes().get(1) == Some(&b':') && text.as_bytes()[0].is_ascii_alphabetic()); + if !windows_path { + return Ok(()); + } + let verbatim = text.strip_prefix("//?/"); + let ordinary = match verbatim { + Some(rest) + if rest + .get(..4) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("UNC/")) => + { + format!("//{}", &rest[4..]) + } + Some(rest) => rest.to_string(), + None => text.clone(), + }; + let drive = ordinary.as_bytes().get(1..3) == Some(b":/") + && ordinary.as_bytes()[0].is_ascii_alphabetic(); + let network = ordinary.starts_with("//"); + // Server/share names identify the UNC route, not local file components. + let components: Vec<_> = ordinary + .split('/') + .filter(|part| !part.is_empty()) + .collect(); + let skip = if network { + 2 + } else if drive { + 1 + } else { + 0 + }; + let invalid_component = components.iter().skip(skip).any(|part| { + if matches!(*part, "." | "..") { + return verbatim.is_some(); + } + let basename = part + .split('.') + .next() + .unwrap_or_default() + .to_ascii_uppercase(); + let reserved = matches!(basename.as_str(), "CON" | "PRN" | "AUX" | "NUL") + || basename + .strip_prefix("COM") + .or_else(|| basename.strip_prefix("LPT")) + .is_some_and(|number| { + matches!( + number, + "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³" + ) + }); + part.ends_with('.') || part.ends_with(' ') || reserved + }); + if text.starts_with("//./") + || (verbatim.is_some() && !drive && !(network && components.len() >= 2)) + || invalid_component + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Git does not support Windows paths requiring verbatim semantics (trailing dots/spaces, reserved names or device namespaces)", + )); + } + Ok(()) +} + +/// Removes a Windows verbatim prefix only for paths safe in ordinary native form. +/// Canonical roots cross the UI boundary and return as Git working directories; +/// unsupported names fail explicitly instead of silently accessing a sibling. +pub fn simplified_canonical_path(path: PathBuf) -> std::io::Result { + validate_windows_git_path(&path)?; + let text = path.to_string_lossy(); + let normalized = text.replace('\\', "/"); + if normalized + .get(..8) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("//?/UNC/")) + { + Ok(PathBuf::from(format!(r"\\{}", &text[8..]))) + } else if normalized.starts_with("//?/") { + Ok(PathBuf::from(&text[4..])) + } else { + Ok(path) + } +} + +/// Validates before filesystem I/O and again after canonicalization (symlinks). +pub(crate) fn canonicalize_simplified(path: &Path) -> std::io::Result { + validate_windows_git_path(path)?; + fs::canonicalize(path).and_then(simplified_canonical_path) +} + +/// Missing worktrees may still be listed, but invalid path identities never fall back. +pub(crate) fn canonicalize_or_original(path: &Path) -> std::io::Result { + match canonicalize_simplified(path) { + Err(error) if error.kind() != std::io::ErrorKind::InvalidInput => Ok(path.to_path_buf()), + result => result, + } +} + +pub(crate) fn git_path_error(error: std::io::Error) -> CoreError { + if error.kind() == std::io::ErrorKind::InvalidInput { + CoreError::new(ErrorCode::InvalidRequest, error.to_string()) + } else { + CoreError::new(ErrorCode::WorkspaceNotFound, "Workspace does not exist") + } +} + +pub(crate) fn validate_root(raw_root: &str) -> Result { + let root = canonicalize_simplified(Path::new(raw_root)).map_err(git_path_error)?; + if !fs::is_dir(&root)? { + return Err(CoreError::new( + ErrorCode::WorkspaceNotFound, + "Workspace does not exist", + )); + } + Ok(root.to_string_lossy().to_string()) +} + +fn repository_scan_error(error: std::io::Error) -> CoreError { + if error.kind() == std::io::ErrorKind::InvalidInput { + return git_path_error(error); + } + CoreError::new( + ErrorCode::Unknown, + "Could not complete workspace repository discovery", + ) + .with_details(error.to_string()) +} + +/// Resolves the gitdir a `.git` gitfile points at, mirroring Git's `read_gitfile`: +/// the target may be absolute or relative to the gitfile's own directory. +/// Returns `None` when the file is not a valid gitfile Git would open. +fn resolve_gitfile_gitdir(gitfile: &Path, contents: &str) -> Option { + let line = contents.lines().next()?; + let target = line.strip_prefix("gitdir:")?.trim().trim_matches('"'); + if target.is_empty() { + return None; + } + let target_path = Path::new(target); + if target_path.is_absolute() { + return Some(target_path.to_path_buf()); + } + Some(gitfile.parent()?.join(target_path)) +} + +/// A discovered `.git` entry only counts as a repository when Git can open it: +/// a directory needs `HEAD`, and a gitfile pointer must resolve to a gitdir that +/// holds `HEAD`. Stale submodule or worktree pointers (for example a leftover +/// submodule gitfile whose `.git/modules` directory was removed) would otherwise +/// be listed as a workspace repository and fail every status or reference load +/// for the healthy sibling repositories. +fn git_entry_opens_as_repository(git_path: &Path) -> bool { + if git_path.is_dir() { + return git_path.join("HEAD").is_file(); + } + let contents = match std::fs::read_to_string(git_path) { + Ok(contents) => contents, + Err(_) => return false, + }; + resolve_gitfile_gitdir(git_path, &contents) + .map(|gitdir| gitdir.join("HEAD").is_file()) + .unwrap_or(false) +} + +pub(crate) fn discover_containing_repository(root: &Path) -> Result, CoreError> { + let output = execute_git_readonly( + &root.to_string_lossy(), + &["rev-parse".into(), "--show-toplevel".into()], + None, + )?; + if output.exit_code != 0 { + return Ok(None); + } + let repository_root = output.stdout.trim_end_matches(['\r', '\n']); + if repository_root.is_empty() { + return Ok(None); + } + // Discovery must not return an unprojected host path after an IO failure. + let path = canonicalize_simplified(Path::new(repository_root)).map_err(git_path_error)?; + Ok(Some(path)) +} + +fn sort_workspace_repository_paths(paths: &mut [PathBuf], workspace_root: &Path) { + paths.sort_by(|left, right| { + let left_is_workspace = left == workspace_root; + let right_is_workspace = right == workspace_root; + if left_is_workspace != right_is_workspace { + return right_is_workspace.cmp(&left_is_workspace); + } + + let left_inside_workspace = left.starts_with(workspace_root); + let right_inside_workspace = right.starts_with(workspace_root); + if left_inside_workspace != right_inside_workspace { + return right_inside_workspace.cmp(&left_inside_workspace); + } + + let left_depth = left.components().count(); + let right_depth = right.components().count(); + left_depth + .cmp(&right_depth) + .then_with(|| path_sort_key(left).cmp(&path_sort_key(right))) + .then_with(|| left.cmp(right)) + }); +} + +fn path_sort_key(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/").to_lowercase() +} + +pub(crate) fn validate_paths(paths: &[String]) -> Result, CoreError> { + if paths.is_empty() || paths.iter().any(|path| !is_safe_pathspec(path)) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Git operation contains an invalid path", + )); + } + Ok(paths.to_vec()) +} + +pub(crate) fn repository_root(root: &str) -> Result { + git_resolved_path(root, &["rev-parse", "--show-toplevel"], "repository root") +} + +pub(crate) fn git_path(root: &str, path: &str) -> Result { + git_resolved_path( + root, + &["rev-parse", "--path-format=absolute", "--git-path", path], + "Git metadata path", + ) +} + +pub(crate) fn git_resolved_path( + root: &str, + arguments: &[&str], + label: &str, +) -> Result { + let arguments = arguments + .iter() + .map(|value| value.to_string()) + .collect::>(); + let response = execute_git_readonly(root, &arguments, None)?; + if response.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + format!("Could not resolve {label}"), + ) + .with_details(response.output)); + } + let path = response.output.trim_end_matches(['\r', '\n']); + if path.is_empty() { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + format!("Could not resolve {label}"), + )); + } + validate_windows_git_path(Path::new(path)).map_err(git_path_error)?; + Ok(lithe_workspace::local_path(Path::new(path))) +} + +pub(crate) fn nul_pathspec_input(paths: &[String]) -> String { + paths + .iter() + .flat_map(|path| [path.as_str(), "\0"]) + .collect() +} + +pub(crate) fn empty_tree_oid(root: &str) -> Result { + let hashed = execute_git_readonly( + root, + &[ + "hash-object".to_string(), + "-t".to_string(), + "tree".to_string(), + "--stdin".to_string(), + ], + Some(String::new()), + )?; + if hashed.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not resolve Git's empty tree", + ) + .with_details(hashed.output)); + } + let oid = hashed.stdout.trim(); + validate_revision(oid)?; + Ok(oid.to_string()) +} + +pub(crate) fn is_safe_pathspec(path: &str) -> bool { + let normalized = path.replace('\\', "/"); + !normalized.is_empty() + && !normalized.starts_with('/') + && !normalized.split('/').any(|component| component == "..") + && !normalized.contains(':') +} + +pub(crate) fn null_device() -> &'static str { + #[cfg(windows)] + { + "NUL" + } + #[cfg(not(windows))] + { + "/dev/null" + } +} + +pub(crate) fn relative_or_absolute(path: &Path, root: &Path) -> String { + path.strip_prefix(root) + .map(|relative| { + let value = relative.to_string_lossy().replace('\\', "/"); + if value.is_empty() { + ".".to_string() + } else { + value + } + }) + .unwrap_or_else(|_| path.to_string_lossy().replace('\\', "/")) +} diff --git a/rust/crates/core/src/git/repository_colors.rs b/rust/crates/git/src/repository_colors.rs similarity index 87% rename from rust/crates/core/src/git/repository_colors.rs rename to rust/crates/git/src/repository_colors.rs index 9993cf42..2a6c7109 100644 --- a/rust/crates/core/src/git/repository_colors.rs +++ b/rust/crates/git/src/repository_colors.rs @@ -1,6 +1,6 @@ //! Paged, platform-neutral palette slot assignment for ordered repository identities. -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::Deserialize; use serde_json::{json, Value}; use std::collections::HashSet; @@ -20,7 +20,9 @@ struct Request { repository_ids: Vec, } -pub(crate) fn assign_command(payload: Value) -> Result { +/// Handles `git.repositoryColorAssignments`: assigns palette slots to one page of +/// ordered repository identities. +pub fn assign_command(payload: Value) -> Result { let request: Request = serde_json::from_value(payload).map_err(|_| invalid())?; if request.repository_ids.len() > MAX_PAGE || request.offset > request.total_count diff --git a/rust/crates/git/src/requests.rs b/rust/crates/git/src/requests.rs new file mode 100644 index 00000000..fd044a7e --- /dev/null +++ b/rust/crates/git/src/requests.rs @@ -0,0 +1,560 @@ +//! Request payloads of the `git.*` commands and the command response they share. + +use super::*; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Request to discover Git repositories belonging to one opened workspace. +pub struct WorkspaceRepositoriesRequest { + pub(crate) root: String, + /// Optional traversal budget; defaults to all eligible directories below the workspace. + #[serde(default = "default_repository_scan_max_directories")] + pub max_directories: usize, + /// Optional traversal depth; defaults to the complete eligible workspace tree. + #[serde(default = "default_repository_scan_max_depth")] + pub max_depth: usize, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Request for a deterministic porcelain status snapshot. +pub struct GitStatusRequest { + pub(crate) root: String, + /// Staging-based UIs must retain additions deleted only from the worktree. + /// Omission preserves the Windows final-worktree projection. + #[serde(default)] + pub include_index_only_changes: bool, + /// Discovered native root bindings used to exclude independently owned untracked paths. + #[serde(default)] + pub repository_roots: Vec, + /// Report large wholly untracked directories as one entry with + /// `untrackedFileCount` instead of one entry per file. Omission preserves + /// the file-level `--untracked-files=all` projection. + #[serde(default)] + pub summarize_untracked_directories: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Request for the directories and files a host watcher should observe. +pub struct GitWatchContextRequest { + pub(crate) root: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Request for all worktrees registered in the current repository. +pub struct GitWorktreesRequest { + pub(crate) root: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Request for branch and publication state used by pull request creation. +pub struct GitPullRequestContextRequest { + pub(crate) root: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +/// Worktree-aware branch defaults and publication requirements for a pull request. +pub struct GitPullRequestContextResponse { + /// Checked-out local branch, or `None` when HEAD is detached. + pub current_branch: Option, + /// Best-effort branch that should receive the pull request. + pub suggested_base_branch: Option, + /// Branch name shown when the current commit must be published first. + pub suggested_publish_branch: Option, + /// Whether GitHub cannot yet see the current local HEAD. + pub requires_publish: bool, + /// Whether the worktree has no checked-out local branch. + pub detached: bool, + /// Whether tracked or untracked working-tree changes are not part of HEAD. + pub has_uncommitted_changes: bool, +} + +/// Executes one Git operation without invoking a shell. +/// +/// The command boundary is intentionally argument-based. This keeps command +/// construction in the application layer while making process execution +/// available to every UI binding through the same Rust core. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitCommandRequest { + pub(crate) root: String, + #[serde(default)] + pub(crate) arguments: Vec, + #[serde(default)] + pub(crate) input: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Request to resolve the repository root containing a workspace path. +pub struct GitRepositoryRootRequest { + pub(crate) root: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// One Git subprocess executed while fulfilling a shared Git command. +pub struct GitCommandInvocation { + /// Exact arguments passed to the Git executable, excluding the executable name. + pub arguments: Vec, + /// Text captured from the Git process standard output stream. + pub stdout: String, + /// Text captured from the Git process standard error stream. + pub stderr: String, + /// Exit status returned by the Git process. + pub exit_code: i32, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +/// Stable output returned by argument-based Git execution. +pub struct GitCommandResponse { + /// Exact arguments for the final Git subprocess, retained for compatibility. + pub arguments: Vec, + /// Backward-compatible concatenation of standard output followed by standard error. + pub output: String, + /// Text captured from the final Git process standard output stream. + pub stdout: String, + /// Text captured from the final Git process standard error stream. + pub stderr: String, + /// Exit status returned by the final Git process. + pub exit_code: i32, + /// Every Git subprocess executed for the operation, in execution order. + pub invocations: Vec, + /// Failure discovered after one or more subprocesses were recorded. + #[serde(skip_serializing_if = "Option::is_none")] + pub operation_error: Option, + /// Present when a stash restore kept its entry because the working tree + /// contains an unresolved merge. Keeping this out of the prose response + /// lets bindings offer recovery actions without matching localized Git + /// output. + #[serde(skip_serializing_if = "Option::is_none")] + pub stash_restore: Option, + /// Present when a tag deletion succeeded, carrying everything a host needs + /// to offer a restore without re-querying the repository. + #[serde(skip_serializing_if = "Option::is_none")] + pub tag_deletion: Option, + /// Present when a local branch deletion succeeded, carrying the commit the + /// branch pointed at so the host can offer to recreate it. + #[serde(skip_serializing_if = "Option::is_none")] + pub branch_deletion: Option, + /// Durable recovery point and authoritative outcome for a reviewed history action. + #[serde(skip_serializing_if = "Option::is_none")] + pub history_rewrite: Option, + /// Internal composite-operation guard; subsequent probes must not change its outcome. + #[serde(skip)] + pub(crate) outcome_authoritative: bool, + /// Non-fatal follow-up failures after the requested repository mutation succeeded. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub warnings: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Stable non-fatal outcome emitted after a Git mutation has already succeeded. +pub struct GitOperationWarning { + /// Machine-readable warning category interpreted by platform presentation. + pub code: String, + /// Safe English fallback for clients without a localized presentation. + pub message: String, + /// Optional Git diagnostic retained for troubleshooting. + #[serde(skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +impl GitOperationWarning { + pub(crate) fn new(code: &str, message: &str, details: Option) -> Self { + Self { + code: code.to_string(), + message: message.to_string(), + details, + } + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Recovery context when restoring a stash produces conflicts. +pub struct GitStashRestoreResponse { + pub(crate) stash_reference: String, + pub(crate) conflicted_paths: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Deletion record that lets a host rebuild the deleted tag later. +/// +/// `deleted_target` is the commit the deleted ref resolved to (peeled for +/// annotated tags), so a restore can re-point a new tag at the same commit. +pub struct GitTagDeletionResponse { + /// Short name of the deleted tag, without the `refs/tags/` prefix. + pub name: String, + pub(crate) deleted_target: String, + /// `lightweight` or `annotated`, taken from the tag object type. + pub kind: String, + /// Annotation message; `None` only for lightweight tags. Empty annotated + /// messages remain `Some` so a restore does not change the tag form. + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Deletion record that lets a host recreate the deleted local branch later. +pub struct GitBranchDeletionResponse { + /// Short branch name, without the `refs/heads/` prefix. + pub name: String, + /// Commit the deleted branch pointed at when it was removed. + pub deleted_target: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Complete identity of a Git reference supplied by a platform client. +pub struct GitReferenceRequest { + /// Fully qualified reference, such as `refs/remotes/origin/main`. + pub full_name: String, + /// User-facing short name, such as `origin/main`. + pub short_name: String, + /// Reference namespace: `local`, `remote`, or `tag`. + pub kind: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Repository snapshot that a previously reviewed push preview was based on. +pub struct GitPushExpectationRequest { + /// Local branch resolved when the preview was created. + pub local_branch: String, + /// Commit at the tip of the local branch when the preview was created. + pub local_head: String, + /// Push remote resolved from the repository configuration. + pub remote: String, + /// Destination branch name on the resolved remote. + pub remote_branch: String, + /// Destination relationship recorded by the preview. Optional so an older + /// caller that predates target kinds keeps its previous behaviour. + #[serde(default)] + pub target_kind: Option, + /// Locally observed destination OID, or `None` when the remote branch was absent. + #[serde(default)] + pub remote_tracking_oid: Option, + /// Exact reviewed tags that may be sent with the branch. + #[serde(default)] + pub tags: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// One tag identity copied from a push preview into the mutation request. +pub struct GitPushTagExpectationRequest { + /// Fully qualified tag reference under `refs/tags/`. + pub full_name: String, + /// Tag object ID observed while the preview was created. + pub object_id: String, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Typed mutation request translated into a controlled Git invocation. +pub struct GitWriteRequest { + pub(crate) root: String, + /// Stable mutation discriminator interpreted by [`write()`]. + pub operation: String, + /// Optional Fetch choices; omitted values preserve the legacy scope and pruning policy. + #[serde(default)] + pub fetch_options: Option, + #[serde(default)] + pub(crate) paths: Vec, + #[serde(default)] + pub(crate) reference: Option, + /// Preferred typed reference. Legacy callers may still use `reference` and + /// `referenceKind`, but new cross-platform workflows send all identity fields. + #[serde(default)] + pub git_reference: Option, + /// Reference category used by checkout: `local`, `remote`, or `tag`. + #[serde(default)] + pub reference_kind: Option, + #[serde(default)] + pub(crate) revision: Option, + /// Commit revisions selected by an operation that rewrites a contiguous range. + #[serde(default)] + pub revisions: Vec, + #[serde(default)] + pub(crate) name: Option, + #[serde(default)] + pub(crate) message: Option, + /// `createTag`: create an explicitly lightweight tag, overriding `tag.gpgSign`. + /// Omission keeps the legacy message-based annotated/lightweight choice. + #[serde(default)] + pub lightweight: Option, + /// `createTag`: sign the annotated tag with the configured GPG key. + /// Omission keeps the legacy unsigned form. + #[serde(default)] + pub signed: Option, + #[serde(default)] + pub(crate) remote: Option, + #[serde(default)] + pub(crate) destination: Option, + /// Operation-specific strategy, such as reset mode or pull reconciliation. + #[serde(default)] + pub mode: Option, + /// Complete local branch ref for a background update after Fetch. The guarded + /// path integrates its fetched upstream without a second network wait. + /// Omission preserves interactive Pull compatibility. + #[serde(default)] + pub expected_branch: Option, + #[serde(default)] + pub(crate) include_untracked: bool, + #[serde(default)] + pub(crate) checkout: bool, + /// Worktree creation mode; omitted values retain `newBranch` compatibility. + #[serde(default)] + pub worktree_mode: Option, + /// Whether worktree creation should leave tracked files unpopulated. + /// Independent of `checkout`, which belongs to branch creation workflows. + #[serde(default)] + pub no_checkout: bool, + #[serde(default)] + pub(crate) amend: bool, + /// `commit`: the author as `Name `, in place of the configured identity. + /// The committer stays the configured identity. + #[serde(default)] + pub author: Option, + /// `commit`: append a `Signed-off-by` trailer for the committer. + #[serde(default)] + pub sign_off: bool, + /// Reviewed HEAD/index required by a planned workspace commit. + #[serde(default)] + pub expected_commit_state: Option, + /// Exact child commits to record; requires expected_commit_state. + #[serde(default)] + pub gitlink_updates: Vec, + #[serde(default)] + pub(crate) force: bool, + /// Tag scope for push: `none`, `all`, or `reachable`. + #[serde(default)] + pub push_tags: Option, + /// Optional reviewed preview snapshot that must still match before pushing. + #[serde(default)] + pub expected_push: Option, + /// Verify submodule commits are published before a workspace branch push. + #[serde(default)] + pub check_submodules: bool, + /// Destination the user explicitly confirmed. Required only when the branch + /// tracks a divergent upstream; every other destination stays implicit. + #[serde(default)] + pub push_target: Option, + #[serde(default)] + pub(crate) auto_stash: bool, + /// Mandatory immutable preview for undo, reword, squash, and drop. + #[serde(default)] + pub expected_state: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Request to resolve the exact destination and commits for a branch push. +pub struct GitPushPreviewRequest { + pub(crate) root: String, + #[serde(default)] + pub(crate) reference: Option, + /// Preferred complete local branch identity. + #[serde(default)] + pub git_reference: Option, + #[serde(default = "default_push_preview_limit")] + pub(crate) limit: usize, + /// Tag scope to resolve into an immutable preview: `none`, `all`, or `reachable`. + #[serde(default)] + pub push_tags: Option, + /// Destination the user picked for a divergent upstream. The preview then + /// describes that exact remote branch, so a reviewed push can be bound to it. + /// Ignored when the branch is not divergent. + #[serde(default)] + pub push_target: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Request for a structured diff suitable for side-by-side rendering. +pub struct GitDiffRequest { + pub(crate) root: String, + /// Inspect a saved stash, including its untracked parent, independently of the worktree. + #[serde(default)] + pub stash_reference: Option, + /// Return validated per-file sections instead of a flattened diff. + #[serde(default)] + pub include_file_sections: bool, + /// Omit the redundant whole-file patch when individual hunks suffice. + #[serde(default)] + pub omit_file_patch: bool, + pub(crate) pathspecs: Vec, + #[serde(default)] + pub(crate) reference: Option, + /// Compare the supplied legacy `reference` target against Git's empty tree. + /// This is used for cumulative review ranges that begin with a root commit. + #[serde(default)] + pub empty_tree_base: bool, + #[serde(default)] + pub(crate) git_reference: Option, + /// Optional typed comparison target. When present, `gitReference` is the + /// base and Core constructs the validated two-reference range. + #[serde(default)] + pub target_git_reference: Option, + #[serde(default)] + pub(crate) commit: Option, + #[serde(default)] + pub(crate) staged: bool, + #[serde(default)] + pub(crate) untracked: bool, + /// Compare HEAD with the complete worktree state that a selected-path + /// commit would stage, independently of the real index. + #[serde(default)] + pub worktree_snapshot: bool, + #[serde(default = "default_review_context_lines")] + pub(crate) context_lines: usize, + #[serde(default)] + pub(crate) ignore_all_whitespace: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Request to apply a patch to the index or working tree. +pub struct GitApplyRequest { + pub(crate) root: String, + pub(crate) patch: String, + /// Patch target or validation mode, including `stage`, `unstage`, and `worktree`. + pub mode: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Request for metadata and parent information about one commit. +pub struct GitCommitRequest { + pub(crate) root: String, + pub(crate) commit: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Request for the paths changed by one commit. +pub struct GitCommitFilesRequest { + pub(crate) root: String, + pub(crate) commit: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Request to compare a reference with the current checkout. +pub struct GitComparisonRequest { + pub(crate) root: String, + #[serde(default)] + pub(crate) reference: Option, + #[serde(default)] + pub(crate) git_reference: Option, + /// Optional typed target for a comparison between two references. + #[serde(default)] + pub target_git_reference: Option, + /// Include untracked worktree files; incompatible with reference-to-reference comparisons. + #[serde(default)] + pub include_untracked: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Request for the repository's ordered stash list. +pub struct GitStashesRequest { + pub(crate) root: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Request to identify local edits that would block switching references. +pub struct GitCheckoutPreflightRequest { + pub(crate) root: String, + #[serde(default)] + pub(crate) reference: Option, + #[serde(default)] + pub(crate) git_reference: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Request to find staged files that still contain conflict markers. +pub struct GitConflictMarkerRequest { + pub(crate) root: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Request to determine whether a merge or rebase can start safely. +pub struct GitIntegrationPreflightRequest { + pub(crate) root: String, + #[serde(default)] + pub(crate) reference: Option, + #[serde(default)] + pub(crate) git_reference: Option, + /// Either "merge" or "rebase"; the two have different tolerances for a dirty tree. + pub operation: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Request to determine whether the tracked branch can fast-forward. +pub struct GitPullPreflightRequest { + pub(crate) root: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Request to inspect an interrupted merge, rebase, cherry-pick, or revert. +pub struct GitOperationStateRequest { + pub(crate) root: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Request for line attribution on one workspace-relative file. +pub struct GitBlameRequest { + pub(crate) root: String, + pub(crate) path: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Request for the committed history of one one-based snapshot line range. +pub struct GitLineHistoryRequest { + pub(crate) root: String, + /// Workspace-relative file path validated as a safe pathspec. + pub path: String, + /// Editor buffer content at query time; the line range addresses this + /// snapshot, not the on-disk file, so unsaved edits stay answerable. + pub content: String, + /// First one-based snapshot line to trace, inclusive. + pub start_line: usize, + /// Last one-based snapshot line to trace, inclusive. + pub end_line: usize, + /// Maximum deduplicated entries to return; defaults to 50, capped at 200. + #[serde(default)] + pub limit: Option, +} + +fn default_review_context_lines() -> usize { + 80 +} + +fn default_push_preview_limit() -> usize { + DEFAULT_PUSH_PREVIEW_LIMIT +} + +fn default_repository_scan_max_directories() -> usize { + DEFAULT_REPOSITORY_SCAN_MAX_DIRECTORIES +} + +fn default_repository_scan_max_depth() -> usize { + DEFAULT_REPOSITORY_SCAN_MAX_DEPTH +} diff --git a/rust/crates/core/src/git/rewrite.rs b/rust/crates/git/src/rewrite.rs similarity index 96% rename from rust/crates/core/src/git/rewrite.rs rename to rust/crates/git/src/rewrite.rs index 0b99b793..75fdb2a8 100644 --- a/rust/crates/core/src/git/rewrite.rs +++ b/rust/crates/git/src/rewrite.rs @@ -1,7 +1,7 @@ //! Reviewed local history mutations with immutable snapshots and durable recovery refs. use super::*; -use crate::remote::workspace_io::{self as fs, File}; +use lithe_workspace::fs::{self, File}; use sha2::{Digest, Sha256}; use std::collections::{BTreeSet, HashMap}; use std::sync::{Mutex, OnceLock}; @@ -17,7 +17,7 @@ static RECOVERY_SEQUENCE: AtomicU64 = AtomicU64::new(0); #[serde(rename_all = "camelCase")] /// Read-only eligibility and complete-message request for one history action. pub struct GitHistoryRewritePreviewRequest { - pub root: String, + pub(crate) root: String, /// One of the four history actions, or the native rebase adapter's `interactiveRebase`. pub operation: String, /// Selected commits; Core resolves revisions and orders them oldest first. @@ -30,10 +30,10 @@ pub struct GitHistoryRewritePreviewRequest { pub struct GitHistoryRewriteExpectation { /// Full checked-out local branch reference. pub branch: String, - pub head: String, + pub(crate) head: String, /// Opaque digest of checkout identity, refs, index, and working-file contents. pub state_token: String, - pub operation: String, + pub(crate) operation: String, /// Full selected OIDs in oldest-to-newest order. pub revisions: Vec, } @@ -77,12 +77,12 @@ pub struct GitHistoryRewritePreviewResponse { #[serde(rename_all = "camelCase")] /// Durable recovery identity and authoritative mutation outcome, including partial success. pub struct GitHistoryRewriteResult { - pub operation: String, - pub branch: String, - pub original_head: String, + pub(crate) operation: String, + pub(crate) branch: String, + pub(crate) original_head: String, /// Prepared replacement OID, even when its installation could not be confirmed. pub new_head: Option, - pub recovery_reference: String, + pub(crate) recovery_reference: String, /// True only after the replacement branch OID was installed or observed. pub mutation_applied: bool, /// False if interruption also prevented determining the branch's final state. @@ -134,9 +134,8 @@ impl RewriteLease { .map_err(|_| invalid("Unsupported Git metadata path encoding"))? .trim(), ); - let common = crate::remote::workspace::identity_key( - &fs::canonicalize(&common).map_err(snapshot_io_error)?, - ); + let common = + lithe_workspace::identity_key(&fs::canonicalize(&common).map_err(snapshot_io_error)?); let mut active = ACTIVE_REWRITES .get_or_init(|| Mutex::new(HashMap::new())) .lock() @@ -181,9 +180,12 @@ struct HistoryActionCapabilitiesRequest { revisions: Vec, } -// This is only a menu hint. History rewrite preview remains the authority for -// dirty worktrees, operation state, mutable refs and execution authorization. -pub(crate) fn history_action_capabilities_command( +/// Handles `git.historyActionCapabilities`: resolves action eligibility against +/// the checkout's actual history. +/// +/// This is only a menu hint. History rewrite preview remains the authority for +/// dirty worktrees, operation state, mutable refs and execution authorization. +pub fn history_action_capabilities_command( payload: serde_json::Value, ) -> Result { let request: HistoryActionCapabilitiesRequest = serde_json::from_value(payload) @@ -441,7 +443,7 @@ pub fn history_rewrite_preview( ); } for hash in affected.iter().rev() { - crate::protocol::cancellation::check()?; + lithe_protocol::cancellation::check()?; let commit = read_rewrite_commit(&root, hash)?; let raw_commit = checked_output(&root, &["cat-file", "commit", hash])?; let raw_commit = std::str::from_utf8(&raw_commit); @@ -621,7 +623,7 @@ fn workspace_snapshot(root: &str) -> Result { .split(|byte| *byte == 0) .filter(|path| !path.is_empty()) { - crate::protocol::cancellation::check()?; + lithe_protocol::cancellation::check()?; #[cfg(unix)] let relative = { use std::os::unix::ffi::OsStrExt; @@ -641,7 +643,7 @@ fn workspace_snapshot(root: &str) -> Result { let mut content_hasher = Sha256::new(); let mut buffer = [0_u8; 64 * 1024]; loop { - crate::protocol::cancellation::check()?; + lithe_protocol::cancellation::check()?; let count = file.read(&mut buffer).map_err(snapshot_io_error)?; if count == 0 { break; @@ -908,10 +910,10 @@ pub(super) fn execute( }; // Cancellation can race Git's successful ref write. Probe with a short, // independent cleanup deadline before describing the mutation as absent. - let observed = crate::protocol::cancellation::with_cleanup_deadline( - Duration::from_secs(2), - || checked_output(root, &["rev-parse", "--verify", &expected.branch]), - ); + let observed = + lithe_protocol::cancellation::with_cleanup_deadline(Duration::from_secs(2), || { + checked_output(root, &["rev-parse", "--verify", &expected.branch]) + }); match observed { Ok(oid) => { let oid = String::from_utf8_lossy(&oid); diff --git a/rust/crates/core/src/git/rollback.rs b/rust/crates/git/src/rollback.rs similarity index 94% rename from rust/crates/core/src/git/rollback.rs rename to rust/crates/git/src/rollback.rs index 87558907..64a107e2 100644 --- a/rust/crates/core/src/git/rollback.rs +++ b/rust/crates/git/src/rollback.rs @@ -4,7 +4,7 @@ use super::{ execute_git_with_environment, is_safe_pathspec, parse_status, validate_root, LS_FILES_PATHSPEC_BUDGET, }; -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::{BTreeSet, HashSet}; @@ -54,7 +54,8 @@ fn safe_path(path: &str) -> bool { is_safe_pathspec(path) && !path.contains('\0') } -pub(crate) fn rollback_plan_command(payload: Value) -> Result { +/// Handles `git.rollbackPlan`: plans whole-file rollback from Git change identities. +pub fn rollback_plan_command(payload: Value) -> Result { let request: Request = serde_json::from_value(payload).map_err(|_| invalid())?; if request.changes.len() > MAX_CHANGES { return Err(invalid()); @@ -139,9 +140,12 @@ struct PrepareRequest { delete_added_files: bool, } -// Recheck the selected identities after confirmation, without enumerating an -// entire repository. Keep both sides of a rename in the same bounded Git call. -pub(crate) fn rollback_prepare_command(payload: Value) -> Result { +/// Handles `git.rollbackPrepare`: rechecks the selected identities after +/// confirmation, then plans the rollback. +/// +/// It never enumerates an entire repository, and keeps both sides of a rename in +/// the same bounded Git call. +pub fn rollback_prepare_command(payload: Value) -> Result { let request: PrepareRequest = serde_json::from_value(payload).map_err(|_| invalid())?; rollback_plan_command( json!({"changes": request.changes, "deleteAddedFiles": request.delete_added_files}), diff --git a/rust/crates/core/src/git/setup.rs b/rust/crates/git/src/setup.rs similarity index 97% rename from rust/crates/core/src/git/setup.rs rename to rust/crates/git/src/setup.rs index f4da1a51..750096b0 100644 --- a/rust/crates/core/src/git/setup.rs +++ b/rust/crates/git/src/setup.rs @@ -31,9 +31,9 @@ impl GitIdentityScope { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GitSetupRequest { - pub root: String, + pub(crate) root: String, #[serde(default)] - pub scope: GitIdentityScope, + pub(crate) scope: GitIdentityScope, } /// Only commit identity keys are editable; arbitrary Git config is not exposed. @@ -57,10 +57,10 @@ impl GitIdentityKey { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GitConfigureIdentityRequest { - pub root: String, - pub scope: GitIdentityScope, - pub key: GitIdentityKey, - pub value: Option, + pub(crate) root: String, + pub(crate) scope: GitIdentityScope, + pub(crate) key: GitIdentityKey, + pub(crate) value: Option, } /// Repository state is independent of history filters; effective values include diff --git a/rust/crates/core/src/git/shelf.rs b/rust/crates/git/src/shelf.rs similarity index 98% rename from rust/crates/core/src/git/shelf.rs rename to rust/crates/git/src/shelf.rs index a8a81d2f..fb43e01b 100644 --- a/rust/crates/core/src/git/shelf.rs +++ b/rust/crates/git/src/shelf.rs @@ -4,7 +4,7 @@ use super::patch_exchange::{ self, PatchApplyRequest, PatchExportRequest, PatchPreviewRequest, PatchSource, PatchTarget, }; use super::{apply, execute_git_readonly, GitApplyRequest}; -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::BTreeSet; @@ -228,7 +228,7 @@ fn execute(request: Request) -> Result { } /// Executes one Shelf step; hosts persist the snapshot and guard each call's session. -pub(crate) fn shelf_command(value: Value) -> Result { +pub fn shelf_command(value: Value) -> Result { let request = serde_json::from_value(value).map_err(|_| failure("Invalid Shelf request"))?; execute(request) } diff --git a/rust/crates/git/src/stash.rs b/rust/crates/git/src/stash.rs new file mode 100644 index 00000000..dd8ad327 --- /dev/null +++ b/rust/crates/git/src/stash.rs @@ -0,0 +1,251 @@ +//! Stash listing, apply and pop, and pulls that stash local changes around the update. + +use super::*; + +/// Lists stashes with stable references and parsed metadata. +pub fn stashes(request: GitStashesRequest) -> Result { + let root = validate_root(&request.root)?; + let response = readonly_command(GitCommandRequest { + root, + arguments: vec![ + "stash".to_string(), + "list".to_string(), + // `--date` would also turn `%gd` into `stash@{}`, so the date is asked for in + // its own ISO form and the reference stays the positional `stash@{N}`. + "--pretty=format:%gd%x1f%gs%x1f%ai%x1f%H".to_string(), + ], + input: None, + })?; + if response.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Git stash list failed") + .with_details(response.output), + ); + } + Ok(GitStashesResponse { + stashes: response.output.lines().filter_map(parse_stash).collect(), + }) +} + +pub(crate) fn pop_stash(root: &str, reference: &str) -> Result { + let mut result = execute_git( + root, + &["stash".into(), "pop".into(), reference.to_string()], + None, + )?; + let conflicted_paths = conflicted_paths(root)?; + let entry_was_kept = stash_reference_exists(root, reference)?; + if entry_was_kept && (!conflicted_paths.is_empty() || result.exit_code == 0) { + result.stash_restore = Some(GitStashRestoreResponse { + stash_reference: reference.to_string(), + conflicted_paths, + }); + } + Ok(result) +} + +pub(crate) fn apply_stash(root: &str, reference: &str) -> Result { + let mut result = execute_git( + root, + &["stash".into(), "apply".into(), reference.to_string()], + None, + )?; + let conflicted_paths = conflicted_paths(root)?; + if !conflicted_paths.is_empty() { + result.exit_code = 1; + result.stash_restore = Some(GitStashRestoreResponse { + stash_reference: reference.to_string(), + conflicted_paths, + }); + } + Ok(result) +} + +pub(crate) fn pull_with_auto_stash( + root: &str, + pull_arguments: &[String], +) -> Result { + let status = execute_git_readonly( + root, + &[ + "status".into(), + "--porcelain=v1".into(), + "-z".into(), + "--untracked-files=all".into(), + ], + None, + )?; + if status.exit_code != 0 { + return Ok(status); + } + if status.stdout.is_empty() { + return execute_git(root, pull_arguments, None); + } + + let stash_marker = format!( + "lithe: auto-stash before pull:{}:{}", + std::process::id(), + AUTO_STASH_SEQUENCE.fetch_add(1, Ordering::Relaxed) + ); + let stashed = execute_git( + root, + &[ + "stash".into(), + "push".into(), + "--include-untracked".into(), + "--message".into(), + stash_marker.clone(), + ], + None, + )?; + if stashed.exit_code != 0 { + return Ok(stashed); + } + let stash_oid = find_stash_oid_by_message(root, &stash_marker)?.ok_or_else(|| { + CoreError::new( + ErrorCode::ProcessFailed, + "Pull created a temporary stash but could not identify it", + ) + })?; + + let pulled = execute_git(root, pull_arguments, None)?; + if pulled.exit_code != 0 { + return Ok(pulled); + } + + let mut restored = execute_git( + root, + &["stash".into(), "apply".into(), stash_oid.clone()], + None, + )?; + let conflicted_paths = conflicted_paths(root)?; + let stash_reference = stash_reference_for_oid(root, &stash_oid)?; + if restored.exit_code != 0 || !conflicted_paths.is_empty() { + restored.exit_code = 1; + restored.stash_restore = Some(GitStashRestoreResponse { + stash_reference: stash_reference.unwrap_or(stash_oid), + conflicted_paths, + }); + return Ok(restored); + } + + let Some(stash_reference) = stash_reference else { + restored.warnings.push(GitOperationWarning::new( + "git_stash_drop_failed", + "The pull and stash restore succeeded, but the saved stash could not be located for removal", + None, + )); + return Ok(restored); + }; + let dropped = execute_git( + root, + &["stash".into(), "drop".into(), stash_reference], + None, + )?; + if dropped.exit_code != 0 { + restored.warnings.push(GitOperationWarning::new( + "git_stash_drop_failed", + "The pull and stash restore succeeded, but the saved stash could not be removed", + Some(dropped.output), + )); + } + Ok(restored) +} + +fn find_stash_oid_by_message(root: &str, message: &str) -> Result, CoreError> { + let list = execute_git_readonly( + root, + &["stash".into(), "list".into(), "--format=%H%x09%gs".into()], + None, + )?; + if list.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Could not inspect Git stashes") + .with_details(list.output), + ); + } + Ok(list.stdout.lines().find_map(|line| { + let (oid, subject) = line.split_once('\t')?; + subject.contains(message).then(|| oid.to_string()) + })) +} + +pub(crate) fn stash_reference_for_oid(root: &str, oid: &str) -> Result, CoreError> { + let list = execute_git_readonly( + root, + &["stash".into(), "list".into(), "--format=%gd%x09%H".into()], + None, + )?; + if list.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Could not inspect Git stashes") + .with_details(list.output), + ); + } + Ok(list.stdout.lines().find_map(|line| { + let (reference, commit) = line.split_once('\t')?; + (commit == oid).then(|| reference.to_string()) + })) +} + +pub(crate) fn conflicted_paths(root: &str) -> Result, CoreError> { + let response = execute_git( + root, + &[ + "diff".into(), + "--name-only".into(), + "--diff-filter=U".into(), + "--".into(), + ], + None, + )?; + if response.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Git conflict status failed") + .with_details(response.output), + ); + } + let mut paths = response + .output + .lines() + .map(str::trim) + .filter(|path| !path.is_empty()) + .map(String::from) + .collect::>(); + paths.sort(); + paths.dedup(); + Ok(paths) +} + +pub(crate) fn stash_reference_exists(root: &str, reference: &str) -> Result { + let list = execute_git( + root, + &["stash".into(), "list".into(), "--format=%gd".into()], + None, + )?; + if list.exit_code != 0 { + return Ok(false); + } + Ok(list.output.lines().any(|line| line.trim() == reference)) +} + +pub(crate) fn find_stash_reference(root: &str, message: &str) -> Result, CoreError> { + let list = execute_git( + root, + &["stash".into(), "list".into(), "--format=%gd%x09%gs".into()], + None, + )?; + if list.exit_code != 0 { + return Ok(None); + } + Ok(list.output.lines().find_map(|line| { + let (reference, subject) = line.split_once('\t')?; + subject + .contains(message) + .then(|| reference.trim().to_string()) + })) +} + +/// Marker on stashes created by smart checkout, so the restore step can tell whether +/// `git stash pop` consumed its entry or kept it after a conflict. +pub(crate) const AUTO_STASH_MESSAGE: &str = "lithe: auto-stash before checkout"; diff --git a/rust/crates/git/src/status.rs b/rust/crates/git/src/status.rs new file mode 100644 index 00000000..16e9f8cc --- /dev/null +++ b/rust/crates/git/src/status.rs @@ -0,0 +1,403 @@ +//! Repository status, watch context, pull request context and ignored paths. + +use super::*; + +/// Resolves the Git administrative paths and references a watcher must observe. +pub fn watch_context( + request: GitWatchContextRequest, +) -> Result, CoreError> { + let root = canonicalize_simplified(Path::new(&request.root)).map_err(git_path_error)?; + if !fs::is_dir(&root)? { + return Err(CoreError::new( + ErrorCode::WorkspaceNotFound, + "Workspace does not exist", + )); + } + + let repository_root = run_git(&root, &["rev-parse", "--show-toplevel"])?; + if !repository_root.status.success() { + return Ok(None); + } + let git_directory = run_git(&root, &["rev-parse", "--absolute-git-dir"])?; + let git_common_directory = run_git( + &root, + &["rev-parse", "--path-format=absolute", "--git-common-dir"], + )?; + + Ok(Some(GitWatchContextResponse { + repository_root: canonical_git_output(repository_root, "repository root")?, + git_directory: canonical_git_output(git_directory, "Git directory")?, + git_common_directory: canonical_git_output(git_common_directory, "Git common directory")?, + })) +} + +fn canonical_git_output(output: std::process::Output, label: &str) -> Result { + if !output.status.success() { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + format!("Could not resolve {label}"), + ) + .with_details(String::from_utf8_lossy(&output.stderr))); + } + let raw_path = String::from_utf8_lossy(&output.stdout); + let path = PathBuf::from(raw_path.trim_end_matches(['\r', '\n'])); + canonicalize_simplified(&path) + .map(|path| path.to_string_lossy().into_owned()) + .map_err(|error| { + if error.kind() == std::io::ErrorKind::InvalidInput { + return git_path_error(error); + } + CoreError::new( + ErrorCode::ProcessFailed, + format!("Could not resolve {label}"), + ) + .with_details(error.to_string()) + }) +} + +/// Returns the checked-out branch and whether its HEAD must be pushed before a PR. +pub fn pull_request_context( + request: GitPullRequestContextRequest, +) -> Result { + let root = validate_root(&request.root)?; + let current_branch = optional_current_branch(&root)?; + let detached = current_branch.is_none(); + let remote_default = command_value( + &root, + &[ + "symbolic-ref", + "--quiet", + "--short", + "refs/remotes/origin/HEAD", + ], + ) + .map(|value| value.trim_start_matches("origin/").to_string()); + + let suggested_base_branch = if let Some(branch) = current_branch.as_deref() { + created_from_branch(&root, branch).or(remote_default) + } else { + detached_start_branch(&root).or(remote_default) + }; + let requires_publish = current_branch + .as_deref() + .is_none_or(|branch| branch_requires_publish(&root, branch)); + let suggested_publish_branch = if requires_publish { + current_branch.clone().or_else(|| { + command_value(&root, &["rev-parse", "--short=8", "HEAD"]) + .map(|short_hash| format!("codex/pr-{short_hash}")) + }) + } else { + None + }; + let has_uncommitted_changes = command_value( + &root, + &["status", "--porcelain", "--untracked-files=normal"], + ) + .is_some(); + + Ok(GitPullRequestContextResponse { + current_branch, + suggested_base_branch, + suggested_publish_branch, + requires_publish, + detached, + has_uncommitted_changes, + }) +} + +pub(crate) fn command_value(root: &str, arguments: &[&str]) -> Option { + let arguments = arguments + .iter() + .map(|value| value.to_string()) + .collect::>(); + let response = execute_git_readonly(root, &arguments, None).ok()?; + let value = response.output.trim(); + (response.exit_code == 0 && !value.is_empty()).then(|| value.to_string()) +} + +fn created_from_branch(root: &str, branch: &str) -> Option { + let reference = format!("refs/heads/{branch}"); + let response = command_value(root, &["reflog", "show", "--format=%gs", &reference])?; + let prefix = "branch: Created from "; + response.lines().find_map(|line| { + let source = line.strip_prefix(prefix)?; + if matches!(source, "HEAD" | "FETCH_HEAD" | "ORIG_HEAD") { + // Publishing a detached worktree creates its branch from HEAD. + // Preserve the worktree's original branch as the PR base instead + // of falling back to origin/HEAD after the branch is published. + detached_start_branch(root) + } else { + Some(source.trim_start_matches("origin/").to_string()) + } + }) +} + +fn detached_start_branch(root: &str) -> Option { + let reflog = command_value(root, &["reflog", "show", "--format=%H", "HEAD"])?; + // Reflog output is newest-first; the final entry is the commit at which + // this worktree's HEAD was initialized. + let starting_commit = reflog.lines().last()?.trim(); + let references = command_value( + root, + &[ + "for-each-ref", + "--sort=refname", + "--format=%(refname)", + "--points-at", + starting_commit, + "refs/heads", + "refs/remotes/origin", + ], + )?; + references + .lines() + .find_map(|reference| reference.strip_prefix("refs/heads/").map(str::to_string)) + .or_else(|| { + references.lines().find_map(|reference| { + reference + .strip_prefix("refs/remotes/origin/") + .filter(|branch| *branch != "HEAD") + .map(str::to_string) + }) + }) +} + +fn branch_requires_publish(root: &str, branch: &str) -> bool { + let origin_branch = format!("refs/remotes/origin/{branch}"); + if command_value(root, &["rev-parse", "--verify", &origin_branch]).is_none() { + return true; + } + let comparison = format!("{origin_branch}..HEAD"); + match command_value(root, &["rev-list", "--count", &comparison]) + .and_then(|count| count.parse::().ok()) + { + Some(0) => false, + Some(_) | None => true, + } +} + +/// Returns the normalized repository status and branch context. +pub fn status(request: GitStatusRequest) -> Result { + let root = canonicalize_simplified(Path::new(&request.root)).map_err(git_path_error)?; + if !fs::is_dir(&root)? { + return Err(CoreError::new( + ErrorCode::WorkspaceNotFound, + "Workspace does not exist", + )); + } + let repository_root_output = run_git(&root, &["rev-parse", "--show-toplevel"])?; + if !repository_root_output.status.success() { + return Ok(GitStatusResponse { + repository_root: None, + branch: None, + ahead: 0, + behind: 0, + changes: Vec::new(), + }); + } + let repository_root_text = String::from_utf8_lossy(&repository_root_output.stdout); + let repository_root_path = PathBuf::from(repository_root_text.trim_end_matches(['\r', '\n'])); + let repository_root = + canonicalize_or_original(&repository_root_path).map_err(git_path_error)?; + let branch = run_git(&repository_root, &["branch", "--show-current"]) + .ok() + .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string()) + .filter(|branch| !branch.is_empty()) + .or_else(|| Some("detached".to_string())); + // `normal` lets Git stop at each wholly untracked directory instead of + // enumerating every file below it; the projection below restores file + // entries for directories that are small or contain nested repositories. + let untracked_files = if request.summarize_untracked_directories { + "--untracked-files=normal" + } else { + "--untracked-files=all" + }; + let status_output = run_git( + &repository_root, + &[ + "-c", + "core.quotepath=false", + "status", + "--porcelain=v2", + "--ignore-submodules=none", + "-z", + untracked_files, + ], + )?; + if !status_output.status.success() { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Git status failed") + .with_details(String::from_utf8_lossy(&status_output.stderr)), + ); + } + let mut changes = parse_status(&status_output.stdout, request.include_index_only_changes); + if request.summarize_untracked_directories { + changes = untracked_directories::expand_collapsed_directories(&repository_root, changes)?; + } + untracked_directories::retain_outside_known_roots( + &mut changes, + &repository_root, + &request.repository_roots, + )?; + let (ahead, behind) = tracking_counts(&repository_root); + Ok(GitStatusResponse { + repository_root: Some(relative_or_absolute(&repository_root, &root)), + branch, + ahead, + behind, + changes, + }) +} + +fn tracking_counts(repository_root: &Path) -> (usize, usize) { + let Ok(output) = run_git( + repository_root, + &["rev-list", "--left-right", "--count", "@{upstream}...HEAD"], + ) else { + return (0, 0); + }; + if !output.status.success() { + return (0, 0); + } + let text = String::from_utf8_lossy(&output.stdout); + let mut values = text + .split_whitespace() + .filter_map(|value| value.parse().ok()); + let behind = values.next().unwrap_or(0); + let ahead = values.next().unwrap_or(0); + (ahead, behind) +} + +/// Resolves ignore rules through Git, including info/exclude, global rules, and negations. +/// +/// Paths are workspace-relative candidates owned by the search index. NUL-delimited +/// stdin preserves whitespace and avoids command-line limits. Missing Git/repositories +/// leave status unknown rather than guessing from directory names. +pub fn ignored_paths( + directory: &Path, + paths: &[String], +) -> Result>, CoreError> { + if paths.is_empty() { + return Ok(Some(HashSet::new())); + } + let response = execute_git_readonly( + &directory.to_string_lossy(), + &["check-ignore".into(), "--stdin".into(), "-z".into()], + Some(nul_pathspec_input(paths)), + ); + lithe_protocol::cancellation::check()?; + let Ok(response) = response else { + return Ok(None); + }; + if ![0, 1].contains(&response.exit_code) { + return Ok(None); + } + Ok(Some( + response + .stdout + .split('\0') + .filter(|path| !path.is_empty()) + .map(str::to_string) + .collect(), + )) +} + +/// Upper bound on the pathspec bytes passed to one `git ls-files` call. +/// +/// Windows limits a whole command line to 32,767 UTF-16 units; batching well +/// below it keeps large reactors from failing to start the query. +pub(crate) const LS_FILES_PATHSPEC_BUDGET: usize = 16 * 1024; + +/// Splits `paths` into consecutive non-empty batches whose summed byte length +/// stays within `budget`; a single longer path forms its own batch. +pub(crate) fn pathspec_batches(paths: &[String], budget: usize) -> Vec<&[String]> { + let mut batches = Vec::new(); + let mut start = 0; + let mut used = 0; + for (index, path) in paths.iter().enumerate() { + if index > start && used + path.len() > budget { + batches.push(&paths[start..index]); + start = index; + used = 0; + } + used += path.len(); + } + if start < paths.len() { + batches.push(&paths[start..]); + } + batches +} + +pub(crate) fn parse_status(output: &[u8], include_index_only_changes: bool) -> Vec { + let mut changes = Vec::new(); + let mut records = output + .split(|byte| *byte == 0) + .filter(|record| !record.is_empty()); + while let Some(record) = records.next() { + let record = String::from_utf8_lossy(record); + let (status, submodule, path, original_path) = match record.as_bytes().first() { + Some(b'?') => ("??".to_string(), None, record[2..].to_string(), None), + Some(kind @ (b'1' | b'2' | b'u')) => { + // v2 headers have fixed field counts; the remaining path may + // contain whitespace. A rename's old path is the next NUL record. + let count = match kind { + b'1' => 9, + b'2' => 10, + _ => 11, + }; + let fields = record.splitn(count, ' ').collect::>(); + if fields.len() != count { + continue; + } + let status = fields[1].replace('.', " "); + let submodule = if fields[2].starts_with('S') { + Some(crate::contracts::GitSubmoduleStatus { + commit_changed: fields[2].as_bytes().get(1) == Some(&b'C'), + tracked_changes: fields[2].as_bytes().get(2) == Some(&b'M'), + untracked_changes: fields[2].as_bytes().get(3) == Some(&b'U'), + }) + } else { + None + }; + let original = if *kind == b'2' { + records + .next() + .map(|path| String::from_utf8_lossy(path).to_string()) + } else { + None + }; + (status, submodule, fields[count - 1].to_string(), original) + } + _ => continue, + }; + let bytes = status.as_bytes(); + if bytes.len() != 2 { + continue; + } + let x = bytes[0] as char; + let y = bytes[1] as char; + // Preserve the existing final-worktree projection used by Windows. + if !include_index_only_changes && x == 'A' && y == 'D' { + continue; + } + let can_toggle_staging = (x != ' ' && x != '?') + || submodule.as_ref().is_none_or(|s| s.commit_changed) + || x == 'U' + || y == 'U' + || y == 'D' + || (x == 'A' && y == 'A'); + changes.push(GitChange { + can_toggle_staging, + path, + original_path, + status, + staged: x != ' ' && x != '?', + worktree: y != ' ' && y != '?', + untracked: x == '?' && y == '?', + submodule, + untracked_file_count: None, + }); + } + changes.sort_by(|left, right| left.path.cmp(&right.path)); + changes +} diff --git a/rust/crates/git/src/tests.rs b/rust/crates/git/src/tests.rs new file mode 100644 index 00000000..daf045a1 --- /dev/null +++ b/rust/crates/git/src/tests.rs @@ -0,0 +1,964 @@ +use super::{ + annotation_message_from_tag_object, parse_commit, parse_commit_lookup, parse_diff, + pathspec_batches, simplified_canonical_path, structured_diff_from_output, GitCommandInvocation, + GitCommandResponse, GitProcessOutput, +}; +use crate::contracts::{ + GitCommitLookupResponse, GitCommitResponse, GitHistoryPageResponse, GitHistoryResponse, + GitPushPreviewResponse, GitPushTagResponse, GitPushTargetKind, GitReferenceResponse, + GitReferencesResponse, +}; +use lithe_protocol::{CoreError, ErrorCode}; +use serde_json::Value; +use std::path::PathBuf; + +/// Only an exact `Name ` names an author; the options keep Git's order. +#[test] +fn commit_identity_requires_an_exact_author() { + assert_eq!( + super::commit_identity(Some("张三 "), true).unwrap(), + ["--author=张三 ", "--signoff"] + ); + assert!(super::commit_identity(None, false).unwrap().is_empty()); + for author in ["", " A ", "A ", "A ", "A <>", "A\t"] { + let error = super::commit_identity(Some(author), false).unwrap_err(); + assert!( + matches!(error.code, ErrorCode::GitCommitAuthorInvalid), + "{author:?}" + ); + } +} + +#[test] +fn unforced_removal_refused_for_local_changes_gains_its_own_code() { + let failed = |stderr: &str| { + GitProcessOutput { + stdout: Vec::new(), + stderr: stderr.as_bytes().to_vec(), + exit_code: 128, + } + .into_command_response(&["worktree".to_string(), "remove".to_string()]) + }; + let mut dirty = failed( + "fatal: '/tmp/feature' contains modified or untracked files, use --force to delete it\n", + ); + super::classify_unclean_worktree_removal(&mut dirty); + let error = dirty.operation_error.expect("operation error"); + assert!(matches!(error.code, ErrorCode::GitWorktreeHasChanges)); + assert_eq!( + error.message, + "fatal: '/tmp/feature' contains modified or untracked files, use --force to delete it" + ); + assert_eq!( + serde_json::to_value(CoreError::new(ErrorCode::GitWorktreeHasChanges, "x")).unwrap() + ["code"], + "git_worktree_has_changes" + ); + + // Submodules also need --force, but for a different reason; that refusal + // keeps Git's plain failure rather than claiming local changes. + let mut submodules = + failed("fatal: working trees containing submodules cannot be moved or removed\n"); + super::classify_unclean_worktree_removal(&mut submodules); + assert!(submodules.operation_error.is_none()); + + // A more specific error that is already present is kept. + let mut specific = failed("fatal: '/tmp/feature' contains modified or untracked files\n"); + specific.operation_error = Some(CoreError::new(ErrorCode::Cancelled, "cancelled")); + super::classify_unclean_worktree_removal(&mut specific); + assert!(matches!( + specific.operation_error.unwrap().code, + ErrorCode::Cancelled + )); + + let mut removed = failed(""); + removed.exit_code = 0; + super::classify_unclean_worktree_removal(&mut removed); + assert!(removed.operation_error.is_none()); +} + +#[test] +fn saved_stash_sections_reject_lossy_or_oversized_patch_before_git() { + let output = |stdout| super::GitProcessOutput { + stdout, + stderr: Vec::new(), + exit_code: 0, + }; + let invalid = super::structured_numstat_output( + output(vec![b'1', b'\t', b'0', b'\t', b'a', 0, 0, 0xff]), + true, + ) + .unwrap_err(); + assert_eq!(invalid.message, "Saved patch must contain valid UTF-8"); + let oversized = super::structured_numstat_output( + output( + [ + b"1\t0\ta\0\0".as_slice(), + &vec![b'x'; super::patch_exchange::MAX_PATCH_BYTES + 1], + ] + .concat(), + ), + true, + ) + .unwrap_err(); + assert_eq!(oversized.message, "Saved patch exceeds 32 MiB"); + let empty = super::structured_numstat_output(output(Vec::new()), true).unwrap(); + assert!(empty.files.unwrap().is_empty()); +} + +#[test] +fn temporary_git_context_is_private_and_cleans_up_under_a_public_parent() { + let parent = super::TemporaryGitCommitContext::prepare_snapshot(Some(&"a".repeat(40))) + .expect("owned fixture parent should be created"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&parent.directory, std::fs::Permissions::from_mode(0o755)) + .expect("fixture parent should allow public traversal"); + } + let context = super::TemporaryGitCommitContext::prepare_in( + &parent.directory, + "private-index", + Some(&"b".repeat(40)), + ) + .expect("private context should be created atomically"); + let directory = context.directory.clone(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(&directory).unwrap().permissions().mode() & 0o077, + 0 + ); + } + std::fs::write(&context.index_path, b"tracked/path\0object-id") + .expect("index fixture should stay inside the owned context"); + assert_eq!( + std::fs::read(directory.join("HEAD")).unwrap(), + format!("{}\n", "b".repeat(40)).as_bytes() + ); + drop(context); + assert!( + !directory.exists(), + "HEAD and index should be removed with their owner" + ); +} + +#[test] +fn simplified_canonical_path_strips_windows_verbatim_prefixes() { + // Windows canonicalization yields verbatim paths. Consumers normalize + // separators, which would turn them into `//?/C:/...` and break every + // later lookup of the discovered repository root. + assert_eq!( + simplified_canonical_path(PathBuf::from(r"\\?\C:\work\repo")).unwrap(), + PathBuf::from(r"C:\work\repo") + ); + assert_eq!( + simplified_canonical_path(PathBuf::from(r"\\?\UNC\server\share\repo")).unwrap(), + PathBuf::from(r"\\server\share\repo") + ); + assert_eq!( + simplified_canonical_path(PathBuf::from("/work/repo")).unwrap(), + PathBuf::from("/work/repo") + ); +} + +#[test] +fn windows_git_paths_follow_shared_identity_policy() { + let cases: Value = + serde_json::from_str(include_str!("../../../fixtures/git/windows-paths.json")).unwrap(); + for case in cases["accepted"].as_array().unwrap() { + let path = + simplified_canonical_path(PathBuf::from(case["input"].as_str().unwrap())).unwrap(); + assert_eq!( + path.to_string_lossy().replace('\\', "/"), + case["normalized"].as_str().unwrap() + ); + } + for path in cases["rejected"].as_array().unwrap() { + let path = PathBuf::from(path.as_str().unwrap()); + let error = simplified_canonical_path(path.clone()).unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput, "{path:?}"); + // A missing/stale worktree must not bypass the same identity check. + assert!(super::canonicalize_or_original(&path).is_err()); + } +} + +#[test] +fn repository_root_discovery_does_not_acquire_a_write_lease() { + struct Repository(std::path::PathBuf); + impl Drop for Repository { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.0).expect("temporary repository should be removed"); + } + } + + let repository = Repository(std::env::temp_dir().join(format!( + "lithe-repository-root-{}-{}", + std::process::id(), + super::TEMPORARY_INDEX_SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + ))); + std::fs::create_dir_all(&repository.0).unwrap(); + let root = repository.0.to_string_lossy().into_owned(); + let initialized = super::command(super::GitCommandRequest { + root: root.clone(), + arguments: vec!["init".into(), "-q".into()], + input: None, + }) + .unwrap(); + assert_eq!(initialized.exit_code, 0); + + let lease = super::rewrite::RewriteLease::acquire(&root).unwrap(); + let response = crate::test_envelope( + crate::test_request(serde_json::json!({ "root": root.clone() })) + .and_then(super::discover_repository_root), + ); + drop(lease); + + let expected_root = super::canonicalize_simplified(&repository.0) + .expect("temporary repository should canonicalize") + .to_string_lossy() + .replace('\\', "/"); + assert_eq!(response["ok"], true, "{response}"); + assert_eq!( + response["data"], + serde_json::json!(expected_root), + "{response}" + ); +} + +#[test] +fn argument_and_typed_writes_reject_an_active_repository_lease() { + struct Repository(std::path::PathBuf); + impl Drop for Repository { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.0).expect("temporary repository should be removed"); + } + } + + let repository = Repository(std::env::temp_dir().join(format!( + "lithe-write-lease-{}-{}", + std::process::id(), + super::TEMPORARY_INDEX_SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + ))); + std::fs::create_dir_all(&repository.0).unwrap(); + let root = repository.0.to_string_lossy().into_owned(); + let _deadline = lithe_protocol::cancellation::Scope::begin(None, Some(5_000)); + let initialized = super::command(super::GitCommandRequest { + root: root.clone(), + arguments: vec!["init".into(), "-q".into()], + input: None, + }) + .unwrap(); + assert_eq!(initialized.exit_code, 0); + + let lease = super::rewrite::RewriteLease::acquire(&root).unwrap(); + let (completed, completion) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || { + let run = + |payload: Value, operation: fn(Value) -> Result| { + let _deadline = lithe_protocol::cancellation::Scope::begin(None, Some(2_000)); + let mut payload = payload; + payload["root"] = serde_json::json!(root); + crate::test_envelope(operation(payload)) + }; + let results = [ + run( + serde_json::json!({"arguments":["config", "test.writer", "raw"]}), + |payload| crate::test_request(payload).and_then(super::command), + ), + run(serde_json::json!({"operation":"stageAll"}), |payload| { + crate::test_request(payload).and_then(super::write) + }), + ]; + let _ = completed.send(results); + }); + + // The competing commands must finish while the writer still owns its + // lease. Always release it before asserting, so a blocking regression + // can terminate and be joined even when the first deadline is missed. + let results = completion.recv_timeout(std::time::Duration::from_secs(5)); + drop(lease); + if results.is_err() { + completion + .recv_timeout(std::time::Duration::from_secs(5)) + .expect("competing writer should terminate after lease cleanup"); + } + worker.join().expect("competing writer should not panic"); + for result in results.expect("competing writers must fail without waiting for the lease") { + assert_eq!(result["ok"], false, "{result}"); + assert_eq!(result["error"]["code"], "invalid_request", "{result}"); + assert_eq!( + result["error"]["message"], + "Another Git write operation is running in this repository" + ); + } +} + +#[test] +fn tag_annotation_parser_preserves_crlf_and_trailing_blank_lines() { + let raw = concat!( + "object abc123\n", + "type commit\n", + "tag v1.0\n", + "tagger Lithe Test 0 +0000\n", + "\n", + "release\r\n", + "\r\n", + "details\r\n", + "\r\n" + ); + + assert_eq!( + annotation_message_from_tag_object(raw).as_deref(), + Some("release\r\n\r\ndetails\r\n\r\n") + ); +} + +#[test] +fn tag_annotation_parser_removes_only_the_signature_block() { + let raw = concat!( + "object abc123\n", + "type commit\n", + "tag v1.0\n", + "tagger Lithe Test 0 +0000\n", + "\n", + "release\r\n", + "\r\n", + "-----BEGIN PGP SIGNATURE-----\r\n", + "signature-data\r\n", + "-----END PGP SIGNATURE-----\r\n" + ); + + assert_eq!( + annotation_message_from_tag_object(raw).as_deref(), + Some("release\r\n\r\n") + ); +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct TagWriteFixture { + target: String, + cases: Vec, + rejected_cases: Vec, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct TagWriteArgumentCase { + name: String, + request: TagWriteFields, + arguments: Vec, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct TagWriteFields { + message: Option, + lightweight: Option, + signed: Option, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct TagWriteRejectedCase { + name: String, + request: TagWriteFields, + error: TagWriteRejectedError, +} + +#[derive(serde::Deserialize)] +struct TagWriteRejectedError { + code: String, + message: String, +} + +#[test] +fn create_tag_arguments_follow_the_lightweight_and_signature_matrix() { + // The fixture pins the argument matrix shared with the Windows host, + // including the combinations that must fail before any subprocess. + let fixture: TagWriteFixture = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../fixtures/git/tag-write-v1.json" + ))) + .expect("tag write fixture should be valid JSON"); + for case in &fixture.cases { + super::validate_create_tag_form( + case.request.message.as_deref(), + case.request.lightweight, + case.request.signed, + ) + .unwrap_or_else(|error| panic!("{}: {}", case.name, error.message)); + assert_eq!( + super::create_tag_arguments( + "v1.0", + &fixture.target, + case.request.message.as_deref(), + case.request.lightweight, + case.request.signed, + ), + case.arguments, + "{}", + case.name + ); + } + for case in &fixture.rejected_cases { + let error = super::validate_create_tag_form( + case.request.message.as_deref(), + case.request.lightweight, + case.request.signed, + ) + .expect_err(&case.name); + let encoded = serde_json::to_value(&error).expect("tag form error should encode"); + assert_eq!(encoded["code"], case.error.code, "{}", case.name); + assert_eq!(error.message, case.error.message, "{}", case.name); + } +} + +#[cfg(target_os = "windows")] +#[test] +fn background_git_processes_do_not_create_windows_console() { + assert_eq!(super::git_process_creation_flags(), 0x08000000); +} + +// A Latin-1 `é` (0xE9) decodes to U+FFFD; writing that patch back would +// replace the original byte with EF BF BD, so the response must say so. +#[test] +fn structured_diff_reports_lossy_utf8_decoding() { + let lossy = structured_diff_from_output(GitProcessOutput { + stdout: b"diff --git a/a.txt b/a.txt\n--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-caf\xe9\r\n+cafe\r\n".to_vec(), + stderr: Vec::new(), + exit_code: 1, + }); + assert!(lossy.has_decoding_loss); + assert!(lossy.patch.contains('\u{FFFD}')); + + let faithful = structured_diff_from_output(GitProcessOutput { + stdout: + "diff --git a/a.txt b/a.txt\n--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-café\r\n+cafe\r\n" + .as_bytes() + .to_vec(), + stderr: Vec::new(), + exit_code: 1, + }); + assert!(!faithful.has_decoding_loss); + assert!(faithful.patch.contains("café\r\n")); +} + +#[test] +fn structured_diff_does_not_parse_git_stderr_as_file_content() { + let output = GitProcessOutput { + stdout: b"diff --git a/c.txt b/c.txt\n--- /dev/null\n+++ b/c.txt\n@@ -0,0 +1,3 @@\n+c\n+cc\n+ccc\n".to_vec(), + stderr: b"warning: CRLF will be replaced by LF\n".to_vec(), + exit_code: 1, + }; + + let diff = structured_diff_from_output(output); + + assert!(!diff.patch.contains("warning:")); + assert!(diff + .rows + .iter() + .any(|row| row.right.as_deref() == Some("ccc"))); + assert!(!diff.rows.iter().any(|row| row + .right + .as_deref() + .is_some_and(|line| line.contains("warning:")))); +} + +#[test] +fn traced_response_uses_the_final_invocation_for_compatibility_fields() { + let mut response = GitCommandResponse { + arguments: vec!["status".into()], + output: "stale".into(), + stdout: "stale".into(), + stderr: String::new(), + exit_code: 0, + invocations: vec![ + GitCommandInvocation { + arguments: vec!["status".into()], + stdout: "status\n".into(), + stderr: String::new(), + exit_code: 0, + }, + GitCommandInvocation { + arguments: vec!["stash".into(), "list".into()], + stdout: "stash@{0}\n".into(), + stderr: String::new(), + exit_code: 0, + }, + ], + operation_error: None, + stash_restore: None, + tag_deletion: None, + branch_deletion: None, + history_rewrite: None, + outcome_authoritative: false, + warnings: Vec::new(), + }; + + super::synchronize_final_invocation(&mut response); + + assert_eq!(response.arguments, vec!["stash", "list"]); + assert_eq!(response.stdout, "stash@{0}\n"); + assert_eq!(response.stderr, ""); + assert_eq!(response.output, "stash@{0}\n"); + assert_eq!(response.exit_code, 0); +} + +#[test] +fn traced_core_error_becomes_a_failed_response_with_invocations() { + let result = super::with_git_invocation_trace(|| { + let response = GitProcessOutput { + stdout: b"prepared\n".to_vec(), + stderr: Vec::new(), + exit_code: 0, + } + .into_command_response(&["stash".into(), "push".into()]); + super::record_git_invocation(&response); + Err(CoreError::new( + ErrorCode::ProcessFailed, + "Follow-up probe failed", + )) + }); + + let response = result.expect("a partial Git failure should retain command data"); + assert_eq!(response.exit_code, 0); + assert_eq!(response.arguments, vec!["stash", "push"]); + assert_eq!(response.output, "prepared\n"); + assert_eq!(response.invocations.len(), 1); + assert_eq!( + response + .operation_error + .as_ref() + .map(|error| &error.message), + Some(&"Follow-up probe failed".to_string()) + ); +} + +#[test] +fn git_process_response_preserves_executed_arguments() { + let arguments = vec!["status".to_string(), "--short".to_string()]; + let response = GitProcessOutput { + stdout: b" M README.md\n".to_vec(), + stderr: Vec::new(), + exit_code: 0, + } + .into_command_response(&arguments); + + assert_eq!(response.arguments, arguments); + assert_eq!(response.output, " M README.md\n"); + assert_eq!(response.stdout, " M README.md\n"); + assert_eq!(response.stderr, ""); + assert_eq!(response.exit_code, 0); +} + +#[test] +fn command_response_matches_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../fixtures/git/command-response-v1.json" + ))) + .expect("Git command response fixture should be valid JSON"); + let response = GitCommandResponse { + arguments: vec![ + "checkout".into(), + "HEAD".into(), + "--".into(), + "README.md".into(), + ], + output: String::new(), + stdout: String::new(), + stderr: String::new(), + exit_code: 0, + invocations: vec![ + GitCommandInvocation { + arguments: vec![ + "status".into(), + "--porcelain".into(), + "--untracked-files=all".into(), + "--".into(), + "README.md".into(), + ], + stdout: " M README.md\n".into(), + stderr: String::new(), + exit_code: 0, + }, + GitCommandInvocation { + arguments: vec![ + "checkout".into(), + "HEAD".into(), + "--".into(), + "README.md".into(), + ], + stdout: String::new(), + stderr: String::new(), + exit_code: 0, + }, + ], + operation_error: None, + stash_restore: None, + tag_deletion: None, + branch_deletion: None, + history_rewrite: None, + outcome_authoritative: false, + warnings: Vec::new(), + }; + + assert_eq!( + serde_json::to_value(response).expect("Git response should serialize"), + fixture + ); +} + +#[test] +fn parse_commit_splits_the_author_utc_offset_from_the_displayed_date() { + let line = |date: &str| format!("h\u{1f}s\u{1f}\u{1f}A\u{1f}a@x\u{1f}{date}\u{1f}msg\u{1f}"); + + let east = parse_commit(&line("2026/09/30 21:05 +0800")).expect("commit should parse"); + assert_eq!(east.date, "2026/09/30 21:05"); + assert_eq!(east.date_utc_offset_minutes, Some(480)); + assert_eq!(east.date_unix_seconds, Some(1790773500)); + + let west = parse_commit(&line("2026/09/30 08:05 -0330")).expect("commit should parse"); + assert_eq!(west.date, "2026/09/30 08:05"); + assert_eq!(west.date_utc_offset_minutes, Some(-210)); + + // Output without a recognizable zone stays visible unchanged instead of being trimmed. + let legacy = parse_commit(&line("2026/09/30 08:05")).expect("commit should parse"); + let invalid = parse_commit(&line("2026/09/30 08:05 +1260")).expect("commit should parse"); + assert_eq!(invalid.date, "2026/09/30 08:05 +1260"); + assert_eq!(invalid.date_utc_offset_minutes, None); + assert_eq!(invalid.date_unix_seconds, None); + + assert_eq!(legacy.date, "2026/09/30 08:05"); + assert_eq!(legacy.date_utc_offset_minutes, None); +} + +#[test] +fn history_response_matches_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../fixtures/git/history-response-v1.json" + ))) + .expect("Git history response fixture should be valid JSON"); + let feature = GitReferenceResponse { + full_name: "refs/heads/feature/recent".into(), + short_name: "feature/recent".into(), + kind: "local".into(), + peels_to_commit: true, + is_current: true, + upstream_short_name: None, + ahead: 0, + behind: 0, + }; + let main = GitReferenceResponse { + full_name: "refs/heads/main".into(), + short_name: "main".into(), + kind: "local".into(), + peels_to_commit: true, + is_current: false, + upstream_short_name: Some("origin/main".into()), + ahead: 2, + behind: 1, + }; + let response = GitHistoryResponse { + references: vec![feature.clone(), main.clone()], + recent_references: vec![feature, main], + commits: vec![GitCommitResponse { + hash: "0123456789abcdef0123456789abcdef01234567".into(), + short_hash: "0123456".into(), + parent_hashes: Vec::new(), + author_name: "Lithe Test".into(), + author_email: "test@example.invalid".into(), + date: "2026/08/30 12:00".into(), + date_utc_offset_minutes: Some(480), + date_unix_seconds: None, + subject: "Initial commit".into(), + decorations: "HEAD -> feature/recent".into(), + }], + has_more: false, + user_name: Some("Lithe Test".into()), + user_email: Some("test@example.invalid".into()), + }; + + assert_eq!( + serde_json::to_value(response).expect("Git history response should serialize"), + fixture + ); +} + +#[test] +fn references_response_matches_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../fixtures/git/references-response-v1.json" + ))) + .expect("Git references response fixture should be valid JSON"); + let feature = GitReferenceResponse { + full_name: "refs/heads/feature/recent".into(), + short_name: "feature/recent".into(), + kind: "local".into(), + peels_to_commit: true, + is_current: true, + upstream_short_name: None, + ahead: 0, + behind: 0, + }; + let main = GitReferenceResponse { + full_name: "refs/heads/main".into(), + short_name: "main".into(), + kind: "local".into(), + peels_to_commit: true, + is_current: false, + upstream_short_name: Some("origin/main".into()), + ahead: 2, + behind: 1, + }; + let response = GitReferencesResponse { + references: vec![feature.clone(), main.clone()], + recent_references: vec![feature, main], + user_name: Some("Lithe Test".into()), + user_email: Some("test@example.invalid".into()), + }; + + assert_eq!( + serde_json::to_value(response).expect("Git references response should serialize"), + fixture + ); +} + +#[test] +fn history_page_response_matches_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../fixtures/git/history-page-response-v1.json" + ))) + .expect("Git history page response fixture should be valid JSON"); + let response = GitHistoryPageResponse { + commits: vec![GitCommitResponse { + hash: "0123456789abcdef0123456789abcdef01234567".into(), + short_hash: "0123456".into(), + parent_hashes: Vec::new(), + author_name: "Lithe Test".into(), + author_email: "test@example.invalid".into(), + date: "2026/08/30 12:00".into(), + date_utc_offset_minutes: Some(480), + date_unix_seconds: None, + subject: "Initial commit".into(), + decorations: "HEAD -> feature/recent".into(), + }], + next_cursor: Some("git-history-cursor-fixture".into()), + next_offset: None, + has_more: true, + }; + + assert_eq!( + serde_json::to_value(response).expect("Git history page response should serialize"), + fixture + ); +} + +#[test] +fn commit_lookup_response_matches_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../fixtures/git/commit-lookup-response-v1.json" + ))) + .expect("Git commit lookup response fixture should be valid JSON"); + let response = GitCommitLookupResponse { + commit: GitCommitResponse { + hash: "0123456789abcdef0123456789abcdef01234567".into(), + short_hash: "0123456".into(), + parent_hashes: vec!["89abcdef0123456789abcdef0123456789abcdef".into()], + author_name: "Lithe Test".into(), + author_email: "test@example.invalid".into(), + date: "2026/08/30 12:00".into(), + date_utc_offset_minutes: Some(480), + date_unix_seconds: None, + subject: "Fix commit details".into(), + decorations: "HEAD -> feature/recent".into(), + }, + body: "Explain why the change is needed.\n\n- keep blank lines\n and indentation".into(), + }; + + assert_eq!( + serde_json::to_value(response).expect("Git commit lookup response should serialize"), + fixture + ); +} + +#[test] +fn commit_lookup_keeps_separator_characters_inside_the_body() { + // The body may contain the header's unit separator or CRLF line breaks; + // only the first NUL delimits the header from the free-form message. + let output = "abc\u{1f}ab\u{1f}p1 p2\u{1f}Dev\u{1f}dev@example.invalid\u{1f}2026/08/30 12:00\u{1f}Subject\u{1f}tag: v1\0Line \u{1f} one\r\n\r\nLine two\n\n"; + let lookup = parse_commit_lookup(output).expect("lookup output should parse"); + assert_eq!(lookup.commit.parent_hashes, ["p1", "p2"]); + assert_eq!(lookup.commit.subject, "Subject"); + assert_eq!(lookup.commit.decorations, "tag: v1"); + assert_eq!(lookup.body, "Line \u{1f} one\r\n\r\nLine two"); + + let subject_only = parse_commit_lookup( + "abc\u{1f}ab\u{1f}\u{1f}Dev\u{1f}dev@example.invalid\u{1f}2026/08/30 12:00\u{1f}Only\u{1f}\0", + ) + .expect("subject-only output should parse"); + assert_eq!(subject_only.body, ""); + assert!(parse_commit_lookup("missing delimiter").is_none()); +} + +#[test] +fn push_preview_response_matches_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../fixtures/git/push-preview-v1.json" + ))) + .expect("Git push preview fixture should be valid JSON"); + let response = GitPushPreviewResponse { + local_branch: "feature/core".into(), + local_head: "2222222222222222222222222222222222222222".into(), + remote: "origin".into(), + remote_branch: "feature/core".into(), + target_kind: GitPushTargetKind::Tracking, + creates_remote_branch: false, + remote_tracking_oid: Some("1111111111111111111111111111111111111111".into()), + upstream: Some("origin/feature/core".into()), + tags: vec![GitPushTagResponse { + full_name: "refs/tags/v1.0.0".into(), + object_id: "3333333333333333333333333333333333333333".into(), + }], + commits: vec![GitCommitResponse { + hash: "2222222222222222222222222222222222222222".into(), + short_hash: "2222222".into(), + parent_hashes: vec!["1111111111111111111111111111111111111111".into()], + author_name: "Lithe Developer".into(), + author_email: "developer@lithe.local".into(), + date: "2026/08/31 10:30".into(), + date_utc_offset_minutes: Some(-300), + date_unix_seconds: None, + subject: "Add push preview".into(), + decorations: "HEAD -> feature/core".into(), + }], + has_more: false, + }; + + assert_eq!( + serde_json::to_value(response).expect("Git push preview should serialize"), + fixture + ); +} + +#[test] +fn command_error_response_matches_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../fixtures/git/command-error-response-v1.json" + ))) + .expect("Git command error response fixture should be valid JSON"); + let response = GitCommandResponse { + arguments: vec![ + "stash".into(), + "push".into(), + "--include-untracked".into(), + "--message".into(), + "Lithe Smart Checkout".into(), + ], + output: "No local changes to save\n".into(), + stdout: "No local changes to save\n".into(), + stderr: String::new(), + exit_code: 0, + invocations: vec![GitCommandInvocation { + arguments: vec![ + "stash".into(), + "push".into(), + "--include-untracked".into(), + "--message".into(), + "Lithe Smart Checkout".into(), + ], + stdout: "No local changes to save\n".into(), + stderr: String::new(), + exit_code: 0, + }], + operation_error: Some(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git reference", + )), + stash_restore: None, + tag_deletion: None, + branch_deletion: None, + history_rewrite: None, + outcome_authoritative: false, + warnings: Vec::new(), + }; + + assert_eq!( + serde_json::to_value(response).expect("Git error response should serialize"), + fixture + ); +} + +#[test] +fn structured_diff_matches_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../fixtures/git/diff.json" + ))) + .expect("diff fixture should be valid JSON"); + let patch = fixture["patch"] + .as_str() + .expect("fixture patch should be text"); + let (rows, hunks, _) = parse_diff(patch); + let expected = &fixture["expected"]; + let kinds = rows.iter().map(|row| row.kind.as_str()).collect::>(); + let old_lines = rows + .iter() + .map(|row| row.old_line.map(|line| line as u64)) + .collect::>(); + let new_lines = rows + .iter() + .map(|row| row.new_line.map(|line| line as u64)) + .collect::>(); + let expected_kinds = expected["rowKinds"] + .as_array() + .expect("fixture kinds should be an array") + .iter() + .map(|kind| kind.as_str().expect("fixture kind should be text")) + .collect::>(); + let expected_old_lines = expected["oldLines"] + .as_array() + .expect("fixture old lines should be an array") + .iter() + .map(Value::as_u64) + .collect::>(); + let expected_new_lines = expected["newLines"] + .as_array() + .expect("fixture new lines should be an array") + .iter() + .map(Value::as_u64) + .collect::>(); + assert_eq!(kinds, expected_kinds); + assert_eq!(old_lines, expected_old_lines); + assert_eq!(new_lines, expected_new_lines); + assert_eq!( + hunks.len(), + expected["hunkCount"] + .as_u64() + .expect("fixture count should be a number") as usize + ); +} + +#[test] +fn pathspec_batches_stay_within_the_budget_and_keep_every_path() { + let paths = ["aaaa", "bbbb", "cc", "dddddddddd", "e"] + .map(String::from) + .to_vec(); + + let batches = pathspec_batches(&paths, 8); + + assert_eq!( + batches, + vec![&paths[0..2], &paths[2..3], &paths[3..4], &paths[4..5]] + ); + assert!(pathspec_batches(&[], 8).is_empty()); +} diff --git a/rust/crates/core/src/git/untracked_directories.rs b/rust/crates/git/src/untracked_directories.rs similarity index 98% rename from rust/crates/core/src/git/untracked_directories.rs rename to rust/crates/git/src/untracked_directories.rs index cd942212..f9b9fb4c 100644 --- a/rust/crates/core/src/git/untracked_directories.rs +++ b/rust/crates/git/src/untracked_directories.rs @@ -15,7 +15,8 @@ use super::{ canonicalize_or_original, canonicalize_simplified, git_path_error, is_safe_pathspec, pathspec_batches, run_git, LS_FILES_PATHSPEC_BUDGET, }; -use crate::protocol::{CoreError, ErrorCode, GitChange, GitUntrackedDirectoryResponse}; +use crate::contracts::{GitChange, GitUntrackedDirectoryResponse}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::Deserialize; use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -24,7 +25,7 @@ use std::path::{Path, PathBuf}; /// /// Smaller directories keep one entry per file so a new source folder still /// shows its files directly, matching `--untracked-files=all`. -pub(crate) const UNTRACKED_DIRECTORY_SUMMARY_THRESHOLD: usize = 100; +pub const UNTRACKED_DIRECTORY_SUMMARY_THRESHOLD: usize = 100; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/rust/crates/core/src/git/update_plan.rs b/rust/crates/git/src/update_plan.rs similarity index 90% rename from rust/crates/core/src/git/update_plan.rs rename to rust/crates/git/src/update_plan.rs index 4a77a885..3c2355df 100644 --- a/rust/crates/core/src/git/update_plan.rs +++ b/rust/crates/git/src/update_plan.rs @@ -12,7 +12,7 @@ //! fast-forward only. A fast-forward never invents a merge commit or replays //! local history, so it is the default when nobody expressed a preference. -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -178,7 +178,7 @@ fn invalid(message: &str) -> CoreError { } /// Handles `git.updatePlan`. -pub(crate) fn command(payload: Value) -> Result { +pub fn command(payload: Value) -> Result { let request: Request = serde_json::from_value(payload).map_err(|error| { invalid("Invalid Git update plan request").with_details(error.to_string()) })?; @@ -309,31 +309,3 @@ fn default_strategy(preflight: &Preflight, fast_forward_possible: bool) -> Strat Some(configured) => configured, } } - -#[cfg(test)] -mod tests { - use serde_json::{json, Value}; - - fn call(payload: Value) -> Value { - serde_json::from_str(&crate::execute_json( - &json!({"id":"update", "command":"git.updatePlan", "payload":payload}).to_string(), - )) - .unwrap() - } - - #[test] - fn shared_fixture_steps_are_the_only_update_rules() { - let fixture: Value = - serde_json::from_str(include_str!("../../../../fixtures/git/update-plan-v1.json")) - .unwrap(); - for case in fixture["cases"].as_array().unwrap() { - let response = call(case["request"].clone()); - assert_eq!(response["ok"], true, "{}: {response}", case["name"]); - assert_eq!(response["data"], case["plan"], "{}", case["name"]); - } - for case in fixture["invalid"].as_array().unwrap() { - let response = call(case["request"].clone()); - assert_eq!(response["ok"], false, "{}", case["name"]); - } - } -} diff --git a/rust/crates/core/src/git/workspace_commit.rs b/rust/crates/git/src/workspace_commit.rs similarity index 95% rename from rust/crates/core/src/git/workspace_commit.rs rename to rust/crates/git/src/workspace_commit.rs index 56049010..62257d12 100644 --- a/rust/crates/core/src/git/workspace_commit.rs +++ b/rust/crates/git/src/workspace_commit.rs @@ -8,7 +8,7 @@ use super::{ commit_state, DivergentPushDestination, GitCommitGitlink, GitCommitState, GitWriteRequest, PushTargetChoiceRequest, }; -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; @@ -28,8 +28,8 @@ pub struct RepositoryBinding { #[serde(rename_all = "camelCase")] /// A mode-160000 index entry between two discovered repositories. pub struct Relation { - pub parent: String, - pub child: String, + pub(crate) parent: String, + pub(crate) child: String, /// Gitlink path relative to the parent repository. pub path: String, } @@ -73,44 +73,44 @@ pub struct PushTargetQuestion { #[serde(rename_all = "camelCase")] /// Exact repositories, options, and Git state presented for review. pub struct Plan { - pub repositories: Vec, - pub message: String, - pub amend: bool, + pub(crate) repositories: Vec, + pub(crate) message: String, + pub(crate) amend: bool, /// The author of every commit as `Name `; omitted for the configured identity. #[serde(default, skip_serializing_if = "Option::is_none")] pub author: Option, /// Whether every commit gets a `Signed-off-by` trailer for the committer. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub sign_off: bool, - pub push: bool, + pub(crate) push: bool, /// Confirmed destinations by repository ID, only for branches whose upstream has /// a different name. Each repository is guarded separately because two /// repositories in one batch can track unrelated branches. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub push_targets: BTreeMap, - pub include_parent_references: bool, + pub(crate) include_parent_references: bool, /// Optional local selection guard; omitted by clients without changelists. #[serde(default, skip_serializing_if = "Option::is_none")] pub path_scope: Option, - pub is_retry: bool, + pub(crate) is_retry: bool, /// Child-before-parent order, preserving input order for independent roots. pub ordered_ids: Vec, - pub propagated_relations: Vec, - pub dependency_relations: Vec, - pub states: BTreeMap, - pub committed_ids: BTreeSet, - pub pending_push_ids: BTreeSet, + pub(crate) propagated_relations: Vec, + pub(crate) dependency_relations: Vec, + pub(crate) states: BTreeMap, + pub(crate) committed_ids: BTreeSet, + pub(crate) pending_push_ids: BTreeSet, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] /// Per-repository outcome; successful mutations are never rolled back as a batch. pub struct RepositoryResult { - pub committed: bool, - pub pushed: bool, + pub(crate) committed: bool, + pub(crate) pushed: bool, /// Stable presentation key, separate from optional Git diagnostics. pub status: String, - pub detail: String, + pub(crate) detail: String, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -118,27 +118,27 @@ pub struct RepositoryResult { /// In-memory continuation returned unchanged by clients to the next step or retry. /// It owns no native resources and is discarded when the workspace closes. pub struct Session { - pub plan: Plan, + pub(crate) plan: Plan, /// Last observed states, including successful commits and failed-hook index changes. pub states: BTreeMap, - pub results: BTreeMap, - pub blocked: BTreeSet, + pub(crate) results: BTreeMap, + pub(crate) blocked: BTreeSet, /// Next repository; a successful commit keeps this position for its separate push. pub cursor: usize, - pub command_failed: bool, - pub finished: bool, - pub succeeded: bool, - pub can_retry: bool, + pub(crate) command_failed: bool, + pub(crate) finished: bool, + pub(crate) succeeded: bool, + pub(crate) can_retry: bool, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] /// Builds a fresh plan; retry and reviewed snapshots are optional continuations. pub struct PrepareRequest { - pub repositories: Vec, - pub message: String, + pub(crate) repositories: Vec, + pub(crate) message: String, #[serde(default)] - pub amend: bool, + pub(crate) amend: bool, /// The author of every commit as `Name `; omitted for the configured identity. #[serde(default)] pub author: Option, @@ -146,28 +146,28 @@ pub struct PrepareRequest { #[serde(default)] pub sign_off: bool, #[serde(default)] - pub push: bool, + pub(crate) push: bool, /// Destinations the user confirmed by repository ID, answering earlier /// [`PushTargetQuestion`]s. A retry also inherits the previous plan's entries. #[serde(default)] pub push_targets: BTreeMap, - pub include_parent_references: bool, + pub(crate) include_parent_references: bool, /// Restricts commits without rewriting the user's index. #[serde(default)] pub path_scope: Option, #[serde(default)] - pub previous: Option, + pub(crate) previous: Option, #[serde(default)] - pub reviewed: Option, + pub(crate) reviewed: Option, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] /// Review decision and initial continuation, both computed by Core. pub struct Preparation { - pub session: Session, - pub review_changed: bool, - pub requires_confirmation: bool, + pub(crate) session: Session, + pub(crate) review_changed: bool, + pub(crate) requires_confirmation: bool, /// Pushes that still need a destination choice. While this is non-empty the /// session must not be stepped: committing first would leave the push unable to /// proceed. Core also refuses the step of an unanswered repository, so a client @@ -181,7 +181,7 @@ pub struct Preparation { #[serde(rename_all = "camelCase")] /// Executes at most one commit or push using a fresh host operation context. pub struct StepRequest { - pub session: Session, + pub(crate) session: Session, } fn invalid(message: &str) -> CoreError { @@ -641,7 +641,7 @@ pub fn step(request: StepRequest) -> Result { // Read-only cleanup has its own bounded deadline, preserving a commit // that completed just before cancellation without starting another write. let after = - crate::protocol::cancellation::with_cleanup_deadline(Duration::from_secs(5), || { + lithe_protocol::cancellation::with_cleanup_deadline(Duration::from_secs(5), || { commit_state::inspect_root(&root) }); let advanced = after.as_ref().is_ok_and(|s| { diff --git a/rust/crates/core/src/git/workspace_commit/path_scope.rs b/rust/crates/git/src/workspace_commit/path_scope.rs similarity index 98% rename from rust/crates/core/src/git/workspace_commit/path_scope.rs rename to rust/crates/git/src/workspace_commit/path_scope.rs index ddee8ed4..77ab1372 100644 --- a/rust/crates/core/src/git/workspace_commit/path_scope.rs +++ b/rust/crates/git/src/workspace_commit/path_scope.rs @@ -1,7 +1,7 @@ //! Read-only path guards for local changelists; Git remains the index authority. use super::{invalid, Plan}; -use crate::git::GitCommitState; -use crate::protocol::CoreError; +use crate::GitCommitState; +use lithe_protocol::CoreError; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; diff --git a/rust/crates/core/src/git/workspace_commit/tests.rs b/rust/crates/git/src/workspace_commit/tests.rs similarity index 98% rename from rust/crates/core/src/git/workspace_commit/tests.rs rename to rust/crates/git/src/workspace_commit/tests.rs index 9a2f04c7..a60fc4f5 100644 --- a/rust/crates/core/src/git/workspace_commit/tests.rs +++ b/rust/crates/git/src/workspace_commit/tests.rs @@ -172,7 +172,7 @@ fn missing_unfinished_roots_and_cycles_fail_closed() { #[test] fn shared_workflow_fixture_is_the_complete_portable_contract() { let fixture: serde_json::Value = serde_json::from_str(include_str!( - "../../../../../fixtures/git/workspace-commit-workflow-v1.json" + "../../../../fixtures/git/workspace-commit-workflow-v1.json" )) .unwrap(); let request = serde_json::from_value(fixture["request"].clone()).unwrap(); @@ -275,7 +275,7 @@ fn changelist_scope_is_checked_on_continuations_and_compared_during_review() { #[test] fn local_changelist_scope_fixture_defines_default_and_custom_membership() { let fixture: serde_json::Value = serde_json::from_str(include_str!( - "../../../../../fixtures/git/local-changelist-scope-v1.json" + "../../../../fixtures/git/local-changelist-scope-v1.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { diff --git a/rust/crates/core/src/git/workspace_status.rs b/rust/crates/git/src/workspace_status.rs similarity index 61% rename from rust/crates/core/src/git/workspace_status.rs rename to rust/crates/git/src/workspace_status.rs index 70918a07..553e1194 100644 --- a/rust/crates/core/src/git/workspace_status.rs +++ b/rust/crates/git/src/workspace_status.rs @@ -1,12 +1,13 @@ //! Plans publication of fresh and previously confirmed repository snapshots. -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct Request { +/// Roots discovery reports now, the roots published before, and one read outcome per current root. +pub struct Request { roots: Vec, previous_roots: Vec, outcomes: Vec, @@ -21,7 +22,8 @@ struct Outcome { #[derive(Serialize)] #[serde(rename_all = "camelCase")] -pub(crate) struct Plan { +/// Which snapshot each root publishes, and which roots are now confirmed or failed. +pub struct Plan { entries: Vec, confirmed_roots: Vec, failed_roots: Vec, @@ -43,7 +45,7 @@ fn invalid() -> CoreError { /// A failed read preserves an existing snapshot, never proves a clean tree, /// and never resurrects a root that discovery has removed. Hosts retain the /// actual snapshots and apply this plan under their own publication generation. -pub(crate) fn plan(request: Request) -> Result { +pub fn plan(request: Request) -> Result { if request.roots.len() > 10_000 || request.previous_roots.len() > 10_000 || request.outcomes.len() != request.roots.len() @@ -96,41 +98,3 @@ pub(crate) fn plan(request: Request) -> Result { failed_roots, }) } - -#[cfg(test)] -mod tests { - use serde_json::json; - - fn call(payload: serde_json::Value) -> serde_json::Value { - serde_json::from_str(&crate::execute_json( - &json!({"id":"status", "command":"git.workspaceStatusMerge", "payload":payload}) - .to_string(), - )) - .unwrap() - } - - #[test] - fn keeps_failed_existing_snapshots_and_only_confirms_successes() { - let response = call( - json!({"roots":["a","b","c"], "previousRoots":["a","b","removed"], "outcomes":[ - {"root":"a","success":true}, {"root":"b","success":false}, {"root":"c","success":false} - ]}), - ); - assert_eq!(response["ok"], true, "{response}"); - assert_eq!( - response["data"], - json!({"entries":[{"root":"a","source":"current"},{"root":"b","source":"previous"},{"root":"c","source":"missing"}], "confirmedRoots":["a"], "failedRoots":["b","c"]}) - ); - } - - #[test] - fn rejects_incomplete_duplicate_and_foreign_outcomes() { - for payload in [ - json!({"roots":["a","b"],"previousRoots":[],"outcomes":[{"root":"a","success":true}]}), - json!({"roots":["a","b"],"previousRoots":[],"outcomes":[{"root":"a","success":true},{"root":"a","success":false}]}), - json!({"roots":["a"],"previousRoots":[],"outcomes":[{"root":"b","success":true}]}), - ] { - assert_eq!(call(payload)["ok"], false); - } - } -} diff --git a/rust/crates/git/src/worktrees.rs b/rust/crates/git/src/worktrees.rs new file mode 100644 index 00000000..83da44b0 --- /dev/null +++ b/rust/crates/git/src/worktrees.rs @@ -0,0 +1,323 @@ +//! Linked worktrees: listing, creation, removal and unclean-removal classification. + +use super::*; + +/// Returns one metadata-only snapshot for every registered worktree. +pub fn worktrees(request: GitWorktreesRequest) -> Result { + let root = validate_root(&request.root)?; + Ok(GitWorktreesResponse { + worktrees: list_worktrees(&root)?, + }) +} + +fn list_worktrees(root: &str) -> Result, CoreError> { + let arguments = vec![ + "worktree".to_string(), + "list".to_string(), + "--porcelain".to_string(), + "-z".to_string(), + ]; + let response = execute_git_readonly(root, &arguments, None)?; + if response.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Git worktree listing failed") + .with_details(response.output), + ); + } + let current_root = canonicalize_or_original(&repository_root(root)?).map_err(git_path_error)?; + let mut records = Vec::new(); + let mut fields = Vec::new(); + for field in response.stdout.split('\0') { + if field.is_empty() { + if !fields.is_empty() { + records.push(parse_worktree_record( + &fields, + records.is_empty(), + ¤t_root, + )?); + fields.clear(); + } + } else { + fields.push(field); + } + } + if !fields.is_empty() { + records.push(parse_worktree_record( + &fields, + records.is_empty(), + ¤t_root, + )?); + } + // Git currently emits the primary worktree first, but sorting here makes + // that display contract explicit and stable across Git versions. + records.sort_by(|left, right| { + right + .is_primary + .cmp(&left.is_primary) + .then_with(|| left.path.cmp(&right.path)) + }); + Ok(records) +} + +fn worktree_paths_match(left: &str, right: &str) -> bool { + let left_path = PathBuf::from(left); + let right_path = PathBuf::from(right); + if left_path == right_path { + return true; + } + match (fs::canonicalize(&left_path), fs::canonicalize(&right_path)) { + (Ok(left), Ok(right)) => left == right, + _ => false, + } +} + +fn parse_worktree_record( + fields: &[&str], + is_primary: bool, + current_root: &Path, +) -> Result { + let path = fields + .iter() + .find_map(|field| field.strip_prefix("worktree ")) + .filter(|path| !path.is_empty()) + .ok_or_else(|| { + CoreError::new( + ErrorCode::ProcessFailed, + "Git returned an invalid worktree record", + ) + })?; + let head = fields + .iter() + .find_map(|field| field.strip_prefix("HEAD ")) + .unwrap_or_default() + .to_string(); + let branch = fields + .iter() + .find_map(|field| field.strip_prefix("branch ")) + .map(str::to_string); + let value_after_marker = |marker: &str| { + fields.iter().find_map(|field| { + if *field == marker { + Some(None) + } else { + field + .strip_prefix(&format!("{marker} ")) + .map(|value| Some(value.to_string())) + } + }) + }; + let lock = value_after_marker("locked"); + let prunable = value_after_marker("prunable"); + let reported_path = PathBuf::from(path); + let normalized_path = canonicalize_or_original(&reported_path).map_err(git_path_error)?; + Ok(GitWorktreeResponse { + path: normalized_path.to_string_lossy().to_string(), + head, + branch, + is_current: normalized_path == current_root, + is_primary, + is_bare: fields.contains(&"bare"), + is_detached: fields.contains(&"detached"), + is_locked: lock.is_some(), + lock_reason: lock.flatten(), + is_prunable: prunable.is_some(), + prune_reason: prunable.flatten(), + }) +} + +pub(crate) fn create_worktree( + root: &str, + request: &GitWriteRequest, +) -> Result { + let destination = required_text(request.destination.as_deref(), "worktree destination")?; + if destination.starts_with('-') || destination.contains(['\0', '\n', '\r']) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git worktree destination", + )); + } + let mut arguments = vec!["worktree".into(), "add".into()]; + if request.no_checkout { + arguments.push("--no-checkout".into()); + } + let mode = request.worktree_mode.as_deref().unwrap_or("newBranch"); + let source = match mode { + "newBranch" => { + let branch = validated_branch_name(root, request.name.as_deref())?; + let reference = request + .git_reference + .as_ref() + .ok_or_else(invalid_git_reference) + .and_then(|reference| validated_git_reference(root, reference))?; + if reference.kind == "remote" && request.revision.is_none() { + // Branch creation and upstream setup remain one Git mutation. + arguments.push("--track".into()); + } else { + // An explicit commit has no tracking identity. Freeze the + // result independently of branch.autoSetupMerge preferences. + arguments.push("--no-track".into()); + } + arguments.extend(["-b".into(), branch]); + if let Some(revision) = request.revision.as_deref() { + resolve_commit_revision(root, &validated_revision(Some(revision))?)? + } else { + reference.full_name + } + } + "existingBranch" => { + if request.name.is_some() || request.revision.is_some() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Existing-branch worktrees do not accept a new branch name or revision", + )); + } + let reference = request + .git_reference + .as_ref() + .ok_or_else(invalid_git_reference) + .and_then(|reference| validated_git_reference(root, reference))?; + if reference.kind != "local" { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "An existing-branch worktree requires a local Git reference", + )); + } + let branch = validated_branch_name(root, Some(&reference.short_name))?; + resolve_commit_revision(root, &reference.full_name)?; + // Git recognizes an existing branch from its short name here; + // supplying refs/heads/... would create a detached checkout instead. + // The complete identity was validated above; Git enforces occupancy. + branch + } + "detached" => { + if request.name.is_some() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "A detached worktree does not accept a branch name", + )); + } + arguments.push("--detach".into()); + let reference = request + .git_reference + .as_ref() + .map(|reference| validated_git_reference(root, reference)) + .transpose()?; + let revision = if let Some(revision) = request.revision.as_deref() { + validated_revision(Some(revision))? + } else { + reference.ok_or_else(invalid_git_reference)?.full_name + }; + // A fixed OID prevents a concurrently moved ref from changing the + // detached commit after this request has selected its starting point. + resolve_commit_revision(root, &revision)? + } + _ => { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Unsupported Git worktree creation mode", + )) + } + }; + let destination = lithe_workspace::remote_path(Path::new(&destination)) + .to_string_lossy() + .into_owned(); + arguments.extend(["--".into(), destination, source]); + execute_git(root, &arguments, None) +} + +pub(crate) fn mutate_worktree( + root: &str, + request: &GitWriteRequest, +) -> Result { + let destination = required_text(request.destination.as_deref(), "worktree destination")?; + if destination.starts_with('-') || destination.contains(['\0', '\n', '\r']) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git worktree destination", + )); + } + let entries = list_worktrees(root)?; + let target = entries + .iter() + .find(|entry| worktree_paths_match(&entry.path, &destination)) + .ok_or_else(|| { + CoreError::new( + ErrorCode::GitWorktreeNotRegistered, + "The selected path is not a registered Git worktree", + ) + })?; + let removing = request.operation == "removeWorktree"; + // Each refusal has its own code so hosts can explain it without parsing + // messages. The primary worktree is checked first: it can never be removed, + // locked or unlocked, whichever worktree the request was made from. + if target.is_primary { + return Err(CoreError::new( + ErrorCode::GitWorktreePrimary, + "The primary Git worktree cannot be removed, locked or unlocked", + )); + } + if removing && target.is_current { + return Err(CoreError::new( + ErrorCode::GitWorktreeCurrent, + "The current Git worktree cannot be removed", + )); + } + if removing && target.is_locked { + return Err(CoreError::new( + ErrorCode::GitWorktreeLocked, + "Unlock the Git worktree before removing it", + )); + } + + let mut arguments = vec!["worktree".to_string()]; + match request.operation.as_str() { + "removeWorktree" => { + arguments.push("remove".into()); + if request.force { + arguments.push("--force".into()); + } + } + "lockWorktree" => arguments.push("lock".into()), + "unlockWorktree" => arguments.push("unlock".into()), + _ => unreachable!("caller restricts worktree mutations"), + } + let destination = lithe_workspace::remote_path(Path::new(&destination)) + .to_string_lossy() + .into_owned(); + arguments.extend(["--".into(), destination]); + let mut response = execute_git(root, &arguments, None)?; + if removing && !request.force { + classify_unclean_worktree_removal(&mut response); + } + Ok(response) +} + +/// Git's C-locale refusal of `git worktree remove` without `--force` for a +/// checkout with modified or untracked files (`check_clean_worktree` in Git's +/// `builtin/worktree.c`). Every Git child runs with `LC_ALL=C`, so the text is +/// fixed whatever the user's system language is. +const UNCLEAN_WORKTREE_REMOVAL_MARKER: &str = "contains modified or untracked files"; + +/// Gives a failed, unforced worktree removal that Git refused for local +/// changes the `git_worktree_has_changes` code, so hosts can offer a forced +/// removal without matching stderr. The message is the first stderr line and +/// the details the combined output. Any other failure is left as it is. +pub(crate) fn classify_unclean_worktree_removal(response: &mut GitCommandResponse) { + if response.exit_code == 0 + || response.operation_error.is_some() + || !response.stderr.contains(UNCLEAN_WORKTREE_REMOVAL_MARKER) + { + return; + } + let message = response + .stderr + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .unwrap_or("The Git worktree has modified or untracked files") + .to_string(); + response.operation_error = Some( + CoreError::new(ErrorCode::GitWorktreeHasChanges, message) + .with_details(response.output.clone()), + ); +} diff --git a/rust/crates/git/src/write.rs b/rust/crates/git/src/write.rs new file mode 100644 index 00000000..e1f8e774 --- /dev/null +++ b/rust/crates/git/src/write.rs @@ -0,0 +1,730 @@ +//! `git.write`: the staged-index, commit, branch, tag, stash, remote and worktree +//! mutations routed through one request. + +use super::*; + +// Shared by ordinary staging and reviewed commit selection under its existing lease. +pub(crate) fn stage_paths(root: &str, paths: &[String]) -> Result { + let paths = validate_paths(paths)?; + let arguments = vec![ + "add".into(), + "-A".into(), + "--pathspec-from-file=-".into(), + "--pathspec-file-nul".into(), + ]; + let literal = [("GIT_LITERAL_PATHSPECS".to_string(), "1".to_string())]; + execute_git_with_environment( + root, + &arguments, + Some(nul_pathspec_input(&paths)), + false, + &literal, + ) +} + +pub(crate) fn unstage_paths(root: &str, paths: &[String]) -> Result { + let paths = validate_paths(paths)?; + let literal = [("GIT_LITERAL_PATHSPECS".to_string(), "1".to_string())]; + let input = nul_pathspec_input(&paths); + let head = execute_git( + root, + &["rev-parse".into(), "--verify".into(), "HEAD".into()], + None, + )?; + if head.exit_code != 0 && commit_state::is_unborn(root)? { + let arguments = vec![ + "rm".into(), + "--cached".into(), + "--force".into(), + "--ignore-unmatch".into(), + "--pathspec-from-file=-".into(), + "--pathspec-file-nul".into(), + ]; + return execute_git_with_environment(root, &arguments, Some(input), false, &literal); + } + let restore = vec![ + "restore".into(), + "--staged".into(), + "--pathspec-from-file=-".into(), + "--pathspec-file-nul".into(), + ]; + let response = + execute_git_with_environment(root, &restore, Some(input.clone()), false, &literal)?; + if response.exit_code == 0 { + return Ok(response); + } + let reset = vec![ + "reset".into(), + "HEAD".into(), + "--pathspec-from-file=-".into(), + "--pathspec-file-nul".into(), + ]; + execute_git_with_environment(root, &reset, Some(input), false, &literal) +} + +/// Executes one supported repository mutation without invoking a shell. +pub fn write(request: GitWriteRequest) -> Result { + let mut response = with_git_invocation_trace(|| write_with_trace(request))?; + authentication_failure::classify(&mut response); + Ok(response) +} + +fn write_with_trace(request: GitWriteRequest) -> Result { + if request.fetch_options.is_some() && request.operation != "fetch" { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Fetch options require a fetch operation", + )); + } + if request.check_submodules && request.operation != "push" { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Submodule publication checks require a push operation", + )); + } + if request.expected_branch.is_some() && request.operation != "pull" { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Branch preconditions require a pull operation", + )); + } + if request.operation != "commit" + && (!request.gitlink_updates.is_empty() + || (request.expected_commit_state.is_some() && request.operation != "push")) + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Commit state preconditions require a commit or push operation", + )); + } + let root = validate_root(&request.root)?; + // Every typed writer shares the same repository lease, including linked + // worktrees. Clone has no existing repository whose state it could race. + let _lease = if request.operation == "clone" { + None + } else { + Some(rewrite::RewriteLease::acquire(&root)?) + }; + let mut arguments: Vec; + + match request.operation.as_str() { + "stage" => return stage_paths(&root, &request.paths), + "unstage" => return unstage_paths(&root, &request.paths), + "discard" => { + let paths = validate_paths(&request.paths)?; + let literal_paths = [("GIT_LITERAL_PATHSPECS".to_string(), "1".to_string())]; + let restore_arguments = ["restore", "--worktree", "--"] + .into_iter() + .map(String::from) + .chain(paths.clone()) + .collect::>(); + let restore = execute_git_with_environment( + &root, + &restore_arguments, + None, + false, + &literal_paths, + )?; + if restore.exit_code == 0 { + return Ok(restore); + } + let mut status_arguments = vec![ + "status".to_string(), + "--porcelain".to_string(), + "--".to_string(), + ]; + status_arguments.extend(paths.clone()); + let status = + execute_git_with_environment(&root, &status_arguments, None, true, &literal_paths)?; + let is_untracked = status.exit_code == 0 + && status + .output + .lines() + .any(|line| line.starts_with("??") || line.starts_with("!!")); + if !is_untracked { + return Ok(restore); + } + arguments = ["clean", "-f", "-d", "--"] + .into_iter() + .map(String::from) + .chain(paths) + .collect(); + return execute_git_with_environment(&root, &arguments, None, false, &literal_paths); + } + "discardAll" => { + let paths = validate_paths(&request.paths)?; + return discard_all(&root, &paths); + } + "stageAll" => arguments = vec!["add".into(), "--all".into()], + "commit" => { + let message = required_text(request.message.as_deref(), "commit message")?; + if request.expected_commit_state.is_some() && !request.paths.is_empty() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Planned commits use the reviewed index", + )); + } + if let Some(expected) = &request.expected_commit_state { + commit_state::prepare(&root, expected, &request.gitlink_updates)?; + } else if !request.gitlink_updates.is_empty() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Submodule updates require a reviewed commit state", + )); + } + let identity = commit_identity(request.author.as_deref(), request.sign_off)?; + if !request.paths.is_empty() { + let paths = validate_paths(&request.paths)?; + return commit_selected_paths(&root, paths, message, request.amend, identity); + } + arguments = vec!["commit".into()]; + if request.amend { + arguments.push("--amend".into()); + } + arguments.extend(identity); + arguments.extend(["-m".into(), message]); + } + "ignore" => { + return append_git_ignore_patterns(&root, &request.paths, GitIgnoreTarget::Repository) + } + "exclude" => { + return append_git_ignore_patterns(&root, &request.paths, GitIgnoreTarget::LocalExclude) + } + "cherryPick" => { + arguments = vec![ + "cherry-pick".into(), + validated_revision(request.revision.as_deref())?, + ]; + } + "revert" => { + arguments = vec![ + "revert".into(), + "--no-edit".into(), + validated_revision(request.revision.as_deref())?, + ]; + } + "reset" => { + let mode = request.mode.as_deref().unwrap_or("--mixed"); + if !["--soft", "--mixed", "--hard"].contains(&mode) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Unsupported reset mode", + )); + } + arguments = vec![ + mode.into(), + validated_revision(request.revision.as_deref())?, + ]; + arguments.insert(0, "reset".into()); + } + "undoCommit" | "editCommitMessage" | "deleteCommit" | "squashCommits" => { + return rewrite::execute(&root, &request); + } + "createBranch" => { + let name = validated_branch_name(&root, request.name.as_deref())?; + let reference = write_request_reference(&root, &request)?; + // Creating a branch is not "checking out a remote branch": it must not + // inherit an upstream. With Git's default `branch.autoSetupMerge`, a + // branch started at `origin/main` would otherwise track `refs/heads/main`, + // and the next push would carry the explicit `refs/heads/:refs/heads/main` + // refspec straight over the remote main branch (issue #1272). Freezing the + // result with `--no-track` keeps it independent of user Git preferences, + // matching the worktree creation path. Deliberate upstream setup stays + // available through `setUpstream` and the `checkout` path. + arguments = if request.checkout { + vec![ + "switch".into(), + "-c".into(), + name, + "--no-track".into(), + reference, + ] + } else { + vec!["branch".into(), "--no-track".into(), name, reference] + }; + } + "publishBranch" => { + return publish_branch(&root, request.name.as_deref()); + } + "renameBranch" => { + let name = validated_branch_name(&root, request.name.as_deref())?; + let reference = write_request_reference(&root, &request)?; + let current = current_branch(&root)?; + let current_reference = format!("refs/heads/{current}"); + arguments = if reference == current || reference == current_reference { + vec!["branch".into(), "-m".into(), name] + } else { + vec!["branch".into(), "-m".into(), reference, name] + }; + } + "setUpstream" => { + let branch = validated_branch_name(&root, request.name.as_deref())?; + let upstream = request + .git_reference + .as_ref() + .ok_or_else(invalid_git_reference) + .and_then(|reference| validated_git_reference(&root, reference))?; + if upstream.kind != "remote" { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "A branch upstream must be a remote Git reference", + )); + } + arguments = vec![ + "branch".into(), + format!("--set-upstream-to={}", upstream.full_name), + branch, + ]; + } + "unsetUpstream" => { + let branch = validated_branch_name(&root, request.name.as_deref())?; + arguments = vec!["branch".into(), "--unset-upstream".into(), branch]; + } + "deleteBranch" => { + let reference = write_request_reference(&root, &request)?; + let branch = local_branch_name(&reference)?; + if current_branch(&root)?.as_str() == branch { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The current branch cannot be deleted", + )); + } + return delete_branch(&root, &branch); + } + "merge" => { + let reference = write_request_reference(&root, &request)?; + if is_current_reference(&root, &reference)? { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The current branch cannot be merged into itself", + )); + } + arguments = vec!["merge".into(), "--no-edit".into(), reference]; + } + "rebase" => { + let reference = write_request_reference(&root, &request)?; + if is_current_reference(&root, &reference)? { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The current branch cannot be rebased onto itself", + )); + } + arguments = vec!["rebase".into(), reference]; + } + "checkoutAndRebase" => return mutations::checkout_and_rebase(&root, request), + "createWorktree" => return create_worktree(&root, &request), + "removeWorktree" | "lockWorktree" | "unlockWorktree" => { + return mutate_worktree(&root, &request) + } + "pruneWorktrees" => { + arguments = vec![ + "worktree".into(), + "prune".into(), + "--verbose".into(), + "--expire=now".into(), + ] + } + "repairWorktrees" => arguments = vec!["worktree".into(), "repair".into()], + "updateBranch" => return update_local_branch(&root, &request), + "fetch" => { + if execution_policy::current().detailed_fetch { + return fetch_execution::execute(&root, request.fetch_options); + } + let options = configuration::fetch_options(&root, request.fetch_options)?; + arguments = fetch::arguments(&options)?; + if let Some(remote) = &options.remote { + if read_git_config_value(&root, &format!("remote.{remote}.url"))?.is_none() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Fetch remote is not configured", + )); + } + } + } + // Strategy comes from the caller because only the user can decide whether a + // divergent history should be merged or replayed. Absent a choice we stay on + // `--ff-only`, which refuses rather than inventing a merge commit. + "pull" => { + if let Some(expected) = request.expected_branch.as_deref() { + if !expected.starts_with("refs/heads/") || expected == "refs/heads/" { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Expected branch must be a complete local reference", + )); + } + // Repeat the frontend's post-Fetch check under the shared writer + // lease. A changed checkout must never update its replacement. + let current = execute_git( + &root, + &["symbolic-ref".into(), "--quiet".into(), "HEAD".into()], + None, + )?; + if current.exit_code != 0 || current.output.trim() != expected { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The selected branch changed; review and retry", + )); + } + if request.auto_stash + || request.reference.is_some() + || request.git_reference.is_some() + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Guarded background updates use the fetched upstream without auto-stash", + )); + } + // The host already fetched. Freeze the selected branch's upstream + // commit and integrate locally; `git pull` would fetch again and + // reopen a long window for an external client to switch HEAD. + let upstream = execute_git( + &root, + &[ + "rev-parse".into(), + "--verify".into(), + // Git's upstream selector takes a branch name, while the + // checkout guards above/below compare complete refs. + format!( + "{}@{{upstream}}^{{commit}}", + expected.strip_prefix("refs/heads/").unwrap() + ), + ], + None, + )?; + if upstream.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The selected branch has no fetched upstream commit", + )); + } + let revision = upstream.stdout.trim().to_owned(); + arguments = match request.mode.as_deref() { + None | Some("ffOnly") => vec!["merge".into(), "--ff-only".into(), revision], + Some("merge") => vec!["merge".into(), "--no-edit".into(), revision], + Some("rebase") => vec!["rebase".into(), revision], + Some(other) => { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + format!("Unknown pull strategy '{other}'"), + )) + } + }; + let current = execute_git( + &root, + &["symbolic-ref".into(), "--quiet".into(), "HEAD".into()], + None, + )?; + if current.exit_code != 0 || current.output.trim() != expected { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The selected branch changed; review and retry", + )); + } + return execute_git(&root, &arguments, None); + } + arguments = match request.mode.as_deref() { + None | Some("ffOnly") => vec!["pull".into(), "--ff-only".into()], + Some("merge") => vec!["pull".into(), "--no-rebase".into(), "--no-edit".into()], + Some("rebase") => vec!["pull".into(), "--rebase".into()], + Some(other) => { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + format!("Unknown pull strategy '{other}'"), + )) + } + }; + let explicit_reference = if let Some(reference) = request.git_reference.as_ref() { + let reference = validated_git_reference(&root, reference)?; + if reference.kind != "remote" { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Explicit pull requires a remote Git reference", + )); + } + Some(reference.full_name) + } else if let Some(reference) = request.reference.as_deref() { + if request.reference_kind.as_deref() != Some("remote") { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Explicit pull requires a remote Git reference", + )); + } + Some(validated_reference(Some(reference))?) + } else { + None + }; + if let Some(reference) = explicit_reference { + let (remote, branch) = mutations::remote_branch_components(&root, &reference)?; + arguments.extend(["--".into(), remote, branch]); + } + if request.auto_stash { + return pull_with_auto_stash(&root, &arguments); + } + } + "deleteRemoteBranch" => { + let reference = request + .git_reference + .as_ref() + .ok_or_else(invalid_git_reference)?; + let reference = validated_git_reference(&root, reference)?; + if reference.kind != "remote" { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Remote branch deletion requires a remote Git reference", + )); + } + let (remote, branch) = + mutations::remote_branch_components(&root, &reference.full_name)?; + arguments = vec![ + "push".into(), + "--delete".into(), + "--".into(), + remote, + format!("refs/heads/{branch}"), + ]; + } + "push" => { + if let Some(expected) = &request.expected_commit_state { + if commit_state::inspect_root(&root)? != *expected { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Push state changed; review the commit plan again", + )); + } + } + let reference = optional_write_request_reference(&root, &request)?; + return push( + &root, + reference.as_deref(), + request.force, + request.push_tags.as_deref(), + request.expected_push.as_ref(), + request.check_submodules, + request.push_target, + ); + } + "checkout" => return checkout(&root, request), + "checkoutRevision" => { + arguments = vec![ + "switch".into(), + "--detach".into(), + validated_revision(request.revision.as_deref())?, + ]; + } + "createTag" => { + let name = validated_tag_name(request.name.as_deref())?; + let requested_target = validated_revision(request.revision.as_deref())?; + // Reject an impossible tag form before probing the repository; the + // typed fields only refine the legacy message-based tag choice. + validate_create_tag_form( + request.message.as_deref(), + request.lightweight, + request.signed, + )?; + // Existence and resolvability probes run before Git so a duplicate + // or unresolvable target fails with a stable message instead of + // leaving the caller to parse localized `git tag` stderr. + if tag_exists(&root, &name)? { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + format!("A tag named '{name}' already exists"), + )); + } + // Git can tag trees and blobs, but the deletion/restore contract + // promises a commit target, so anything else is rejected here and + // the tag is created against the resolved commit id. + let Some(target) = resolved_commit_target(&root, &requested_target)? else { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + format!("Could not resolve tag target '{requested_target}'"), + )); + }; + arguments = create_tag_arguments( + &name, + &target, + request.message.as_deref(), + request.lightweight, + request.signed, + ); + } + "deleteTag" => return delete_tag(&root, request.name.as_deref()), + "clone" => { + let remote = required_text(request.remote.as_deref(), "clone source")?; + let destination = required_text(request.destination.as_deref(), "clone destination")?; + if destination.starts_with('-') || destination.contains('\0') { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid clone destination", + )); + } + let destination = lithe_workspace::remote_path(Path::new(&destination)) + .to_string_lossy() + .into_owned(); + arguments = vec!["clone".into(), "--".into(), remote, destination]; + } + "stashPush" => { + arguments = vec!["stash".into(), "push".into()]; + if request.include_untracked { + arguments.push("--include-untracked".into()); + } + if let Some(message) = request.message.filter(|value| !value.trim().is_empty()) { + arguments.extend(["-m".into(), message]); + } + if !request.paths.is_empty() { + let paths = validate_paths(&request.paths)?; + arguments.extend([ + "--pathspec-from-file=-".into(), + "--pathspec-file-nul".into(), + ]); + return execute_git(&root, &arguments, Some(nul_pathspec_input(&paths))); + } + } + "operationContinue" | "operationAbort" | "operationSkip" => { + return resolve_operation(&root, &request.operation) + } + "stashApply" | "stashDrop" => { + let requested = validated_stash_reference(request.reference.as_deref())?; + // Resolve immutable selections under the repository write lease. Older + // callers may still supply a positional stash reference. + let reference = if matches!(requested.len(), 40 | 64) + && requested.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + stash_reference_for_oid(&root, &requested)?.ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "The selected stash no longer exists", + ) + })? + } else { + requested + }; + if request.operation == "stashApply" { + return apply_stash(&root, &reference); + } + arguments = vec!["stash".into(), "drop".into(), reference]; + } + "stashPop" => { + let requested = validated_stash_reference(request.reference.as_deref())?; + // Resolve immutable selections under the repository write lease. Older + // callers may still supply a positional stash reference. + let reference = if matches!(requested.len(), 40 | 64) + && requested.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + stash_reference_for_oid(&root, &requested)?.ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "The selected stash no longer exists", + ) + })? + } else { + requested + }; + return pop_stash(&root, &reference); + } + _ => { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Unsupported Git write operation", + )) + } + } + + execute_git(&root, &arguments, None) +} + +/// Discards both index and working-tree content for an explicitly confirmed rollback. +/// The normal `discard` operation deliberately preserves staged content, while +/// this explicit operation is destructive for the whole path. +fn discard_all(root: &str, paths: &[String]) -> Result { + let mut tracked = Vec::new(); + let mut untracked = Vec::new(); + let mut tracked_paths = HashSet::new(); + let mut untracked_paths = HashSet::new(); + let literal_paths = [("GIT_LITERAL_PATHSPECS".to_string(), "1".to_string())]; + let mut final_response = successful_git_result(); + // Rollback is often called once per selected file. Never enumerate unrelated + // untracked trees for every file, and keep path arguments below Windows limits. + for batch in pathspec_batches(paths, LS_FILES_PATHSPEC_BUDGET) { + let mut arguments = vec![ + "status".to_string(), + "--porcelain=v2".to_string(), + "-z".to_string(), + "--untracked-files=all".to_string(), + "--".to_string(), + ]; + arguments.extend_from_slice(batch); + let status = execute_git_with_environment(root, &arguments, None, true, &literal_paths)?; + if status.exit_code != 0 { + return Ok(status); + } + // The shared parser consumes rename source records too; a source name + // beginning with `?? ` must not be mistaken for an untracked path. + for change in parse_status(status.stdout.as_bytes(), true) { + if change.untracked { + untracked_paths.insert(change.path); + } else { + tracked_paths.insert(change.path); + } + } + final_response = status; + } + for path in paths { + // A summarized untracked directory (`dir/`) is reported file by file. + // A staged deletion can coexist with an untracked disk copy at the + // same path. Restore HEAD rather than cleaning that retained copy. + let is_untracked = !tracked_paths.contains(path.as_str()) + && (untracked_paths.contains(path.as_str()) + || (path.ends_with('/') + && untracked_paths + .iter() + .any(|untracked| untracked.starts_with(path.as_str())))); + if is_untracked { + untracked.push(path.clone()); + } else { + tracked.push(path.clone()); + } + } + + if !tracked.is_empty() { + let arguments = vec![ + "restore".to_string(), + "--source=HEAD".to_string(), + "--staged".to_string(), + "--worktree".to_string(), + "--pathspec-from-file=-".to_string(), + "--pathspec-file-nul".to_string(), + ]; + // Passing pathspecs over stdin avoids the Windows command-line limit for + // large source-control selections and preserves unusual path characters. + let pathspec_input = tracked + .iter() + .flat_map(|path| [path.as_str(), "\0"]) + .collect::(); + let restored = execute_git_with_environment( + root, + &arguments, + Some(pathspec_input), + false, + &literal_paths, + )?; + if restored.exit_code != 0 { + return Ok(restored); + } + final_response = restored; + } + for batch in pathspec_batches(&untracked, LS_FILES_PATHSPEC_BUDGET) { + let mut arguments = vec![ + "clean".to_string(), + "-f".to_string(), + "-d".to_string(), + "--".to_string(), + ]; + arguments.extend_from_slice(batch); + let cleaned = execute_git_with_environment(root, &arguments, None, false, &literal_paths)?; + if cleaned.exit_code != 0 { + return Ok(cleaned); + } + final_response = cleaned; + } + Ok(final_response) +} diff --git a/rust/crates/core/src/tests/fixtures/branch-name-cleanup-parity-v1.json b/rust/crates/git/tests/fixtures/branch-name-cleanup-parity-v1.json similarity index 100% rename from rust/crates/core/src/tests/fixtures/branch-name-cleanup-parity-v1.json rename to rust/crates/git/tests/fixtures/branch-name-cleanup-parity-v1.json diff --git a/rust/crates/github/src/lib.rs b/rust/crates/github/src/lib.rs index 330a4048..5b1311e2 100644 --- a/rust/crates/github/src/lib.rs +++ b/rust/crates/github/src/lib.rs @@ -868,7 +868,7 @@ fn normalize_pull_request_list(value: Value) -> Result { .iter() .map(normalize_pull_request) .collect::, _>>()?; - requests.sort_by(|left, right| right.number.cmp(&left.number)); + requests.sort_by_key(|request| std::cmp::Reverse(request.number)); Ok(serde_json::to_value(requests).expect("pull request list encodes")) } diff --git a/rust/crates/lsp-client/Cargo.toml b/rust/crates/lsp-client/Cargo.toml new file mode 100644 index 00000000..b66017f2 --- /dev/null +++ b/rust/crates/lsp-client/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "lithe-lsp-client" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "The stateful LSP client: capability negotiation, documents, feature requests and the provider protocols a server offers" + +[dependencies] +lithe-diagnostics = { path = "../diagnostics" } +lithe-document-uri = { path = "../document-uri" } +lithe-plugin-editor = { path = "../plugin-editor" } +lithe-plugin-keymap-import = { path = "../plugin-keymap-import" } +lithe-plugin-model = { path = "../plugin-model" } +lithe-plugin-rpc = { path = "../plugin-rpc" } +lithe-protocol = { path = "../protocol" } +lithe-text = { path = "../text" } +lithe-vcs = { path = "../vcs" } +serde = { version = "1.0", features = ["derive", "rc"] } +serde_json = { version = "1.0", features = ["preserve_order"] } +url = "2.5" diff --git a/rust/crates/core/src/lsp/interface/build_provider.rs b/rust/crates/lsp-client/src/build_provider.rs similarity index 93% rename from rust/crates/core/src/lsp/interface/build_provider.rs rename to rust/crates/lsp-client/src/build_provider.rs index 01da9cf6..39bb5ef8 100644 --- a/rust/crates/core/src/lsp/interface/build_provider.rs +++ b/rust/crates/lsp-client/src/build_provider.rs @@ -14,7 +14,7 @@ //! source-backed native configuration. The rules live in the plugin contract //! (`shared/plugins/contract/v1/README.md`, "Build providers"). -use crate::plugins::capabilities::{versions as capability_versions, BUILD_PROVIDERS}; +use lithe_plugin_model::capabilities::{versions as capability_versions, BUILD_PROVIDERS}; use serde_json::{json, Value}; /// Key under `capabilities.experimental` on both sides of the exchange. @@ -29,12 +29,12 @@ fn supported_versions() -> Vec { /// Feature name recorded in the session's server capabilities once a /// provider answered a supported version. Every build method is available to /// a session that carries it. -pub(crate) const BUILD_PROVIDER_FEATURE: &str = "litheBuild"; +pub const BUILD_PROVIDER_FEATURE: &str = "litheBuild"; /// Task execution shares the workspace session and operation lifecycle. pub(crate) const EXECUTE_METHOD: &str = "lithe/build/execute"; /// Prepares a bounded launch, while the host owns the application. -pub(crate) const PREPARE_RUN_METHOD: &str = "lithe/build/prepareRun"; +pub const PREPARE_RUN_METHOD: &str = "lithe/build/prepareRun"; /// Returns non-sensitive native settings and source/write metadata. pub(crate) const CONFIGURATION_METHOD: &str = "lithe/build/configuration"; /// Provider-owned native editing; only a receipt crosses the wire. @@ -42,7 +42,7 @@ pub(crate) const EDIT_CONFIGURATION_METHOD: &str = "lithe/build/editConfiguratio /// Prepares a native debug target, never an adapter process. pub(crate) const PREPARE_DEBUG_METHOD: &str = "lithe/build/prepareDebug"; /// Runs tests while retaining the bounded execution lifecycle. -pub(crate) const TEST_METHOD: &str = "lithe/build/test"; +pub const TEST_METHOD: &str = "lithe/build/test"; /// Returns exact selectors; decoding remains a separate Core command. pub(crate) const DISCOVER_TESTS_METHOD: &str = "lithe/build/discoverTests"; /// Progress never uses caller-chosen host operation IDs. @@ -67,7 +67,7 @@ pub(crate) fn negotiated(capabilities: &Value) -> bool { } /// Whether `method` is a request of the negotiated protocol. -pub(crate) fn is_build_method(method: &str) -> bool { +pub fn is_build_method(method: &str) -> bool { MODEL_METHODS.contains(&method) || is_execution_method(method) || method == DISCOVER_TESTS_METHOD @@ -78,12 +78,12 @@ pub(crate) fn is_build_method(method: &str) -> bool { } /// Whether the method shares bounded output, progress, and final result handling. -pub(crate) fn is_execution_method(method: &str) -> bool { +pub fn is_execution_method(method: &str) -> bool { matches!(method, EXECUTE_METHOD | TEST_METHOD) } /// A preparation has progress and cancellation, but its reply is a launch plan. -pub(crate) fn is_progress_method(method: &str) -> bool { +pub fn is_progress_method(method: &str) -> bool { is_execution_method(method) || method == PREPARE_RUN_METHOD || method == PREPARE_DEBUG_METHOD } @@ -109,7 +109,7 @@ pub(crate) fn progress(params: Option<&Value>) -> Option { /// Stable outcome for a provider-owned task. Tool failure is an exit code, /// while malformed replies and JSON-RPC errors are protocol failures. -pub(crate) fn execution_outcome(reply: &Value) -> Result { +pub fn execution_outcome(reply: &Value) -> Result { if reply.get("error").is_some() { return Err(( "plugin.build.failed", @@ -221,7 +221,7 @@ mod tests { #[test] fn execution_replies_and_progress_match_the_shared_contract() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../../../shared/plugins/contract/v1/fixtures/build-providers/build-execution-protocol.json" + "../../../../shared/plugins/contract/v1/fixtures/build-providers/build-execution-protocol.json" )) .unwrap(); assert_eq!( diff --git a/rust/crates/lsp-client/src/client.rs b/rust/crates/lsp-client/src/client.rs new file mode 100644 index 00000000..1a2c3587 --- /dev/null +++ b/rust/crates/lsp-client/src/client.rs @@ -0,0 +1,284 @@ +//! Pure LSP client-state transitions and JSON-RPC message construction. + +use super::types::*; +use lithe_document_uri::{file_path_for_uri, file_path_from_uri, hex_value}; +use lithe_protocol::{CoreError, ErrorCode}; +use lithe_text::apply_content_changes_sequential; +use serde_json::{json, Value}; + +mod capabilities; +mod diagnostics; +mod json_rpc; +mod lifecycle; +mod requests; +mod results; +mod server_messages; +mod validation; + +pub use capabilities::*; +pub(crate) use diagnostics::*; +pub use json_rpc::*; +pub use lifecycle::*; +pub(crate) use requests::*; +pub(crate) use results::*; +pub use server_messages::*; +pub(crate) use validation::*; + +#[cfg(test)] +mod semantic_tests { + use super::*; + + fn options() -> Value { + json!({"full": true, "legend": {"tokenTypes": ["class", "method"], "tokenModifiers": ["declaration"]}}) + } + + #[test] + fn semantic_tokens_decode_relative_utf16_positions_and_reject_malformed_data() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../fixtures/lsp/semantic-tokens-v1.json" + )) + .unwrap(); + let options = fixture["serverOptions"].clone(); + let legend = semantic_token_legend(&options); + let value = semantic_token_result( + legend, + Some(&json!({"data": [0, 6, 5, 0, 1, 2, 4, 3, 1, 0, 0, 7, 2, 1, 0]})), + ) + .unwrap(); + assert_eq!(value, fixture["expectedResult"]); + assert_eq!(value["tokens"][0]["startChar"], 6); + assert_eq!(value["tokens"][1]["line"], 2); + assert_eq!(value["tokens"][2]["startChar"], 11); + assert_eq!(value["tokenTypes"], json!(["class", "method"])); + assert_eq!( + semantic_token_result(legend, Some(&Value::Null)).unwrap()["tokens"], + json!([]) + ); + for data in [ + json!([0]), + json!([0, 0, 0, 0, 0]), + json!([0, 0, 1, 2, 0]), + json!([-1, 0, 1, 0, 0]), + ] { + assert!(semantic_token_result(legend, Some(&json!({"data": data}))).is_none()); + } + assert!( + semantic_token_legend(&json!({"range": true, "legend": options["legend"]})).is_none() + ); + } + + #[test] + fn semantic_registration_snapshot_survives_unregister_and_refresh_is_acknowledged() { + let mut state = LspClientState::default(); + apply_dynamic_registration( + &mut state, + &json!({"params": {"registrations": [{"id": "semantic", "method": "textDocument/semanticTokens", "registerOptions": options()}]}}), + ); + assert!(state + .server_capabilities + .contains(&"semanticTokens".to_string())); + let id = allocate_request(&mut state, "textDocument/semanticTokens/full"); + apply_dynamic_unregistration( + &mut state, + &json!({"params": {"unregisterations": [{"id": "semantic", "method": "textDocument/semanticTokens"}]}}), + ); + assert!(!state + .server_capabilities + .contains(&"semanticTokens".to_string())); + let response = client_apply_server_message(ClientApplyServerMessageRequest { + state, + message: json!({"jsonrpc": "2.0", "id": id, "result": {"data": [0,6,5,0,1]}}) + .to_string(), + }) + .unwrap(); + assert_eq!( + response.events[0].result.as_ref().unwrap()["tokens"][0]["length"], + 5 + ); + assert!(response.state.pending_semantic_legends.is_empty()); + let refresh = client_apply_server_message(ClientApplyServerMessageRequest { + state: response.state, message: json!({"jsonrpc": "2.0", "id": "refresh", "method": "workspace/semanticTokens/refresh"}).to_string(), + }).unwrap(); + assert_eq!(refresh.events[0].kind, "semanticTokensRefresh"); + assert_eq!(refresh.messages.len(), 1); + assert!(refresh.messages[0].contains("result")); + } +} + +#[cfg(test)] +mod formatting_and_completion_tests { + use super::*; + + fn request(method: &str, options: Option, with_range: bool) -> ClientFeatureRequest { + ClientFeatureRequest { + query: None, + state: LspClientState::default(), + uri: "file:///w/a.c".to_string(), + method: method.to_string(), + position: None, + new_name: None, + range: with_range.then_some(LspRange { + start: LspPosition { + line: 1, + utf16_column: 2, + }, + end: LspPosition { + line: 3, + utf16_column: 0, + }, + }), + diagnostics: Vec::new(), + completion_context: None, + completion_item: None, + code_action: None, + command: None, + formatting_options: options, + include_declaration: None, + } + } + + #[test] + fn formatting_keeps_defaults_without_caller_options() { + let params = + feature_request_params(&request("textDocument/formatting", None, false)).unwrap(); + assert_eq!( + params["options"], + json!({"tabSize": 4, "insertSpaces": true, "trimTrailingWhitespace": true, + "insertFinalNewline": true, "trimFinalNewlines": true}) + ); + } + + #[test] + fn formatting_and_range_formatting_merge_caller_options() { + let options = + json!({"tabSize": 2, "insertSpaces": false, "trimFinalNewlines": false, "custom": "x"}); + let full = feature_request_params(&request( + "textDocument/formatting", + Some(options.clone()), + false, + )) + .unwrap(); + assert_eq!(full["options"]["tabSize"], 2); + assert_eq!(full["options"]["insertSpaces"], false); + assert_eq!(full["options"]["trimFinalNewlines"], false); + assert_eq!(full["options"]["custom"], "x"); + assert_eq!(full["options"]["insertFinalNewline"], true); + let range = feature_request_params(&request( + "textDocument/rangeFormatting", + Some(options), + true, + )) + .unwrap(); + assert_eq!(range["range"]["start"], json!({"line": 1, "character": 2})); + assert_eq!(range["range"]["end"], json!({"line": 3, "character": 0})); + assert_eq!(range["options"]["tabSize"], 2); + assert!( + feature_request_params(&request("textDocument/rangeFormatting", None, false)).is_err() + ); + } + + #[test] + fn invalid_formatting_options_are_rejected() { + for options in [ + json!({"tabSize": 0}), + json!({"tabSize": "4"}), + json!({"insertSpaces": 1}), + json!({"nested": {}}), + json!([1]), + ] { + assert!(feature_request_params(&request( + "textDocument/formatting", + Some(options), + false + )) + .is_err()); + } + } + + #[test] + fn range_formatting_capability_and_result_are_normalized() { + let features = + feature_names_from_capabilities(&json!({"documentRangeFormattingProvider": true})); + assert!(features.contains(&"rangeFormatting".to_string())); + let result = lsp_feature_result_for_method( + Some("textDocument/rangeFormatting"), + Some(&json!([{ + "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 1}}, + "newText": "x" + }])), + ) + .unwrap(); + assert_eq!(result["edits"][0]["newText"], "x"); + } + + #[test] + fn document_symbol_golden_fixture_matches_normalization() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../fixtures/lsp/document-symbols-v1.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let result = lsp_feature_result_for_method( + Some("textDocument/documentSymbol"), + Some(&case["serverResult"]), + ) + .unwrap(); + assert_eq!(result, case["expected"], "case {}", case["name"]); + } + } + + #[test] + fn document_symbol_capability_request_and_dynamic_name() { + let features = feature_names_from_capabilities(&json!({"documentSymbolProvider": true})); + assert!(features.contains(&"documentSymbols".to_string())); + assert_eq!( + feature_name_for_method("textDocument/documentSymbol"), + Some("documentSymbols") + ); + let mut request = request("textDocument/documentSymbol", None, false); + request.uri = "file:///a.rs".to_string(); + assert_eq!( + feature_request_params(&request).unwrap(), + json!({"textDocument": {"uri": "file:///a.rs"}}) + ); + assert!(validate_lsp_method("textDocument/documentSymbol").is_ok()); + } + + #[test] + fn completion_options_union_static_and_dynamic_providers() { + let mut state = LspClientState::default(); + assert!(completion_options(&state).is_none()); + state.server_capabilities.push("completion".into()); + state.completion_providers.insert( + String::new(), + json!({"triggerCharacters": [".", ":", ""], "allCommitCharacters": [";"]}), + ); + apply_dynamic_registration( + &mut state, + &json!({"params": {"registrations": [{"id": "c1", "method": "textDocument/completion", + "registerOptions": {"triggerCharacters": [":", ">"]}}]}}), + ); + assert_eq!( + completion_options(&state).unwrap(), + json!({"triggerCharacters": [".", ":", ">"], "allCommitCharacters": [";"]}) + ); + apply_dynamic_unregistration( + &mut state, + &json!({"params": {"unregisterations": [{"id": "c1", "method": "textDocument/completion"}]}}), + ); + // Withdrawing the completion registration also withdraws the feature. + assert!(completion_options(&state).is_none()); + assert!(!state.completion_providers.contains_key("c1")); + } + + #[test] + fn completion_result_preserves_is_incomplete() { + let result = lsp_feature_result_for_method( + Some("textDocument/completion"), + Some(&json!({"isIncomplete": true, "items": [{"label": "a"}]})), + ) + .unwrap(); + assert_eq!(result["isIncomplete"], true); + assert_eq!(result["items"][0]["label"], "a"); + } +} diff --git a/rust/crates/lsp-client/src/client/capabilities.rs b/rust/crates/lsp-client/src/client/capabilities.rs new file mode 100644 index 00000000..799a27fb --- /dev/null +++ b/rust/crates/lsp-client/src/client/capabilities.rs @@ -0,0 +1,415 @@ +//! Server capabilities, dynamic registration and semantic tokens. + +use super::*; + +/// `capabilities.experimental`: every Lithe protocol a plugin process may +/// speak next to LSP, each negotiated on its own key. +pub(crate) fn experimental_capabilities( + plugin_owned: bool, + toolchain_providers: &std::collections::BTreeSet, + keymap_import_providers: &std::collections::BTreeSet, +) -> Value { + let mut experimental = crate::build_provider::client_experimental_capabilities(); + if plugin_owned { + let (logging_key, logging_offer) = + crate::plugin_logging::client_experimental_capabilities(); + experimental[logging_key] = logging_offer; + let (functions_key, functions_offer) = lithe_plugin_rpc::client_experimental_capabilities(); + experimental[functions_key] = functions_offer; + let (vcs_key, vcs_offer) = crate::vcs_provider::client_experimental_capabilities(); + experimental[vcs_key] = vcs_offer; + if let Some((toolchain_key, toolchain_offer)) = + crate::toolchain_provider::client_experimental_capabilities(toolchain_providers) + { + experimental[toolchain_key] = toolchain_offer; + } + if let Some((key, offer)) = + crate::keymap_provider::client_experimental_capabilities(keymap_import_providers) + { + experimental[key] = offer; + } + } + experimental +} + +pub(crate) fn text_document_sync_kind(capabilities: &Value) -> LspTextDocumentSyncKind { + let sync = capabilities.get("textDocumentSync"); + let change = match sync { + Some(Value::Number(value)) => value.as_i64().unwrap_or(1), + Some(Value::Object(value)) => value.get("change").and_then(Value::as_i64).unwrap_or(1), + _ => 1, + }; + match change { + 0 => LspTextDocumentSyncKind::None, + 2 => LspTextDocumentSyncKind::Incremental, + _ => LspTextDocumentSyncKind::Full, + } +} + +pub(crate) 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, + "documentRangeFormattingProvider", + "rangeFormatting", + ); + add_capability( + &mut values, + capabilities, + "documentSymbolProvider", + "documentSymbols", + ); + add_capability(&mut values, capabilities, "inlayHintProvider", "inlayHints"); + add_capability( + &mut values, + capabilities, + "foldingRangeProvider", + "foldingRanges", + ); + add_capability( + &mut values, + capabilities, + "documentLinkProvider", + "documentLinks", + ); + add_capability(&mut values, capabilities, "codeLensProvider", "codeLens"); + if capabilities + .get("semanticTokensProvider") + .and_then(semantic_token_legend) + .is_some() + { + insert_unique(&mut values, "semanticTokens"); + } + add_capability( + &mut values, + capabilities, + "workspaceSymbolProvider", + "workspaceSymbols", + ); + 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"); + } + if crate::build_provider::negotiated(capabilities) { + insert_unique(&mut values, crate::build_provider::BUILD_PROVIDER_FEATURE); + } + if lithe_plugin_rpc::negotiate(capabilities).is_some() { + insert_unique(&mut values, lithe_plugin_rpc::FUNCTIONS_FEATURE); + } + if crate::vcs_provider::negotiate(capabilities).is_some() { + insert_unique(&mut values, crate::vcs_provider::VCS_PROVIDER_FEATURE); + } + 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), + _ => {} + } +} + +pub(crate) 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 registration.get("method").and_then(Value::as_str) == Some("textDocument/semanticTokens") + { + if let (Some(id), Some(options)) = ( + registration.get("id").and_then(Value::as_str), + registration.get("registerOptions"), + ) { + if semantic_token_legend(options).is_some() { + state + .semantic_token_providers + .insert(id.to_string(), options.clone()); + insert_unique(&mut state.server_capabilities, "semanticTokens"); + } + } + continue; + } + if registration.get("method").and_then(Value::as_str) == Some("textDocument/completion") { + if let (Some(id), Some(options)) = ( + registration.get("id").and_then(Value::as_str), + registration + .get("registerOptions") + .filter(|options| options.is_object()), + ) { + state + .completion_providers + .insert(id.to_string(), options.clone()); + } + } + 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"); + } + } + } +} + +pub(crate) 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 unregistration.get("method").and_then(Value::as_str) + == Some("textDocument/semanticTokens") + { + if let Some(id) = unregistration.get("id").and_then(Value::as_str) { + state.semantic_token_providers.remove(id); + } + if state.semantic_token_providers.is_empty() { + state + .server_capabilities + .retain(|feature| feature != "semanticTokens"); + } + continue; + } + if unregistration.get("method").and_then(Value::as_str) == Some("textDocument/completion") { + if let Some(id) = unregistration.get("id").and_then(Value::as_str) { + state.completion_providers.remove(id); + } + } + 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); + } + } +} + +/// Completion trigger and all-commit characters the session's providers declared. +/// +/// Returns `{ "triggerCharacters": [...], "allCommitCharacters": [...] }`, each +/// list the de-duplicated union over the static capability and dynamic +/// registrations in registration order, or `None` while the session offers no +/// completion. Entries that are not non-empty strings are dropped. +pub fn completion_options(state: &LspClientState) -> Option { + if !state + .server_capabilities + .iter() + .any(|feature| feature == "completion") + { + return None; + } + let collect = |key: &str| { + let mut values: Vec = Vec::new(); + for options in state.completion_providers.values() { + for value in options + .get(key) + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .filter(|value| !value.is_empty()) + { + insert_unique(&mut values, value); + } + } + values + }; + Some(json!({ + "triggerCharacters": collect("triggerCharacters"), + "allCommitCharacters": collect("allCommitCharacters"), + })) +} + +pub(crate) 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/rangeFormatting" => Some("rangeFormatting"), + "textDocument/documentSymbol" => Some("documentSymbols"), + "textDocument/inlayHint" => Some("inlayHints"), + "textDocument/foldingRange" => Some("foldingRanges"), + "textDocument/documentLink" => Some("documentLinks"), + "textDocument/codeLens" => Some("codeLens"), + "textDocument/codeAction" => Some("codeActions"), + "workspace/symbol" => Some("workspaceSymbols"), + "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()); + } +} + +/// A range-only provider cannot serve the full-document operation offered here. +pub(crate) fn semantic_token_legend(options: &Value) -> Option<&Value> { + let full = options.get("full")?; + if full != &Value::Bool(true) && !full.is_object() { + return None; + } + let legend = options.get("legend")?; + if !legend + .get("tokenTypes")? + .as_array()? + .iter() + .all(Value::is_string) + || !legend + .get("tokenModifiers")? + .as_array()? + .iter() + .all(Value::is_string) + { + return None; + } + Some(legend) +} + +/// Decode relative UTF-16 positions once in Core. Both editors consume the +/// same normalized tokens and the exact legend used by this server session. +pub(crate) fn semantic_token_result( + legend: Option<&Value>, + result: Option<&Value>, +) -> Option { + let legend = legend?; + let empty = Vec::new(); + let data = match result { + None | Some(Value::Null) => &empty, + Some(value) => value.get("data")?.as_array()?, + }; + if data.len() % 5 != 0 { + return None; + } + let mut tokens = Vec::with_capacity(data.len() / 5); + let (mut line, mut column) = (0_u64, 0_u64); + for chunk in data.chunks_exact(5) { + let numbers: Vec = chunk.iter().map(Value::as_u64).collect::>()?; + if numbers.iter().any(|value| *value > u32::MAX as u64) { + return None; + } + line = line.checked_add(numbers[0])?; + column = if numbers[0] == 0 { + column.checked_add(numbers[1])? + } else { + numbers[1] + }; + if line > u32::MAX as u64 + || column > u32::MAX as u64 + || numbers[2] == 0 + || numbers[3] >= legend["tokenTypes"].as_array()?.len() as u64 + { + return None; + } + tokens.push(json!({"line": line, "startChar": column, "length": numbers[2], "tokenType": numbers[3], "tokenModifiers": numbers[4]})); + } + Some( + json!({"tokenTypes": legend["tokenTypes"], "tokenModifiers": legend["tokenModifiers"], "tokens": tokens}), + ) +} diff --git a/rust/crates/lsp-client/src/client/diagnostics.rs b/rust/crates/lsp-client/src/client/diagnostics.rs new file mode 100644 index 00000000..83b6cd45 --- /dev/null +++ b/rust/crates/lsp-client/src/client/diagnostics.rs @@ -0,0 +1,79 @@ +//! Parsing published diagnostics. + +use super::*; + +pub(crate) 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, + }), + 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() + }) + .unwrap_or_default() +} + +pub(crate) fn parse_lsp_range(value: &Value) -> Option { + Some(LspRangeResponse { + start: parse_lsp_position(value.get("start")?)?, + end: parse_lsp_position(value.get("end")?)?, + }) +} + +pub(crate) fn parse_lsp_position(value: &Value) -> Option { + Some(LspPositionResponse { + line: value.get("line")?.as_i64()?, + utf16_column: value.get("character")?.as_i64()?, + }) +} diff --git a/rust/crates/lsp-client/src/client/json_rpc.rs b/rust/crates/lsp-client/src/client/json_rpc.rs new file mode 100644 index 00000000..5517c2c5 --- /dev/null +++ b/rust/crates/lsp-client/src/client/json_rpc.rs @@ -0,0 +1,95 @@ +//! JSON-RPC request, notification, result and error framing. + +use super::*; + +pub(crate) fn json_rpc_request(id: &str, method: &str, params: Value) -> Result { + encode_json_rpc(json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params + })) +} + +/// Encodes a JSON-RPC notification. +pub fn json_rpc_notification(method: &str, params: Value) -> Result { + encode_json_rpc(json!({ + "jsonrpc": "2.0", + "method": method, + "params": params + })) +} + +pub(crate) 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) +} + +/// Encodes a successful JSON-RPC response to request `id`. +pub fn json_rpc_result(id: &Value, result: Value) -> Result { + encode_json_rpc(json!({ + "jsonrpc": "2.0", + "id": id, + "result": result + })) +} + +/// A JSON-RPC error whose `data.code` carries a stable plugin-contract code. +pub fn json_rpc_error_with_code( + id: &Value, + code: i64, + message: &str, + contract_code: &str, +) -> Result { + json_rpc_error_with_data(id, code, message, json!({ "code": contract_code })) +} + +/// A JSON-RPC error with the given `data`, which holds at least the stable +/// plugin-contract `code`. +pub fn json_rpc_error_with_data( + id: &Value, + code: i64, + message: &str, + data: Value, +) -> Result { + encode_json_rpc(json!({ + "jsonrpc": "2.0", + "id": id, + "error": { "code": code, "message": message, "data": data } + })) +} + +pub(crate) 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()) + }) +} + +pub(crate) 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, + }) +} diff --git a/rust/crates/lsp-client/src/client/lifecycle.rs b/rust/crates/lsp-client/src/client/lifecycle.rs new file mode 100644 index 00000000..6add9cf8 --- /dev/null +++ b/rust/crates/lsp-client/src/client/lifecycle.rs @@ -0,0 +1,346 @@ +//! Initializing a client, syncing documents, shutting down and sending feature requests. + +use super::*; + +/// Creates a host-owned initialize request for stateless client tests. +#[cfg(test)] +pub fn client_initialize(request: ClientInitializeRequest) -> Result { + client_initialize_for_plugin(request, false) +} + +/// Offers plugin functions only when the runtime owns an identified plugin session. +pub fn client_initialize_for_plugin( + request: ClientInitializeRequest, + plugin_owned: bool, +) -> 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( + &id, + "initialize", + json!({ + "processId": request.process_id, + "clientInfo": { + "name": "Lithe", + "version": env!("CARGO_PKG_VERSION") + }, + "rootUri": &request.root_uri, + "workspaceFolders": [{ + "uri": &request.root_uri, + "name": workspace_name + }], + "capabilities": { + "textDocument": { + "synchronization": { "didSave": true }, + "completion": { + "dynamicRegistration": true, + "contextSupport": true, + "completionItem": { + "snippetSupport": true, + "documentationFormat": ["markdown", "plaintext"], + // Some servers (Intelephense, for one) omit typed class and + // namespace labels unless the client advertises labelDetailsSupport. + "labelDetailsSupport": true, + "resolveSupport": { + "properties": [ + "detail", + "documentation", + "textEdit", + "additionalTextEdits", + "labelDetails" + ] + } + } + }, + "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 }, + "rangeFormatting": { "dynamicRegistration": true }, + "documentSymbol": { + "dynamicRegistration": true, + "hierarchicalDocumentSymbolSupport": true + }, + "inlayHint": { "dynamicRegistration": true }, + "semanticTokens": { + "dynamicRegistration": true, + "requests": { "full": true, "range": false }, + "tokenTypes": ["namespace", "type", "class", "enum", "interface", "struct", "typeParameter", "parameter", "variable", "property", "enumMember", "event", "function", "method", "macro", "keyword", "modifier", "comment", "string", "number", "regexp", "operator", "decorator"], + "tokenModifiers": ["declaration", "definition", "readonly", "static", "deprecated", "abstract", "async", "modification", "documentation", "defaultLibrary"], + "formats": ["relative"], + "overlappingTokenSupport": false, + "multilineTokenSupport": false + }, + "foldingRange": { + "dynamicRegistration": true, + "lineFoldingOnly": false, + "foldingRangeKind": { + "valueSet": ["comment", "imports", "region"] + }, + "foldingRange": { + "collapsedText": true + } + }, + "codeLens": { "dynamicRegistration": true }, + "codeAction": { + "dynamicRegistration": true, + "resolveSupport": { + "properties": ["edit", "command"] + }, + "codeActionLiteralSupport": { + "codeActionKind": { + "valueSet": ["quickfix", "refactor", "source"] + } + } + }, + "publishDiagnostics": { + "versionSupport": true, + "relatedInformation": true, + "tagSupport": { + "valueSet": [1, 2] + } + } + }, + "workspace": { "semanticTokens": { "refreshSupport": true }, + "applyEdit": true, + "configuration": true, + "workspaceFolders": true, + "workspaceEdit": { + "documentChanges": true + }, + "executeCommand": { "dynamicRegistration": true }, + "symbol": { + "dynamicRegistration": true, + "symbolKind": { + "valueSet": [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, + 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26 + ] + } + } + }, + "window": { + "workDoneProgress": true + }, + "experimental": experimental_capabilities(plugin_owned, &state.offered_toolchain_providers, &state.offered_keymap_import_providers) + }, + "initializationOptions": request.initialization_options + }), + )?; + Ok(client_response(state, vec![message], Vec::new())) +} + +/// Opens a document and emits the corresponding LSP notification. +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.into(), + }; + let message = json_rpc_notification( + "textDocument/didOpen", + json!({ + "textDocument": { + "uri": document.uri, + "languageId": document.language_id, + "version": document.version, + "text": document.text + } + }), + )?; + state.diagnostics.remove(&request.uri); + state.diagnostic_versions.remove(&request.uri); + state.open_documents.insert(request.uri, document); + Ok(client_response(state, vec![message], Vec::new())) +} + +/// Updates a document and emits a monotonically versioned change notification. +/// +/// `content_changes` follow LSP `didChange` semantics: they apply in array order +/// and each range is relative to the result of the previous change. `text` is +/// optional alongside them; when present it is checked against the replay. +pub fn client_change_document( + request: ClientChangeDocumentRequest, +) -> Result { + validate_uri(&request.uri)?; + let mut state = request.state; + let Some(document) = state.open_documents.get(&request.uri).cloned() else { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Cannot change a document that is not open in the LSP client.", + )); + }; + let replayed = if request.content_changes.is_empty() { + None + } else { + match apply_content_changes_sequential(&document.text, &request.content_changes) { + Ok(text) => Some(text), + Err(_) if !request.text.is_empty() => None, + Err(error) => return Err(error), + } + }; + // A host snapshot that disagrees with the replay means one side missed an + // edit, so the snapshot replaces the document instead of extending the drift. + let (next_text, replayed) = match replayed { + Some(text) if request.text.is_empty() || text == request.text => (text, true), + _ => (request.text, false), + }; + let wire_incremental = (replayed + && state.text_document_sync == LspTextDocumentSyncKind::Incremental + && request + .content_changes + .iter() + .all(|change| change.range.is_some())) + .then_some(request.content_changes); + let content_changes = if let Some(changes) = wire_incremental.as_ref() { + json!(changes + .iter() + .map(|change| { + let range = change.range.expect("incremental changes require a range"); + json!({ + "range": { + "start": { + "line": range.start.line, + "character": range.start.utf16_column + }, + "end": { + "line": range.end.line, + "character": range.end.utf16_column + } + }, + "text": change.text + }) + }) + .collect::>()) + } else { + json!([{ "text": next_text }]) + }; + let mut document = document; + document.version += 1; + document.text = next_text.into(); + let message = json_rpc_notification( + "textDocument/didChange", + json!({ + "textDocument": { + "uri": document.uri, + "version": document.version + }, + "contentChanges": content_changes + }), + )?; + state.open_documents.insert(request.uri, document); + Ok(client_response(state, vec![message], Vec::new())) +} + +/// Closes a document and clears diagnostics owned by its URI. +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.", + )); + }; + state.diagnostics.remove(&request.uri); + state.diagnostic_versions.remove(&request.uri); + let message = json_rpc_notification( + "textDocument/didClose", + json!({ + "textDocument": { + "uri": document.uri + } + }), + )?; + Ok(client_response(state, vec![message], Vec::new())) +} + +/// Begins the two-step LSP shutdown and exit handshake. +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())) +} + +/// Translates one feature request into its JSON-RPC call. Every method except +/// `workspace/symbol` must name a document the client has open. +pub fn client_feature_request( + request: ClientFeatureRequest, +) -> Result { + // `workspace/symbol` is workspace-wide: it names no document, so an empty URI is accepted. + let workspace_wide = request.method == "workspace/symbol"; + if !workspace_wide { + 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 !workspace_wide && !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(crate) fn client_response( + state: LspClientState, + messages: Vec, + events: Vec, +) -> LspClientResponse { + LspClientResponse { + state, + messages, + events, + } +} + +pub(crate) fn allocate_request(state: &mut LspClientState, method: &str) -> String { + let id = state.next_request_id.to_string(); + if method == "textDocument/semanticTokens/full" { + if let Some(legend) = state + .semantic_token_providers + .values() + .rev() + .find_map(semantic_token_legend) + { + state + .pending_semantic_legends + .insert(id.clone(), legend.clone()); + } + } + state.next_request_id += 1; + state + .pending_requests + .insert(id.clone(), method.to_string()); + id +} diff --git a/rust/crates/lsp-client/src/client/requests.rs b/rust/crates/lsp-client/src/client/requests.rs new file mode 100644 index 00000000..1cd92982 --- /dev/null +++ b/rust/crates/lsp-client/src/client/requests.rs @@ -0,0 +1,216 @@ +//! Parameters of feature requests and the LSP shapes they use. + +use super::*; + +pub(crate) fn feature_request_params(request: &ClientFeatureRequest) -> Result { + let text_document = json!({ "uri": request.uri }); + match request.method.as_str() { + "textDocument/completion" => { + let context = request.completion_context.as_ref(); + let kind = context + .and_then(|v| v.get("triggerKind")) + .and_then(Value::as_u64) + .unwrap_or(1); + if !(1..=3).contains(&kind) + || context.is_some_and(|v| { + !v.is_object() || v.get("triggerKind").and_then(Value::as_u64).is_none() + }) + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid completion trigger kind", + )); + } + let mut trigger = json!({"triggerKind": kind}); + if kind == 2 { + let character = context + .and_then(|v| v.get("triggerCharacter")) + .and_then(Value::as_str) + .filter(|v| v.chars().count() == 1) + .ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "Completion trigger character is required", + ) + })?; + trigger["triggerCharacter"] = json!(character); + } + Ok(json!({"textDocument": text_document, + "position": lsp_position_json(required_position(request)?), "context": trigger})) + } + "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": request.include_declaration.unwrap_or(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": formatting_options_json(request.formatting_options.as_ref())? + })), + "textDocument/rangeFormatting" => Ok(json!({ + "textDocument": text_document, + "range": lsp_range_json(required_range(request)?), + "options": formatting_options_json(request.formatting_options.as_ref())? + })), + "textDocument/inlayHint" => Ok(json!({ + "textDocument": text_document, + "range": lsp_range_json(required_range(request)?) + })), + "textDocument/semanticTokens/full" + | "textDocument/documentSymbol" + | "textDocument/foldingRange" + | "textDocument/documentLink" + | "textDocument/codeLens" => Ok(json!({ + "textDocument": text_document + })), + "workspace/symbol" => Ok(json!({ + "query": request.query.clone().unwrap_or_default() + })), + "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.", + ) + }) +} + +/// Builds the LSP `FormattingOptions` for a formatting request. +/// +/// Without caller options the historical defaults apply. Caller options are +/// merged over them, so a host passing only `tabSize` keeps the other +/// defaults. Values must be booleans, integers or strings as LSP requires, +/// and `tabSize` must be a positive integer. +fn formatting_options_json(supplied: Option<&Value>) -> Result { + let mut options = json!({ + "tabSize": 4, + "insertSpaces": true, + "trimTrailingWhitespace": true, + "insertFinalNewline": true, + "trimFinalNewlines": true + }); + let Some(supplied) = supplied.filter(|value| !value.is_null()) else { + return Ok(options); + }; + let invalid = || CoreError::new(ErrorCode::InvalidRequest, "Invalid formatting options"); + let supplied = supplied.as_object().ok_or_else(invalid)?; + for (key, value) in supplied { + let valid = match key.as_str() { + "tabSize" => value + .as_u64() + .is_some_and(|size| (1..=1024).contains(&size)), + "insertSpaces" + | "trimTrailingWhitespace" + | "insertFinalNewline" + | "trimFinalNewlines" => value.is_boolean(), + _ => value.is_boolean() || value.is_string() || value.is_i64() || value.is_u64(), + }; + if !valid { + return Err(invalid().with_details(key.clone())); + } + options[key] = value.clone(); + } + Ok(options) +} + +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": lsp_range_response_json(diagnostic.range), + "severity": diagnostic.severity, + "message": diagnostic.message, + "source": diagnostic.source, + "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 + } + }) +} diff --git a/rust/crates/lsp-client/src/client/results.rs b/rust/crates/lsp-client/src/client/results.rs new file mode 100644 index 00000000..87a2357b --- /dev/null +++ b/rust/crates/lsp-client/src/client/results.rs @@ -0,0 +1,552 @@ +//! Parsing feature results into Core shapes. + +use super::*; + +pub(crate) 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), + "isIncomplete": result.get("isIncomplete").and_then(Value::as_bool).unwrap_or(false) + })), + 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" | "textDocument/rangeFormatting") => Some(json!({ + "edits": result + .as_array() + .map(|edits| edits.iter().filter_map(parse_lsp_text_edit_value).collect::>()) + .unwrap_or_default() + })), + Some("textDocument/documentSymbol") => Some(json!({ + "symbols": parse_document_symbols(result) + })), + Some("textDocument/inlayHint") => Some(json!({ + "hints": parse_inlay_hints(result) + })), + Some("textDocument/foldingRange") => Some(json!({ + "ranges": parse_folding_ranges(result) + })), + Some("textDocument/documentLink") => Some(json!({ + "links": parse_document_links(result) + })), + Some("workspace/symbol") => Some(json!({ + "symbols": parse_workspace_symbols(result) + })), + Some("textDocument/codeLens") => Some(json!({ + "lenses": parse_code_lenses(result) + })), + 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!({ "value": result })), + // Wrapped like a command result so no provider-specific normalizer + // reinterprets fields of the document Core decodes. + Some(method) + if crate::build_provider::is_build_method(method) + || method == lithe_plugin_rpc::INVOKE_METHOD => + { + Some(json!({ "value": 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(parse_completion_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); + let mut mapped = json!({ + "label": label, + "insertText": insert_text, + "insertTextFormat": item.get("insertTextFormat").and_then(Value::as_u64).unwrap_or(1) + }); + if let Some(kind) = item.get("kind").and_then(Value::as_i64) { + mapped["kind"] = json!(kind); + } + if let Some(detail) = item.get("detail").and_then(Value::as_str) { + mapped["detail"] = json!(detail); + } + if let Some(documentation) = completion_documentation(item.get("documentation")) { + mapped["documentation"] = json!(documentation); + } + if let Some(sort_text) = item.get("sortText").and_then(Value::as_str) { + mapped["sortText"] = json!(sort_text); + } + if let Some(filter_text) = item.get("filterText").and_then(Value::as_str) { + mapped["filterText"] = json!(filter_text); + } + if let Some(edit) = item.get("textEdit").and_then(parse_lsp_text_edit_value) { + mapped["textEdit"] = edit; + } + if let Some(edits) = item + .get("additionalTextEdits") + .and_then(Value::as_array) + .map(|edits| { + edits + .iter() + .filter_map(parse_lsp_text_edit_value) + .collect::>() + }) + .filter(|edits| !edits.is_empty()) + { + mapped["additionalTextEdits"] = json!(edits); + } + // `data: null` makes some servers treat resolve as a no-op, so only + // forward a real resolve token. Namespace display lives in labelDetails; + // import `use`/`import` edits usually arrive only from completionItem/resolve. + if let Some(data) = item.get("data").filter(|value| !value.is_null()) { + mapped["data"] = data.clone(); + } + if let Some(details) = item.get("labelDetails").filter(|value| value.is_object()) { + mapped["labelDetails"] = details.clone(); + } + Some(mapped) +} + +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 { + object + .get("language") + .and_then(Value::as_str) + .map(|value| (value.to_string(), true)) + } + } + 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_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, + } +} + +/// Normalizes a `textDocument/documentSymbol` result into a unified tree. +/// +/// Servers answer with either `DocumentSymbol[]` (hierarchical) or +/// `SymbolInformation[]` (flat); the two are told apart per item by the +/// presence of `location`. Conversion rules: +/// - `DocumentSymbol` keeps `name`, `detail`, `kind`, `range`, +/// `selectionRange` and recursively normalized `children`. +/// - `SymbolInformation` becomes a childless symbol whose `range` and +/// `selectionRange` are both `location.range`, with `containerName` kept as +/// a hint; Core does not guess a hierarchy from container names. +/// - `deprecated` is true for the legacy `deprecated` flag or tag `1`. +/// - Positions use `{ line, utf16Column }`. Items with a missing name, kind or +/// range are dropped; `null` or a non-array result yields an empty list. +fn parse_document_symbols(result: &Value) -> Vec { + result + .as_array() + .map(|items| items.iter().filter_map(parse_document_symbol).collect()) + .unwrap_or_default() +} + +fn parse_document_symbol(item: &Value) -> Option { + let name = item.get("name").and_then(Value::as_str)?; + let kind = item.get("kind").and_then(Value::as_i64)?; + let tags: Vec = item + .get("tags") + .and_then(Value::as_array) + .map(|tags| tags.iter().filter_map(Value::as_i64).collect()) + .unwrap_or_default(); + let deprecated = item + .get("deprecated") + .and_then(Value::as_bool) + .unwrap_or(false) + || tags.contains(&1); + let (range, selection_range, children, container) = if let Some(location) = item.get("location") + { + let range = parse_lsp_range_value(location.get("range")?)?; + (range.clone(), range, Vec::new(), item.get("containerName")) + } else { + let range = parse_lsp_range_value(item.get("range")?)?; + let selection = item + .get("selectionRange") + .and_then(parse_lsp_range_value) + .unwrap_or_else(|| range.clone()); + let children = item + .get("children") + .and_then(Value::as_array) + .map(|children| children.iter().filter_map(parse_document_symbol).collect()) + .unwrap_or_default(); + (range, selection, children, None) + }; + Some(json!({ + "name": name, + "detail": item.get("detail").and_then(Value::as_str), + "kind": kind, + "tags": tags, + "deprecated": deprecated, + "range": range, + "selectionRange": selection_range, + "containerName": container.and_then(Value::as_str), + "children": children + })) +} + +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() +} + +/// Accepts both `SymbolInformation[]` and `WorkspaceSymbol[]`. A location with +/// only a URI keeps a null range, because the server expects `workspaceSymbol/resolve`. +fn parse_workspace_symbols(result: &Value) -> Vec { + result + .as_array() + .map(|values| { + values + .iter() + .filter_map(|symbol| { + let location = symbol.get("location")?; + Some(LspWorkspaceSymbolResponse { + name: symbol.get("name")?.as_str()?.to_string(), + kind: symbol.get("kind").and_then(Value::as_i64), + container_name: symbol + .get("containerName") + .and_then(Value::as_str) + .map(str::to_string), + uri: location.get("uri")?.as_str()?.to_string(), + range: location.get("range").and_then(parse_lsp_range), + }) + }) + .collect() + }) + .unwrap_or_default() +} + +/// Links without a range are dropped; a missing target stays `None` because the +/// server may supply it only through `documentLink/resolve`, which Core does not call. +fn parse_document_links(result: &Value) -> Vec { + result + .as_array() + .map(|values| { + values + .iter() + .filter_map(|link| { + Some(LspDocumentLinkResponse { + range: parse_lsp_range(link.get("range")?)?, + target: link + .get("target") + .and_then(Value::as_str) + .map(str::to_string), + tooltip: link + .get("tooltip") + .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() + } 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)?; + let file_path = file_path_for_uri(uri); + let is_read_only = file_path.is_none(); + Some(json!({ + "uri": uri, + "filePath": file_path, + "range": range, + "isReadOnly": is_read_only, + "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) + })) +} diff --git a/rust/crates/lsp-client/src/client/server_messages.rs b/rust/crates/lsp-client/src/client/server_messages.rs new file mode 100644 index 00000000..0a5073d2 --- /dev/null +++ b/rust/crates/lsp-client/src/client/server_messages.rs @@ -0,0 +1,526 @@ +//! Applying messages the language server sends. + +use super::*; + +/// Reduces a server message for host-owned stateless client tests. +#[cfg(test)] +pub fn client_apply_server_message( + request: ClientApplyServerMessageRequest, +) -> Result { + 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()) + })?; + client_apply_server_message_for_plugin(request.state, &message, false) +} + +/// Applies function protocol messages only for sessions with a runtime-owned plugin identity. +/// +/// Takes the message already parsed: the engine parses each server message once, +/// outside the session state lock, and reduces it while holding that lock. +pub fn client_apply_server_message_for_plugin( + mut state: LspClientState, + message: &Value, + plugin_owned: bool, +) -> Result { + 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)?; + // Servers may re-encode the URIs they echo back (VS Code's + // URI library writes `d%3A` for a Windows drive while this + // host opened the document as `d:`), so a raw map lookup + // silently drops every published diagnostic on Windows. + // Match through the normalized form and publish under the + // document's own canonical URI. + let document = open_document_for_published_uri(&state.open_documents, uri); + let Some(document) = document else { + return Ok(client_response(state, responses, events)); + }; + let document_uri = document.uri.clone(); + let version = params.get("version").and_then(Value::as_i64); + if version.is_some_and(|version| version != document.version) { + return Ok(client_response(state, responses, events)); + } + let diagnostics = parse_diagnostics(params.get("diagnostics")); + state + .diagnostics + .insert(document_uri.clone(), diagnostics.clone()); + if let Some(version) = version { + state + .diagnostic_versions + .insert(document_uri.clone(), version); + } else { + state.diagnostic_versions.remove(&document_uri); + } + events.push(LspClientEvent { + kind: "diagnostics".to_string(), + request_id: None, + method: None, + uri: Some(document_uri), + version, + 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)?); + } + } + lithe_plugin_rpc::REGISTER_METHOD | lithe_plugin_rpc::UNREGISTER_METHOD + if plugin_owned + && !state.shutdown_requested + && state + .server_capabilities + .iter() + .any(|feature| feature == lithe_plugin_rpc::FUNCTIONS_FEATURE) => + { + let params = message.get("params"); + let outcome = if method == lithe_plugin_rpc::REGISTER_METHOD { + lithe_plugin_rpc::apply_registration(&state.plugin_functions, params) + .map(|registry| (registry, Vec::new())) + } else { + lithe_plugin_rpc::apply_unregistration(&state.plugin_functions, params) + }; + match outcome { + Ok((registry, removed)) => { + state.plugin_functions = registry; + if let Some(id) = request_id { + responses.push(json_rpc_result(id, Value::Null)?); + } + events.push(LspClientEvent { + kind: "functionsChanged".to_string(), + request_id: None, + method: Some(method.to_string()), + uri: None, + version: None, + diagnostics: None, + result: Some(json!({ + "functions": state.plugin_functions, + "removed": removed + })), + error: None, + }); + } + Err(reason) => { + if let Some(id) = request_id { + responses.push(json_rpc_error_with_code( + id, + -32602, + reason, + lithe_plugin_rpc::codes::INVALID_REGISTRATION, + )?); + } + } + } + } + crate::build_provider::PROGRESS_METHOD + if !state.shutdown_requested + && state.server_capabilities.iter().any(|feature| { + feature == crate::build_provider::BUILD_PROVIDER_FEATURE + }) => + { + if let Some(result) = crate::build_provider::progress(message.get("params")) { + events.push(LspClientEvent { + kind: "buildProgress".to_string(), + request_id: None, + method: Some(method.to_string()), + uri: None, + version: None, + diagnostics: None, + result: Some(result), + error: None, + }); + } + } + lithe_plugin_rpc::PROGRESS_METHOD + if plugin_owned + && !state.shutdown_requested + && state + .server_capabilities + .iter() + .any(|feature| feature == lithe_plugin_rpc::FUNCTIONS_FEATURE) => + { + // Malformed reports are dropped here; reports for an operation + // that is no longer running are dropped by the engine. + if let Some(progress) = lithe_plugin_rpc::parse_progress(message.get("params")) { + let mut result = json!({ "operationID": progress.operation_id }); + if let Some(text) = progress.message { + result["message"] = json!(text); + } + if let Some(percentage) = progress.percentage { + result["percentage"] = json!(percentage); + } + events.push(LspClientEvent { + kind: "functionProgress".to_string(), + request_id: None, + method: Some(method.to_string()), + uri: None, + version: None, + diagnostics: None, + result: Some(result), + error: None, + }); + } + } + "workspace/semanticTokens/refresh" => { + if let Some(id) = request_id { + responses.push(json_rpc_result(id, Value::Null)?); + } + events.push(LspClientEvent { + kind: "semanticTokensRefresh".to_string(), + request_id: None, + method: Some(method.to_string()), + uri: None, + version: None, + diagnostics: None, + result: None, + error: None, + }); + } + "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 plugin_owned && request_id.is_some() && method.starts_with("lithe/host/") => { + let id = request_id.expect("guarded above"); + match lithe_plugin_rpc::host_request( + method, + message.get("params"), + state.plugin_function_version, + &state.host_services, + ) { + // The engine answers once the host has; the request + // carries the backend's own ID back to it. + Ok(lithe_plugin_rpc::HostRoute::Answer(mut result)) => { + // getCapabilities also reports the options of the + // granted host services. + if let (lithe_plugin_rpc::GET_CAPABILITIES, Some(editor)) = + (method, &state.host_options.editor) + { + editor.report(&mut result, &state.host_services); + } + responses.push(json_rpc_result(id, result)?) + } + Ok(lithe_plugin_rpc::HostRoute::Forward(service)) => { + events.push(LspClientEvent { + kind: "hostRequest".to_string(), + request_id: None, + method: Some(method.to_string()), + uri: None, + version: None, + diagnostics: None, + result: Some(json!({ + "id": id, + "service": service, + "params": lithe_plugin_rpc::forwarded_host_params( + method, + message.get("params"), + ), + })), + error: None, + }) + } + // Another plugin's function: the engine's relay checks the + // call against the plugins' manifests and answers the + // backend itself, so the reducer only names the request. + Ok(lithe_plugin_rpc::HostRoute::Relay) => events.push(LspClientEvent { + kind: "functionRelayRequest".to_string(), + request_id: None, + method: Some(method.to_string()), + uri: None, + version: None, + diagnostics: None, + result: Some(json!({ + "id": id, + "params": message.get("params").cloned().unwrap_or(Value::Null), + })), + error: None, + }), + Err(refusal) => { + let mut data = json!({ "code": refusal.code }); + if let Some(details) = refusal.details { + data["details"] = details; + } + responses.push(json_rpc_error_with_data( + id, + refusal.rpc_code, + refusal.message, + data, + )?) + } + } + } + _ => { + if let Some(id) = request_id { + responses.push(match lithe_plugin_rpc::refused_backend_request(method) { + Some(code) => json_rpc_error_with_code( + id, + -32601, + "The host does not serve this request to a plugin process.", + code, + )?, + None => 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, + version: 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") + .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), + ); + if plugin_owned + && crate::plugin_logging::negotiated( + result.get("capabilities").unwrap_or(&Value::Null), + ) + { + state + .server_capabilities + .push(crate::plugin_logging::FEATURE.into()); + } + let functions = plugin_owned + .then(|| { + lithe_plugin_rpc::negotiate( + result.get("capabilities").unwrap_or(&Value::Null), + ) + }) + .flatten(); + if let Some((version, functions)) = functions { + state.plugin_functions = functions; + state.plugin_function_version = Some(version); + events.push(LspClientEvent { + kind: "functionsChanged".to_string(), + request_id: None, + method: Some("initialize".to_string()), + uri: None, + version: None, + diagnostics: None, + result: Some(json!({ "functions": state.plugin_functions, "removed": [] })), + error: None, + }); + } else { + state.plugin_functions.clear(); + state.plugin_function_version = None; + state + .server_capabilities + .retain(|feature| feature != lithe_plugin_rpc::FUNCTIONS_FEATURE); + } + // Only a plugin's own process may act as a VCS provider; a + // host-owned server that happens to answer is ignored. + state.vcs_provider = plugin_owned + .then(|| { + crate::vcs_provider::negotiate( + result.get("capabilities").unwrap_or(&Value::Null), + ) + }) + .flatten(); + match &state.vcs_provider { + Some(negotiation) => events.push(LspClientEvent { + kind: crate::vcs_provider::NEGOTIATED_EVENT.to_string(), + request_id: None, + method: Some("initialize".to_string()), + uri: None, + version: None, + diagnostics: None, + result: Some(json!(negotiation)), + error: None, + }), + None => state + .server_capabilities + .retain(|feature| feature != crate::vcs_provider::VCS_PROVIDER_FEATURE), + } + state.toolchain_providers = plugin_owned + .then(|| { + crate::toolchain_provider::negotiate( + result.get("capabilities").unwrap_or(&Value::Null), + &state.offered_toolchain_providers, + ) + }) + .flatten() + .unwrap_or_default(); + if !state.toolchain_providers.is_empty() { + events.push(LspClientEvent { + kind: crate::toolchain_provider::NEGOTIATED_EVENT.to_string(), + request_id: None, + method: Some("initialize".to_string()), + uri: None, + version: None, + diagnostics: None, + result: Some(json!({ "providers": state.toolchain_providers })), + error: None, + }); + } + state.keymap_import_providers = plugin_owned + .then(|| { + crate::keymap_provider::negotiate( + result.get("capabilities").unwrap_or(&Value::Null), + &state.offered_keymap_import_providers, + ) + }) + .flatten() + .unwrap_or_default(); + if !state.keymap_import_providers.is_empty() { + events.push(LspClientEvent { + kind: crate::keymap_provider::NEGOTIATED_EVENT.to_string(), + request_id: None, + method: Some("initialize".to_string()), + uri: None, + version: None, + diagnostics: None, + result: Some(json!({ "providers": state.keymap_import_providers })), + error: None, + }); + } + state.text_document_sync = + text_document_sync_kind(result.get("capabilities").unwrap_or(&Value::Null)); + state.text_document_save_include_text = match result + .get("capabilities") + .and_then(|value| value.pointer("/textDocumentSync/save")) + { + Some(Value::Bool(true)) => Some(false), + Some(Value::Object(options)) => Some( + options + .get("includeText") + .and_then(Value::as_bool) + .unwrap_or(false), + ), + _ => None, + }; + if let Some(options) = result + .get("capabilities") + .and_then(|c| c.get("completionProvider")) + .filter(|options| options.is_object()) + { + state + .completion_providers + .insert(String::new(), options.clone()); + } + if let Some(options) = result + .get("capabilities") + .and_then(|c| c.get("semanticTokensProvider")) + { + if semantic_token_legend(options).is_some() { + state + .semantic_token_providers + .insert(String::new(), options.clone()); + } + } + 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.semantic_token_providers.clear(); + state.completion_providers.clear(); + state.text_document_sync = LspTextDocumentSyncKind::Full; + state.open_documents.clear(); + state.diagnostics.clear(); + state.diagnostic_versions.clear(); + state.plugin_functions.clear(); + state.plugin_function_version = None; + state.vcs_provider = None; + responses.push(json_rpc_message_without_params(None, "exit")?); + } + let result = if pending.as_deref() == Some("textDocument/semanticTokens/full") { + semantic_token_result( + state.pending_semantic_legends.remove(&id).as_ref(), + message.get("result"), + ) + } else { + 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, + version: None, + diagnostics: None, + result, + error: message.get("error").map(|value| value.to_string()), + }); + } + + Ok(client_response(state, responses, events)) +} + +/// Resolves a server-published document URI against the client's open +/// documents, tolerating URI re-encoding differences. +/// +/// Language servers frequently normalize the URIs a client sends them: VS +/// Code's URI library (used by Intelephense, typescript-language-server, and +/// others) percent-encodes the Windows drive colon (`d:` becomes `d%3A`) and +/// lowercases the drive letter. A raw map lookup therefore misses even though +/// the document is open. Comparison preserves URI component boundaries and +/// folds the Windows drive letter while keeping Unix paths case-sensitive. +fn open_document_for_published_uri<'a>( + documents: &'a std::collections::BTreeMap, + uri: &str, +) -> Option<&'a LspClientDocument> { + if let Some(document) = documents.get(uri) { + return Some(document); + } + let normalized = normalize_uri_for_comparison(uri); + documents + .iter() + .find(|(key, _)| normalize_uri_for_comparison(key) == normalized) + .map(|(_, document)| document) +} diff --git a/rust/crates/lsp-client/src/client/validation.rs b/rust/crates/lsp-client/src/client/validation.rs new file mode 100644 index 00000000..7e32a815 --- /dev/null +++ b/rust/crates/lsp-client/src/client/validation.rs @@ -0,0 +1,102 @@ +//! URI and method validation. + +use super::*; + +pub(crate) 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(()) + } +} + +pub(crate) fn normalize_uri_for_comparison(uri: &str) -> String { + let serialized = url::Url::parse(uri) + .map(|url| url.to_string()) + .unwrap_or_else(|_| uri.to_owned()); + let bytes = serialized.as_bytes(); + let mut canonical = Vec::with_capacity(bytes.len()); + let mut index = 0; + const HEX: &[u8; 16] = b"0123456789ABCDEF"; + 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])) + { + let decoded = (high << 4) | low; + if decoded.is_ascii_alphanumeric() || b"-._~".contains(&decoded) { + canonical.push(decoded); + } else { + canonical.extend_from_slice(&[b'%', HEX[high as usize], HEX[low as usize]]); + } + index += 3; + continue; + } + } + canonical.push(bytes[index]); + index += 1; + } + // The input was UTF-8 and only ASCII escapes were replaced. + let mut normalized = String::from_utf8(canonical).expect("URI normalization preserves UTF-8"); + const FILE_PREFIX: &str = "file:///"; + if let Some(path) = normalized.strip_prefix(FILE_PREFIX) { + let bytes = path.as_bytes(); + if bytes.len() >= 4 && bytes[0].is_ascii_alphabetic() && &bytes[1..4] == b"%3A" { + let drive_index = FILE_PREFIX.len(); + normalized.replace_range(drive_index + 1..drive_index + 4, ":"); + } + let bytes = normalized.as_bytes(); + let drive_index = FILE_PREFIX.len(); + if bytes.len() > drive_index + 1 + && bytes[drive_index].is_ascii_alphabetic() + && bytes[drive_index + 1] == b':' + { + normalized[drive_index..drive_index + 1].make_ascii_lowercase(); + } + } + normalized +} + +pub(crate) 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() +} + +pub(crate) 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/rangeFormatting" + | "textDocument/documentSymbol" + | "textDocument/inlayHint" + | "textDocument/semanticTokens/full" + | "textDocument/foldingRange" + | "textDocument/documentLink" + | "workspace/symbol" + | "textDocument/codeLens" + | "textDocument/codeAction" + | "completionItem/resolve" + | "codeAction/resolve" + | "workspace/executeCommand" => Ok(()), + _ => Err(CoreError::new( + ErrorCode::NotSupported, + "Unsupported LSP client request method.", + ) + .with_details(method.to_string())), + } +} diff --git a/rust/crates/core/src/lsp/interface/keymap_provider.rs b/rust/crates/lsp-client/src/keymap_provider.rs similarity index 85% rename from rust/crates/core/src/lsp/interface/keymap_provider.rs rename to rust/crates/lsp-client/src/keymap_provider.rs index 0e798a09..bd95ff66 100644 --- a/rust/crates/core/src/lsp/interface/keymap_provider.rs +++ b/rust/crates/lsp-client/src/keymap_provider.rs @@ -1,17 +1,17 @@ //! Negotiation for the independent shortcut import provider protocol. The //! protocol version is the `keymapImportProviders` capability version. -use crate::plugins::capabilities::{versions as capability_versions, KEYMAP_IMPORT_PROVIDERS}; +use lithe_plugin_model::capabilities::{versions as capability_versions, KEYMAP_IMPORT_PROVIDERS}; use serde_json::{json, Value}; use std::collections::BTreeSet; const EXPERIMENTAL_KEY: &str = "litheKeymapImport"; /// Client event carrying the provider subset selected during initialization. -pub(crate) const NEGOTIATED_EVENT: &str = "keymapImportProvidersNegotiated"; +pub const NEGOTIATED_EVENT: &str = "keymapImportProvidersNegotiated"; /// Host-to-provider request for one explicitly selected source snapshot. -pub(crate) const IMPORT_METHOD: &str = "lithe/keymap/import"; +pub const IMPORT_METHOD: &str = "lithe/keymap/import"; /// Maximum encoded provider reply forwarded to the host for Core validation. -pub(crate) const MAX_REPLY_BYTES: usize = 1024 * 1024; +pub const MAX_REPLY_BYTES: usize = 1024 * 1024; /// Protocol versions this client speaks: the versions of the /// `keymapImportProviders` capability. @@ -21,10 +21,10 @@ fn supported_versions() -> Vec { fn valid_offer(providers: &BTreeSet) -> bool { !providers.is_empty() - && providers.len() <= crate::plugins::keymap_import::MAX_PROVIDERS + && providers.len() <= lithe_plugin_keymap_import::MAX_PROVIDERS && providers .iter() - .all(|id| crate::plugins::keymap_import::valid_provider_id(id)) + .all(|id| lithe_plugin_keymap_import::valid_provider_id(id)) } /// Offers this protocol only for a bounded set of declared provider identities. @@ -67,7 +67,7 @@ mod tests { #[test] fn shortcut_providers_negotiate_only_an_offered_subset() { let fixtures: Value = serde_json::from_str(include_str!( - "../../../../../fixtures/keymap/provider-negotiation-v1.json" + "../../../fixtures/keymap/provider-negotiation-v1.json" )) .unwrap(); for case in fixtures["cases"].as_array().unwrap() { @@ -107,8 +107,8 @@ mod tests { #[test] fn backend_cannot_recursively_invoke_an_import() { assert_eq!( - super::super::plugin_function::refused_backend_request(IMPORT_METHOD), - Some(super::super::plugin_function::codes::RECURSIVE_CALL) + lithe_plugin_rpc::refused_backend_request(IMPORT_METHOD), + Some(lithe_plugin_rpc::codes::RECURSIVE_CALL) ); } } diff --git a/rust/crates/lsp-client/src/lib.rs b/rust/crates/lsp-client/src/lib.rs new file mode 100644 index 00000000..8e99a46f --- /dev/null +++ b/rust/crates/lsp-client/src/lib.rs @@ -0,0 +1,23 @@ +//! The stateful LSP client Lithe runs for every language server and plugin +//! backend: initialization and capability negotiation, open documents and +//! their versions, feature requests and the host models their responses are +//! shaped into, and the experimental provider protocols (build, VCS, +//! toolchain, keymap import, plugin logging) a server can offer. +//! +//! Everything here is a pure function of the client state and one message. +//! Processes, threads and timeouts belong to `lithe-lsp-engine`. + +pub mod build_provider; +mod client; +pub mod keymap_provider; +pub mod plugin_logging; +pub mod toolchain_provider; +mod types; +pub mod vcs_provider; + +pub use client::*; +use lithe_plugin_model::capabilities::offered_versions; +pub use types::*; + +#[cfg(test)] +mod tests; diff --git a/rust/crates/core/src/lsp/interface/plugin_logging.rs b/rust/crates/lsp-client/src/plugin_logging.rs similarity index 86% rename from rust/crates/core/src/lsp/interface/plugin_logging.rs rename to rust/crates/lsp-client/src/plugin_logging.rs index 9ff58cab..f1a27713 100644 --- a/rust/crates/core/src/lsp/interface/plugin_logging.rs +++ b/rust/crates/lsp-client/src/plugin_logging.rs @@ -3,13 +3,15 @@ //! The protocol version is the plugin API version (the base contract), //! negotiated in `initialize` under `capabilities.experimental.litheLogging`. -use crate::plugins::declarative::SUPPORTED_PLUGIN_API_VERSIONS; +use lithe_plugin_model::capabilities::SUPPORTED_PLUGIN_API_VERSIONS; use serde::Deserialize; use serde_json::{json, Value}; use std::collections::{BTreeMap, BTreeSet}; -pub(super) const FEATURE: &str = "litheLogging"; -pub(super) const METHOD: &str = "lithe/log"; +/// Key of the protocol under `capabilities.experimental`. +pub const FEATURE: &str = "litheLogging"; +/// Notification a plugin sends one diagnostic fact with. +pub const METHOD: &str = "lithe/log"; const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991; /// Closed facts select catalog severity; a plugin cannot choose a disk event or channel. @@ -42,12 +44,12 @@ fn supported_versions() -> Vec { } /// The client half of the negotiation, placed under `capabilities.experimental`. -pub(super) fn client_experimental_capabilities() -> (&'static str, Value) { +pub(crate) fn client_experimental_capabilities() -> (&'static str, Value) { (FEATURE, json!({ "versions": supported_versions() })) } /// Whether the server's `initialize` capabilities chose a version this client offered. -pub(super) fn negotiated(capabilities: &Value) -> bool { +pub(crate) fn negotiated(capabilities: &Value) -> bool { capabilities .pointer("/experimental/litheLogging/version") .and_then(Value::as_u64) @@ -70,19 +72,21 @@ fn known_operation(operation: &str, functions: &BTreeSet) -> bool { ) || operation == super::keymap_provider::IMPORT_METHOD || super::build_provider::is_build_method(operation) - || crate::vcs::is_vcs_method(operation) + || lithe_vcs::is_vcs_method(operation) } /// A catalog fact with no plugin-selected identity, message, severity, or raw error text. -pub(super) struct Fact { - pub(super) event: &'static str, - pub(super) fields: BTreeMap, +pub struct Fact { + /// Catalog event the fact maps to. + pub event: &'static str, + /// Validated catalog fields of the event. + pub fields: BTreeMap, /// Wire correlation is resolved to the owning host operation by the runtime. - pub(super) wire_operation: Option, + pub wire_operation: Option, } /// Rejects unknown fields and unregistered operations without echoing private input. -pub(super) fn decode(plugin: &str, functions: &BTreeSet, params: &Value) -> Option { +pub fn decode(plugin: &str, functions: &BTreeSet, params: &Value) -> Option { let input = Input::deserialize(params).ok()?; if !token(&input.operation, 64) || !known_operation(&input.operation, functions) @@ -134,7 +138,7 @@ mod tests { #[test] fn shared_plugin_logging_fixture_matches_ingress_and_catalog() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../../../shared/plugins/contract/v1/fixtures/backend/plugin-logging.json" + "../../../../shared/plugins/contract/v1/fixtures/backend/plugin-logging.json" )) .unwrap(); let functions = fixture["registeredFunctions"] @@ -151,10 +155,10 @@ mod tests { { let fact = decode(fixture["plugin"].as_str().unwrap(), &functions, input).unwrap(); assert_eq!(fact.event, fixture["events"][index]); - let mut record = crate::diagnostics::record::StructuredLogEvent { + let mut record = lithe_diagnostics::record::StructuredLogEvent { ts: "2026-11-04T00:00:00.000Z".into(), seq: 0, - level: crate::diagnostics::catalog::event_by_id(fact.event) + level: lithe_diagnostics::catalog::event_by_id(fact.event) .unwrap() .level, event: fact.event.into(), @@ -163,7 +167,7 @@ mod tests { fields: fact.fields, }; let channel = - crate::diagnostics::record::prepare_catalog_event(&mut record, &Value::Null) + lithe_diagnostics::record::prepare_catalog_event(&mut record, &Value::Null) .unwrap(); assert_eq!(channel, fixture["channel"]); assert_eq!( @@ -195,8 +199,8 @@ mod tests { } let fact = decode("io.example.database", &functions, &input).unwrap(); assert_eq!(fact.event, expected); - crate::diagnostics::catalog::validate_fields(fact.event, &fact.fields).unwrap(); - let metadata = crate::diagnostics::catalog::event_by_id(expected).unwrap(); + lithe_diagnostics::catalog::validate_fields(fact.event, &fact.fields).unwrap(); + let metadata = lithe_diagnostics::catalog::event_by_id(expected).unwrap(); assert_eq!(serde_json::to_value(metadata.level).unwrap(), level); assert_eq!(fact.wire_operation.as_deref(), Some("request-7")); for (field, value) in [ diff --git a/rust/crates/core/src/lsp/tests.rs b/rust/crates/lsp-client/src/tests.rs similarity index 86% rename from rust/crates/core/src/lsp/tests.rs rename to rust/crates/lsp-client/src/tests.rs index bd0e37a9..b437a9d2 100644 --- a/rust/crates/core/src/lsp/tests.rs +++ b/rust/crates/lsp-client/src/tests.rs @@ -1,271 +1,11 @@ use super::*; -use crate::protocol::ErrorCode; +use lithe_protocol::ErrorCode; use serde_json::{json, Value}; -#[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:///C:/work/Main.java"), - "C:/work/Main.java" - ); - assert_eq!( - file_path_from_uri("file:///C%3A/work/A%20B.java"), - "C:/work/A B.java" - ); - assert_eq!( - 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] -fn document_uri_matches_shared_fixture() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../../fixtures/lsp/document-uri-v1.json" - )) - .unwrap(); - for case in fixture["cases"].as_array().unwrap() { - let response: Value = serde_json::from_str(&crate::execute_json( - &json!({"command":"lsp.documentUri", "payload":{ - "path":case["path"], "platform":case["platform"] - }}) - .to_string(), - )) - .unwrap(); - if case["uri"].is_null() { - assert_eq!(response["ok"], false, "{}: {response}", case["name"]); - } else { - assert_eq!(response["ok"], true, "{}: {response}", case["name"]); - assert_eq!(response["data"]["uri"], case["uri"], "{}", case["name"]); - } - } -} - -#[test] -fn document_paths_resolve_inside_lsp_requests_without_changing_virtual_uris() { - let mut request = json!({"uri":{"path":"C:\\work\\A#B.txt","platform":"auto"}, - "virtualUri":"untitled://contents/A#B", "text":"unchanged", - "diagnostics":[{"relatedInformation":[{"location":{"uri":{"path":"/work/A#B.txt","platform":"posix"}}}, - {"location":{"uri":"untitled://contents/A#B"}}]}]}); - resolve_request_paths("lsp.request", &mut request).unwrap(); - assert_eq!(request["uri"], "file:///C:/work/A%23B.txt"); - assert_eq!(request["virtualUri"], "untitled://contents/A#B"); - assert_eq!( - request["diagnostics"][0]["relatedInformation"][0]["location"]["uri"], - "file:///work/A%23B.txt" - ); - assert_eq!( - request["diagnostics"][0]["relatedInformation"][1]["location"]["uri"], - "untitled://contents/A#B" - ); - let mut changes = json!({"changes":[ - {"uri":{"path":"/work/A?.txt","platform":"posix"},"kind":"changed"}, - {"uri":"file:///already%20encoded","kind":"deleted"} - ]}); - resolve_request_paths("lsp.workspaceFilesChanged", &mut changes).unwrap(); - assert_eq!(changes["changes"][0]["uri"], "file:///work/A%3F.txt"); - assert_eq!(changes["changes"][1]["uri"], "file:///already%20encoded"); - let mut invalid = json!({"uri":{"path":"relative","platform":"posix"}}); - assert!(resolve_request_paths("lsp.syncDocument", &mut invalid).is_err()); -} - -#[test] -fn document_uri_accepts_non_ascii_windows_extended_length_paths() { - let path = format!("C:\\{}\\Main.txt", vec!["目".repeat(100); 120].join("\\")); - assert!(path.len() > 32_768); - assert!(path.encode_utf16().count() < 32_767); - let response: Value = serde_json::from_str(&crate::execute_json( - &json!({"command":"lsp.documentUri", "payload":{"path":path,"platform":"windows"}}) - .to_string(), - )) - .unwrap(); - assert_eq!(response["ok"], true); - assert!(response["data"]["uri"] - .as_str() - .unwrap() - .starts_with("file:///C:/")); -} - #[test] fn document_uri_identity_keeps_reserved_characters_distinct() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../../fixtures/lsp/document-uri-v1.json" - )) - .unwrap(); + let fixture: Value = + serde_json::from_str(include_str!("../../../fixtures/lsp/document-uri-v1.json")).unwrap(); for case in fixture["identityCases"].as_array().unwrap() { let opened_uri = case["opened"].as_str().unwrap(); let published_uri = case["published"].as_str().unwrap(); @@ -1351,54 +1091,7 @@ fn client_core_rejects_duplicate_shutdown_requests() { } #[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() { +fn client_core_shapes_feature_responses_for_host_models() { let opened = client_open_document(ClientOpenDocumentRequest { state: LspClientState::default(), uri: "file:///tmp/project/main.rs".to_string(), @@ -1742,8 +1435,8 @@ fn client_core_shapes_feature_responses_for_swift_models() { "kind": 3, "textEdit": { "range": { - "start": { "line": 0, "utf16Column": 12 }, - "end": { "line": 0, "utf16Column": 14 } + "start": { "line": 0, "character": 12 }, + "end": { "line": 0, "character": 14 } }, "newText": "launch" }, @@ -2482,24 +2175,17 @@ fn client_core_rejects_unknown_server_requests_with_method_not_found() { #[test] fn client_diagnostic_code_action_fixture_preserves_core_fields() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../fixtures/lsp/diagnostic-code-actions-v1.json" + "../../../fixtures/lsp/diagnostic-code-actions-v1.json" )) .unwrap(); for case in fixture["severityCases"].as_array().unwrap() { let mut diagnostic = fixture["diagnostic"].clone(); diagnostic["severity"] = case["severity"].clone(); - // Exercise the exact JSON boundary used by Windows, including nullable - // severity, unknown tags and UTF-16 related locations. - let semantic: SemanticRequest = serde_json::from_value(json!({ - "sessionId": "fixture-session", "operation": "codeActions", - "uri": fixture["uri"], "range": diagnostic["range"], - "diagnostics": [diagnostic] - })) - .unwrap(); - assert_eq!( - serde_json::to_value(&semantic.diagnostics[0]).unwrap(), - diagnostic - ); + // Exercise the exact JSON boundary used by Windows (the diagnostics of a + // codeActions request), including nullable severity, unknown tags and + // UTF-16 related locations. + let decoded: LspClientDiagnostic = serde_json::from_value(diagnostic.clone()).unwrap(); + assert_eq!(serde_json::to_value(&decoded).unwrap(), diagnostic); let opened = client_open_document(ClientOpenDocumentRequest { state: LspClientState::default(), uri: fixture["uri"].as_str().unwrap().to_string(), @@ -2539,7 +2225,7 @@ fn client_diagnostic_code_action_fixture_preserves_core_fields() { #[test] fn client_completion_preserves_refresh_context_and_incomplete_lists() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../fixtures/lsp/completion-refresh-v1.json" + "../../../fixtures/lsp/completion-refresh-v1.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { @@ -2601,35 +2287,3 @@ fn client_completion_rejects_invalid_trigger_context() { )); } } - -#[test] -fn text_edits_follow_shared_windows_fixture() { - let fixture: Value = - serde_json::from_str(include_str!("../../../../fixtures/lsp/text-edits-v1.json")).unwrap(); - for case in fixture["cases"].as_array().unwrap() { - let edits: Vec = case["edits"].as_array().unwrap().iter().map(|edit| serde_json::json!({ - "range": { "start": { "line": edit["range"]["start"]["line"], "utf16Column": edit["range"]["start"]["character"] }, - "end": { "line": edit["range"]["end"]["line"], "utf16Column": edit["range"]["end"]["character"] } }, - "newText": edit["newText"] - })).collect(); - let result = apply_text_edits( - serde_json::from_value(serde_json::json!({ "text": case["text"], "edits": edits })) - .unwrap(), - ); - if let Some(expected) = case.get("expectedError") { - assert_eq!( - result.unwrap_err().details.as_deref(), - expected.as_str(), - "{}", - case["name"] - ); - } else { - assert_eq!( - result.unwrap().text, - case["expectedText"].as_str().unwrap(), - "{}", - case["name"] - ); - } - } -} diff --git a/rust/crates/core/src/lsp/interface/toolchain_provider.rs b/rust/crates/lsp-client/src/toolchain_provider.rs similarity index 89% rename from rust/crates/core/src/lsp/interface/toolchain_provider.rs rename to rust/crates/lsp-client/src/toolchain_provider.rs index 36d47de5..6871f472 100644 --- a/rust/crates/core/src/lsp/interface/toolchain_provider.rs +++ b/rust/crates/lsp-client/src/toolchain_provider.rs @@ -2,12 +2,13 @@ //! //! The manifest lists the providers; `initialize` offers them under //! `capabilities.experimental.litheToolchains`, and the backend selects a version -//! (the `toolchainProviders` capability version) and a subset of the offer. `toolchains/query` is written only to a session whose -//! answer passed [`negotiate`]. Replies stay untrusted here: Core transports them -//! unchanged, and `toolchains.decodeReply` validates them against native facts. +//! (the `toolchainProviders` capability version) and a subset of the offer. +//! `toolchains/query` is written only to a session whose answer passed the +//! negotiation. Replies stay untrusted here: Core transports them unchanged, +//! and `toolchains.decodeReply` validates them against native facts. //! The rules live in `shared/plugins/contract/v1/toolchains/README.md`. -use crate::plugins::capabilities::{versions as capability_versions, TOOLCHAIN_PROVIDERS}; +use lithe_plugin_model::capabilities::{versions as capability_versions, TOOLCHAIN_PROVIDERS}; use serde_json::{json, Value}; use std::collections::BTreeSet; @@ -21,10 +22,10 @@ fn supported_versions() -> Vec { } /// The only method this protocol writes. -pub(crate) const QUERY_METHOD: &str = "toolchains/query"; +pub const QUERY_METHOD: &str = "toolchains/query"; /// Client event that carries the negotiated providers to the engine. -pub(crate) const NEGOTIATED_EVENT: &str = "toolchainProvidersNegotiated"; +pub const NEGOTIATED_EVENT: &str = "toolchainProvidersNegotiated"; /// The offer for a session launched with `providers`; `None` when it declares none. pub(crate) fn client_experimental_capabilities( @@ -59,7 +60,7 @@ pub(crate) fn negotiate(capabilities: &Value, offered: &BTreeSet) -> Opt /// Wraps the complete JSON-RPC response for `toolchains.decodeReply`, which owns /// every success and error rule, with the wire ID the request was written under. -pub(crate) fn reply_outcome(response: &Value) -> Value { +pub fn reply_outcome(response: &Value) -> Value { json!({ "value": { "id": response.get("id"), "response": response } }) } diff --git a/rust/crates/core/src/lsp/interface/types.rs b/rust/crates/lsp-client/src/types.rs similarity index 79% rename from rust/crates/core/src/lsp/interface/types.rs rename to rust/crates/lsp-client/src/types.rs index 5c6681ef..186d00eb 100644 --- a/rust/crates/core/src/lsp/interface/types.rs +++ b/rust/crates/lsp-client/src/types.rs @@ -5,67 +5,47 @@ use serde_json::Value; use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; -#[derive(Debug, Clone, Copy, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Half-open document range expressed in zero-based LSP coordinates. -pub struct LspRange { - pub start: LspPosition, - pub end: LspPosition, -} - -#[derive(Debug, Clone, Copy, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Zero-based LSP line and UTF-16 code-unit column. -pub struct LspPosition { - pub line: i64, - pub utf16_column: i64, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -/// Normalized text replacement returned to host applications. -pub struct LspTextEditResponse { - pub range: LspRangeResponse, - pub new_text: String, -} +pub use lithe_text::{ + LspDocumentContentChange, LspPosition, LspPositionResponse, LspRange, LspRangeResponse, +}; #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// Normalized inlay hint independent of provider-specific extensions. pub struct LspInlayHintResponse { - pub position: LspPositionResponse, - pub label: String, + pub(crate) position: LspPositionResponse, + pub(crate) label: String, /// Numeric LSP `InlayHintKind`, when supplied by the server. pub kind: Option, - pub tooltip: Option, - pub padding_left: bool, - pub padding_right: bool, - pub text_edits: Vec, - pub data: Option, + pub(crate) tooltip: Option, + pub(crate) padding_left: bool, + pub(crate) padding_right: bool, + pub(crate) text_edits: Vec, + pub(crate) data: Option, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// Normalized folding range using UTF-16 columns when supplied by the server. pub struct LspFoldingRangeResponse { - pub start_line: i64, - pub start_utf16_column: Option, - pub end_line: i64, - pub end_utf16_column: Option, + pub(crate) start_line: i64, + pub(crate) start_utf16_column: Option, + pub(crate) end_line: i64, + pub(crate) end_utf16_column: Option, /// Server-provided LSP fold category such as `comment`, `imports`, or `region`. pub kind: Option, - pub collapsed_text: Option, + pub(crate) collapsed_text: Option, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// Normalized workspace symbol from either `SymbolInformation` or `WorkspaceSymbol`. pub struct LspWorkspaceSymbolResponse { - pub name: String, + pub(crate) name: String, /// Numeric LSP `SymbolKind`, when supplied by the server. pub kind: Option, - pub container_name: Option, - pub uri: String, + pub(crate) container_name: Option, + pub(crate) uri: String, /// Null when the server returned only a URI and expects `workspaceSymbol/resolve`. pub range: Option, } @@ -74,34 +54,18 @@ pub struct LspWorkspaceSymbolResponse { #[serde(rename_all = "camelCase")] /// Normalized document link; `target` is absent when the server defers it to resolve. pub struct LspDocumentLinkResponse { - pub range: LspRangeResponse, - pub target: Option, - pub tooltip: Option, + pub(crate) range: LspRangeResponse, + pub(crate) target: Option, + pub(crate) tooltip: Option, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// Normalized CodeLens payload awaiting an optional resolve operation. pub struct LspCodeLensResponse { - pub range: LspRangeResponse, - pub command: Option, - pub data: Option, -} - -#[derive(Debug, Clone, Copy, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -/// Serializable form of an LSP document range. -pub struct LspRangeResponse { - pub start: LspPositionResponse, - pub end: LspPositionResponse, -} - -#[derive(Debug, Clone, Copy, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -/// Serializable form of a zero-based LSP position. -pub struct LspPositionResponse { - pub line: i64, - pub utf16_column: i64, + pub(crate) range: LspRangeResponse, + pub(crate) command: Option, + pub(crate) data: Option, } #[derive(Debug, Clone, Copy, Default, Deserialize, Eq, PartialEq, Serialize)] @@ -117,17 +81,6 @@ pub enum LspTextDocumentSyncKind { Incremental, } -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -/// One LSP `textDocument/didChange` content change. -pub struct LspDocumentContentChange { - /// Inclusive start / exclusive end range; omitted for a full-document replacement. - #[serde(default)] - pub range: Option, - #[serde(default)] - pub text: String, -} - /// Options a host declares for the services it serves one plugin session, /// from `lsp.startServer.hostOptions`. Core reports them to the backend /// through `lithe/host/getCapabilities`, only for granted methods. @@ -136,7 +89,7 @@ pub struct LspDocumentContentChange { pub struct HostOptions { /// Icons, source bound and provider types of the editor service. #[serde(default, skip_serializing_if = "Option::is_none")] - pub editor: Option, + pub editor: Option, } impl HostOptions { @@ -150,12 +103,16 @@ impl HostOptions { #[serde(rename_all = "camelCase")] /// Pure client protocol state carried between JSON command invocations. pub struct LspClientState { + /// ID the next outbound request takes. #[serde(default = "default_next_request_id")] pub next_request_id: u64, + /// Whether the `initialize` response arrived. #[serde(default)] pub initialized: bool, + /// Whether `shutdown` was sent; no new requests follow it. #[serde(default)] pub shutdown_requested: bool, + /// Feature names the server's capabilities advertise. #[serde(default)] pub server_capabilities: Vec, /// `completionProvider` options by registration id: the static capability @@ -172,16 +129,21 @@ pub struct LspClientState { /// `None` means saves were not requested; `Some` is the server's includeText flag. #[serde(default, skip_serializing_if = "Option::is_none")] pub text_document_save_include_text: Option, + /// Documents synchronized with the server, by URI. #[serde(default)] pub open_documents: BTreeMap, + /// Method of every request awaiting a response, by request ID. #[serde(default)] pub pending_requests: BTreeMap, /// Legend captured when a semantic request is sent; registrations may change /// before the response arrives. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub pending_semantic_legends: BTreeMap, + /// Latest published diagnostics, by document URI. #[serde(default)] pub diagnostics: BTreeMap>, + /// Document version each published diagnostic set belongs to, + /// when the server named one. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub diagnostic_versions: BTreeMap, /// Plugin functions the backend registered, once `litheFunctions` was @@ -256,8 +218,11 @@ fn default_next_request_id() -> u64 { #[serde(rename_all = "camelCase")] /// Document version and contents currently synchronized with the server. pub struct LspClientDocument { + /// Document URI as the host opened it. pub uri: String, + /// LSP language identifier. pub language_id: String, + /// Version sent with the last `didOpen` or `didChange`. pub version: i64, /// Shared so that copying the client state, which every request, document /// change, and server message does while holding the session locks, does @@ -269,42 +234,47 @@ pub struct LspClientDocument { #[serde(rename_all = "camelCase")] /// Diagnostic normalized to fields supported by every frontend. pub struct LspClientDiagnostic { - pub range: LspRangeResponse, - pub severity: Option, + pub(crate) range: LspRangeResponse, + pub(crate) severity: Option, + /// Diagnostic text as the server reported it. pub message: String, - pub source: Option, - pub code: Option, + pub(crate) source: Option, + pub(crate) code: Option, #[serde(default)] - pub tags: Vec, + pub(crate) tags: Vec, #[serde(default)] - pub related_information: Vec, + pub(crate) related_information: Vec, } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] /// Related diagnostic message and its source location. pub struct LspClientDiagnosticRelatedInformation { - pub location: LspClientDiagnosticLocation, - pub message: String, + pub(crate) location: LspClientDiagnosticLocation, + pub(crate) message: String, } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] /// URI and range referenced by related diagnostic information. pub struct LspClientDiagnosticLocation { - pub uri: String, - pub range: LspRangeResponse, + pub(crate) uri: String, + pub(crate) range: LspRangeResponse, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] /// Inputs for constructing the LSP initialize request and initial client state. pub struct ClientInitializeRequest { + /// Client state to start from. #[serde(default)] pub state: LspClientState, + /// Workspace root URI. pub root_uri: String, + /// Host process ID; `None` sends `null`. #[serde(default)] pub process_id: Option, + /// Server-specific `initializationOptions`. #[serde(default)] pub initialization_options: Option, } @@ -313,10 +283,14 @@ pub struct ClientInitializeRequest { #[serde(rename_all = "camelCase")] /// Inputs for opening a complete document in pure client state. pub struct ClientOpenDocumentRequest { + /// Current client state. #[serde(default)] pub state: LspClientState, + /// Document URI. pub uri: String, + /// LSP language identifier. pub language_id: String, + /// Complete document text. pub text: String, } @@ -324,8 +298,10 @@ pub struct ClientOpenDocumentRequest { #[serde(rename_all = "camelCase")] /// Inputs for updating a synchronized document's contents. pub struct ClientChangeDocumentRequest { + /// Current client state. #[serde(default)] pub state: LspClientState, + /// URI of an open document. pub uri: String, /// Complete text after the change. Optional when `content_changes` is set. #[serde(default)] @@ -339,8 +315,10 @@ pub struct ClientChangeDocumentRequest { #[serde(rename_all = "camelCase")] /// Inputs for closing a document in pure client state. pub struct ClientCloseDocumentRequest { + /// Current client state. #[serde(default)] pub state: LspClientState, + /// URI of an open document. pub uri: String, } @@ -348,6 +326,7 @@ pub struct ClientCloseDocumentRequest { #[serde(rename_all = "camelCase")] /// Inputs for the LSP shutdown handshake. pub struct ClientShutdownRequest { + /// Current client state. #[serde(default)] pub state: LspClientState, } @@ -356,28 +335,38 @@ pub struct ClientShutdownRequest { #[serde(rename_all = "camelCase")] /// Generic feature request translated into one provider-neutral JSON-RPC call. pub struct ClientFeatureRequest { + /// Current client state. #[serde(default)] pub state: LspClientState, + /// Document the request is about; empty for `workspace/symbol`. pub uri: String, + /// LSP method to call. pub method: String, + /// Cursor position for position-based methods. #[serde(default)] pub position: Option, /// Search text for `workspace/symbol`; absent means the empty query. #[serde(default)] pub query: Option, + /// New symbol name for `rename`. #[serde(default)] pub new_name: Option, + /// Selection for range-based methods. #[serde(default)] pub range: Option, + /// Diagnostics in `range`, sent as the `codeAction` context. #[serde(default)] pub diagnostics: Vec, /// LSP completion trigger context; validated by the shared request mapper. #[serde(default)] pub completion_context: Option, + /// Item to complete for `completionItem/resolve`. #[serde(default)] pub completion_item: Option, + /// Action to complete for `codeAction/resolve`. #[serde(default)] pub code_action: Option, + /// Command to run for `workspace/executeCommand`. #[serde(default)] pub command: Option, /// Caller `FormattingOptions` for `formatting` and `rangeFormatting`. @@ -402,8 +391,11 @@ pub struct ClientApplyServerMessageRequest { #[serde(rename_all = "camelCase")] /// Updated client state, outbound messages, and host-facing events. pub struct LspClientResponse { + /// Client state after the step. pub state: LspClientState, + /// Encoded JSON-RPC messages to write to the server, in order. pub messages: Vec, + /// Events for the host, in order. pub events: Vec, } @@ -414,18 +406,25 @@ pub struct LspClientEvent { /// Pure-client event category: `diagnostics`, `notification`, `response`, /// `error`, `functionsChanged`, or `functionProgress`. pub kind: String, + /// Request a `response` or `error` event answers. #[serde(skip_serializing_if = "Option::is_none")] pub request_id: Option, + /// Method of the notification or the answered request. #[serde(skip_serializing_if = "Option::is_none")] pub method: Option, + /// Document a `diagnostics` event is about. #[serde(skip_serializing_if = "Option::is_none")] pub uri: Option, + /// Document version the diagnostics belong to, when the server named one. #[serde(skip_serializing_if = "Option::is_none")] pub version: Option, + /// Published diagnostics of a `diagnostics` event. #[serde(skip_serializing_if = "Option::is_none")] pub diagnostics: Option>, + /// Result or notification parameters, shaped into the host model. #[serde(skip_serializing_if = "Option::is_none")] pub result: Option, + /// Error message of an `error` event. #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } diff --git a/rust/crates/core/src/lsp/interface/vcs_provider.rs b/rust/crates/lsp-client/src/vcs_provider.rs similarity index 93% rename from rust/crates/core/src/lsp/interface/vcs_provider.rs rename to rust/crates/lsp-client/src/vcs_provider.rs index 1cf82777..28fcdad8 100644 --- a/rust/crates/core/src/lsp/interface/vcs_provider.rs +++ b/rust/crates/lsp-client/src/vcs_provider.rs @@ -9,8 +9,8 @@ //! offered, a valid VCS ID, and every required capability. The rules live in //! `rust/contracts/vcs-provider-v1.md`. -use crate::plugins::capabilities::{versions as capability_versions, VCS_PROVIDERS}; -use crate::vcs::{negotiated_capabilities, Capabilities}; +use lithe_plugin_model::capabilities::{versions as capability_versions, VCS_PROVIDERS}; +use lithe_vcs::{negotiated_capabilities, Capabilities}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -28,7 +28,7 @@ fn supported_versions() -> Vec { pub(crate) const VCS_PROVIDER_FEATURE: &str = "litheVcs"; /// Client event that carries the negotiated provider to the engine. -pub(crate) const NEGOTIATED_EVENT: &str = "vcsProviderNegotiated"; +pub const NEGOTIATED_EVENT: &str = "vcsProviderNegotiated"; /// Longest provider error message passed to the host, in UTF-8 bytes. const MAX_MESSAGE_BYTES: usize = 4096; @@ -85,7 +85,7 @@ pub(crate) fn negotiate(capabilities: &Value) -> Option { let vcs = answer .get("vcs") .and_then(Value::as_str) - .filter(|vcs| crate::vcs::is_vcs_id(vcs))?; + .filter(|vcs| lithe_vcs::is_vcs_id(vcs))?; Some(VcsNegotiation { vcs: vcs.to_string(), capabilities: negotiated_capabilities(answer.get("capabilities")?)?, @@ -94,9 +94,11 @@ pub(crate) fn negotiate(capabilities: &Value) -> Option { /// A failed VCS request: a contract code and the provider's message. #[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct VcsFailure { - pub(crate) code: &'static str, - pub(crate) message: String, +pub struct VcsFailure { + /// Stable contract error code. + pub code: &'static str, + /// The provider's message, cut to a bounded length. + pub message: String, } fn bounded_message(value: &str) -> String { @@ -110,7 +112,7 @@ fn bounded_message(value: &str) -> String { /// Normalizes the provider's JSON-RPC response. A success is wrapped as /// `{ "value": result }` like build-provider replies, so the host passes /// `value` to `vcs.decodeReply` unchanged. -pub(crate) fn reply_outcome(response: &Value) -> Result { +pub fn reply_outcome(response: &Value) -> Result { if let Some(error) = response.get("error") { let message = error .get("message") diff --git a/rust/crates/lsp-engine/Cargo.toml b/rust/crates/lsp-engine/Cargo.toml new file mode 100644 index 00000000..96085918 --- /dev/null +++ b/rust/crates/lsp-engine/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "lithe-lsp-engine" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Stateful language-server sessions: child processes, request deadlines, event queues, plugin-server calls and the cross-plugin function relay" + +[dependencies] +lithe-diagnostics = { path = "../diagnostics" } +lithe-document-uri = { path = "../document-uri" } +lithe-lsp-client = { path = "../lsp-client" } +lithe-lsp-transport = { path = "../lsp-transport" } +lithe-plugin-function-relay = { path = "../plugin-function-relay" } +lithe-plugin-language-servers = { path = "../plugin-language-servers" } +lithe-plugin-rpc = { path = "../plugin-rpc" } +lithe-plugin-server-policy = { path = "../plugin-server-policy" } +lithe-project = { path = "../project" } +lithe-protocol = { path = "../protocol" } +lithe-text = { path = "../text" } +lithe-vcs = { path = "../vcs" } +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0", features = ["preserve_order"] } +url = "2.5" +lithe-workspace = { path = "../workspace" } + +[dev-dependencies] +lithe-diagnostics = { path = "../diagnostics", features = ["test-support"] } +lithe-git = { path = "../git" } +lithe-plugin-editor = { path = "../plugin-editor" } +lithe-lsp-transport = { path = "../lsp-transport", features = ["test-support"] } +lithe-project = { path = "../project", features = ["test-support"] } diff --git a/rust/crates/lsp-engine/src/configuration.rs b/rust/crates/lsp-engine/src/configuration.rs new file mode 100644 index 00000000..d48d64a0 --- /dev/null +++ b/rust/crates/lsp-engine/src/configuration.rs @@ -0,0 +1,206 @@ +//! Plugin workspace configuration and indexing scope pushed into running sessions. + +use super::*; + +/// Rejects plugin configuration that is not a JSON object, the only shape +/// `workspace/configuration` sections can be looked up in. +pub(crate) fn checked_workspace_configuration( + configuration: Option, +) -> Result, CoreError> { + match configuration { + None | Some(Value::Null) => Ok(None), + Some(configuration @ Value::Object(_)) => Ok(Some(configuration)), + Some(_) => Err(CoreError::new( + ErrorCode::InvalidRequest, + "workspaceConfiguration must be a JSON object.", + )), + } +} + +/// Answers one `workspace/configuration` item from plugin configuration. +/// +/// A section names a dotted path (`python.analysis`); an exact top-level key +/// wins over the path so servers that use dotted keys still match. A missing +/// section answers `null`, which tells the server to use its own default. +pub(crate) fn configuration_section(configuration: &Value, section: Option<&str>) -> Value { + let Some(section) = section.filter(|section| !section.is_empty()) else { + return configuration.clone(); + }; + if let Some(value) = configuration.get(section) { + return value.clone(); + } + section + .split('.') + .try_fold(configuration, |value, key| value.get(key)) + .cloned() + .unwrap_or(Value::Null) +} + +/// `workspace/didChangeConfiguration` carrying the whole plugin configuration. +pub(crate) fn plugin_configuration_notification(configuration: &Value) -> String { + json!({ + "jsonrpc": "2.0", + "method": "workspace/didChangeConfiguration", + "params": { "settings": configuration } + }) + .to_string() +} + +pub(crate) fn apply_indexing_to_sessions( + root: &Path, + sessions: Vec>, +) -> Result<(), CoreError> { + let root_uri = url::Url::from_directory_path(root).ok(); + let canonical_root = root_uri + .as_ref() + .and_then(|uri| canonical_workspace_root(uri.as_str())); + let sessions = sessions + .into_iter() + .filter(|session| { + canonical_workspace_root(&session.root_uri) == canonical_root + && session.active.load(Ordering::Acquire) + }) + .collect::>(); + if sessions.is_empty() { + return Ok(()); + } + let prepared = lithe_project::indexing::prepare_provider_exclusions(root)?; + let roots = lithe_project::indexing::excluded_roots(root); + let revision = prepared.as_ref().map_or(0, |policy| policy.revision); + if let Some(policy) = &prepared { + policy.check()?; + } + for session in sessions { + if let Some(policy) = &prepared { + policy.check()?; + } + if ensure_not_terminal(session.lock_state()?.lifecycle).is_err() { + continue; + } + if canonical_workspace_root(&session.root_uri) != canonical_root { + continue; + } + let mut previous = session.lock_indexing_roots()?; + if *previous == roots && session.indexing_revision.load(Ordering::Acquire) == revision { + continue; + } + let documents = session + .lock_state()? + .client + .open_documents + .keys() + .cloned() + .collect::>(); + for uri in documents { + if lithe_project::indexing::uri_is_excluded(&session.root_uri, &uri)? { + session.close_document(&uri)?; + } + } + let diagnostic_uris = session + .lock_state()? + .client + .diagnostics + .keys() + .cloned() + .collect::>(); + for uri in diagnostic_uris { + if lithe_project::indexing::uri_is_excluded(&session.root_uri, &uri)? { + let mut state = session.lock_state()?; + state.client.diagnostics.remove(&uri); + state.client.diagnostic_versions.remove(&uri); + push_diagnostics_event(&session, &mut state, &uri, None, Vec::new()); + } + } + let pending_uris = session + .lock_state()? + .pending_workspace_file_changes + .keys() + .cloned() + .collect::>(); + for uri in pending_uris { + if lithe_project::indexing::uri_is_excluded(&session.root_uri, &uri)? { + session + .lock_state()? + .pending_workspace_file_changes + .remove(&uri); + } + } + // Exclusions are folded into the configuration answered through + // `workspace/configuration`, so an initialized server is told to pull + // the changed sections again instead of keeping its old import scope. + { + let order = session.lock_outbound_order()?; + if session.lock_state()?.client.initialized { + if let Some(configuration) = session.plugin_configuration() { + session.send_messages_or_fail( + &order, + vec![plugin_configuration_notification(&configuration)], + "indexingChanged", + )?; + } + } + } + *previous = roots.clone(); + session.indexing_revision.store(revision, Ordering::Release); + } + Ok(()) +} + +impl RuntimeSession { + /// Plugin configuration the server currently sees, if any. + pub(crate) fn plugin_configuration(&self) -> Option> { + let base = self + .plugin_configuration + .lock() + .map(|configuration| configuration.clone()) + .unwrap_or_else(|poisoned| poisoned.into_inner().clone()); + crate::scan_settings::configuration(&self.provider_id, &self.root_uri, base.as_deref()) + .map(Arc::new) + } + + pub(crate) fn update_plugin_configuration( + &self, + configuration: Value, + ) -> Result { + if self.plugin_id.is_none() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Only plugin-provided language servers accept plugin configuration.", + )); + } + let configuration = Arc::new(configuration); + let outbound_order = self.lock_outbound_order()?; + let initialized = { + let state = self.lock_state()?; + if matches!( + state.lifecycle, + LspLifecycleState::Stopping + | LspLifecycleState::Stopped + | LspLifecycleState::Failed + ) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The language session is no longer running.", + )); + } + // Under `outbound_order`, an uninitialized client sends the stored + // configuration with its `initialized` notification instead. + state.client.initialized + }; + *self + .plugin_configuration + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(configuration.clone()); + let messages = if initialized { + vec![plugin_configuration_notification( + self.plugin_configuration() + .as_deref() + .unwrap_or(&configuration), + )] + } else { + Vec::new() + }; + self.send_messages_or_fail(&outbound_order, messages, "workspaceConfigurationUpdate")?; + Ok(UpdateWorkspaceConfigurationResponse { sent: initialized }) + } +} diff --git a/rust/crates/lsp-engine/src/documents.rs b/rust/crates/lsp-engine/src/documents.rs new file mode 100644 index 00000000..917cda03 --- /dev/null +++ b/rust/crates/lsp-engine/src/documents.rs @@ -0,0 +1,445 @@ +//! Document synchronization: open, change, save and close, workspace file changes, +//! queued documents and the paths diagnostics are matched against. + +use super::*; + +impl RuntimeSession { + pub(crate) fn save_document(&self, request: SaveDocumentRequest) -> Result { + let outbound_order = self.lock_outbound_order()?; + let message = { + let state = self.lock_state()?; + ensure_not_terminal(state.lifecycle)?; + if state.lifecycle != LspLifecycleState::Ready { + return Ok(false); + } + let Some(include_text) = state.client.text_document_save_include_text else { + return Ok(false); + }; + let Some(document) = state.client.open_documents.get(&request.uri) else { + return Ok(false); + }; + // A server may reload its working copy from the saved text. A + // delayed save must not discard a newer editor revision already + // synchronized. + if *document.text != request.text { + return Ok(false); + } + let mut params = json!({"textDocument": {"uri": request.uri}}); + if include_text { + params["text"] = json!(request.text); + } + json!({"jsonrpc": "2.0", "method": "textDocument/didSave", "params": params}) + .to_string() + }; + self.send_messages_or_fail(&outbound_order, vec![message], "saveDocument")?; + Ok(true) + } + + pub(crate) fn workspace_files_changed( + &self, + changes: Vec, + ) -> Result<(), CoreError> { + if changes.is_empty() { + return Ok(()); + } + let _indexing_order = self.lock_indexing_roots()?; + let mut normalized = BTreeMap::new(); + for change in changes { + if lithe_project::indexing::uri_is_excluded(&self.root_uri, &change.uri)? { + continue; + } + if !change.uri.contains("://") || change.uri.contains('\0') { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Workspace file changes require absolute document URIs.", + ) + .with_details(change.uri)); + } + // The last event for a URI represents its final filesystem state + // and prevents a watcher burst from causing redundant imports. + normalized.insert(change.uri, change.kind); + } + if normalized.is_empty() { + return Ok(()); + } + let outbound_order = self.lock_outbound_order()?; + let message = { + let mut state = self.lock_state()?; + ensure_not_terminal(state.lifecycle)?; + if !state.client.initialized { + state.pending_workspace_file_changes.extend(normalized); + return Ok(()); + } + workspace_file_changes_notification(normalized) + }; + self.send_messages_or_fail(&outbound_order, vec![message], "workspaceFilesChanged") + } + + pub(crate) fn sync_document( + &self, + request: SyncDocumentRequest, + ) -> Result { + self.sync_document_after_policy_check(request, || {}) + } + + pub(crate) fn sync_document_after_policy_check( + &self, + request: SyncDocumentRequest, + after_policy_check: impl FnOnce(), + ) -> Result { + let _indexing_order = self.lock_indexing_roots()?; + if lithe_project::indexing::uri_is_excluded(&self.root_uri, &request.uri)? { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Document is excluded from indexing", + )); + } + after_policy_check(); + let uri = request.uri; + let mut messages = Vec::new(); + let mut stale_cancellations = Vec::new(); + let document_version; + let outbound_order = self.lock_outbound_order()?; + { + let mut state = self.lock_state()?; + ensure_not_terminal(state.lifecycle)?; + if let Some(document) = state.client.open_documents.get(&uri) { + // Hosts commonly publish both a ranged edit and the resulting + // full text. Repeating that full text must not create another + // server reconciliation cycle or advance the semantic version. + if request.content_changes.is_empty() && *document.text == *request.text { + return Ok(SyncDocumentResponse { + document_version: document.version.max(1), + changed: false, + }); + } + } + if state.client.initialized { + 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, + content_changes: request.content_changes, + })? + } else { + let text = unopened_document_text( + state.client.open_documents.get(&uri), + request.text, + &request.content_changes, + )?; + client_open_document(ClientOpenDocumentRequest { + state: state.client.clone(), + uri: uri.clone(), + language_id: request.language_id, + text, + })? + }; + state.client = response.state; + messages = response.messages; + document_version = state + .client + .open_documents + .get(&uri) + .map(|document| document.version) + .unwrap_or_default(); + stale_cancellations = + cancel_stale_document_requests_locked(self, &mut state, &uri, document_version); + } 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. + let text = unopened_document_text( + state.client.open_documents.get(&uri), + request.text, + &request.content_changes, + )?; + 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: text.into(), + }, + ); + // Version zero remains an internal "not opened on the wire" + // sentinel. Consumers receive version one, which is the exact + // version the latest queued text will have after initialize. + document_version = 1; + } + } + messages.extend(stale_cancellations); + self.send_messages_or_fail(&outbound_order, messages, "documentSync")?; + Ok(SyncDocumentResponse { + document_version, + changed: true, + }) + } + + pub(crate) fn close_document(&self, uri: &str) -> Result<(), CoreError> { + let mut messages = Vec::new(); + let cleared; + let outbound_order = self.lock_outbound_order()?; + { + 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(&outbound_order, messages, "documentClose") + } +} + +/// The file a `file:` URI resolves to once symbolic links are followed, or +/// `None` for other schemes and for files that do not exist. +fn resolved_file_of_uri(uri: &str) -> Option { + let path = url::Url::parse(uri).ok()?.to_file_path().ok()?; + lithe_workspace::fs::canonicalize(path).ok() +} + +/// Which open documents the host reached through a symbolic link. +/// +/// Only such a document can be the target of a diagnostics rewrite, so the +/// rest are remembered as examined and never resolved again while open. +#[derive(Default)] +pub(crate) struct LinkedDocumentPaths { + /// Open documents whose file has already been resolved. + pub(crate) examined: BTreeSet, + /// Resolved file of each examined document that differs from its own + /// path, keyed by the document's URI. + pub(crate) resolved: BTreeMap, +} + +impl LinkedDocumentPaths { + /// Drops closed documents and resolves each newly opened one once. + fn refresh(&mut self, open_documents: &BTreeMap) { + self.examined.retain(|uri| open_documents.contains_key(uri)); + self.resolved + .retain(|uri, _| open_documents.contains_key(uri)); + for uri in open_documents.keys() { + if self.examined.contains(uri) { + continue; + } + let Some(resolved) = resolved_file_of_uri(uri) else { + // Not on disk yet (an unsaved document); look again next time. + continue; + }; + self.examined.insert(uri.clone()); + let own = url::Url::parse(uri) + .ok() + .and_then(|url| url.to_file_path().ok()); + if own.as_ref() != Some(&resolved) { + self.resolved.insert(uri.clone(), resolved); + } + } + } +} + +/// Re-addresses published diagnostics to the open document they describe. +/// +/// A server reports a file at its resolved location while the host may have +/// opened it through a symbolic link (a server may answer `/private/tmp/...` +/// for a document opened as `/tmp/...` on macOS). The client reducer matches URIs +/// textually and would drop such diagnostics, so when no open document carries +/// the published URI this returns the message rewritten to the URI of the open +/// document that resolves to the same file. Returns `None` when the message +/// needs no change. +/// +/// Servers publish for many files that are not open, so this runs on a hot +/// path. Only documents opened through a link can be rewrite targets, and +/// `linked` remembers those, which makes the common case free of file-system +/// calls and the linked case a single `canonicalize` of the published file. +pub(crate) fn diagnostics_addressed_to_open_document( + open_documents: &BTreeMap, + linked: &mut LinkedDocumentPaths, + message: &Value, +) -> Option { + let published = message.get("params")?.get("uri")?.as_str()?; + if open_documents.contains_key(published) { + return None; + } + linked.refresh(open_documents); + if linked.resolved.is_empty() { + return None; + } + let target = resolved_file_of_uri(published)?; + let document_uri = linked + .resolved + .iter() + .find(|(_, resolved)| **resolved == target) + .map(|(uri, _)| uri.clone())?; + let mut message = message.clone(); + message["params"]["uri"] = Value::String(document_uri); + Some(message) +} + +fn cancel_stale_document_requests_locked( + session: &RuntimeSession, + state: &mut SessionState, + uri: &str, + document_version: i64, +) -> Vec { + let stale_ids: Vec = state + .pending + .iter() + .filter(|(_, pending)| { + pending.document_uri.as_deref() == Some(uri) + && pending + .document_version + .is_some_and(|version| version < document_version) + && is_stale_sensitive_method(&pending.method) + }) + .map(|(request_id, _)| request_id.clone()) + .collect(); + let mut messages = Vec::new(); + for request_id in stale_ids { + let Some(pending) = state.pending.remove(&request_id) else { + continue; + }; + state.client.pending_requests.remove(&request_id); + state.client.pending_semantic_legends.remove(&request_id); + if let Some(operation_id) = pending.operation_id.as_deref() { + state.request_by_operation.remove(operation_id); + state.function_by_operation.remove(operation_id); + let error = runtime_error( + session, + "staleDocumentVersion", + "request", + "The document changed before the language-server result arrived.", + FailureContext::request(&pending.method, Some(uri)) + .with_underlying(Some("A newer document version superseded this request.")), + ); + push_request_event( + session, + state, + operation_id, + &pending.method, + None, + Some(error), + ); + } + messages.push( + json!({ + "jsonrpc": "2.0", + "method": "$/cancelRequest", + "params": { "id": request_id } + }) + .to_string(), + ); + } + messages +} + +fn is_stale_sensitive_method(method: &str) -> bool { + matches!( + method, + "textDocument/completion" + | "textDocument/hover" + | "textDocument/inlayHint" + | "textDocument/foldingRange" + | "textDocument/codeLens" + | "codeLens/resolve" + | "textDocument/implementation" + ) +} + +/// Resolves the text of a document that has not been opened on the wire yet. +/// +/// Hosts may send only ranged edits, which then extend the text Core retained. +/// Edits for a document Core never received cannot be resolved, so the host +/// resends the full text. +fn unopened_document_text( + stored: Option<&LspClientDocument>, + text: String, + changes: &[LspDocumentContentChange], +) -> Result { + if !text.is_empty() || changes.is_empty() { + return Ok(text); + } + let Some(stored) = stored else { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Cannot apply ranged edits to a document Core has not received.", + ) + .with_details("documentNotSynchronized")); + }; + lithe_text::apply_content_changes_sequential(&stored.text, changes) +} + +pub(crate) fn flush_queued_documents_locked( + state: &mut SessionState, +) -> Result, CoreError> { + 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.to_string(), + })?; + state.client = response.state; + messages.extend(response.messages); + } + if !state.pending_workspace_file_changes.is_empty() { + let changes = std::mem::take(&mut state.pending_workspace_file_changes); + messages.push(workspace_file_changes_notification(changes)); + } + Ok(messages) +} + +fn workspace_file_changes_notification( + changes: BTreeMap, +) -> String { + let changes: Vec<_> = changes + .into_iter() + .map(|(uri, kind)| { + let event_type = match kind { + WorkspaceFileChangeKind::Created => 1, + WorkspaceFileChangeKind::Changed => 2, + WorkspaceFileChangeKind::Deleted => 3, + }; + json!({ "uri": uri, "type": event_type }) + }) + .collect(); + json!({ + "jsonrpc": "2.0", + "method": "workspace/didChangeWatchedFiles", + "params": { "changes": changes } + }) + .to_string() +} diff --git a/rust/crates/lsp-engine/src/engine.rs b/rust/crates/lsp-engine/src/engine.rs new file mode 100644 index 00000000..f6072498 --- /dev/null +++ b/rust/crates/lsp-engine/src/engine.rs @@ -0,0 +1,406 @@ +//! The process-wide session table: start attempts, workspace roots, lookup and destruction. + +use super::*; + +impl LspEngine { + pub(crate) fn new() -> Self { + Self::with_launcher(Arc::new(SystemProcessLauncher)) + } + + pub(crate) fn with_launcher(launcher: Arc) -> Self { + let sessions = Arc::new(Mutex::new(BTreeMap::new())); + Self { + next_session_id: AtomicU64::new(1), + next_operation_id: AtomicU64::new(1), + relay: RelayHub::start(&sessions), + sessions, + failed_starts: Mutex::new(BTreeSet::new()), + launcher, + } + } + + pub(crate) fn next_operation_id(&self) -> String { + format!( + "lsp-operation-{}", + self.next_operation_id.fetch_add(1, Ordering::Relaxed) + ) + } + + pub(crate) fn start_server( + &self, + request: StartServerRequest, + ) -> Result { + let mut registered = None; + let result = self.start_server_owned(request, |id| registered = Some(id.to_owned())); + if result.is_err() { + if let Some(id) = registered { + if self.lock_sessions()?.contains_key(&id) { + self.lock_failed_starts()?.insert(id); + // Preserve the original startup error. Incomplete cleanup + // stays owned; an explicit startup retry reaps it before + // any replacement process can be launched. + // log-exempt: Same failure already recorded: this cleanup follows the startup failure returned below and traced by this request's Core failure; residue stays in failed_starts and the next start reports ProcessFailed. + let _ = self.cleanup_failed_start(); + } + } + } + result + } + + pub(crate) fn lock_failed_starts(&self) -> Result>, CoreError> { + self.failed_starts.lock().map_err(|_| { + CoreError::new( + ErrorCode::Unknown, + "Failed startup cleanup lock was poisoned.", + ) + }) + } + + pub(crate) fn cleanup_failed_start(&self) -> Result<(), CoreError> { + // Serialize cleanup attempts, but never hold the session registry or + // protocol-state lock across process disposal. Each retry owns at most + // one two-second disposal; a backlog must not make one call unbounded. + let mut failed = self.lock_failed_starts()?; + if let Some(id) = failed.first().cloned() { + if self.lock_sessions()?.contains_key(&id) { + self.destroy(&id)?; + } + failed.remove(&id); + } + if !failed.is_empty() { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Previous language-server startup cleanup is still pending. Retry startup to continue cleanup.", + )); + } + Ok(()) + } + + pub(crate) fn start_server_owned( + &self, + request: StartServerRequest, + registered: impl FnOnce(&str), + ) -> Result { + let attempt = startup_attempt(&request); + let result = self.start_server_attempt(request, &attempt, registered); + // A session that already reached a terminal result took the attempt; + // otherwise the process never started. Cancellation is not a result. + match &result { + Err(error) if !matches!(error.code, ErrorCode::Cancelled) => { + plugin_operations::finish(&attempt, Some(AttemptStage::Launch)); + } + Err(_) => plugin_operations::abandon(&attempt), + Ok(_) => {} + } + result + } + + fn start_server_attempt( + &self, + request: StartServerRequest, + attempt: &AttemptSlot, + registered: impl FnOnce(&str), + ) -> Result { + validate_start_request(&request)?; + self.cleanup_failed_start()?; + if lithe_workspace::is_remote() + && request + .session_policy + .as_ref() + .is_some_and(|policy| policy.remote == RemoteSupport::Unsupported) + { + return Err(CoreError::new( + ErrorCode::NotSupported, + "The plugin's language server does not support remote workspaces", + )); + } + // Initial provider settings must include native Git decisions even when + // no session or inventory warm-up has run. Never scan under registry locks. + let indexing_root = canonical_workspace_path(&request.root_uri); + let indexing_context = if let Some(root) = &indexing_root { + lithe_project::indexing::prepare_provider_exclusions(root)? + } else { + None + }; + let check_indexing = || { + if let Some(context) = &indexing_context { + return context.check(); + } + if indexing_root.as_ref().is_some_and(|root| { + lithe_project::indexing::active_provider_context(root).is_some() + }) { + return Err(CoreError::new( + ErrorCode::Cancelled, + "Indexing policy changed", + )); + } + Ok(()) + }; + let session_id = format!( + "lsp-session-{}", + self.next_session_id.fetch_add(1, Ordering::Relaxed) + ); + let workspace_root = PathBuf::from(&request.working_directory); + 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 workspace_configuration = + checked_workspace_configuration(request.workspace_configuration)?; + let initialize = client_initialize_for_plugin( + ClientInitializeRequest { + state: LspClientState { + offered_toolchain_providers: if request.plugin_id.is_some() { + request.toolchain_providers.iter().cloned().collect() + } else { + Default::default() + }, + offered_keymap_import_providers: if request.plugin_id.is_some() { + request.keymap_import_providers.iter().cloned().collect() + } else { + Default::default() + }, + ..LspClientState::default() + }, + root_uri: request.root_uri.clone(), + process_id: (!lithe_workspace::is_remote()).then_some(std::process::id() as i64), + initialization_options: request.initialization_options, + }, + request.plugin_id.is_some(), + )?; + let process = self.launcher.launch(LspProcessSpec { + executable: PathBuf::from(&request.executable_path), + arguments: request.arguments, + working_directory: workspace_root, + environment: request.environment, + })?; + + let request_id = (initialize.state.next_request_id - 1).to_string(); + let now = Instant::now(); + let process_id = process.handle.process_id(); + let plugin_server = request.session_policy.map(PluginServerState::new); + let session = Arc::new(RuntimeSession { + startup_attempt: Arc::clone(attempt), + id: session_id.clone(), + log_identity: SessionLogIdentity::new( + request.role, + &request.provider_id, + request.plugin_id.as_deref(), + request.log_subject.as_deref(), + ), + provider_id: request.provider_id, + plugin_id: request.plugin_id, + workspace_host: lithe_workspace::current(), + plugin_configuration: Mutex::new(workspace_configuration.map(Arc::new)), + root_uri: request.root_uri, + indexing_roots: Mutex::default(), + indexing_revision: std::sync::atomic::AtomicU64::new(0), + outbound_order: Mutex::new(()), + state: Mutex::new(SessionState { + lifecycle: LspLifecycleState::Created, + client: LspClientState { + host_services: request.host_services.into_iter().collect(), + host_options: request.host_options, + ..initialize.state + }, + pending: BTreeMap::from([( + request_id, + PendingRequest { + kind: PendingKind::Initialize, + operation_id: None, + method: "initialize".to_string(), + document_uri: None, + document_version: None, + created_at: now, + deadline: now + initialize_timeout, + }, + )]), + request_by_operation: BTreeMap::new(), + function_by_operation: BTreeMap::new(), + linked_document_paths: LinkedDocumentPaths::default(), + pending_workspace_file_changes: 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, + deadline_cancellations: Vec::new(), + maintenance_write_deadline: None, + plugin_server, + relay_write_deadline: None, + host_requests: BTreeMap::new(), + next_host_request_id: 1, + detailed_diagnostics: false, + requested_diagnostics: false, + rejected_plugin_diagnostics: false, + plugin_diagnostics_window: Instant::now(), + plugin_diagnostics_count: 0, + }), + event_signal: Condvar::new(), + process: process.handle, + active: AtomicBool::new(true), + relay: Arc::clone(&self.relay), + }); + session.transition(LspLifecycleState::Created, None)?; + session.transition(LspLifecycleState::ProcessStarting, None)?; + session.transition(LspLifecycleState::Initializing, None)?; + session.log( + "info", + EngineLog::ProcessStarted, + "Language server process started", + None, + ); + + // Register before checking the prepared generation. A policy change + // either retires that generation or sees this session for its update. + self.lock_sessions()? + .insert(session_id.clone(), session.clone()); + if let Err(error) = check_indexing() { + // A retired start is cancelled, not a handshake failure. Abandon + // the attempt before `fail` or the process exit can record it. + plugin_operations::abandon(attempt); + session.fail( + "indexingChanged", + "initialize", + "Indexing policy changed during language-server startup.", + Some(core_error_detail(&error)), + None, + ); + session.active.store(false, Ordering::Release); + session.kill_process(); + // No native identity has been published yet. Forget the session + // only after disposal proves that its process tree has exited. + if session.process.dispose(Duration::from_secs(2)).is_ok() { + self.lock_sessions()?.remove(&session_id); + } else { + registered(&session_id); + } + return Err(error); + } + // Native owners learn the identity before initialize writes can fail. + // They keep their package barrier until destroy verifies disposal. + registered(&session_id); + session.spawn_readers(process.output, process.errors); + session.spawn_monitor(); + session.spawn_outbound_maintenance(); + let outbound_order = session.lock_outbound_order()?; + if let Err(error) = session.send_messages(&outbound_order, initialize.messages) { + // startServer fails here, so the attempt failed to launch. + plugin_operations::finish(attempt, Some(AttemptStage::Launch)); + session.fail( + "transportFailed", + "initialize", + "Could not write the initialize request.", + Some(core_error_detail(&error)), + None, + ); + session.kill_process(); + return Err(error); + } + + if let Err(error) = check_indexing() { + // The readers already run; abandon before `fail` and the kill so + // neither the failure nor the observed exit records the cancel. + plugin_operations::abandon(attempt); + session.fail( + "indexingChanged", + "initialize", + "Indexing policy changed during language-server startup.", + Some(core_error_detail(&error)), + None, + ); + session.active.store(false, Ordering::Release); + session.kill_process(); + // The native owner already holds this identity. Keep it until + // destroy verifies disposal and returns a positive receipt. + return Err(error); + } + + Ok(StartServerResponse { + channel: session.log_identity.channel(), + session_id, + state: LspLifecycleState::Initializing, + process_id, + }) + } + + pub(crate) fn session(&self, session_id: &str) -> Result, CoreError> { + self.lock_sessions()? + .get(session_id) + .cloned() + .ok_or_else(|| unknown_session(session_id)) + } + + pub(crate) 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.", + )); + } + session.process.dispose(Duration::from_secs(2))?; + self.lock_sessions()?.remove(session_id); + Ok(()) + } + + pub(crate) fn lock_sessions( + &self, + ) -> Result>>, CoreError> { + self.sessions.lock().map_err(|_| { + CoreError::new( + ErrorCode::Unknown, + "Language-server session registry lock was poisoned.", + ) + }) + } +} + +/// The workspace directory with symbolic links resolved, when it exists. +/// +/// Resolving links touches the file system, so it happens here in the engine +/// rather than in the pure normalizer that receives the result. +fn canonical_workspace_path(root_uri: &str) -> Option { + let path = url::Url::parse(root_uri).ok()?.to_file_path().ok()?; + // Core policy identity uses the native canonical path, including Windows' + // verbatim prefix. A protocol/display path is not a policy registry key. + lithe_workspace::fs::canonicalize(path).ok() +} + +pub(crate) fn canonical_workspace_root(root_uri: &str) -> Option { + let canonical = canonical_workspace_path(root_uri)?; + let canonical = canonical.to_string_lossy().replace('\\', "/"); + // Windows canonical paths carry a verbatim `\\?\` prefix that language servers never report. + Some( + canonical + .strip_prefix("//?/UNC/") + .map(|rest| format!("//{rest}")) + .or_else(|| canonical.strip_prefix("//?/").map(str::to_string)) + .unwrap_or(canonical), + ) +} + +// Only a declarative language server carries a host attempt. SDK backends report +// their own operations through `lithe/log`, so a stray handoff on another role is +// ignored rather than rejecting the start. The handoff names its plugin, since a +// plugin API 1 server starts with no session plugin; a session another plugin +// owns never takes the attempt, so no fact lands on a plugin that did not run. +fn startup_attempt(request: &StartServerRequest) -> AttemptSlot { + match &request.startup_operation { + Some(handoff) + if request.role == SessionRole::LanguageServer + && request + .plugin_id + .as_ref() + .is_none_or(|owner| *owner == handoff.plugin_id) => + { + Attempt::slot(&LANGUAGE_SERVER_START, handoff.clone()) + } + _ => Attempt::none(), + } +} diff --git a/rust/crates/lsp-engine/src/events.rs b/rust/crates/lsp-engine/src/events.rs new file mode 100644 index 00000000..2784e231 --- /dev/null +++ b/rust/crates/lsp-engine/src/events.rs @@ -0,0 +1,568 @@ +//! The session event queue: request completions, diagnostics, features, server info, +//! logs, failures and state clearing. + +use super::*; + +pub(crate) fn enqueue_runtime_event( + session: &RuntimeSession, + state: &mut SessionState, + mut event: LspRuntimeEvent, +) { + if event.event.is_some() { + let (id, fields) = ( + event.event.take().expect("source catalog identity"), + event.fields.take().expect("source catalog fields"), + ); + let level = if id == "log.dropped" { + Some(lithe_diagnostics::record::LogLevel::Warn) + } else { + lithe_diagnostics::catalog::event_by_id(&id).map(|entry| entry.level) + }; + // In-memory v1 detail is retained for old hosts. The v2 fields always pass + // the same normalization and catalog routing boundary as persisted facts. + // A fact that fails that boundary keeps only its v1 fields; the runtime + // event itself is never dropped and Core never panics on source data. + if let Some(level) = level { + let mut fact = lithe_diagnostics::record::StructuredLogEvent { + ts: String::new(), + level, + seq: 0, + event: id.clone(), + op: None, + msg: String::new(), + fields, + }; + if let Ok(channel) = lithe_diagnostics::record::prepare_catalog_event( + &mut fact, + &json!({"channel": session.log_identity.channel()}), + ) { + event.channel = Some(channel); + event.plugin_id = session.plugin_id.clone(); + event.role = Some(session.log_identity.role); + event.event = Some(id); + event.fields = Some(fact.fields); + event.catalog_level = Some(fact.level); + } + } + } + state.events.push_back(event); + session.event_signal.notify_all(); +} + +pub(crate) fn push_request_event( + session: &RuntimeSession, + state: &mut SessionState, + operation_id: &str, + method: &str, + result: Option, + error: Option, +) { + if state + .plugin_server + .as_ref() + .is_some_and(|server| server.owns(operation_id)) + { + complete_plugin_server_request(session, state, operation_id, method, result, error); + return; + } + // No host operation waits for a relayed call: the relay answers the + // calling plugin, so the event never reaches the host. + if relay_hub::is_relayed_operation(operation_id, method) { + session.relay.callee_finished( + &session.id, + operation_id, + relay_hub::callee_outcome(result, error), + ); + return; + } + enqueue_request_completed(session, state, operation_id, method, result, error); +} + +pub(crate) fn enqueue_request_completed( + session: &RuntimeSession, + state: &mut SessionState, + operation_id: &str, + method: &str, + result: Option, + error: Option, +) { + let sequence = take_sequence(state); + enqueue_runtime_event( + session, + state, + LspRuntimeEvent { + kind: "requestCompleted".to_string(), + channel: None, + plugin_id: None, + role: None, + event: None, + fields: None, + catalog_level: None, + 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, + completion_options: None, + server_info: None, + level: None, + message: None, + detail: None, + }, + ); +} + +pub(crate) fn push_diagnostics_event( + session: &RuntimeSession, + state: &mut SessionState, + uri: &str, + version: Option, + diagnostics: Vec, +) { + let sequence = take_sequence(state); + enqueue_runtime_event( + session, + state, + LspRuntimeEvent { + kind: "diagnostics".to_string(), + channel: None, + plugin_id: None, + role: None, + event: None, + fields: None, + catalog_level: None, + 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, + completion_options: None, + server_info: None, + level: None, + message: None, + detail: None, + }, + ); +} + +pub(crate) fn push_semantic_tokens_refresh_event( + session: &RuntimeSession, + state: &mut SessionState, +) { + let sequence = take_sequence(state); + enqueue_runtime_event( + session, + state, + LspRuntimeEvent { + kind: "semanticTokensRefresh".to_string(), + channel: None, + plugin_id: None, + role: None, + event: None, + fields: None, + catalog_level: None, + 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, + completion_options: None, + server_info: None, + level: None, + message: None, + detail: None, + }, + ); +} + +pub(crate) fn push_features_event( + session: &RuntimeSession, + state: &mut SessionState, + capabilities: Vec, +) { + let completion_options = lithe_lsp_client::completion_options(&state.client); + let sequence = take_sequence(state); + enqueue_runtime_event( + session, + state, + LspRuntimeEvent { + kind: "featuresChanged".to_string(), + channel: None, + plugin_id: None, + role: None, + event: None, + fields: None, + catalog_level: None, + 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), + completion_options, + server_info: None, + level: None, + message: None, + detail: None, + }, + ); +} + +pub(crate) fn push_server_info_event( + session: &RuntimeSession, + state: &mut SessionState, + info: LspServerInfo, +) { + let sequence = take_sequence(state); + enqueue_runtime_event( + session, + state, + LspRuntimeEvent { + kind: "serverInfoChanged".to_string(), + channel: None, + plugin_id: None, + role: None, + event: None, + fields: None, + catalog_level: None, + 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, + completion_options: None, + server_info: Some(info), + level: None, + message: None, + detail: None, + }, + ); +} + +/// Accepts only negotiated scalar plugin facts. Persistence and overload accounting +/// reuse the bounded Core handoff instead of copying protocol params into v1 detail. +pub(crate) fn receive_plugin_diagnostic( + session: &RuntimeSession, + state: &mut SessionState, + params: Option<&Value>, +) { + let Some(plugin) = session.plugin_id.as_deref() else { + return; + }; + let plugin_identity = SessionLogIdentity::new( + SessionRole::Backend, + &session.provider_id, + Some(plugin), + None, + ); + let plugin = plugin_identity.segment(); + let valid = state + .client + .server_capabilities + .iter() + .any(|name| name == lithe_lsp_client::plugin_logging::FEATURE) + .then(|| { + lithe_lsp_client::plugin_logging::decode( + plugin, + &state.client.plugin_functions, + params.unwrap_or(&Value::Null), + ) + }) + .flatten(); + let Some(fact) = valid else { + if !state.rejected_plugin_diagnostics { + state.rejected_plugin_diagnostics = true; + if lithe_diagnostics::core_events::record( + "plugins.diagnostics.rejected", + BTreeMap::from([ + ("plugin".into(), json!(plugin)), + ("code".into(), json!("invalid_diagnostic")), + ]), + None, + ) + .is_err() + { + lithe_diagnostics::core_events::note_dropped(); + } + } + return; + }; + if state.plugin_diagnostics_window.elapsed() >= Duration::from_secs(60) { + state.plugin_diagnostics_window = Instant::now(); + state.plugin_diagnostics_count = 0; + } + if state.plugin_diagnostics_count >= MAX_PLUGIN_DIAGNOSTICS_PER_MINUTE { + lithe_diagnostics::core_events::note_dropped(); + return; + } + state.plugin_diagnostics_count += 1; + // SDK contexts carry wire request IDs, not reusable UI operation IDs. Only + // a live request owned by this session may extend the host's correlation. + let operation = fact.wire_operation.as_deref().and_then(|wire| { + state + .pending + .get(wire) + .and_then(|pending| pending.operation_id.clone()) + }); + if lithe_diagnostics::core_events::record(fact.event, fact.fields, operation).is_err() { + lithe_diagnostics::core_events::note_dropped(); + } +} + +pub(crate) fn push_log_event( + session: &RuntimeSession, + state: &mut SessionState, + level: &str, + identity: impl Into, + message: &str, + detail: Option, +) { + push_classified_log_event(session, state, level, identity, message, detail, None); +} + +pub(crate) fn push_classified_log_event( + session: &RuntimeSession, + state: &mut SessionState, + level: &str, + identity: impl Into, + message: &str, + detail: Option, + error: Option, +) { + let input = identity.into(); + let (event_id, fields) = + input + .identity + .fact(&session.log_identity, &input.fields, error.as_ref()); + let sequence = take_sequence(state); + enqueue_runtime_event( + session, + state, + LspRuntimeEvent { + kind: "log".to_string(), + channel: None, + plugin_id: None, + role: None, + event: Some(event_id), + fields: Some(fields), + catalog_level: None, + 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, + capabilities: None, + completion_options: None, + server_info: None, + level: Some(level.to_string()), + message: Some(message.to_string()), + detail: detail.filter(|value| !value.is_empty()), + }, + ); +} + +pub(crate) fn take_sequence(state: &mut SessionState) -> u64 { + let sequence = state.next_sequence; + state.next_sequence += 1; + sequence +} + +/// What a runtime failure concerns beyond its code, stage and message. The +/// default is a failure of the session itself, outside any request. +#[derive(Clone, Copy, Default)] +pub(crate) struct FailureContext<'a> { + method: Option<&'a str>, + document_uri: Option<&'a str>, + underlying: Option<&'a str>, + process_exit_code: Option, +} + +impl<'a> FailureContext<'a> { + /// A failure of one request, and of the document it names, if any. + pub(crate) fn request(method: &'a str, document_uri: Option<&'a str>) -> Self { + Self { + method: Some(method), + document_uri, + ..Self::default() + } + } + + /// The lower-level message behind the failure. + pub(crate) fn with_underlying(self, underlying: Option<&'a str>) -> Self { + Self { underlying, ..self } + } + + /// The exit code of the process whose end caused the failure. + pub(crate) fn with_exit_code(self, process_exit_code: Option) -> Self { + Self { + process_exit_code, + ..self + } + } +} + +pub(crate) fn runtime_error( + session: &RuntimeSession, + code: &str, + stage: &str, + message: &str, + context: FailureContext<'_>, +) -> LspRuntimeError { + LspRuntimeError { + code: code.to_string(), + provider_id: session.provider_id.clone(), + session_id: session.id.clone(), + stage: stage.to_string(), + method: context.method.map(ToString::to_string), + document_uri: context.document_uri.map(ToString::to_string), + message: message.to_string(), + underlying_message: context.underlying.map(ToString::to_string), + process_exit_code: context.process_exit_code, + plugin_error_code: None, + details: None, + } +} + +pub(crate) 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)| { + matches!( + pending.kind, + PendingKind::Feature | PendingKind::PluginServer + ) + }) + .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); + state.client.pending_semantic_legends.remove(&request_id); + if let Some(operation_id) = pending.operation_id.as_deref() { + state.request_by_operation.remove(operation_id); + state.function_by_operation.remove(operation_id); + let error = runtime_error( + session, + code, + stage, + message, + FailureContext::request(&pending.method, pending.document_uri.as_deref()) + .with_exit_code(exit_code), + ); + push_request_event( + session, + state, + operation_id, + &pending.method, + None, + Some(error), + ); + } + } + // Requests held behind a gate have no pending entry, so they end here. + let held = state + .plugin_server + .as_ref() + .map(|server| server.all_held()) + .unwrap_or_default(); + for waiter in held { + let error = runtime_error( + session, + code, + stage, + message, + FailureContext::request(&waiter.method, None).with_exit_code(exit_code), + ); + push_request_event( + session, + state, + &waiter.operation_id, + &waiter.method, + None, + Some(error), + ); + } +} + +pub(crate) 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()); + } +} + +pub(crate) fn clear_runtime_state(session: &RuntimeSession, state: &mut SessionState) { + clear_runtime_diagnostics(session, state); + state.client.initialized = false; + state.client.text_document_save_include_text = None; + state.client.shutdown_requested = false; + state.client.server_capabilities.clear(); + state.client.completion_providers.clear(); + state.client.open_documents.clear(); + state.client.pending_requests.clear(); + state.pending.clear(); + state.request_by_operation.clear(); + state.function_by_operation.clear(); + clear_function_registry(session, state); + state.client.vcs_provider = None; + state.client.toolchain_providers.clear(); + state.client.keymap_import_providers.clear(); + state.linked_document_paths = LinkedDocumentPaths::default(); + state.deadline_cancellations.clear(); + state.maintenance_write_deadline = None; + state.relay_write_deadline = None; + state.pending_workspace_file_changes.clear(); + state.initialize_deadline = None; + state.shutdown_deadline = None; + push_features_event(session, state, Vec::new()); +} diff --git a/rust/crates/lsp-engine/src/function_calls.rs b/rust/crates/lsp-engine/src/function_calls.rs new file mode 100644 index 00000000..28971d3f --- /dev/null +++ b/rust/crates/lsp-engine/src/function_calls.rs @@ -0,0 +1,205 @@ +//! Plugin function calls: pending call lookup, progress, the published function registry +//! and revocation. + +use super::*; + +/// Resolves a function mapping only while both pending tables still own its exact request. +fn pending_function_request<'a>( + state: &'a SessionState, + operation_id: &str, + call: &PendingFunctionCall, +) -> Option<&'a PendingRequest> { + if state.request_by_operation.get(operation_id) != Some(&call.request_id) + || state + .client + .pending_requests + .get(&call.request_id) + .map(String::as_str) + != Some(lithe_plugin_rpc::INVOKE_METHOD) + { + return None; + } + state.pending.get(&call.request_id).filter(|pending| { + pending.kind == PendingKind::Feature + && pending.method == lithe_plugin_rpc::INVOKE_METHOD + && pending.operation_id.as_deref() == Some(operation_id) + }) +} + +/// Ends every running call of the withdrawn functions with +/// `plugin.function.revoked` and returns the `$/cancelRequest` messages +/// telling the backend to stop. A reply that still arrives is late and is +/// ignored like any reply to a request that is no longer pending. +pub(crate) fn revoke_function_calls_locked( + session: &RuntimeSession, + state: &mut SessionState, + removed: &[String], +) -> Vec { + let revoked: Vec = state + .function_by_operation + .iter() + .filter(|(operation, call)| { + removed.contains(&call.function_id) + && pending_function_request(state, operation, call).is_some() + }) + .map(|(operation, _)| operation.clone()) + .collect(); + let mut cancellations = Vec::new(); + for operation_id in revoked { + state.function_by_operation.remove(&operation_id); + let Some(request_id) = state.request_by_operation.remove(&operation_id) else { + continue; + }; + state.pending.remove(&request_id); + state.client.pending_requests.remove(&request_id); + let failure = lithe_plugin_rpc::FunctionFailure { + code: lithe_plugin_rpc::codes::REVOKED, + message: "The plugin withdrew this function while it was running.".to_string(), + plugin_error_code: None, + }; + let error = function_runtime_error(session, failure); + push_request_event( + session, + state, + &operation_id, + lithe_plugin_rpc::INVOKE_METHOD, + None, + Some(error), + ); + cancellations.push( + json!({ + "jsonrpc": "2.0", + "method": "$/cancelRequest", + "params": { "id": request_id } + }) + .to_string(), + ); + } + cancellations +} + +/// Publishes a progress report of a call that is still running. Reports for +/// an unknown, finished, cancelled, or revoked operation are dropped, so a +/// late report never revives an ended call in the host. +pub(crate) fn push_function_progress_locked( + session: &RuntimeSession, + state: &mut SessionState, + progress: Option, +) { + let Some(mut progress) = progress else { + return; + }; + let Some(wire_operation_id) = progress.get("operationID").and_then(Value::as_str) else { + return; + }; + let Some(operation_id) = state + .function_by_operation + .iter() + .find_map(|(operation, call)| { + (call.request_id == wire_operation_id + && pending_function_request(state, operation, call).is_some()) + .then(|| operation.clone()) + }) + else { + return; + }; + progress["operationID"] = json!(operation_id); + let sequence = take_sequence(state); + enqueue_runtime_event( + session, + state, + LspRuntimeEvent { + kind: "requestProgress".to_string(), + channel: None, + plugin_id: None, + role: None, + event: None, + fields: None, + catalog_level: None, + sequence, + provider_id: session.provider_id.clone(), + session_id: session.id.clone(), + state: None, + operation_id: Some(operation_id), + method: Some(lithe_plugin_rpc::INVOKE_METHOD.to_string()), + uri: None, + version: None, + diagnostics: None, + result: Some(progress), + error: None, + capabilities: None, + completion_options: None, + server_info: None, + level: None, + message: None, + detail: None, + }, + ); +} + +pub(crate) fn push_functions_event( + session: &RuntimeSession, + state: &mut SessionState, + result: Value, +) { + let sequence = take_sequence(state); + enqueue_runtime_event( + session, + state, + LspRuntimeEvent { + kind: "functionsChanged".to_string(), + channel: None, + plugin_id: None, + role: None, + event: None, + fields: None, + catalog_level: None, + 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: Some(result), + error: None, + capabilities: None, + completion_options: None, + server_info: None, + level: None, + message: None, + detail: None, + }, + ); +} + +pub(crate) fn function_runtime_error( + session: &RuntimeSession, + failure: lithe_plugin_rpc::FunctionFailure, +) -> LspRuntimeError { + let mut error = runtime_error( + session, + failure.code, + "request", + &failure.message, + FailureContext::request(lithe_plugin_rpc::INVOKE_METHOD, None), + ); + error.plugin_error_code = failure.plugin_error_code; + error +} + +/// Withdraws the session registry when shutdown or process failure ends its lifetime. +pub(crate) fn clear_function_registry(session: &RuntimeSession, state: &mut SessionState) { + let removed: Vec<_> = std::mem::take(&mut state.client.plugin_functions) + .into_iter() + .collect(); + if !removed.is_empty() { + push_functions_event( + session, + state, + json!({ "functions": [], "removed": removed }), + ); + } +} diff --git a/rust/crates/core/src/lsp/interface/engine_function_provider.rs b/rust/crates/lsp-engine/src/function_provider.rs similarity index 97% rename from rust/crates/core/src/lsp/interface/engine_function_provider.rs rename to rust/crates/lsp-engine/src/function_provider.rs index 0d36d1c4..ffa6ff09 100644 --- a/rust/crates/core/src/lsp/interface/engine_function_provider.rs +++ b/rust/crates/lsp-engine/src/function_provider.rs @@ -3,7 +3,7 @@ //! locally owned clients, and installed versions, may receive the result. use super::engine; -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::Deserialize; use serde_json::{json, Value}; use std::collections::BTreeMap; @@ -53,7 +53,7 @@ fn invalid() -> CoreError { } fn root_identity(uri: &str) -> Result { - if uri.len() > crate::lsp::MAX_PATH_BYTES || uri.contains('\0') { + if uri.len() > lithe_document_uri::MAX_PATH_BYTES || uri.contains('\0') { return Err(invalid()); } let parsed = Url::parse(uri).map_err(|_| invalid())?; diff --git a/rust/crates/core/src/lsp/interface/engine_function_relay_tests.rs b/rust/crates/lsp-engine/src/function_relay_tests.rs similarity index 99% rename from rust/crates/core/src/lsp/interface/engine_function_relay_tests.rs rename to rust/crates/lsp-engine/src/function_relay_tests.rs index 18863713..9330d4c3 100644 --- a/rust/crates/core/src/lsp/interface/engine_function_relay_tests.rs +++ b/rust/crates/lsp-engine/src/function_relay_tests.rs @@ -12,12 +12,12 @@ //! bounded readers or on a bounded poll of the session events, and time-based //! outcomes use synthetic instants passed to `expire_deadlines_at`. -use super::super::function_relay::RelaySnapshot; -use super::super::plugin_function::FunctionReference; -use super::super::process::{LspProcessLauncher, LspProcessSpec, LspProcessStreams}; -use super::super::scripted::ScriptedServer; use super::tests::start_request; use super::*; +use lithe_lsp_transport::process::{LspProcessLauncher, LspProcessSpec, LspProcessStreams}; +use lithe_lsp_transport::scripted::ScriptedServer; +use lithe_plugin_function_relay::RelaySnapshot; +use lithe_plugin_rpc::FunctionReference; const PYTHON: &str = "dev.lithe.fixture.python"; const GO: &str = "dev.lithe.fixture.go"; diff --git a/rust/crates/lsp-engine/src/host_requests.rs b/rust/crates/lsp-engine/src/host_requests.rs new file mode 100644 index 00000000..7cf7d5e8 --- /dev/null +++ b/rust/crates/lsp-engine/src/host_requests.rs @@ -0,0 +1,139 @@ +//! Requests a plugin backend sends to the host: forwarding and cancellation. + +use super::*; + +/// Handles a backend's `$/cancelRequest` for one of its own pending host +/// requests: the backend gets `plugin.host.cancelled` at once, so the +/// pending slot frees without waiting for the host, and the host gets a +/// `hostRequestCancelled` event naming the `hostRequestID` so it can stop +/// the work. A later answer from the host for that ID is refused like any +/// unknown ID. Returns false when the notification names no pending host +/// request, so it is handled like any other notification. +pub(crate) fn cancel_host_request_locked( + session: &RuntimeSession, + state: &mut SessionState, + params: Option<&Value>, + outbound: &mut Vec, +) -> Result { + if state.client.plugin_function_version.is_none() { + return Ok(false); + } + let Some(backend_id) = params.and_then(|params| params.get("id")) else { + return Ok(false); + }; + let Some(host_request_id) = state + .host_requests + .iter() + .find(|(_, pending)| *pending == backend_id) + .map(|(host_request_id, _)| host_request_id.clone()) + else { + // Not a host request: it may name a call the backend relayed to + // another plugin. + return Ok(session.relay.cancel_from_caller(&session.id, backend_id)); + }; + let backend_id = state + .host_requests + .remove(&host_request_id) + .unwrap_or(Value::Null); + outbound.push(json_rpc_error_with_code( + &backend_id, + -32800, + "The plugin process cancelled this host-service request.", + lithe_plugin_rpc::codes::HOST_CANCELLED, + )?); + let sequence = take_sequence(state); + enqueue_runtime_event( + session, + state, + LspRuntimeEvent { + kind: "hostRequestCancelled".to_string(), + channel: None, + plugin_id: None, + role: None, + event: None, + fields: None, + catalog_level: None, + 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: Some(json!({ "hostRequestID": host_request_id })), + error: None, + capabilities: None, + completion_options: None, + server_info: None, + level: None, + message: None, + detail: None, + }, + ); + Ok(true) +} + +/// Publishes the session's registered functions after a registry change. +/// Publishes a backend's well-formed host-service request as `hostRequest`, +/// or answers it at once when the session already has the most requests the +/// host may owe it. Returns the messages to send to the backend. +pub(crate) fn forward_host_request_locked( + session: &RuntimeSession, + state: &mut SessionState, + method: String, + request: Value, +) -> Result, CoreError> { + let backend_id = request.get("id").cloned().unwrap_or(Value::Null); + if state.host_requests.len() >= lithe_plugin_rpc::MAX_PENDING_HOST_REQUESTS { + let code = lithe_plugin_rpc::codes::HOST_LIMIT_EXCEEDED; + return Ok(vec![json_rpc_error_with_code( + &backend_id, + lithe_plugin_rpc::host_rpc_code(code), + "Too many host-service requests are waiting for the host.", + code, + )?]); + } + let host_request_id = format!("host-{}", state.next_host_request_id); + state.next_host_request_id += 1; + state + .host_requests + .insert(host_request_id.clone(), backend_id); + let sequence = take_sequence(state); + enqueue_runtime_event( + session, + state, + LspRuntimeEvent { + kind: "hostRequest".to_string(), + channel: None, + plugin_id: None, + role: None, + event: None, + fields: None, + catalog_level: None, + sequence, + provider_id: session.provider_id.clone(), + session_id: session.id.clone(), + state: None, + operation_id: None, + method: Some(method), + uri: None, + version: None, + diagnostics: None, + result: Some(json!({ + "hostRequestID": host_request_id, + "service": request.get("service").cloned().unwrap_or(Value::Null), + "params": request.get("params").cloned().unwrap_or(Value::Null), + })), + error: None, + capabilities: None, + completion_options: None, + server_info: None, + level: None, + message: None, + detail: None, + }, + ); + Ok(Vec::new()) +} diff --git a/rust/crates/lsp-engine/src/lib.rs b/rust/crates/lsp-engine/src/lib.rs new file mode 100644 index 00000000..b5b7f3f8 --- /dev/null +++ b/rust/crates/lsp-engine/src/lib.rs @@ -0,0 +1,349 @@ +//! Stateful language-server sessions coordinating client state and child processes. + +use lithe_lsp_client::{ + client_apply_server_message_for_plugin, client_initialize_for_plugin, json_rpc_error_with_code, + json_rpc_error_with_data, json_rpc_notification, json_rpc_result, +}; +mod logging; +mod scan_settings; + +use lithe_diagnostics::plugin_operations::{ + self as plugin_operations, Attempt, AttemptHandoff, AttemptSlot, Stage as AttemptStage, + LANGUAGE_SERVER_START, +}; +use lithe_lsp_client::{ + client_change_document, client_close_document, client_feature_request, client_open_document, + client_shutdown, ClientChangeDocumentRequest, ClientCloseDocumentRequest, ClientFeatureRequest, + ClientInitializeRequest, ClientOpenDocumentRequest, ClientShutdownRequest, LspClientDiagnostic, + LspClientDocument, LspClientState, LspDocumentContentChange, LspPosition, LspRange, +}; +use lithe_lsp_transport::framing::{ + frame_message, parse_server_messages, FrameMessageRequest, ParseServerMessagesRequest, +}; +use lithe_lsp_transport::process::{ + LspProcessHandle, LspProcessLauncher, LspProcessSpec, SystemProcessLauncher, +}; +use lithe_plugin_language_servers::{ + RemoteSupport, DEFAULT_REQUEST_TIMEOUT_MILLISECONDS, DID_CHANGE_CONFIGURATION, + MAX_REQUEST_TIMEOUT_MILLISECONDS, +}; +use lithe_plugin_server_policy::{self as plugin_server, Admission, Arrival, PluginServerState}; +use lithe_protocol::{CoreError, ErrorCode}; +pub use logging::{resolve_engine_log_subject, ResolveEngineLogSubjectRequest}; +use logging::{ + EngineLog, EngineLogInput, SessionLogIdentity, SessionRole, StderrFact, StderrLines, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Condvar, Mutex, MutexGuard, OnceLock}; +use std::thread; +use std::time::{Duration, Instant}; + +// Cold language-server runtimes can spend more than ten seconds loading their +// bundles before answering initialize. This remains one bounded handshake, +// independent of request deadlines, with normal cancellation and disposal. +const DEFAULT_INITIALIZE_TIMEOUT_MS: u64 = 60_000; +const DEFAULT_REQUEST_TIMEOUT_MS: u64 = 30_000; +const DEFAULT_SHUTDOWN_TIMEOUT_MS: u64 = 2_000; +/// Upper bound of one build-provider request. A model request can load a +/// large build, so it may outlive the ordinary request timeout, but never +/// stays pending without a deadline. +const MAX_BUILD_PROVIDER_TIMEOUT_MS: u64 = 60 * 60_000; +/// Upper bound of one VCS-provider request. An update or check-in of a large +/// working copy over a slow link can take long, but never stays pending +/// without a deadline. +const MAX_VCS_PROVIDER_TIMEOUT_MS: u64 = 60 * 60_000; +/// Longest toolchain query a host may ask for; queries only read manager state. +const MAX_TOOLCHAIN_QUERY_TIMEOUT_MS: u64 = 60_000; +/// Bounds one provider request; the host can cancel sooner when a snapshot expires. +const MAX_KEYMAP_IMPORT_TIMEOUT_MS: u64 = 60_000; +const MONITOR_INTERVAL_MS: u64 = 10; +/// Shared plugin diagnostic budget per process session and monotonic minute. +const MAX_PLUGIN_DIAGNOSTICS_PER_MINUTE: u16 = 600; + +static ENGINE: OnceLock = OnceLock::new(); + +mod relay_hub; +use relay_hub::RelayHub; +mod function_provider; +pub use function_provider::function_provider_command; + +mod configuration; +mod documents; +mod engine; +mod events; +mod function_calls; +mod host_requests; +mod lifecycle; +mod operations; +mod plugin_server_calls; +mod provider_events; +mod requests; +mod server_messages; +mod state; +mod validation; + +use configuration::*; +use documents::*; +use engine::*; +use events::*; +use function_calls::*; +use host_requests::*; +use lifecycle::*; +use operations::*; +use plugin_server_calls::*; +use provider_events::*; +pub use requests::*; +use state::*; +use validation::*; + +/// Applies a plugin setting with `effect: immediate` without a restart. +/// +/// The stored configuration answers later `workspace/configuration` requests; +/// an initialized server is also told through `workspace/didChangeConfiguration` +/// so servers that pull configuration only on that signal refresh too. +pub fn update_workspace_configuration( + request: UpdateWorkspaceConfigurationRequest, +) -> Result { + let configuration = + checked_workspace_configuration(Some(request.configuration))?.unwrap_or_else(|| json!({})); + engine() + .session(&request.session_id)? + .update_plugin_configuration(configuration) +} + +/// Withdraw excluded working copies and update supported provider import configuration. +pub fn indexing_changed(root: &Path) -> Result<(), CoreError> { + let sessions = engine() + .sessions + .lock() + .map_err(|_| CoreError::new(ErrorCode::Unknown, "LSP registry lock was poisoned"))? + .values() + .cloned() + .collect::>(); + apply_indexing_to_sessions(root, sessions) +} + +/// Starts a managed language-server process and begins LSP initialization. +pub fn start_server(request: StartServerRequest) -> Result { + engine().start_server(request) +} + +/// Native host ownership hook; it does not add an application command. +pub fn start_server_owned( + request: StartServerRequest, + registered: impl FnOnce(&str), +) -> Result { + engine().start_server_owned(request, registered) +} + +/// Performs a bounded graceful shutdown while retaining the session record. +pub fn stop_server(request: SessionRequest) -> Result<(), CoreError> { + engine().session(&request.session_id)?.stop() +} + +/// Opens or replaces the synchronized contents of one document. +pub fn sync_document(request: SyncDocumentRequest) -> Result { + engine() + .session(&request.session_id)? + .sync_document(request) +} + +/// Notifies an interested server after a successful native save. Returns false +/// for unopened documents, unsupported saves, or a save superseded by new edits. +pub fn save_document(request: SaveDocumentRequest) -> Result { + engine() + .session(&request.session_id)? + .save_document(request) +} + +/// Forwards external source and build-configuration changes without restarting +/// the server or invalidating its durable workspace state. +pub fn workspace_files_changed(request: WorkspaceFilesChangedRequest) -> Result<(), CoreError> { + engine() + .session(&request.session_id)? + .workspace_files_changed(request.changes) +} + +/// Notifies the server that a synchronized document has closed. +pub fn close_document(request: CloseDocumentRequest) -> Result<(), CoreError> { + engine() + .session(&request.session_id)? + .close_document(&request.uri) +} + +/// Queues a semantic request and returns its operation identifier immediately. +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 }) +} + +/// Sends one build-provider request to a session that negotiated the protocol. +pub fn build_provider_request( + request: BuildProviderRequest, +) -> Result { + let operation_id = request + .operation_id + .clone() + .unwrap_or_else(|| engine().next_operation_id()); + engine() + .session(&request.session_id)? + .build_request(request, operation_id.clone())?; + Ok(OperationResponse { operation_id }) +} + +/// Sends one `toolchains/query` to a session that negotiated its provider. +pub fn toolchain_provider_request( + request: ToolchainProviderRequest, +) -> Result { + let operation_id = request + .operation_id + .clone() + .unwrap_or_else(|| engine().next_operation_id()); + engine() + .session(&request.session_id)? + .toolchain_request(request, operation_id.clone())?; + Ok(OperationResponse { operation_id }) +} + +/// Sends a shortcut import request only to a negotiated plugin provider. +pub fn keymap_import_provider_request( + request: KeymapImportProviderRequest, +) -> Result { + let operation_id = request + .operation_id + .clone() + .unwrap_or_else(|| engine().next_operation_id()); + engine() + .session(&request.session_id)? + .keymap_import_request(request, operation_id.clone())?; + Ok(OperationResponse { operation_id }) +} + +/// Sends one VCS-provider request to a session that negotiated the protocol. +pub fn vcs_provider_request(request: VcsProviderRequest) -> Result { + let operation_id = request + .operation_id + .clone() + .unwrap_or_else(|| engine().next_operation_id()); + engine() + .session(&request.session_id)? + .vcs_request(request, operation_id.clone())?; + Ok(OperationResponse { operation_id }) +} + +/// Answers one `hostRequest` event of a plugin backend with the outcome of +/// the host service it asked for. +pub fn plugin_host_response(request: PluginHostResponse) -> Result<(), CoreError> { + engine() + .session(&request.session_id)? + .host_response(request) +} + +/// Sends one host notification to a plugin backend. +pub fn plugin_host_notify( + request: PluginHostNotification, +) -> Result { + let delivered = engine() + .session(&request.session_id)? + .host_notify(request)?; + Ok(PluginHostNotificationResponse { delivered }) +} + +/// Invokes one plugin function on a session of the function's plugin. +pub fn plugin_function_request( + request: PluginFunctionRequest, +) -> Result { + let operation_id = request + .operation_id + .clone() + .unwrap_or_else(|| engine().next_operation_id()); + engine() + .session(&request.session_id)? + .function_request(request, operation_id.clone(), None)?; + Ok(OperationResponse { operation_id }) +} + +/// Sends one request of a plugin backend to the plugin's own language server. +pub fn plugin_server_request(request: PluginServerRequest) -> Result { + let operation_id = request + .operation_id + .clone() + .unwrap_or_else(|| engine().next_operation_id()); + engine() + .session(&request.session_id)? + .plugin_server_request(request, operation_id.clone())?; + Ok(OperationResponse { operation_id }) +} + +/// Sends one notification of a plugin backend to the plugin's own language server. +pub fn plugin_server_notify( + request: PluginServerNotify, +) -> Result { + let delivered = engine() + .session(&request.session_id)? + .plugin_server_notify(request)?; + Ok(PluginServerNotifyResponse { delivered }) +} + +/// Replaces the host's view of the enabled plugins and live workspace +/// contexts that the cross-plugin function relay checks calls against. Calls +/// in flight that the new view forbids end with an error. +pub fn configure_function_relay( + snapshot: lithe_plugin_function_relay::RelaySnapshot, +) -> Result<(), CoreError> { + engine().relay.configure(snapshot) +} + +/// Cancels a pending semantic request in both client state and the server. +pub fn cancel_operation(request: CancelOperationRequest) -> Result<(), CoreError> { + engine() + .session(&request.session_id)? + .cancel_operation(&request.operation_id) +} + +/// Updates the trace gate only after the corresponding ordered write succeeds. +pub fn set_detailed_diagnostics(request: SetDetailedDiagnosticsRequest) -> Result<(), CoreError> { + engine() + .session(&request.session_id)? + .set_detailed_diagnostics(request.enabled) +} + +/// Drains all currently queued events in deterministic sequence order. +pub fn poll_events(request: SessionRequest) -> Result { + let events = engine().session(&request.session_id)?.poll_events()?; + Ok(PollEventsResponse { events }) +} + +/// Waits until queued events exist or the supplied timeout elapses. +pub fn wait_events(request: WaitEventsRequest) -> Result { + let events = engine() + .session(&request.session_id)? + .wait_events(Duration::from_millis(request.timeout_milliseconds))?; + Ok(PollEventsResponse { events }) +} + +/// Stops a session if necessary and removes all state owned by it. +pub fn destroy_server(request: SessionRequest) -> Result<(), CoreError> { + engine().destroy(&request.session_id) +} + +fn engine() -> &'static LspEngine { + ENGINE.get_or_init(LspEngine::new) +} + +#[cfg(test)] +mod function_relay_tests; + +#[cfg(test)] +mod tests; diff --git a/rust/crates/lsp-engine/src/lifecycle.rs b/rust/crates/lsp-engine/src/lifecycle.rs new file mode 100644 index 00000000..c134ce14 --- /dev/null +++ b/rust/crates/lsp-engine/src/lifecycle.rs @@ -0,0 +1,844 @@ +//! Session lifecycle: reader, monitor and maintenance threads, deadlines, process exit, +//! failure, shutdown and ordered writes. + +use super::*; + +impl RuntimeSession { + pub(crate) fn stop(&self) -> Result<(), CoreError> { + let outbound_order = self.lock_outbound_order()?; + 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_function_registry(self, &mut state); + clear_runtime_diagnostics(self, &mut state); + transition_locked(self, &mut state, LspLifecycleState::Stopping, None); + plugin_operations::abandon(&self.startup_attempt); + 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, + document_version: 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(&outbound_order, messages, "shutdown")?; + if force_kill { + self.kill_process(); + } + Ok(()) + } + + pub(crate) fn poll_events(&self) -> Result, CoreError> { + let mut state = self.lock_state()?; + Ok(state.events.drain(..).collect()) + } + + pub(crate) fn wait_events(&self, timeout: Duration) -> Result, CoreError> { + let mut state = self.lock_state()?; + let deadline = Instant::now() + timeout; + loop { + lithe_protocol::cancellation::check()?; + if !state.events.is_empty() { + return Ok(state.events.drain(..).collect()); + } + if matches!( + state.lifecycle, + LspLifecycleState::Stopped | LspLifecycleState::Failed + ) { + // Returning Ok([]) here would let frontend pumps spin on Core IPC + // forever after the terminal stateChanged event was drained. + let details = match state.lifecycle { + LspLifecycleState::Failed => "sessionFailed", + _ => "sessionStopped", + }; + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Language-server session is no longer running.", + ) + .with_details(details)); + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Ok(Vec::new()); + } + let (guard, wait_result) = + self.event_signal + .wait_timeout(state, remaining) + .map_err(|_| { + CoreError::new( + ErrorCode::Unknown, + "Language-server event wait lock was poisoned.", + ) + })?; + state = guard; + if wait_result.timed_out() && state.events.is_empty() { + return Ok(Vec::new()); + } + } + } + + #[cfg(test)] + pub(crate) fn snapshot(&self) -> Result { + let state = self.lock_state()?; + Ok(EngineSnapshot { + session_id: self.id.clone(), + provider_id: self.provider_id.clone(), + root_uri: self.root_uri.clone(), + state: state.lifecycle, + initialized: state.client.initialized, + open_documents: state.client.open_documents.clone(), + pending_operation_ids: { + let mut ids: Vec = state.request_by_operation.keys().cloned().collect(); + ids.sort(); + ids + }, + diagnostic_versions: state.client.diagnostic_versions.clone(), + }) + } + + pub(crate) fn spawn_readers( + self: &Arc, + mut stdout: Box, + mut stderr: Box, + ) { + let output_session = self.clone(); + thread::spawn(move || { + let _host = lithe_workspace::enter(output_session.workspace_host.clone()); + 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 = [0_u8; 4096]; + let mut lines = StderrLines::default(); + let started = Instant::now(); + let emit = |fact| match fact { + StderrFact::Line { + diagnostic, + truncated, + } => error_session.log( + "debug", + EngineLog::StderrReceived + .with_fields(json!({"diagnostic": diagnostic, "truncated": truncated})), + "Language-server stderr", + Some(diagnostic), + ), + StderrFact::Dropped(count) => error_session.log( + "warning", + EngineLog::Dropped.with_fields(json!({"count": count})), + "Engine stderr lines dropped", + Some(format!("count={count}")), + ), + }; + while error_session.active.load(Ordering::Acquire) { + match stderr.read(&mut chunk) { + Ok(0) => break, + Ok(count) => { + lines.feed(&chunk[..count], started.elapsed().as_millis() as u64, emit) + } + Err(error) => { + error_session.log_issue( + "warning", + "stderr", + "stderrReadFailed", + EngineLog::StderrReadFailed, + "Could not read language-server stderr", + Some(error.to_string()), + ); + break; + } + } + } + lines.finish(started.elapsed().as_millis() as u64, emit); + }); + } + + pub(crate) fn spawn_monitor(self: &Arc) { + let session = self.clone(); + thread::spawn(move || { + while session.active.load(Ordering::Acquire) { + if let Some(exit_code) = session.process.exit_status() { + session.handle_process_exit(exit_code); + break; + } + session.expire_deadlines(); + thread::sleep(Duration::from_millis(MONITOR_INTERVAL_MS)); + } + }); + } +} + +impl RuntimeSession { + /// One session-owned writer sends deadline cancellations off the monitor thread. + /// The monitor remains free to terminate a stalled write and expire other callers. + pub(crate) fn spawn_outbound_maintenance(self: &Arc) { + let session = self.clone(); + thread::spawn(move || { + while session.active.load(Ordering::Acquire) { + session.pump_outbound_maintenance(); + thread::sleep(Duration::from_millis(MONITOR_INTERVAL_MS)); + } + }); + } + + fn pump_outbound_maintenance(&self) { + if !self + .lock_state() + .is_ok_and(|state| !state.deadline_cancellations.is_empty()) + { + return; + } + let Ok(outbound_order) = self.outbound_order.try_lock() else { + return; + }; + let Ok(mut state) = self.lock_state() else { + return; + }; + let messages: Vec = std::mem::take(&mut state.deadline_cancellations) + .into_iter() + .map(|id| { + json!({ "jsonrpc": "2.0", "method": "$/cancelRequest", "params": { "id": id } }) + .to_string() + }) + .collect(); + if messages.is_empty() { + return; + } + state.maintenance_write_deadline = Some(Instant::now() + state.request_timeout); + drop(state); + // Process termination by the monitor releases a blocked native stdin write. + // log-exempt: Same failure already recorded: send_messages_or_fail transitions the session and emits its transport failure. + let _ = self.send_messages_or_fail(&outbound_order, messages, "outboundMaintenance"); + if let Ok(mut state) = self.lock_state() { + state.maintenance_write_deadline = None; + } + } + + fn expire_deadlines(&self) { + self.expire_deadlines_at(Instant::now()); + } + + pub(crate) fn expire_deadlines_at(&self, now: Instant) { + // Relayed calls that no callee runs yet have no session deadline. + self.relay.expire(now); + let mut cancellations = Vec::new(); + let mut initialize_timeout = false; + let mut shutdown_timeout = false; + let mut maintenance_write_timeout = false; + let mut relay_write_timeout = false; + if let Ok(mut state) = self.lock_state() { + maintenance_write_timeout = state + .maintenance_write_deadline + .is_some_and(|deadline| now >= deadline); + if maintenance_write_timeout { + state.maintenance_write_deadline = None; + } + relay_write_timeout = state + .relay_write_deadline + .is_some_and(|deadline| now >= deadline); + if relay_write_timeout { + state.relay_write_deadline = None; + } + if state.lifecycle == LspLifecycleState::Initializing + && state + .initialize_deadline + .is_some_and(|deadline| now >= deadline) + { + state.initialize_deadline = None; + initialize_timeout = true; + // Decided under the state lock, like the initialize response, + // so exactly one of the two records the attempt. + plugin_operations::finish(&self.startup_attempt, Some(AttemptStage::Timeout)); + } + let expired: Vec<_> = state + .pending + .iter() + .filter(|(_, pending)| { + matches!( + pending.kind, + PendingKind::Feature | PendingKind::PluginServer + ) && 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); + state.client.pending_semantic_legends.remove(&request_id); + if let Some(operation_id) = pending.operation_id.as_deref() { + state.request_by_operation.remove(operation_id); + state.function_by_operation.remove(operation_id); + let elapsed = now.saturating_duration_since(pending.created_at); + let error = runtime_error( + self, + "requestTimeout", + "request", + "Language-server request timed out.", + FailureContext::request(&pending.method, pending.document_uri.as_deref()) + .with_underlying(Some(&format!( + "elapsedMilliseconds={}", + elapsed.as_millis() + ))), + ); + push_request_event( + self, + &mut state, + operation_id, + &pending.method, + None, + Some(error), + ); + } + cancellations.push(request_id); + } + let held_expired = state + .plugin_server + .as_ref() + .map(|server| server.expired_held(now)) + .unwrap_or_default(); + for waiter in held_expired { + // Nothing was sent, so there is nothing to cancel on the wire. + let error = runtime_error( + self, + "requestTimeout", + "request", + "Language-server request timed out waiting for the server to become ready.", + FailureContext::request(&waiter.method, None), + ); + push_request_event( + self, + &mut state, + &waiter.operation_id, + &waiter.method, + None, + Some(error), + ); + } + if state.lifecycle == LspLifecycleState::Stopping + && state + .shutdown_deadline + .is_some_and(|deadline| now >= deadline) + { + state.shutdown_deadline = None; + shutdown_timeout = true; + let message = "Language-server shutdown timed out; forcing termination"; + let error = runtime_error( + self, + "shutdownTimeout", + "shutdown", + message, + FailureContext::default(), + ); + push_classified_log_event( + self, + &mut state, + "warning", + EngineLog::ShutdownTimedOut, + message, + None, + Some(error), + ); + } + } + if maintenance_write_timeout { + self.fail( + "transportFailed", + "outboundMaintenance", + "Language-server stdin write timed out.", + None, + None, + ); + self.kill_process(); + } else if relay_write_timeout { + // Killing the process releases the relay lane's blocked stdin write. + self.fail( + "transportFailed", + "relayWrite", + "Language-server stdin write timed out.", + None, + None, + ); + self.kill_process(); + } else if initialize_timeout { + // Timeout termination must not wait behind a blocked stdin write; + // killing the process is what releases that write. + self.fail( + "initializeTimeout", + "initialize", + "Language-server initialization timed out.", + None, + None, + ); + self.kill_process(); + } else if shutdown_timeout { + self.kill_process(); + } + if !cancellations.is_empty() { + if let Ok(mut state) = self.lock_state() { + if !matches!( + state.lifecycle, + LspLifecycleState::Stopped | LspLifecycleState::Failed + ) { + state.deadline_cancellations.extend(cancellations); + } + } + } + } + + fn handle_process_exit(&self, exit_code: Option) { + self.active.store(false, Ordering::Release); + self.process.close_input(); + if let Ok(mut state) = self.lock_state() { + let was_stopping = state.lifecycle == LspLifecycleState::Stopping; + if !was_stopping { + plugin_operations::finish(&self.startup_attempt, Some(AttemptStage::Exit)); + } + if !state.terminal_event_emitted { + let error = (!was_stopping).then(|| { + runtime_error( + self, + "serverExited", + "process", + "Language-server process exited.", + FailureContext::default().with_exit_code(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; + } + } + } + + pub(crate) 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); + state.client.pending_semantic_legends.remove(request_id); + if let Some(operation_id) = pending.operation_id.as_deref() { + state.request_by_operation.remove(operation_id); + state.function_by_operation.remove(operation_id); + let error = runtime_error( + self, + code, + stage, + message, + FailureContext::request(&pending.method, pending.document_uri.as_deref()) + .with_underlying(underlying.as_deref()) + .with_exit_code(exit_code), + ); + push_request_event( + self, + &mut state, + operation_id, + &pending.method, + None, + Some(error), + ); + } + } + } + + pub(crate) 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; + } + // Timeouts were already taken by their caller; every other terminal + // failure before `initialize` succeeded is a handshake failure. + plugin_operations::finish(&self.startup_attempt, Some(AttemptStage::Initialize)); + fail_feature_requests(self, &mut state, code, stage, message, exit_code); + clear_runtime_state(self, &mut state); + let error = runtime_error( + self, + code, + stage, + message, + FailureContext::default() + .with_underlying(underlying.as_deref()) + .with_exit_code(exit_code), + ); + transition_locked(self, &mut state, LspLifecycleState::Failed, Some(error)); + state.terminal_event_emitted = true; + } + } + + pub(crate) fn transition( + &self, + lifecycle: LspLifecycleState, + error: Option, + ) -> Result<(), CoreError> { + let mut state = self.lock_state()?; + transition_locked(self, &mut state, lifecycle, error); + Ok(()) + } + + pub(crate) fn set_detailed_diagnostics(&self, enabled: bool) -> Result<(), CoreError> { + let order = self.lock_outbound_order()?; + let mut state = self.lock_state()?; + let lifecycle = state.lifecycle; + if matches!( + lifecycle, + LspLifecycleState::Stopped | LspLifecycleState::Failed | LspLifecycleState::Stopping + ) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The session is no longer running.", + )); + } + if !state.client.initialized { + state.requested_diagnostics = enabled; + return Ok(()); + } + drop(state); + self.send_messages_or_fail(&order, vec![trace_notification(enabled)], "setTrace")?; + let mut state = self.lock_state()?; + state.requested_diagnostics = enabled; + state.detailed_diagnostics = enabled; + Ok(()) + } + + pub(crate) fn log( + &self, + level: &str, + identity: impl Into, + message: &str, + detail: Option, + ) { + if let Ok(mut state) = self.lock_state() { + push_log_event(self, &mut state, level, identity, message, detail); + } + } + + pub(crate) fn log_issue( + &self, + level: &str, + stage: &str, + code: &str, + identity: impl Into, + message: &str, + detail: Option, + ) { + if let Ok(mut state) = self.lock_state() { + let error = runtime_error(self, code, stage, message, FailureContext::default()); + push_classified_log_event( + self, + &mut state, + level, + identity, + message, + detail, + Some(error), + ); + } + } + + pub(crate) fn send_messages_or_fail( + &self, + outbound_order: &MutexGuard<'_, ()>, + messages: Vec, + stage: &str, + ) -> Result<(), CoreError> { + if messages.is_empty() { + return Ok(()); + } + if let Err(error) = self.send_messages(outbound_order, 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(()) + } + + pub(crate) fn send_messages( + &self, + _outbound_order: &MutexGuard<'_, ()>, + messages: Vec, + ) -> Result<(), CoreError> { + for message in messages { + let message = match &self.workspace_host { + Some(host) => { + lithe_lsp_transport::remote_process::map_message(host, &message, true, None)? + } + None => message, + }; + let frame = frame_message(FrameMessageRequest { message })?.frame; + self.process.write_input(frame.as_bytes())?; + } + Ok(()) + } + + pub(crate) fn kill_process(&self) { + self.process.terminate(); + if let Some(error) = self.process.take_termination_failure() { + // The session owns process recovery; WARN reports cleanup degradation. + // Reuse the role routing and runtime envelope without adding a v1 log row. + if let Ok(mut state) = self.lock_state() { + let category = format!("{:?}", error.kind()); + let error = runtime_error( + self, + "processCleanupFailed", + "cleanup", + "Engine process cleanup failed", + FailureContext::default(), + ); + let input = EngineLog::ProcessCleanupFailed.with_fields(json!({})); + let (id, mut fields) = + input + .identity + .fact(&self.log_identity, &input.fields, Some(&error)); + fields.insert("error_code".into(), json!(category)); + fields.insert("error_domain".into(), json!("io")); + push_cleanup_fact(self, &mut state, id, fields, error); + } + } + } + + pub(crate) fn lock_outbound_order(&self) -> Result, CoreError> { + self.outbound_order.lock().map_err(|_| { + CoreError::new( + ErrorCode::Unknown, + "Language-server outbound ordering lock was poisoned.", + ) + }) + } + + pub(crate) fn lock_indexing_roots(&self) -> Result>, CoreError> { + self.indexing_roots + .lock() + .map_err(|_| CoreError::new(ErrorCode::Unknown, "LSP indexing lock was poisoned")) + } + + pub(crate) fn lock_state(&self) -> Result, CoreError> { + self.state.lock().map_err(|_| { + CoreError::new( + ErrorCode::Unknown, + "Language-server session state lock was poisoned.", + ) + }) + } +} + +pub(crate) fn trace_notification(enabled: bool) -> String { + json!({"jsonrpc":"2.0", "method":"$/setTrace", "params":{"value": if enabled { "verbose" } else { "off" }}}).to_string() +} + +// Cleanup failure is a log row, never a state change: hosts take the latest +// `stateChanged` as the session state, so a stateless one would erase `failed`. +pub(crate) fn push_cleanup_fact( + session: &RuntimeSession, + state: &mut SessionState, + id: String, + fields: std::collections::BTreeMap, + error: LspRuntimeError, +) { + let sequence = take_sequence(state); + enqueue_runtime_event( + session, + state, + LspRuntimeEvent { + kind: "log".to_string(), + channel: None, + plugin_id: None, + role: None, + event: Some(id), + fields: Some(fields), + catalog_level: None, + 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: Some(error), + capabilities: None, + completion_options: None, + server_info: None, + level: Some("warning".to_string()), + message: Some("Engine process cleanup failed".to_string()), + detail: None, + }, + ); +} + +pub(crate) fn transition_locked( + session: &RuntimeSession, + state: &mut SessionState, + lifecycle: LspLifecycleState, + error: Option, +) { + state.lifecycle = lifecycle; + relay_hub::observe_lifecycle(session, lifecycle); + let (id, fields) = EngineLog::SessionUpdated.fact( + &session.log_identity, + &json!({"state": lifecycle}), + error.as_ref(), + ); + let sequence = take_sequence(state); + enqueue_runtime_event( + session, + state, + LspRuntimeEvent { + kind: "stateChanged".to_string(), + channel: None, + plugin_id: None, + role: None, + event: Some(id), + fields: Some(fields), + catalog_level: None, + 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, + completion_options: None, + server_info: None, + level: None, + message: None, + detail: None, + }, + ); +} diff --git a/rust/crates/core/src/lsp/interface/engine_logging.rs b/rust/crates/lsp-engine/src/logging.rs similarity index 91% rename from rust/crates/core/src/lsp/interface/engine_logging.rs rename to rust/crates/lsp-engine/src/logging.rs index 568b2d9e..9d4dca31 100644 --- a/rust/crates/core/src/lsp/interface/engine_logging.rs +++ b/rust/crates/lsp-engine/src/logging.rs @@ -1,7 +1,7 @@ //! Catalog identities, role routing, and bounded stderr framing for managed engines. -use super::engine::LspRuntimeError; -use crate::diagnostics::channel::{digest_segment, identity_segment, validate_segment}; +use crate::LspRuntimeError; +use lithe_diagnostics::channel::{digest_segment, identity_segment, validate_segment}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::BTreeMap; @@ -88,7 +88,7 @@ impl SessionLogIdentity { /// Pure host request for the exact routing identity used at engine startup. #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct ResolveEngineLogSubjectRequest { +pub struct ResolveEngineLogSubjectRequest { /// Explicit protocol role; never inferred from a provider name. role: SessionRole, /// Opaque provider catalog identity, including IDs unsafe as path segments. @@ -102,7 +102,7 @@ pub(crate) struct ResolveEngineLogSubjectRequest { /// Core-derived routing segment and channel, without starting a session or doing I/O. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] -pub(crate) struct ResolvedEngineLogSubject { +pub struct ResolvedEngineLogSubject { /// Complete logical channel selected by the engine role. channel: String, /// Safe identity segment shared by Core and host-owned catalog facts. @@ -110,7 +110,7 @@ pub(crate) struct ResolvedEngineLogSubject { } /// Reuses startup identity rules so pre-start host facts route to the same channel. -pub(crate) fn resolve_engine_log_subject( +pub fn resolve_engine_log_subject( request: ResolveEngineLogSubjectRequest, ) -> ResolvedEngineLogSubject { let identity = SessionLogIdentity::new( @@ -216,12 +216,12 @@ impl EngineLog { .cloned() .unwrap_or_else(|| json!("")), ); - copy(&mut fields, "truncated", &input, "truncated"); + copy(&mut fields, "truncated", input, "truncated"); } Self::ProcessCleanupFailed | Self::StderrReadFailed => failure(&mut fields, code), Self::MessageLogged | Self::MessageShown => { - copy(&mut fields, "diagnostic", &input, "diagnostic"); - copy(&mut fields, "severity", &input, "severity"); + copy(&mut fields, "diagnostic", input, "diagnostic"); + copy(&mut fields, "severity", input, "severity"); let severity = match input["severity"].as_str() { Some("error") => Some("error"), Some("warning") => Some("warning"), @@ -238,13 +238,13 @@ impl EngineLog { } } Self::TraceReceived => { - copy(&mut fields, "diagnostic", &input, "diagnostic"); + copy(&mut fields, "diagnostic", input, "diagnostic"); } Self::NotificationReceived => { - copy(&mut fields, "method", &input, "method"); + copy(&mut fields, "method", input, "method"); } Self::SessionUpdated => { - copy(&mut fields, "state", &input, "state"); + copy(&mut fields, "state", input, "state"); if input["state"] == "failed" { id = identity.event("session.failed"); fields.insert("code".into(), json!(code)); @@ -427,9 +427,9 @@ mod tests { &json!({"type":kind,"message":"upstream fact"}), ); let (id, fields) = event.fact(&identity, &input, None); - crate::diagnostics::catalog::validate_fields(&id, &fields).unwrap(); + lithe_diagnostics::catalog::validate_fields(&id, &fields).unwrap(); assert_eq!( - crate::diagnostics::catalog::event_by_id(&id) + lithe_diagnostics::catalog::event_by_id(&id) .unwrap() .level .label(), @@ -438,7 +438,7 @@ mod tests { } } assert_eq!( - crate::diagnostics::catalog::event_by_id("lsp.stderr.received") + lithe_diagnostics::catalog::event_by_id("lsp.stderr.received") .unwrap() .level .label(), @@ -472,13 +472,14 @@ mod tests { ), (SessionRole::Backend, "opaque:any", None, None), ] { - let response: Value = serde_json::from_str(&crate::runtime::execute_json(&json!({ - "version": 1, "command": "diagnostics.resolveEngineLogSubject", - "payload": {"role": role, "providerId": provider, "pluginId": plugin, "logSubject": subject} - }).to_string())).unwrap(); + let request: ResolveEngineLogSubjectRequest = serde_json::from_value(json!({ + "role": role, "providerId": provider, "pluginId": plugin, "logSubject": subject + })) + .unwrap(); + let response = serde_json::to_value(resolve_engine_log_subject(request)).unwrap(); let expected = SessionLogIdentity::new(role, provider, plugin, subject); assert_eq!( - response["data"], + response, json!({"channel": expected.channel(), "segment": expected.subject}) ); } @@ -487,14 +488,7 @@ mod tests { json!({"role":"unknown", "providerId":"id"}), json!({"role":"language_server", "providerId": 5}), ] { - let response: Value = serde_json::from_str(&crate::runtime::execute_json( - &json!({ - "version":1, "command":"diagnostics.resolveEngineLogSubject", "payload":payload - }) - .to_string(), - )) - .unwrap(); - assert_eq!(response["error"]["code"], "invalid_request"); + assert!(serde_json::from_value::(payload).is_err()); } } @@ -606,7 +600,7 @@ mod tests { EngineLog::SessionUpdated, ] { assert!( - crate::diagnostics::catalog::event_by_id(&identity.event(event.suffix())) + lithe_diagnostics::catalog::event_by_id(&identity.event(event.suffix())) .is_some() ); } @@ -620,7 +614,7 @@ mod tests { ] { let identity = SessionLogIdentity::new(role, "tool-a", Some("io.plugin"), Some("git")); let (id, fields) = EngineLog::ProcessCleanupFailed.fact(&identity, &Value::Null, None); - crate::diagnostics::catalog::validate_fields(&id, &fields).unwrap(); + lithe_diagnostics::catalog::validate_fields(&id, &fields).unwrap(); assert_eq!(fields["error_code"], "operation_failed"); } for (role, value) in [ @@ -634,7 +628,7 @@ mod tests { first.channel(), SessionLogIdentity::new(role, value, Some(value), Some(value)).channel() ); - assert!(crate::diagnostics::channel::LogChannel::parse(&first.channel()).is_ok()); + assert!(lithe_diagnostics::channel::LogChannel::parse(&first.channel()).is_ok()); } let missing = SessionLogIdentity::new(SessionRole::Vcs, "opaque:id", None, None); assert!(missing.subject.starts_with("invalid-")); diff --git a/rust/crates/lsp-engine/src/operations.rs b/rust/crates/lsp-engine/src/operations.rs new file mode 100644 index 00000000..30dc7895 --- /dev/null +++ b/rust/crates/lsp-engine/src/operations.rs @@ -0,0 +1,949 @@ +//! Requests sent to a session: semantic features, provider requests, plugin functions, +//! host replies and notifications, and cancellation. + +use super::*; + +impl RuntimeSession { + pub(crate) fn request( + &self, + request: SemanticRequest, + operation_id: String, + ) -> Result<(), CoreError> { + let document_version = request.uri.as_deref().and_then(|uri| { + self.lock_state().ok().and_then(|state| { + state + .client + .open_documents + .get(uri) + .map(|document| document.version.max(1)) + }) + }); + self.request_with_kind( + request, + operation_id, + PendingKind::Feature, + document_version, + ) + } + + /// Writes a build-provider request. It needs no open document, so it is + /// not a semantic feature request; only the negotiated protocol's methods + /// are accepted, so the host cannot use it as a generic JSON-RPC tunnel. + pub(crate) fn build_request( + &self, + request: BuildProviderRequest, + operation_id: String, + ) -> Result<(), CoreError> { + if !lithe_lsp_client::build_provider::is_build_method(&request.method) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "This is not a build-provider request.", + ) + .with_details(request.method)); + } + let outbound_order = self.lock_outbound_order()?; + 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.", + )); + } + if !state.client.server_capabilities.iter().any(|feature| { + feature == lithe_lsp_client::build_provider::BUILD_PROVIDER_FEATURE + }) { + return Err(CoreError::new( + ErrorCode::NotSupported, + "The process did not agree on a build-provider protocol version.", + ) + .with_details(lithe_lsp_client::build_provider::BUILD_PROVIDER_FEATURE)); + } + if request.params.get("launchOptions").is_some() + && request.method != lithe_lsp_client::build_provider::PREPARE_RUN_METHOD + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Launch options apply only to run preparation.", + )); + } + if (request.params.get("selectorKind").is_some() + || request.params.get("selectors").is_some()) + && request.method != lithe_lsp_client::build_provider::TEST_METHOD + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Opaque selectors require a provider test request.", + )); + } + if request.params.get("testResults").is_some() + && (request.method != lithe_lsp_client::build_provider::TEST_METHOD + || request.params["testResults"] != json!(true)) + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Inline test reports require a provider test request.", + )); + } + let mut params = request.params; + if lithe_lsp_client::build_provider::is_progress_method(&request.method) { + let object = params.as_object_mut().ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "Build task parameters must be an object.", + ) + })?; + object.insert( + "operationID".to_string(), + json!(state.client.next_request_id.to_string()), + ); + } + let timeout = request + .timeout_milliseconds + .map(|milliseconds| { + Duration::from_millis(milliseconds.clamp(1, MAX_BUILD_PROVIDER_TIMEOUT_MS)) + }) + .unwrap_or(state.request_timeout); + let response = allocate_raw_request(state.client.clone(), &request.method, params)?; + let request_id = (response.state.next_request_id - 1).to_string(); + let now = Instant::now(); + state.client = response.state; + state.pending.insert( + request_id.clone(), + PendingRequest { + kind: PendingKind::Feature, + operation_id: Some(operation_id.clone()), + method: request.method, + document_uri: None, + document_version: None, + created_at: now, + deadline: now + timeout, + }, + ); + state + .request_by_operation + .insert(operation_id, request_id.clone()); + (response.messages, request_id) + }; + self.send_request_or_fail(&outbound_order, messages, &request_id) + } + + pub(crate) fn keymap_import_request( + &self, + request: KeymapImportProviderRequest, + operation_id: String, + ) -> Result<(), CoreError> { + // A snapshot reference is an opaque token, never a filesystem path. + // The future host service must separately enforce its session ownership. + if request.snapshot_id.is_empty() + || request.snapshot_id.len() > 128 + || !request + .snapshot_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid import snapshot reference.", + )); + } + let outbound_order = self.lock_outbound_order()?; + let (messages, request_id) = { + let mut state = self.lock_state()?; + if state.lifecycle != LspLifecycleState::Ready || !state.client.initialized { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Plugin backend is not ready.", + )); + } + if state.request_by_operation.contains_key(&operation_id) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The operation ID is already pending.", + )); + } + if !state + .client + .keymap_import_providers + .contains(&request.provider_id) + { + return Err(CoreError::new( + ErrorCode::NotSupported, + "The plugin did not negotiate this import provider.", + ) + .with_details("plugin.keymapImport.providerUnavailable")); + } + let timeout = request + .timeout_milliseconds + .map(|milliseconds| { + Duration::from_millis(milliseconds.clamp(1, MAX_KEYMAP_IMPORT_TIMEOUT_MS)) + }) + .unwrap_or( + state + .request_timeout + .min(Duration::from_millis(MAX_KEYMAP_IMPORT_TIMEOUT_MS)), + ); + let response = allocate_raw_request( + state.client.clone(), + lithe_lsp_client::keymap_provider::IMPORT_METHOD, + json!({"providerID": request.provider_id, "snapshotID": request.snapshot_id}), + )?; + let request_id = (response.state.next_request_id - 1).to_string(); + let now = Instant::now(); + state.client = response.state; + state.pending.insert( + request_id.clone(), + PendingRequest { + kind: PendingKind::Feature, + operation_id: Some(operation_id.clone()), + method: lithe_lsp_client::keymap_provider::IMPORT_METHOD.to_string(), + document_uri: None, + document_version: None, + created_at: now, + deadline: now + timeout, + }, + ); + state + .request_by_operation + .insert(operation_id, request_id.clone()); + (response.messages, request_id) + }; + self.send_request_or_fail(&outbound_order, messages, &request_id) + } + + /// Writes a VCS-provider request planned by `lithe_vcs::plan`, which + /// checks the operation's arguments and negotiated capability. + pub(crate) fn toolchain_request( + &self, + request: ToolchainProviderRequest, + operation_id: String, + ) -> Result<(), CoreError> { + let outbound_order = self.lock_outbound_order()?; + 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 provider = request.params["providerID"].as_str().unwrap_or_default(); + if !state + .client + .toolchain_providers + .iter() + .any(|negotiated| negotiated == provider) + { + return Err(CoreError::new( + ErrorCode::NotSupported, + "The process did not negotiate this toolchain provider.", + ) + .with_details("plugin.toolchain.providerUnavailable")); + } + let timeout = request + .timeout_milliseconds + .map(|milliseconds| { + Duration::from_millis(milliseconds.clamp(1, MAX_TOOLCHAIN_QUERY_TIMEOUT_MS)) + }) + .unwrap_or(state.request_timeout); + let response = allocate_raw_request( + state.client.clone(), + lithe_lsp_client::toolchain_provider::QUERY_METHOD, + request.params, + )?; + let request_id = (response.state.next_request_id - 1).to_string(); + let now = Instant::now(); + state.client = response.state; + state.pending.insert( + request_id.clone(), + PendingRequest { + kind: PendingKind::Feature, + operation_id: Some(operation_id.clone()), + method: lithe_lsp_client::toolchain_provider::QUERY_METHOD.to_string(), + document_uri: None, + document_version: None, + created_at: now, + deadline: now + timeout, + }, + ); + state + .request_by_operation + .insert(operation_id, request_id.clone()); + (response.messages, request_id) + }; + self.send_request_or_fail(&outbound_order, messages, &request_id) + } + + pub(crate) fn vcs_request( + &self, + request: VcsProviderRequest, + operation_id: String, + ) -> Result<(), CoreError> { + let operation = lithe_vcs::Operation::parse(&request.operation).ok_or_else(|| { + CoreError::new(ErrorCode::InvalidRequest, "Unknown VCS operation") + .with_details(request.operation.clone()) + })?; + let outbound_order = self.lock_outbound_order()?; + 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 negotiation = state + .client + .vcs_provider + .as_ref() + .filter(|negotiation| negotiation.vcs == request.vcs) + .ok_or_else(|| { + CoreError::new( + ErrorCode::NotSupported, + format!("The process is not a negotiated {} provider.", request.vcs), + ) + .with_details("vcs.provider.unavailable") + })?; + let planned = lithe_vcs::plan( + operation, + &request.root, + request.tool.as_deref(), + request.arguments, + &negotiation.capabilities, + )?; + let timeout = request + .timeout_milliseconds + .map(|milliseconds| { + Duration::from_millis(milliseconds.clamp(1, MAX_VCS_PROVIDER_TIMEOUT_MS)) + }) + .unwrap_or(state.request_timeout); + let response = + allocate_raw_request(state.client.clone(), planned.method, planned.params)?; + let request_id = (response.state.next_request_id - 1).to_string(); + let now = Instant::now(); + state.client = response.state; + state.pending.insert( + request_id.clone(), + PendingRequest { + kind: PendingKind::Feature, + operation_id: Some(operation_id.clone()), + method: planned.method.to_string(), + document_uri: None, + document_version: None, + created_at: now, + deadline: now + timeout, + }, + ); + state + .request_by_operation + .insert(operation_id, request_id.clone()); + (response.messages, request_id) + }; + self.send_request_or_fail(&outbound_order, messages, &request_id) + } + + /// Writes a `lithe/function/invoke` request. The plan comes from + /// `plugin_function::plan_invocation`, so plugin identity, negotiation, + /// and registration are checked before anything reaches the process. + /// + /// `caller` is the plugin whose call the relay forwards, and `None` for a + /// call the host starts. Only the relay passes `Some`, so a host cannot + /// name a plugin as the caller and a plugin cannot choose its own name. + pub(crate) fn function_request( + &self, + request: PluginFunctionRequest, + operation_id: String, + caller: Option<&str>, + ) -> Result<(), CoreError> { + if caller.is_none() && relay_hub::is_relay_operation_id(&operation_id) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "This language-server operation ID is reserved for relayed plugin calls.", + )); + } + let outbound_order = self.lock_outbound_order()?; + 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 negotiated = state + .client + .server_capabilities + .iter() + .any(|feature| feature == lithe_plugin_rpc::FUNCTIONS_FEATURE); + // Progress echoes this per-request wire ID, never the reusable host ID. + let wire_operation_id = state.client.next_request_id.to_string(); + let params = lithe_plugin_rpc::plan_invocation( + &lithe_plugin_rpc::FunctionSession { + plugin_id: self.plugin_id.as_deref(), + negotiated, + functions: &state.client.plugin_functions, + }, + &request.function, + &wire_operation_id, + &request.params, + )?; + let mut params = params; + lithe_plugin_rpc::attach_context(&mut params, request.context.as_ref())?; + lithe_plugin_rpc::attach_caller(&mut params, caller); + let timeout = request + .timeout_milliseconds + .map(|milliseconds| { + Duration::from_millis( + milliseconds.clamp(1, lithe_plugin_rpc::MAX_FUNCTION_TIMEOUT_MS), + ) + }) + .unwrap_or(state.request_timeout); + let response = allocate_raw_request( + state.client.clone(), + lithe_plugin_rpc::INVOKE_METHOD, + params, + )?; + let request_id = (response.state.next_request_id - 1).to_string(); + let now = Instant::now(); + state.client = response.state; + state.pending.insert( + request_id.clone(), + PendingRequest { + kind: PendingKind::Feature, + operation_id: Some(operation_id.clone()), + method: lithe_plugin_rpc::INVOKE_METHOD.to_string(), + document_uri: None, + document_version: None, + created_at: now, + deadline: now + timeout, + }, + ); + state + .request_by_operation + .insert(operation_id.clone(), request_id.clone()); + state.function_by_operation.insert( + operation_id, + PendingFunctionCall { + function_id: request.function.function_id, + request_id: request_id.clone(), + }, + ); + (response.messages, request_id) + }; + self.send_request_or_fail(&outbound_order, messages, &request_id) + } + + /// Sends the backend the host's answer to one forwarded host-service + /// request. An unknown ID, including one already answered or of a session + /// that restarted, is refused so a late answer never reaches another + /// request. + pub(crate) fn host_response(&self, response: PluginHostResponse) -> Result<(), CoreError> { + let outbound_order = self.lock_outbound_order()?; + let message = { + let mut state = self.lock_state()?; + let backend_id = state + .host_requests + .remove(&response.host_request_id) + .ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "No host-service request with this ID is waiting for an answer.", + ) + })?; + match (response.result, response.error) { + (_, Some(failure)) => { + let (code, rpc_code) = lithe_plugin_rpc::host_failure(failure.code.as_deref()); + let mut data = json!({ "code": code }); + if let Some(details) = + lithe_plugin_rpc::bounded_host_details(failure.details.as_ref()) + { + data["details"] = details; + } + json_rpc_error_with_data( + &backend_id, + rpc_code, + &lithe_plugin_rpc::bounded_host_message(&failure.message), + data, + )? + } + (result, None) => { + let result = result.unwrap_or(Value::Null); + if result.to_string().len() > lithe_plugin_rpc::MAX_RESULT_BYTES { + json_rpc_error_with_code( + &backend_id, + -32014, + "Host result exceeds the byte limit.", + lithe_plugin_rpc::codes::HOST_LIMIT_EXCEEDED, + )? + } else { + json_rpc_result(&backend_id, result)? + } + } + } + }; + self.send_messages_or_fail(&outbound_order, vec![message], "hostResponse") + } + + /// Writes one host notification to a ready plugin-owned backend. + /// Returns `false`, writing nothing, when the backend negotiated no + /// plugin-function protocol version. + pub(crate) fn host_notify( + &self, + notification: PluginHostNotification, + ) -> Result { + let outbound_order = self.lock_outbound_order()?; + let message = { + let state = self.lock_state()?; + if self.plugin_id.is_none() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Only a plugin process receives host notifications.", + )); + } + if state.lifecycle != LspLifecycleState::Ready || !state.client.initialized { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Language server is not ready.", + )); + } + let delivered = lithe_plugin_rpc::host_notification( + ¬ification.method, + ¬ification.params, + state.client.plugin_function_version, + &state.client.host_services, + )?; + if !delivered { + return Ok(false); + } + json_rpc_notification(¬ification.method, notification.params)? + }; + self.send_messages_or_fail(&outbound_order, vec![message], "hostNotify")?; + Ok(true) + } + + /// Admits a backend's request to its language server: it is sent now, + /// held behind a gate, or joined to a command that already runs. + pub(crate) fn plugin_server_request( + &self, + request: PluginServerRequest, + operation_id: String, + ) -> Result<(), CoreError> { + let outbound_order = self.lock_outbound_order()?; + let (messages, request_id) = { + let mut state = self.lock_state()?; + if state.request_by_operation.contains_key(&operation_id) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The language-server operation ID is already pending.", + )); + } + let server = plugin_server_target( + &mut state, + self.plugin_id.as_deref(), + &request.plugin_id, + &request.server_id, + )?; + let now = Instant::now(); + let deadline = now + plugin_server_timeout(request.timeout_milliseconds); + let admission = server.admit_request(Arrival { + operation_id: &operation_id, + method: &request.method, + params: &request.params, + now, + deadline, + })?; + if admission != Admission::Send { + // Held and joined requests are written later, or never. + return Ok(()); + } + match register_plugin_server_request( + &mut state, + &operation_id, + &request.method, + request.params, + now, + deadline, + ) { + Ok(registered) => registered, + Err(error) => { + if let Some(server) = state.plugin_server.as_mut() { + server.finish(&operation_id); + } + return Err(error); + } + } + }; + self.send_request_or_fail(&outbound_order, messages, &request_id) + } + + /// Sends a backend's notification to its language server. Returns whether + /// it was sent now. + pub(crate) fn plugin_server_notify( + &self, + request: PluginServerNotify, + ) -> Result { + { + let mut state = self.lock_state()?; + let server = plugin_server_target( + &mut state, + self.plugin_id.as_deref(), + &request.plugin_id, + &request.server_id, + )?; + server.admit_notification(&request.method, &request.params)?; + } + if request.method == DID_CHANGE_CONFIGURATION { + // The settings also answer the server's own `workspace/configuration` + // requests, so they go through the plugin configuration. + let settings = request + .params + .get("settings") + .filter(|settings| settings.is_object()) + .cloned() + .ok_or_else(|| { + CoreError::new(ErrorCode::InvalidRequest, "settings must be an object.") + .with_details(lithe_plugin_rpc::codes::HOST_INVALID_PARAMS) + })?; + return Ok(self.update_plugin_configuration(settings)?.sent); + } + let message = json_rpc_notification(&request.method, request.params)?; + let outbound_order = self.lock_outbound_order()?; + self.send_messages_or_fail(&outbound_order, vec![message], "pluginServerNotify")?; + Ok(true) + } + + fn request_with_kind( + &self, + request: SemanticRequest, + operation_id: String, + requested_kind: PendingKind, + requested_document_version: Option, + ) -> Result<(), CoreError> { + let outbound_order = self.lock_outbound_order()?; + 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(); + if let (Some(uri), Some(expected_version)) = + (uri.as_deref(), requested_document_version) + { + let current_version = state + .client + .open_documents + .get(uri) + .map(|document| document.version.max(1)) + .ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "The document is not synchronized with the language server.", + ) + })?; + if current_version != expected_version { + return Err(CoreError::new( + ErrorCode::Cancelled, + "The document changed before the language-server request started.", + ) + .with_details(format!( + "expectedVersion={expected_version}, currentVersion={current_version}" + ))); + } + } + 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 pending_kind = requested_kind; + let response = match request.operation { + LspSemanticOperation::WorkspaceSymbols => { + let query = request.query.clone().unwrap_or_default(); + allocate_raw_request(state.client.clone(), method, json!({ "query": query }))? + } + LspSemanticOperation::ExecuteCommand => { + let command = request.command.ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "This language-server request requires a command.", + ) + })?; + allocate_raw_request(state.client.clone(), method, command)? + } + _ => { + let uri = uri.clone().ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "This language-server operation requires a document URI.", + ) + })?; + let document_is_open = state + .client + .open_documents + .get(&uri) + .is_some_and(|document| document.version > 0); + if !document_is_open { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The document is not open in the language server.", + )); + } + let feature_request = ClientFeatureRequest { + state: state.client.clone(), + uri, + method: method.to_string(), + position: request.position, + new_name: request.new_name, + query: request.query, + range: request.range, + diagnostics: request.diagnostics, + completion_context: request.completion_context, + completion_item: request.completion_item, + code_action: request.code_action, + command: request.command, + formatting_options: request.formatting_options, + include_declaration: request.include_declaration, + }; + client_feature_request(feature_request)? + } + }; + let request_id = (response.state.next_request_id - 1).to_string(); + let now = Instant::now(); + let pending = PendingRequest { + kind: pending_kind, + operation_id: Some(operation_id.clone()), + method: method.to_string(), + document_uri: uri.clone(), + document_version: requested_document_version.or_else(|| { + uri.as_deref() + .and_then(|uri| state.client.open_documents.get(uri)) + .map(|document| document.version) + }), + 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) + }; + self.send_request_or_fail(&outbound_order, messages, &request_id) + } + + /// Writes an allocated request. A write failure completes the request + /// with an error and fails the session, because the server's view of the + /// conversation is no longer known. + fn send_request_or_fail( + &self, + outbound_order: &MutexGuard<'_, ()>, + messages: Vec, + request_id: &str, + ) -> Result<(), CoreError> { + if let Err(error) = self.send_messages(outbound_order, 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(()) + } + + pub(crate) fn cancel_operation(&self, operation_id: &str) -> Result<(), CoreError> { + let outbound_order = self.lock_outbound_order()?; + let request_id = { + let mut state = self.lock_state()?; + if state + .plugin_server + .as_ref() + .is_some_and(|server| server.is_unsent(operation_id)) + { + cancel_unsent_plugin_server_request(self, &mut state, operation_id); + return Ok(()); + } + 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); + state.client.pending_semantic_legends.remove(&request_id); + state.function_by_operation.remove(operation_id); + let error = runtime_error( + self, + "requestCancelled", + "request", + "Language-server request was cancelled.", + FailureContext::request(&pending.method, pending.document_uri.as_deref()), + ); + 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(&outbound_order, vec![cancellation], "requestCancel") + } +} + +pub(crate) 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::RangeFormatting => "textDocument/rangeFormatting", + LspSemanticOperation::DocumentSymbols => "textDocument/documentSymbol", + LspSemanticOperation::CodeActions => "textDocument/codeAction", + LspSemanticOperation::ResolveCompletion => "completionItem/resolve", + LspSemanticOperation::ResolveCodeAction => "codeAction/resolve", + LspSemanticOperation::ExecuteCommand => "workspace/executeCommand", + LspSemanticOperation::InlayHints => "textDocument/inlayHint", + LspSemanticOperation::FoldingRanges => "textDocument/foldingRange", + LspSemanticOperation::DocumentLinks => "textDocument/documentLink", + LspSemanticOperation::WorkspaceSymbols => "workspace/symbol", + LspSemanticOperation::SemanticTokens => "textDocument/semanticTokens/full", + 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::RangeFormatting => Some("rangeFormatting"), + LspSemanticOperation::DocumentSymbols => Some("documentSymbols"), + LspSemanticOperation::CodeActions => Some("codeActions"), + LspSemanticOperation::ResolveCompletion => Some("completionResolve"), + LspSemanticOperation::ResolveCodeAction => Some("codeActionResolve"), + LspSemanticOperation::ExecuteCommand => Some("executeCommand"), + LspSemanticOperation::InlayHints => Some("inlayHints"), + LspSemanticOperation::FoldingRanges => Some("foldingRanges"), + LspSemanticOperation::DocumentLinks => Some("documentLinks"), + LspSemanticOperation::WorkspaceSymbols => Some("workspaceSymbols"), + LspSemanticOperation::SemanticTokens => Some("semanticTokens"), + LspSemanticOperation::CodeLens => Some("codeLens"), + } +} + +pub(crate) 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(lithe_lsp_client::LspClientResponse { + state, + messages: vec![message], + events: Vec::new(), + }) +} diff --git a/rust/crates/lsp-engine/src/plugin_server_calls.rs b/rust/crates/lsp-engine/src/plugin_server_calls.rs new file mode 100644 index 00000000..4ef74a1e --- /dev/null +++ b/rust/crates/lsp-engine/src/plugin_server_calls.rs @@ -0,0 +1,223 @@ +//! Requests and notifications a plugin backend sends to the plugin's own language server: +//! targets, admission gates, held requests and answers. + +use super::*; + +/// Ends a request the backend sent to its plugin-owned language server. This +/// is the only place where the session policy releases such an operation, so +/// every way a request can end (answer, cancel, timeout, session failure) +/// leaves the policy consistent. Requests that joined a running command +/// finish with the same result or error. +pub(crate) fn complete_plugin_server_request( + session: &RuntimeSession, + state: &mut SessionState, + operation_id: &str, + method: &str, + result: Option, + mut error: Option, +) { + if let Some(error) = error.as_mut() { + error.code = plugin_server::host_error_code(&error.code).to_string(); + } + let joined = state + .plugin_server + .as_mut() + .map(|server| server.finish(operation_id)) + .unwrap_or_default(); + for waiter in joined { + enqueue_request_completed( + session, + state, + &waiter.operation_id, + &waiter.method, + result.clone(), + error.clone(), + ); + } + enqueue_request_completed(session, state, operation_id, method, result, error); +} + +/// The plugin-owned server a call targets, after checking that the session +/// is that contribution and can take requests now. +pub(crate) fn plugin_server_target<'a>( + state: &'a mut SessionState, + session_plugin_id: Option<&str>, + plugin_id: &str, + server_id: &str, +) -> Result<&'a mut PluginServerState, CoreError> { + let ready = state.lifecycle == LspLifecycleState::Ready && state.client.initialized; + let server = state + .plugin_server + .as_mut() + .ok_or_else(plugin_server::foreign_contribution)?; + server.check_owner(session_plugin_id, plugin_id, server_id)?; + if !ready { + return Err(plugin_server::unavailable()); + } + Ok(server) +} + +pub(crate) fn plugin_server_timeout(requested: Option) -> Duration { + let milliseconds = requested + .unwrap_or(DEFAULT_REQUEST_TIMEOUT_MILLISECONDS) + .clamp(1, MAX_REQUEST_TIMEOUT_MILLISECONDS); + Duration::from_millis(milliseconds) +} + +/// Allocates the JSON-RPC request of an admitted operation and records it as +/// pending. Returns the messages to write and the request ID. +pub(crate) fn register_plugin_server_request( + state: &mut SessionState, + operation_id: &str, + method: &str, + params: Value, + created_at: Instant, + deadline: Instant, +) -> Result<(Vec, String), CoreError> { + let response = allocate_raw_request(state.client.clone(), method, params)?; + let request_id = (response.state.next_request_id - 1).to_string(); + state.client = response.state; + state.pending.insert( + request_id.clone(), + PendingRequest { + kind: PendingKind::PluginServer, + operation_id: Some(operation_id.to_string()), + method: method.to_string(), + document_uri: None, + document_version: None, + created_at, + deadline, + }, + ); + state + .request_by_operation + .insert(operation_id.to_string(), request_id.clone()); + Ok((response.messages, request_id)) +} + +/// Applies a notification the server sent to a plugin-owned session: it may +/// open gates, and the backend may have asked to receive it. Returns the +/// messages of the requests that waited for those gates. +pub(crate) fn observe_plugin_server_notification( + session: &RuntimeSession, + state: &mut SessionState, + method: Option<&str>, + params: Option<&Value>, +) -> Vec { + let (Some(method), Some(server)) = (method, state.plugin_server.as_mut()) else { + return Vec::new(); + }; + let observation = server.observe(method, params.unwrap_or(&Value::Null)); + if let Some(forwarded) = observation.forward { + push_negotiated_provider_event(session, state, forwarded, "pluginServerNotification"); + } + observation + .released + .into_iter() + .flat_map(|held| release_held_plugin_server_request(session, state, held)) + .collect() +} + +/// Sends a request whose gates opened. It keeps the deadline it was +/// admitted with, so the time spent waiting counts against its timeout. +fn release_held_plugin_server_request( + session: &RuntimeSession, + state: &mut SessionState, + held: plugin_server::HeldRequest, +) -> Vec { + let registered = register_plugin_server_request( + state, + &held.operation_id, + &held.method, + held.params, + held.created_at, + held.deadline, + ); + match registered { + Ok((messages, _)) => messages, + Err(error) => { + let failure = runtime_error( + session, + "transportFailed", + "request", + "Could not encode the language-server request.", + FailureContext::request(&held.method, None).with_underlying(Some(&error.message)), + ); + push_request_event( + session, + state, + &held.operation_id, + &held.method, + None, + Some(failure), + ); + Vec::new() + } + } +} + +/// Cancels a plugin-server request that never reached the server, because it +/// waits behind a gate or joined a running command. Nothing is written. +pub(crate) fn cancel_unsent_plugin_server_request( + session: &RuntimeSession, + state: &mut SessionState, + operation_id: &str, +) { + let method = state + .plugin_server + .as_ref() + .and_then(|server| server.method_of(operation_id)) + .unwrap_or_default() + .to_string(); + let error = runtime_error( + session, + "requestCancelled", + "request", + "Language-server request was cancelled.", + FailureContext::request(&method, None), + ); + push_request_event(session, state, operation_id, &method, None, Some(error)); +} + +/// What the backend receives for a plugin-owned server's reply: the result +/// untouched, so no Lithe normalizer reinterprets the server's own protocol, +/// or the server's error code and message as details of +/// `plugin.host.languageServerError`. +pub(crate) fn plugin_server_answer( + session: &RuntimeSession, + method: &str, + reply: &Value, +) -> (Option, Option) { + use lithe_plugin_rpc::{bounded_host_message, codes, MAX_RESULT_BYTES}; + let failure = |code: &str, message: &str| { + runtime_error( + session, + code, + "request", + message, + FailureContext::request(method, None), + ) + }; + if let Some(upstream) = reply.get("error") { + let mut error = failure( + codes::HOST_LANGUAGE_SERVER_ERROR, + "The language server returned an error.", + ); + let message = upstream + .get("message") + .and_then(Value::as_str) + .map(bounded_host_message); + error.details = Some(json!({ "code": upstream.get("code"), "message": message })); + return (None, Some(error)); + } + let result = reply.get("result").cloned().unwrap_or(Value::Null); + let size = serde_json::to_vec(&result).map_or(usize::MAX, |bytes| bytes.len()); + if size > MAX_RESULT_BYTES { + let error = failure( + codes::HOST_LIMIT_EXCEEDED, + "The language server's result is too large.", + ); + return (None, Some(error)); + } + (Some(json!({ "value": result })), None) +} diff --git a/rust/crates/lsp-engine/src/provider_events.rs b/rust/crates/lsp-engine/src/provider_events.rs new file mode 100644 index 00000000..9baed433 --- /dev/null +++ b/rust/crates/lsp-engine/src/provider_events.rs @@ -0,0 +1,140 @@ +//! Events of negotiated providers: build progress, VCS descriptions and the provider selection. + +use super::*; + +/// A wire request ID identifies one exact build operation in this session. +/// Cancellation/completion removes it from pending, so late progress is inert. +pub(crate) fn push_build_progress_locked( + session: &RuntimeSession, + state: &mut SessionState, + progress: Option, +) { + let Some(mut progress) = progress else { + return; + }; + let Some(id) = progress.get("operationID").and_then(Value::as_str) else { + return; + }; + let Some(pending) = state.pending.get(id) else { + return; + }; + if !lithe_lsp_client::build_provider::is_progress_method(&pending.method) { + return; + } + let Some(operation_id) = pending.operation_id.clone() else { + return; + }; + progress["operationID"] = json!(operation_id); + let method = pending.method.clone(); + let sequence = take_sequence(state); + enqueue_runtime_event( + session, + state, + LspRuntimeEvent { + kind: "requestProgress".to_string(), + channel: None, + plugin_id: None, + role: None, + event: None, + fields: None, + catalog_level: None, + sequence, + provider_id: session.provider_id.clone(), + session_id: session.id.clone(), + state: None, + operation_id: Some(operation_id), + method: Some(method), + uri: None, + version: None, + diagnostics: None, + result: Some(progress), + error: None, + capabilities: None, + completion_options: None, + server_info: None, + level: None, + message: None, + detail: None, + }, + ); +} + +/// Publishes the VCS and capabilities a provider negotiated, so the host can +/// check the VCS against the manifest entry and decide which actions to show. +pub(crate) fn push_vcs_provider_event( + session: &RuntimeSession, + state: &mut SessionState, + result: Value, +) { + let sequence = take_sequence(state); + enqueue_runtime_event( + session, + state, + LspRuntimeEvent { + kind: "vcsProvider".to_string(), + channel: None, + plugin_id: None, + role: None, + event: None, + fields: None, + catalog_level: None, + 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: Some(result), + error: None, + capabilities: None, + completion_options: None, + server_info: None, + level: None, + message: None, + detail: None, + }, + ); +} + +/// Publishes the providers a backend selected, so the host queries only those. +pub(crate) fn push_negotiated_provider_event( + session: &RuntimeSession, + state: &mut SessionState, + result: Value, + kind: &str, +) { + let sequence = take_sequence(state); + enqueue_runtime_event( + session, + state, + LspRuntimeEvent { + kind: kind.to_string(), + channel: None, + plugin_id: None, + role: None, + event: None, + fields: None, + catalog_level: None, + 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: Some(result), + error: None, + capabilities: None, + completion_options: None, + server_info: None, + level: None, + message: None, + detail: None, + }, + ); +} diff --git a/rust/crates/core/src/lsp/interface/engine_function_relay.rs b/rust/crates/lsp-engine/src/relay_hub.rs similarity index 99% rename from rust/crates/core/src/lsp/interface/engine_function_relay.rs rename to rust/crates/lsp-engine/src/relay_hub.rs index 1c8a89b5..f6b4f671 100644 --- a/rust/crates/core/src/lsp/interface/engine_function_relay.rs +++ b/rust/crates/lsp-engine/src/relay_hub.rs @@ -18,19 +18,19 @@ //! which the session fails and its process is killed. One such backend cannot //! hold up the calls of other plugins. -use super::super::function_relay::{ +use super::{ + json_rpc_error_with_data, json_rpc_result, push_negotiated_provider_event, LspLifecycleState, + LspRuntimeError, PluginFunctionRequest, RuntimeSession, SessionState, +}; +use lithe_plugin_function_relay::{ CallParent, CalleeOutcome, DispatchPlan, FunctionRelay, RelayAction, RelayFailure, RelayRequest, RelaySnapshot, OPERATION_PREFIX, }; -use super::super::plugin_function::{ +use lithe_plugin_rpc::{ bounded_host_message, codes, host_rpc_code, FunctionReference, FUNCTIONS_INVOKE, FUNCTIONS_PROVIDERS, INVOKE_METHOD, }; -use super::{ - json_rpc_error_with_data, json_rpc_result, push_negotiated_provider_event, LspLifecycleState, - LspRuntimeError, PluginFunctionRequest, RuntimeSession, SessionState, -}; -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde_json::{json, Value}; use std::collections::BTreeMap; use std::sync::{mpsc, Arc, Mutex, MutexGuard, PoisonError, Weak}; diff --git a/rust/crates/lsp-engine/src/requests.rs b/rust/crates/lsp-engine/src/requests.rs new file mode 100644 index 00000000..43c87764 --- /dev/null +++ b/rust/crates/lsp-engine/src/requests.rs @@ -0,0 +1,658 @@ +//! Request, response and event payloads of the `lsp.*` commands. + +use super::*; + +#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +/// Observable lifecycle of one managed language-server session. +pub enum LspLifecycleState { + /// Session state exists, but the child process has not started. + Created, + /// The child process and its standard streams are being created. + ProcessStarting, + /// The process is running and the initialize handshake is pending. + Initializing, + /// Initialization completed and semantic requests may be sent. + Ready, + /// A graceful shutdown is pending or the process is being terminated. + Stopping, + /// The session ended normally and will produce no further events. + Stopped, + /// Startup, protocol handling, or the child process failed terminally. + Failed, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// Validated process, workspace, initialization, and timeout settings for a server. +pub struct StartServerRequest { + pub(crate) provider_id: String, + /// Process role supplied by the host; old hosts retain language-server routing. + #[serde(default)] + pub role: SessionRole, + /// Stable VCS identity supplied explicitly by the provider catalog, never parsed from providerID. + #[serde(default)] + pub log_subject: Option, + /// Plugin that owns the process, for plugin-provided servers and build + /// providers. Only such a session can serve that plugin's functions; + /// host-owned servers leave it out. + #[serde(default)] + pub plugin_id: Option, + /// Declarative `languageServer/start` attempt the host began; Core records its + /// result. Absent for v1 hosts and SDK-owned backend processes. + #[serde(default)] + pub startup_operation: Option, + /// Host services the plugin's permissions grant the session, as the + /// launch plan lists them (`backend.hostServices`). Only a plugin-owned + /// session may name any; Core forwards only these `lithe/host/*` + /// requests to the host. + #[serde(default)] + pub host_services: Vec, + /// Options of the granted host services, such as the icons the host + /// draws for editor actions. Only a plugin-owned session may name any. + #[serde(default)] + pub host_options: lithe_lsp_client::HostOptions, + /// Provider IDs of the plugin's verified manifest. Only a plugin-owned + /// session with some is offered `litheToolchains`. + #[serde(default)] + pub toolchain_providers: Vec, + /// Shortcut import providers declared by this plugin's verified manifest. + #[serde(default)] + pub keymap_import_providers: Vec, + /// What the plugin backend may do with this plugin-owned language server + /// (plan API-25): the requests it may send, the gates they wait for, and + /// the notifications it receives. Only a plugin-owned session has one; + /// without it the backend cannot reach the server through the host. + #[serde(default)] + pub session_policy: Option, + pub(crate) executable_path: String, + #[serde(default)] + pub(crate) arguments: Vec, + #[serde(default)] + pub(crate) environment: BTreeMap, + pub(crate) root_uri: String, + pub(crate) working_directory: String, + #[serde(default)] + pub(crate) initialization_options: Option, + /// Settings a plugin-provided server reads through + /// `workspace/configuration`, already expanded from the plugin's + /// `workspaceConfiguration` template. Must be an object when present. + #[serde(default)] + pub workspace_configuration: Option, + #[serde(default = "default_initialize_timeout")] + pub(crate) initialize_timeout_milliseconds: u64, + #[serde(default = "default_request_timeout")] + pub(crate) request_timeout_milliseconds: u64, + #[serde(default = "default_shutdown_timeout")] + pub(crate) shutdown_timeout_milliseconds: u64, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Identity and initial lifecycle state of a newly created session. +pub struct StartServerResponse { + /// Catalog-derived channel for host diagnostic I/O; never derived from provider ID by hosts. + pub channel: String, + pub(crate) session_id: String, + pub(crate) state: LspLifecycleState, + pub(crate) process_id: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// Request targeting one existing server session. +pub struct SessionRequest { + pub(crate) session_id: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// Complete document contents or incremental edits to open or update in a session. +pub struct SyncDocumentRequest { + pub(crate) session_id: String, + pub(crate) uri: String, + pub(crate) language_id: String, + /// Full document text. May be omitted when `content_changes` extends a + /// document Core already holds. + #[serde(default)] + pub text: String, + /// Edits in the order they happened, each range relative to the previous result. + #[serde(default)] + pub content_changes: Vec, +} + +/// A host-confirmed disk save, carrying the exact saved text rather than newer edits. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SaveDocumentRequest { + pub(crate) session_id: String, + pub(crate) uri: String, + pub(crate) text: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Core-owned document synchronization state returned after an editor update. +pub struct SyncDocumentResponse { + /// Monotonic version used by all subsequent LSP requests and diagnostics. + pub document_version: i64, + /// `false` when the submitted text was already synchronized. + pub changed: bool, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// Workspace file events forwarded through standard LSP watched-file semantics. +pub struct WorkspaceFilesChangedRequest { + pub(crate) session_id: String, + pub(crate) changes: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// One absolute document URI and the filesystem transition observed by a host. +pub struct WorkspaceFileChange { + pub(crate) uri: String, + pub(crate) kind: WorkspaceFileChangeKind, +} + +#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +/// LSP watched-file transition mapped to protocol event types 1, 2, and 3. +pub enum WorkspaceFileChangeKind { + /// The file was created (event type 1). + Created, + /// The file was changed (event type 2). + Changed, + /// The file was deleted (event type 3). + Deleted, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// Request to remove a document from a session's synchronized state. +pub struct CloseDocumentRequest { + pub(crate) session_id: String, + pub(crate) uri: String, +} + +#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +/// Semantic operation normalized across provider-specific LSP capabilities. +pub enum LspSemanticOperation { + /// `textDocument/completion`. + Completion, + /// `textDocument/hover`. + Hover, + /// `textDocument/definition`. + Definition, + /// `textDocument/declaration`. + Declaration, + /// `textDocument/typeDefinition`. + TypeDefinition, + /// `textDocument/references`. + References, + /// `textDocument/implementation`. + Implementation, + /// `textDocument/rename`. + Rename, + /// `textDocument/formatting`. + Formatting, + /// `textDocument/rangeFormatting` over the request's `range`. + RangeFormatting, + /// `textDocument/documentSymbol`: the document outline. + DocumentSymbols, + /// `textDocument/codeAction`. + CodeActions, + /// `completionItem/resolve` for a previously returned completion item. + ResolveCompletion, + /// `codeAction/resolve` for a previously returned action. + ResolveCodeAction, + /// `workspace/executeCommand` using a server-provided command payload. + ExecuteCommand, + /// `textDocument/inlayHint`. + InlayHints, + /// `textDocument/foldingRange`. + FoldingRanges, + /// `textDocument/documentLink`: clickable ranges such as URLs and file references. + DocumentLinks, + /// `workspace/symbol`: workspace-wide symbol search for `query`; names no document. + WorkspaceSymbols, + /// Full `textDocument/semanticTokens/full` with its negotiated legend. + SemanticTokens, + /// `textDocument/codeLens`. + CodeLens, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// Inputs for one asynchronous semantic language-server operation. +pub struct SemanticRequest { + pub(crate) session_id: String, + #[serde(default)] + pub(crate) operation_id: Option, + pub(crate) operation: LspSemanticOperation, + #[serde(default)] + pub(crate) uri: Option, + #[serde(default)] + pub(crate) position: Option, + #[serde(default)] + pub(crate) new_name: Option, + /// Search text for `workspaceSymbols`; absent means the empty query. + #[serde(default)] + pub query: Option, + #[serde(default)] + pub(crate) range: Option, + #[serde(default)] + pub(crate) diagnostics: Vec, + /// LSP completion trigger context; validated by the shared request mapper. + #[serde(default)] + pub completion_context: Option, + #[serde(default)] + pub(crate) completion_item: Option, + #[serde(default)] + pub(crate) code_action: Option, + #[serde(default)] + pub(crate) command: Option, + /// Optional LSP `FormattingOptions` (`tabSize`, `insertSpaces`, ...) for + /// `formatting` and `rangeFormatting`; unset fields keep Core defaults. + #[serde(default)] + pub formatting_options: Option, + /// For `references`: whether the answer lists the declaration itself + /// (LSP `ReferenceContext.includeDeclaration`). Unset means `true`; the + /// usages shown from a declaration (`lsp.declarationOrUsages`) pass + /// `false` so the language server leaves the declaration out. + #[serde(default)] + pub include_declaration: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// One build-provider request, forwarded as `build.modelPlan` or +/// `build.dependencyPlan` planned it. The result arrives as a +/// `requestCompleted` event whose `result.value` is the provider's reply, +/// which the host passes to Core's decoder unchanged. +pub struct BuildProviderRequest { + pub(crate) session_id: String, + #[serde(default)] + pub(crate) operation_id: Option, + /// `lithe/build/model` or `lithe/build/dependencies`. + pub method: String, + /// The plan's `provider.params`, sent as is. + pub params: Value, + /// Deadline of this request, in milliseconds, capped at + /// `MAX_BUILD_PROVIDER_TIMEOUT_MS`; the session's request timeout when absent. + #[serde(default)] + pub timeout_milliseconds: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// One `toolchains/query`, addressed to a session that negotiated the provider +/// named in `params`. The result arrives as a `requestCompleted` event whose +/// `result.value` is `{ id, response }`, the complete untrusted JSON-RPC response, +/// which the host passes to `toolchains.decodeReply`. +pub struct ToolchainProviderRequest { + pub(crate) session_id: String, + #[serde(default)] + pub(crate) operation_id: Option, + /// Query parameters; `toolchains.decodeReply` validates them with the reply. + pub params: Value, + /// Deadline of this request, capped at `MAX_TOOLCHAIN_QUERY_TIMEOUT_MS`. + #[serde(default)] + pub timeout_milliseconds: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// One shortcut import operation against an opaque, host-owned snapshot. +pub struct KeymapImportProviderRequest { + /// Plugin-owned session which negotiated the provider. + pub session_id: String, + /// Optional correlation identity for cancellation and completion events. + #[serde(default)] + pub operation_id: Option, + /// Plugin-local identity of the selected provider. + #[serde(rename = "providerID")] + pub provider_id: String, + /// Host-owned snapshot reference, scoped to this import operation. + #[serde(rename = "snapshotID")] + pub snapshot_id: String, + /// Requested deadline in milliseconds, bounded by the runtime. + #[serde(default)] + pub timeout_milliseconds: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// One VCS-provider request. Core plans the method and params from the +/// operation and its arguments, so the host never writes a raw method. The +/// result arrives as a `requestCompleted` event whose `result.value` is the +/// provider's reply, which the host passes to `vcs.decodeReply`; a failure +/// carries a `vcs.*` code. +pub struct VcsProviderRequest { + pub(crate) session_id: String, + #[serde(default)] + pub(crate) operation_id: Option, + /// The VCS the host launched the session for; a session that negotiated + /// another VCS is refused. + pub vcs: String, + /// Operation name, such as `status` or `checkin`. + pub operation: String, + /// Native absolute path of the working-copy root. + pub root: String, + /// Absolute path of the VCS client the host resolved, when the provider + /// declares one. + #[serde(default)] + pub tool: Option, + /// Operation arguments; see `crate::vcs` for each operation's shape. + #[serde(default)] + pub arguments: Value, + /// Deadline of this request, in milliseconds, capped at + /// `MAX_VCS_PROVIDER_TIMEOUT_MS`; the session's request timeout when absent. + #[serde(default)] + pub timeout_milliseconds: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// The host's answer to one `hostRequest` event: the outcome of a host +/// service a plugin backend asked for. Exactly one of `result` and `error`. +pub struct PluginHostResponse { + pub(crate) session_id: String, + /// The `hostRequestID` of the `hostRequest` event being answered, + /// spelled as the event spells it. + #[serde(rename = "hostRequestID")] + pub host_request_id: String, + /// The service's result; `null` for a write or delete. + #[serde(default)] + pub result: Option, + /// The host could not perform the service. Its message reaches the + /// backend, bounded. Its code passes only when it is one a host may + /// report (`plugin_function::host_failure`); otherwise it is + /// `plugin.host.failed`. + #[serde(default)] + pub error: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// Why the host could not perform a forwarded host-service request. +pub struct PluginHostFailure { + pub(crate) message: String, + /// Stable `plugin.host.*` code of the failure. + #[serde(default)] + pub code: Option, + /// Structured facts a backend may act on, such as the current version + /// of a `plugin.host.versionConflict`. It reaches the backend as + /// `error.data.details` when it is an object within + /// `plugin_function::MAX_HOST_DETAILS_BYTES`; otherwise it is dropped and + /// the code and message still arrive. + #[serde(default)] + pub details: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// One notification from the host to a plugin backend, such as a +/// subscription event or a changed set of workspace contexts. Core admits +/// only the methods of `plugin_function::HOST_NOTIFICATIONS`. +pub struct PluginHostNotification { + pub(crate) session_id: String, + pub(crate) method: String, + pub(crate) params: Value, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Whether a host notification was written to the backend. `false` means +/// the backend negotiated no plugin-function protocol version. +pub struct PluginHostNotificationResponse { + pub(crate) delivered: bool, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// One plugin-function invocation. The result arrives as a `requestCompleted` +/// event whose `result.value` is the function's result; failures carry a +/// stable `plugin.function.*` code. Progress arrives as `requestProgress`. +pub struct PluginFunctionRequest { + pub(crate) session_id: String, + #[serde(default)] + pub(crate) operation_id: Option, + pub(crate) function: lithe_plugin_rpc::FunctionReference, + /// Serializable function input; must be a JSON object. + #[serde(default = "empty_object")] + pub params: Value, + /// Deadline of this call, in milliseconds, capped at one hour; the + /// session's request timeout when absent. + #[serde(default)] + pub timeout_milliseconds: Option, + /// Workspace context `{ contextID, generation }` the call belongs to. + /// It reaches the backend as `params.context`. + #[serde(default)] + pub context: Option, +} + +fn empty_object() -> Value { + json!({}) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// One request a plugin backend sent to its own language server through the +/// host (plan API-25). The reply arrives as a `requestCompleted` event whose +/// `result.value` is the server's result; failures carry a `plugin.host.*` +/// code as `error.code`. +pub struct PluginServerRequest { + /// The plugin-owned language-server session the host resolved. + pub session_id: String, + #[serde(default)] + pub(crate) operation_id: Option, + /// The plugin and server the backend named. Core checks that the + /// session really is that contribution, so a stale or mistaken + /// resolution can never reach another plugin's server. + pub plugin_id: String, + #[serde(rename = "serverID")] + pub(crate) server_id: String, + /// An LSP request method the plugin declared in `requestMethods`. + pub method: String, + #[serde(default = "empty_object")] + pub(crate) params: Value, + /// Deadline in milliseconds, 60000 when absent and at most 300000. + #[serde(default)] + pub timeout_milliseconds: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// One notification a plugin backend sent to its own language server. +pub struct PluginServerNotify { + pub(crate) session_id: String, + pub(crate) plugin_id: String, + #[serde(rename = "serverID")] + pub(crate) server_id: String, + pub(crate) method: String, + #[serde(default = "empty_object")] + pub(crate) params: Value, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Whether the notification reached the server. `false` means the server +/// was not initialized yet and will receive the stored configuration with +/// its `initialized` notification instead. +pub struct PluginServerNotifyResponse { + pub(crate) delivered: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Correlation identifier used to receive or cancel an asynchronous result. +pub struct OperationResponse { + pub(crate) operation_id: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// Request to cancel a pending operation in one session. +pub struct CancelOperationRequest { + pub(crate) session_id: String, + pub(crate) operation_id: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Ordered events drained from a session since the previous poll. +pub struct PollEventsResponse { + pub(crate) events: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// Request that waits until queued events exist or the timeout elapses. +pub struct WaitEventsRequest { + pub(crate) session_id: String, + /// Upper bound for blocking on the session event channel, in milliseconds. + #[serde(default = "default_wait_events_timeout")] + pub timeout_milliseconds: u64, +} + +fn default_wait_events_timeout() -> u64 { + 30_000 +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Sequenced lifecycle, diagnostic, result, or log event from a server session. +pub struct LspRuntimeEvent { + /// Event discriminator such as `stateChanged`, `requestCompleted`, or `diagnostics`. + #[serde(rename = "type")] + pub kind: String, + /// Core-derived catalog channel, present for structured log facts. + #[serde(skip_serializing_if = "Option::is_none")] + pub channel: Option, + /// Plugin that owns this process; omitted for host-owned sessions. + #[serde(skip_serializing_if = "Option::is_none")] + pub plugin_id: Option, + /// Explicit process role, independent of the provider's opaque ID. + #[serde(skip_serializing_if = "Option::is_none")] + pub role: Option, + /// Stable catalog identity; existing v1 fields remain available. + #[serde(skip_serializing_if = "Option::is_none")] + pub event: Option, + /// Catalog disk fields, validated and redacted by Core. + #[serde(skip_serializing_if = "Option::is_none")] + pub fields: Option>, + /// Canonical catalog severity for v2; independent of the preserved v1 level. + #[serde(skip_serializing_if = "Option::is_none")] + pub catalog_level: Option, + pub(crate) sequence: u64, + pub(crate) provider_id: String, + pub(crate) session_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) state: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) operation_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) method: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) uri: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) diagnostics: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) capabilities: Option>, + /// On `featuresChanged` while `completion` is offered: the session's + /// `{ triggerCharacters, allCommitCharacters }`, from `completionProvider`. + #[serde(skip_serializing_if = "Option::is_none")] + pub completion_options: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) server_info: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) level: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) detail: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Server identity reported by the LSP initialize response. +pub struct LspServerInfo { + pub(crate) name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) version: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Structured runtime failure with session and protocol-stage context. +pub struct LspRuntimeError { + pub(crate) code: String, + pub(crate) provider_id: String, + pub(crate) session_id: String, + /// Lifecycle or protocol phase in which the error occurred. + pub stage: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) method: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) document_uri: Option, + pub(crate) message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) underlying_message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) process_exit_code: Option, + /// Plugin-defined code of a failed plugin function (`error.data.code`), + /// passed through for the plugin's own UI; Lithe never branches on it. + #[serde(skip_serializing_if = "Option::is_none")] + pub plugin_error_code: Option, + /// Structured facts of a failed plugin-server request, such as the + /// server's own error `code` and `message`, passed to the backend as the + /// failure's details. Only `lsp.pluginServerRequest` sets it. + #[serde(skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +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 +} + +/// Replaces the plugin configuration of a running plugin-provided session. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct UpdateWorkspaceConfigurationRequest { + pub(crate) session_id: String, + pub(crate) configuration: Value, +} + +/// Whether the change reached the server now or waits for `initialized`. +#[derive(Debug, Serialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct UpdateWorkspaceConfigurationResponse { + pub(crate) sent: bool, +} + +/// Host-owned toggle for one session's upstream trace diagnostics. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SetDetailedDiagnosticsRequest { + /// Opaque session returned by startServer. + pub session_id: String, + /// True sends verbose tracing, false sends off; default sessions start disabled. + pub enabled: bool, +} diff --git a/rust/crates/core/src/lsp/languages/indexing.rs b/rust/crates/lsp-engine/src/scan_settings.rs similarity index 98% rename from rust/crates/core/src/lsp/languages/indexing.rs rename to rust/crates/lsp-engine/src/scan_settings.rs index 385deea4..5625a47b 100644 --- a/rust/crates/core/src/lsp/languages/indexing.rs +++ b/rust/crates/lsp-engine/src/scan_settings.rs @@ -14,13 +14,13 @@ pub(crate) fn configuration(provider: &str, root_uri: &str, base: Option<&Value> return base.cloned(); }; let root = root.canonicalize().unwrap_or(root); - if crate::project::indexing::context(&root).is_none() { + if lithe_project::indexing::context(&root).is_none() { return base.cloned(); } Some(with_roots( provider, base, - &crate::project::indexing::excluded_roots(&root), + &lithe_project::indexing::excluded_roots(&root), )) } diff --git a/rust/crates/lsp-engine/src/server_messages.rs b/rust/crates/lsp-engine/src/server_messages.rs new file mode 100644 index 00000000..0f93ace9 --- /dev/null +++ b/rust/crates/lsp-engine/src/server_messages.rs @@ -0,0 +1,604 @@ +//! Messages read from the server and the backend: responses, server requests, +//! notifications and configuration pulls. + +use super::*; + +impl RuntimeSession { + pub(crate) fn handle_server_message(&self, message: String) -> Result<(), CoreError> { + self.handle_server_message_after_policy_check(message, || {}) + } + + pub(crate) fn handle_server_message_after_policy_check( + &self, + message: String, + after_policy_check: impl FnOnce(), + ) -> 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()) + })?; + // Diagnostics and readiness flushes must share the same policy boundary + // as host document commits and exclusion withdrawal. + let _indexing_order = self.lock_indexing_roots()?; + let outbound_order = self.lock_outbound_order()?; + let response_id = if value.get("method").is_none() { + lsp_value_id(value.get("id")) + } else { + None + }; + let (known_pending, pending_before, old_capabilities, old_completion) = { + 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(), + lithe_lsp_client::completion_options(&state.client), + ) + }; + // 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", + EngineLog::ResponseSkipped.with_fields(json!(response_id)), + "Ignored a late language-server response", + response_id, + ); + return Ok(()); + } + // Only a remote workspace rewrites the message; a local one keeps the + // value parsed above instead of parsing the same text again. + let value = match &self.workspace_host { + Some(host) => { + let mapped = lithe_lsp_transport::remote_process::map_message( + host, + &message, + false, + pending_before + .as_ref() + .map(|pending| pending.method.as_str()), + )?; + serde_json::from_str(&mapped).map_err(|error| { + CoreError::new( + ErrorCode::ParseFailed, + "Invalid mapped LSP server JSON message.", + ) + .with_details(error.to_string()) + })? + } + None => value, + }; + let method = value.get("method").and_then(Value::as_str); + if method == Some("textDocument/publishDiagnostics") { + if let Some(uri) = value.pointer("/params/uri").and_then(Value::as_str) { + if lithe_project::indexing::uri_is_excluded(&self.root_uri, uri).unwrap_or(true) { + return Ok(()); + } + } + } + after_policy_check(); + if method == Some("workspace/configuration") { + if let Some(response) = self.provider_configuration_response(&value)? { + return self.send_messages_or_fail( + &outbound_order, + vec![response], + "serverRequest", + ); + } + } + + let reduced = { + let mut state = self.lock_state()?; + let redirected = if method == Some("textDocument/publishDiagnostics") { + let state = &mut *state; + diagnostics_addressed_to_open_document( + &state.client.open_documents, + &mut state.linked_document_paths, + &value, + ) + } else { + None + }; + client_apply_server_message_for_plugin( + state.client.clone(), + redirected.as_ref().unwrap_or(&value), + self.plugin_id.is_some(), + )? + }; + let mut outbound = reduced.messages; + let mut flush_documents = false; + let mut ready_server_info = None; + 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); + state.client.pending_semantic_legends.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); + state.function_by_operation.remove(operation_id); + } + } + } + + match pending_before.as_ref().map(|pending| pending.kind) { + Some(PendingKind::Initialize) => { + let server_error = value.get("error").map(Value::to_string); + if server_error.is_some() || !state.client.initialized { + state.initialize_deadline = None; + fail_initialize = Some(( + if server_error.is_some() { + "initializeFailed".to_string() + } else { + "invalidServerMessage".to_string() + }, + server_error, + )); + } else { + plugin_operations::finish(&self.startup_attempt, None); + state.initialize_deadline = None; + ready_server_info = parse_server_info(&value); + flush_documents = true; + } + } + Some(PendingKind::Feature) + if pending_before.as_ref().is_some_and(|pending| { + pending.method == lithe_plugin_rpc::INVOKE_METHOD + }) => + { + if let Some(operation_id) = pending_before + .as_ref() + .and_then(|pending| pending.operation_id.as_ref()) + { + let (result, error) = match lithe_plugin_rpc::invocation_outcome(&value) { + Ok(result) => (Some(result), None), + Err(failure) => (None, Some(function_runtime_error(self, failure))), + }; + push_request_event( + self, + &mut state, + operation_id, + lithe_plugin_rpc::INVOKE_METHOD, + result, + error, + ); + } + } + Some(PendingKind::Feature) + if pending_before.as_ref().is_some_and(|pending| { + lithe_lsp_client::build_provider::is_execution_method(&pending.method) + }) => + { + if let Some(pending) = pending_before.as_ref() { + if let Some(operation_id) = &pending.operation_id { + let (result, error) = + match lithe_lsp_client::build_provider::execution_outcome(&value) { + Ok(result) => (Some(result), None), + Err((code, message)) => ( + None, + Some(runtime_error( + self, + code, + "request", + &message, + FailureContext::request(&pending.method, None), + )), + ), + }; + push_request_event( + self, + &mut state, + operation_id, + &pending.method, + result, + error, + ); + } + } + } + Some(PendingKind::Feature) + if pending_before + .as_ref() + .is_some_and(|pending| lithe_vcs::is_vcs_method(&pending.method)) => + { + if let Some(pending) = pending_before.as_ref() { + if let Some(operation_id) = &pending.operation_id { + let (result, error) = + match lithe_lsp_client::vcs_provider::reply_outcome(&value) { + Ok(result) => (Some(result), None), + Err(failure) => ( + None, + Some(runtime_error( + self, + failure.code, + "request", + &failure.message, + FailureContext::request(&pending.method, None), + )), + ), + }; + push_request_event( + self, + &mut state, + operation_id, + &pending.method, + result, + error, + ); + } + } + } + Some(PendingKind::Feature) + if pending_before.as_ref().is_some_and(|pending| { + pending.method == lithe_lsp_client::keymap_provider::IMPORT_METHOD + }) => + { + if let Some(pending) = pending_before.as_ref() { + if let Some(operation_id) = &pending.operation_id { + let (result, error) = if value.to_string().len() + > lithe_lsp_client::keymap_provider::MAX_REPLY_BYTES + { + ( + None, + Some(runtime_error( + self, + "plugin.keymapImport.replyTooLarge", + "request", + "The shortcut import provider reply is too large.", + FailureContext::request(&pending.method, None), + )), + ) + } else { + ( + Some( + json!({"value": {"id": value.get("id"), "response": value}}), + ), + None, + ) + }; + push_request_event( + self, + &mut state, + operation_id, + &pending.method, + result, + error, + ); + } + } + } + Some(PendingKind::Feature) + if pending_before.as_ref().is_some_and(|pending| { + pending.method == lithe_lsp_client::toolchain_provider::QUERY_METHOD + }) => + { + if let Some(pending) = pending_before.as_ref() { + if let Some(operation_id) = &pending.operation_id { + let result = + lithe_lsp_client::toolchain_provider::reply_outcome(&value); + push_request_event( + self, + &mut state, + operation_id, + &pending.method, + Some(result), + None, + ); + } + } + } + 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", + "Language server returned an error.", + FailureContext::request( + &pending.method, + pending.document_uri.as_deref(), + ) + .with_underlying(Some(detail)), + ) + }); + let result = event.and_then(|event| event.result.clone()); + // A plugin-owned server marks locations that are not + // files read-only; answers from other servers pass through. + let result = if state.plugin_server.is_some() { + plugin_server::normalize_navigation_result(result) + } else { + result + }; + push_request_event( + self, + &mut state, + operation_id, + &pending.method, + result, + error, + ); + } + } + } + Some(PendingKind::PluginServer) => { + if let Some(pending) = pending_before.as_ref() { + if let Some(operation_id) = &pending.operation_id { + let (result, error) = + plugin_server_answer(self, &pending.method, &value); + push_request_event( + self, + &mut state, + operation_id, + &pending.method, + result, + 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 == "notification" { + outbound.extend(observe_plugin_server_notification( + self, + &mut state, + event.method.as_deref(), + event.result.as_ref(), + )); + } + if event.kind == "semanticTokensRefresh" { + push_semantic_tokens_refresh_event(self, &mut state); + } else if event.kind == "functionsChanged" { + let result = event.result.unwrap_or_default(); + let removed: Vec = result + .get("removed") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(ToString::to_string) + .collect(); + outbound.extend(revoke_function_calls_locked(self, &mut state, &removed)); + push_functions_event(self, &mut state, result); + relay_hub::observe_functions_changed(self); + } else if event.kind == "functionRelayRequest" { + outbound.extend(self.relay.answer_request( + self, + &state, + event.method.as_deref().unwrap_or_default(), + &event.result.unwrap_or_default(), + )?); + } else if event.kind == "functionProgress" { + push_function_progress_locked(self, &mut state, event.result); + } else if event.kind == "buildProgress" { + push_build_progress_locked(self, &mut state, event.result); + } else if event.kind == "hostRequest" { + outbound.extend(forward_host_request_locked( + self, + &mut state, + event.method.unwrap_or_default(), + event.result.unwrap_or_default(), + )?); + } else if event.kind == lithe_lsp_client::vcs_provider::NEGOTIATED_EVENT { + push_vcs_provider_event(self, &mut state, event.result.unwrap_or_default()); + } else if event.kind == lithe_lsp_client::toolchain_provider::NEGOTIATED_EVENT { + push_negotiated_provider_event( + self, + &mut state, + event.result.unwrap_or_default(), + "toolchainProviders", + ); + } else if event.kind == lithe_lsp_client::keymap_provider::NEGOTIATED_EVENT { + push_negotiated_provider_event( + self, + &mut state, + event.result.unwrap_or_default(), + "keymapImportProviders", + ); + } else 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" + && event.method.as_deref() == Some(lithe_lsp_client::plugin_logging::METHOD) + { + receive_plugin_diagnostic(self, &mut state, event.result.as_ref()); + } else if event.kind == "notification" + && self.plugin_id.is_some() + && event.method.as_deref() == Some("$/cancelRequest") + && cancel_host_request_locked( + self, + &mut state, + event.result.as_ref(), + &mut outbound, + )? + { + // The backend withdrew one of its own host requests. + } else if event.kind == "notification" { + let method = event.method.as_deref().unwrap_or("unknown"); + let params = event.result.as_ref().unwrap_or(&Value::Null); + if method != "$/logTrace" || state.detailed_diagnostics { + let (identity, fields) = EngineLog::notification(method, params); + push_log_event( + self, + &mut state, + "info", + identity.with_fields(fields), + method, + event.result.map(|value| value.to_string()), + ); + } + } + } + if (state.client.server_capabilities != old_capabilities + || lithe_lsp_client::completion_options(&state.client) != old_completion) + && 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(configuration) = self.plugin_configuration() { + outbound.push(plugin_configuration_notification(&configuration)); + } + outbound.extend(self.flush_queued_documents()?); + let diagnostics_enabled = self.lock_state()?.requested_diagnostics; + if diagnostics_enabled { + // LSP forbids setTrace before the initialize response. This batch + // already starts with initialized, under the same outbound lock. + outbound.push(trace_notification(true)); + } + self.send_messages_or_fail(&outbound_order, outbound, "serverResponse")?; + let mut state = self.lock_state()?; + state.detailed_diagnostics = diagnostics_enabled; + if let Some(info) = ready_server_info { + push_server_info_event(self, &mut state, info); + } + push_log_event( + self, + &mut state, + "info", + EngineLog::ProtocolInitialized, + "Language service protocol initialized", + None, + ); + transition_locked(self, &mut state, LspLifecycleState::Ready, None); + let capabilities = state.client.server_capabilities.clone(); + push_features_event(self, &mut state, capabilities); + return Ok(()); + } + self.send_messages_or_fail(&outbound_order, outbound, "serverResponse")?; + Ok(()) + } + + fn provider_configuration_response( + &self, + message: &Value, + ) -> Result, CoreError> { + let Some(id) = message.get("id") else { + return Ok(None); + }; + let Some(configuration) = self.plugin_configuration() else { + return Ok(None); + }; + // Each item asks for one section; `scopeUri` is ignored because a + // session carries one configuration for its whole workspace. + let values: Vec = message + .get("params") + .and_then(|params| params.get("items")) + .and_then(Value::as_array) + .into_iter() + .flatten() + .map(|item| { + configuration_section(&configuration, item.get("section").and_then(Value::as_str)) + }) + .collect(); + 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 queued = { + let state = self.lock_state()?; + state + .pending_workspace_file_changes + .keys() + .cloned() + .chain( + state + .client + .open_documents + .values() + .filter(|document| document.version == 0) + .map(|document| document.uri.clone()), + ) + .collect::>() + }; + for uri in queued { + if lithe_project::indexing::uri_is_excluded(&self.root_uri, &uri)? { + let mut state = self.lock_state()?; + state.pending_workspace_file_changes.remove(&uri); + if state + .client + .open_documents + .get(&uri) + .is_some_and(|document| document.version == 0) + { + state.client.open_documents.remove(&uri); + } + } + } + let mut state = self.lock_state()?; + flush_queued_documents_locked(&mut state) + } +} + +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), + }) +} diff --git a/rust/crates/lsp-engine/src/state.rs b/rust/crates/lsp-engine/src/state.rs new file mode 100644 index 00000000..9ae07df2 --- /dev/null +++ b/rust/crates/lsp-engine/src/state.rs @@ -0,0 +1,150 @@ +//! Engine and session state: the session table, the per-session state behind its lock +//! and the pending request and function-call tables. + +use super::*; + +#[cfg(test)] +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EngineSnapshot { + pub session_id: String, + pub provider_id: String, + /// The workspace this session serves. Replacing a workspace means stopping + /// the session bound to the old root, so callers need to be able to tell + /// which root a session belongs to. + pub root_uri: 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)] +/// Response handling path required by one pending JSON-RPC request. +pub(crate) enum PendingKind { + /// Initial handshake whose result transitions the session to ready. + Initialize, + /// Ordinary semantic feature whose result becomes an operation event. + Feature, + /// Request a plugin backend sent to its own language server through + /// `lsp.pluginServerRequest`; the answer reaches the backend verbatim. + PluginServer, + /// Shutdown handshake after which the engine sends `exit`. + Shutdown, +} + +#[derive(Debug, Clone)] +pub(crate) struct PendingRequest { + pub(crate) kind: PendingKind, + pub(crate) operation_id: Option, + pub(crate) method: String, + pub(crate) document_uri: Option, + /// Document version observed when a presentation request was allocated. + pub(crate) document_version: Option, + pub(crate) created_at: Instant, + pub(crate) deadline: Instant, +} + +/// Links one host operation to the exact backend request and function it owns. +pub(crate) struct PendingFunctionCall { + pub(crate) function_id: String, + /// Also serves as the opaque wire operation ID; request IDs never repeat in a session. + pub(crate) request_id: String, +} + +pub(crate) struct SessionState { + pub(crate) lifecycle: LspLifecycleState, + pub(crate) client: LspClientState, + pub(crate) pending: BTreeMap, + pub(crate) request_by_operation: BTreeMap, + /// Exact function invocation owned by each active host operation. Every + /// terminal path removes its entry before that host operation ID can be reused. + pub(crate) function_by_operation: BTreeMap, + /// Symbolic-link facts about the open documents, rebuilt lazily from + /// `client.open_documents`. + pub(crate) linked_document_paths: LinkedDocumentPaths, + /// Latest workspace event per URI observed before protocol initialization. + /// The engine owns and drains this queue exactly once after `initialized`. + pub(crate) pending_workspace_file_changes: BTreeMap, + pub(crate) events: VecDeque, + pub(crate) next_sequence: u64, + pub(crate) initialize_deadline: Option, + pub(crate) shutdown_deadline: Option, + pub(crate) request_timeout: Duration, + pub(crate) shutdown_timeout: Duration, + pub(crate) terminal_event_emitted: bool, + /// Deadline cancellations awaiting the outbound worker, never written by the monitor. + pub(crate) deadline_cancellations: Vec, + /// Bounds an outbound maintenance write independently of caller deadlines. + pub(crate) maintenance_write_deadline: Option, + /// Policy and waiting requests of a plugin-owned language server; `None` + /// for every other session. + pub(crate) plugin_server: Option, + /// Bounds one write the function relay's lane makes to this session, so a + /// backend that stops reading its stdin cannot hold the lane forever. + pub(crate) relay_write_deadline: Option, + /// Backend requests for a host service awaiting `lsp.hostResponse`, by + /// the opaque ID the host sees, with the backend's own JSON-RPC ID. + pub(crate) host_requests: BTreeMap, + pub(crate) next_host_request_id: u64, + /// Trace is opt-in and serialized with protocol writes through outbound_order. + pub(crate) detailed_diagnostics: bool, + /// Desired trace setting retained until the initialize handshake permits notifications. + pub(crate) requested_diagnostics: bool, + /// Malformed plugin diagnostics are summarized once per process session. + pub(crate) rejected_plugin_diagnostics: bool, + /// Sender-side ingress budget uses the existing process clock, never a timer. + pub(crate) plugin_diagnostics_window: Instant, + pub(crate) plugin_diagnostics_count: u16, +} + +pub(crate) struct RuntimeSession { + /// Declarative plugin start attempt shared with `start_server`. + pub(crate) startup_attempt: AttemptSlot, + pub(crate) id: String, + pub(crate) provider_id: String, + /// Plugin that owns the process, if any; see `StartServerRequest::plugin_id`. + pub(crate) plugin_id: Option, + pub(crate) log_identity: SessionLogIdentity, + /// Fixed workspace host, inherited by protocol workers; never a global selection. + pub(crate) workspace_host: Option, + /// Plugin-declared settings answered to `workspace/configuration`. + /// Replaced as a whole by `lsp.updateWorkspaceConfiguration`; never held + /// across another lock. + pub(crate) plugin_configuration: Mutex>>, + /// Workspace URI the server was initialized with; scopes indexing + /// exclusions and other workspace-wide checks to this session's root. + pub(crate) root_uri: String, + /// Serializes policy eligibility with document/file-event publication and + /// policy withdrawal. Acquire before outbound_order; Git scans stay outside + /// the outbound and registry locks. + pub(crate) indexing_roots: Mutex>, + pub(crate) indexing_revision: std::sync::atomic::AtomicU64, + /// Serializes protocol-state commits with complete outbound message batches. + /// Callers acquire this before `state` whenever an action can write to the + /// server, preserving the same order in memory and on the wire. + pub(crate) outbound_order: Mutex<()>, + pub(crate) state: Mutex, + pub(crate) event_signal: Condvar, + pub(crate) process: Arc, + pub(crate) active: AtomicBool, + /// The engine's cross-plugin function relay; see `engine_function_relay`. + pub(crate) relay: Arc, +} + +/// The engine is a process-owning singleton in production, but it holds no +/// global state of its own beyond the session registry, so tests construct +/// private instances with a scripted launcher. +pub(crate) struct LspEngine { + pub(crate) next_session_id: AtomicU64, + pub(crate) next_operation_id: AtomicU64, + /// Shared with the relay's worker, which reaches sessions by ID. + pub(crate) sessions: Arc>>>, + /// Cross-plugin function calls; every session of this engine uses it. + pub(crate) relay: Arc, + /// Failed wire starts have no returned identity. Retain their cleanup + /// responsibility here and retry disposal before another process starts. + pub(crate) failed_starts: Mutex>, + pub(crate) launcher: Arc, +} diff --git a/rust/crates/lsp-engine/src/tests.rs b/rust/crates/lsp-engine/src/tests.rs new file mode 100644 index 00000000..180f323c --- /dev/null +++ b/rust/crates/lsp-engine/src/tests.rs @@ -0,0 +1,5668 @@ +use super::*; +use lithe_lsp_transport::scripted::ScriptedServer; +/// The capabilities every test needs to reach `Ready` with a usable feature +/// surface. Individual tests narrow or extend this. +fn ready_capabilities() -> Value { + json!({ + "hoverProvider": true, + "definitionProvider": true, + "completionProvider": {}, + "renameProvider": true + }) +} + +pub(super) fn start_request(server: &ScriptedServer) -> StartServerRequest { + let _ = server; + StartServerRequest { + provider_id: "gopls".to_string(), + role: SessionRole::LanguageServer, + log_subject: None, + plugin_id: None, + startup_operation: None, + host_services: Vec::new(), + host_options: Default::default(), + toolchain_providers: Vec::new(), + keymap_import_providers: Vec::new(), + session_policy: None, + executable_path: "/usr/bin/scripted-server".to_string(), + arguments: vec!["--stdio".to_string()], + environment: BTreeMap::new(), + root_uri: "file:///workspace".to_string(), + working_directory: "/workspace".to_string(), + initialization_options: None, + workspace_configuration: None, + initialize_timeout_milliseconds: 10_000, + request_timeout_milliseconds: 10_000, + shutdown_timeout_milliseconds: 10_000, + } +} + +#[test] +fn native_owner_keeps_failed_initialize_identity_until_verified_disposal() { + let server = ScriptedServer::new(); + server.break_input(); + server.block_cleanup(true); + let engine = LspEngine::with_launcher(server.launcher()); + let registered = Mutex::new(None); + let result = engine.start_server_owned(start_request(&server), |id| { + // Registration precedes the very first write, including its failure. + assert!(server.messages().is_empty()); + *registered.lock().unwrap() = Some(id.to_string()); + }); + assert!(result.is_err()); + let id = registered + .into_inner() + .unwrap() + .expect("native owner received the session"); + assert!(engine.session(&id).is_ok()); + assert!(engine.destroy(&id).is_err()); + assert!( + engine.session(&id).is_ok(), + "failed disposal must retain the process owner" + ); + server.block_cleanup(false); + engine + .destroy(&id) + .expect("verified cleanup allows destruction"); + assert!(engine.session(&id).is_err()); +} + +#[test] +fn wire_owner_retries_failed_initialize_disposal_before_another_start() { + let server = ScriptedServer::new(); + server.break_input(); + server.block_cleanup(true); + let engine = LspEngine::with_launcher(server.launcher()); + assert!(engine.start_server(start_request(&server)).is_err()); + let id = engine + .lock_failed_starts() + .unwrap() + .first() + .cloned() + .unwrap(); + let session = engine.session(&id).unwrap(); + let events = session.poll_events().unwrap(); + assert!(events.iter().any(|event| { + event + .error + .as_ref() + .is_some_and(|error| error.code == "transportFailed") + })); + let next_id = engine.next_session_id.load(Ordering::Relaxed); + let error = engine.start_server(start_request(&server)).unwrap_err(); + assert!(matches!(error.code, ErrorCode::ProcessFailed)); + let error = engine + .start_server_owned(start_request(&server), |_| { + panic!("cleanup must precede spawn") + }) + .unwrap_err(); + assert!(matches!(error.code, ErrorCode::ProcessFailed)); + assert_eq!(engine.next_session_id.load(Ordering::Relaxed), next_id); + assert_eq!(engine.lock_sessions().unwrap().len(), 1); + assert!(Arc::ptr_eq(&session, &engine.session(&id).unwrap())); + server.block_cleanup(false); + // The original transport remains broken, but retry first reclaims the + // old owner and also disposes the replacement's failed initialization. + assert!(engine.start_server(start_request(&server)).is_err()); + assert_eq!(engine.next_session_id.load(Ordering::Relaxed), next_id + 1); + assert!(engine.lock_failed_starts().unwrap().is_empty()); + assert!(engine.lock_sessions().unwrap().is_empty()); +} + +#[test] +fn wire_owner_cleans_at_most_one_failed_start_before_rejecting_a_backlog() { + let server = ScriptedServer::new(); + server.break_input(); + let engine = LspEngine::with_launcher(server.launcher()); + let mut ids = BTreeSet::new(); + for _ in 0..2 { + let mut id = None; + assert!(engine + .start_server_owned(start_request(&server), |registered| { + id = Some(registered.to_owned()); + }) + .is_err()); + ids.insert(id.unwrap()); + } + // Simulate two wire starts already in flight before either failure + // established the cleanup barrier. + assert_eq!(ids.len(), 2); + *engine.lock_failed_starts().unwrap() = ids; + let next_id = engine.next_session_id.load(Ordering::Relaxed); + let error = engine.start_server(start_request(&server)).unwrap_err(); + assert!(matches!(error.code, ErrorCode::ProcessFailed)); + assert_eq!(engine.next_session_id.load(Ordering::Relaxed), next_id); + assert_eq!(engine.lock_sessions().unwrap().len(), 1); + assert_eq!(engine.lock_failed_starts().unwrap().len(), 1); + engine.cleanup_failed_start().unwrap(); + assert!(engine.lock_sessions().unwrap().is_empty()); + assert!(engine.lock_failed_starts().unwrap().is_empty()); +} + +#[test] +fn invalid_initial_configuration_is_rejected_before_launch() { + let server = ScriptedServer::new(); + let engine = LspEngine::with_launcher(server.launcher()); + let mut request = start_request(&server); + request.workspace_configuration = Some(json!(["not an object"])); + assert!(engine.start_server(request).is_err()); + assert!(!server.was_launched()); +} + +/// An engine with a scripted server behind it, plus the started session. +struct Harness { + engine: LspEngine, + server: ScriptedServer, + session_id: String, + /// Events are drained by every poll, so the harness accumulates them and + /// tests assert against the whole history. + events: Vec, +} + +struct IndexingPolicyFixture { + root: PathBuf, + revision: u64, +} + +impl IndexingPolicyFixture { + fn new(root: PathBuf) -> Self { + let mut fixture = Self { root, revision: 0 }; + fixture.exclude(&[]); + fixture + } + + fn exclude(&mut self, paths: &[&str]) { + self.revision = lithe_project::indexing::configure( + serde_json::from_value(json!({ + "root": self.root, "manualExcludedDirectories": paths, + "includedDirectories": [], "gitIgnoreEnabled": false + })) + .unwrap(), + crate::indexing_changed, + ) + .unwrap() + .revision; + } +} + +impl Drop for IndexingPolicyFixture { + fn drop(&mut self) { + let _ = lithe_project::indexing::release(lithe_project::indexing::ReleaseIndexingRequest { + root: self.root.to_string_lossy().into_owned(), + owner_id: None, + revision: self.revision, + }); + } +} + +/// A throw-away workspace with a nested directory tree, removed on drop. +struct TemporaryWorkspace { + root: PathBuf, +} + +impl TemporaryWorkspace { + fn recursive(label: &str) -> Self { + static NEXT_ID: AtomicU64 = AtomicU64::new(1); + let root = std::env::temp_dir().join(format!( + "lithe-lsp-{label}-{}-{}", + std::process::id(), + NEXT_ID.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(root.join("reactor/module-a/nested")) + .expect("nested fixture should be creatable"); + std::fs::write( + root.join("reactor/module-a/nested/main.go"), + "package main\n", + ) + .expect("fixture file should be writable"); + Self { root } + } + + fn configure(&self, request: &mut StartServerRequest) { + request.root_uri = url::Url::from_directory_path(&self.root) + .expect("fixture root should convert to a URI") + .to_string(); + request.working_directory = self.root.to_string_lossy().into_owned(); + } +} + +impl Drop for TemporaryWorkspace { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } +} + +impl Harness { + fn start(configure: impl FnOnce(&mut StartServerRequest)) -> Self { + let server = ScriptedServer::new(); + let engine = LspEngine::with_launcher(server.launcher()); + let mut request = start_request(&server); + configure(&mut request); + let started = engine + .start_server(request) + .expect("the server should start"); + Self { + engine, + server, + session_id: started.session_id, + events: Vec::new(), + } + } + + fn ready() -> Self { + let mut harness = Self::start(|_| {}); + harness.server.complete_initialize(ready_capabilities()); + harness.await_state(LspLifecycleState::Ready); + harness + } + + fn session(&self) -> Arc { + self.engine + .session(&self.session_id) + .expect("the session should be registered") + } + + fn poll(&mut self) -> &[LspRuntimeEvent] { + let events = self + .session() + .poll_events() + .expect("polling should succeed"); + self.events.extend(events); + &self.events + } + + /// Waits until the session reports `lifecycle`, draining events as it + /// goes so nothing is lost to the poll that observes the transition. + fn await_state(&mut self, lifecycle: LspLifecycleState) { + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + self.poll(); + if self.snapshot().state == lifecycle { + // The transition can happen between the poll and snapshot; + // drain once more so its co-published events are retained. + self.poll(); + return; + } + thread::sleep(Duration::from_millis(2)); + } + self.poll(); + panic!( + "session stayed in {:?} instead of reaching {lifecycle:?}", + self.snapshot().state + ); + } + + /// Waits until an event matching `matches` has been observed. + fn await_event(&mut self, matches: impl Fn(&LspRuntimeEvent) -> bool) -> &LspRuntimeEvent { + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + self.poll(); + if self.events.iter().any(&matches) { + break; + } + thread::sleep(Duration::from_millis(2)); + } + self.events + .iter() + .find(|event| matches(event)) + .expect("the expected runtime event was never emitted") + } + + fn snapshot(&self) -> EngineSnapshot { + self.session().snapshot().expect("snapshot should succeed") + } + + fn sync(&self, uri: &str, text: &str) { + self.session() + .sync_document(SyncDocumentRequest { + session_id: self.session_id.clone(), + uri: uri.to_string(), + language_id: "go".to_string(), + text: text.to_string(), + content_changes: Vec::new(), + }) + .expect("syncing a document should succeed"); + } + + /// Issues a feature request and returns its opaque operation ID. + fn request(&self, operation: LspSemanticOperation, uri: &str) -> String { + let operation_id = self.engine.next_operation_id(); + self.session() + .request( + SemanticRequest { + query: None, + session_id: self.session_id.clone(), + operation_id: Some(operation_id.clone()), + operation, + uri: Some(uri.to_string()), + position: Some(LspPosition { + line: 0, + utf16_column: 0, + }), + new_name: None, + range: None, + diagnostics: Vec::new(), + completion_context: None, + completion_item: None, + code_action: None, + command: None, + formatting_options: None, + include_declaration: None, + }, + operation_id.clone(), + ) + .expect("a ready session should accept the request"); + operation_id + } + + /// The recorded JSON-RPC message with the given request ID. + fn written_request(&self, request_id: &str) -> Value { + self.server + .messages() + .into_iter() + .find(|message| message["id"] == request_id) + .expect("the request should have been written") + } + + /// The notification the server received for `method`, if any. + fn notification(&self, method: &str) -> Option { + self.server + .messages() + .into_iter() + .find(|message| message.get("method").and_then(Value::as_str) == Some(method)) + } +} + +/// A real cold Windows language server can take longer than ten seconds. +/// Exercise the omitted-field default with an injected instant: it must +/// allow that handshake while still disposing of a server that never replies. +#[test] +fn default_initialize_budget_survives_cold_start_and_still_expires() { + let parsed: StartServerRequest = serde_json::from_value(json!({ + "providerId": "cold-fixture", "executablePath": "/scripted-server", + "rootUri": "file:///workspace", "workingDirectory": "/workspace" + })) + .unwrap(); + let harness = Harness::start(|request| { + request.initialize_timeout_milliseconds = parsed.initialize_timeout_milliseconds; + }); + let session = harness.session(); + let now = Instant::now(); + session.expire_deadlines_at(now + Duration::from_secs(15)); + assert_eq!(harness.snapshot().state, LspLifecycleState::Initializing); + session.expire_deadlines_at(now + Duration::from_secs(61)); + assert_eq!(harness.snapshot().state, LspLifecycleState::Failed); + assert!(session.process.exit_status().is_some()); +} + +/// Criterion 1: a spawned process that never initializes cannot become ready. +#[test] +fn a_server_that_never_answers_initialize_fails_instead_of_becoming_ready() { + let mut harness = Harness::start(|request| { + request.initialize_timeout_milliseconds = 30; + }); + harness.await_state(LspLifecycleState::Failed); + + let states: Vec<_> = harness + .events + .iter() + .filter_map(|event| event.state) + .collect(); + assert!( + !states.contains(&LspLifecycleState::Ready), + "an uninitialized session must never pass through Ready: {states:?}" + ); + let failure = harness + .events + .iter() + .find_map(|event| event.error.as_ref()) + .expect("the timeout should be reported as a runtime error"); + assert_eq!(failure.code, "initializeTimeout"); + assert_eq!(failure.stage, "initialize"); +} + +/// Criterion 2: an initialize error cannot become ready. +#[test] +fn an_initialize_error_response_fails_the_session() { + let mut harness = Harness::start(|_| {}); + let id = harness + .server + .await_request("initialize") + .expect("initialize should be sent"); + harness.server.send(json!({ + "jsonrpc": "2.0", + "id": id, + "error": { "code": -32603, "message": "workspace is unsupported" } + })); + harness.await_state(LspLifecycleState::Failed); + + assert!(!harness.snapshot().initialized); + let failure = harness + .events + .iter() + .find_map(|event| event.error.as_ref()) + .expect("the rejection should be reported as a runtime error"); + assert_eq!(failure.code, "initializeFailed"); + assert!( + failure + .underlying_message + .as_deref() + .is_some_and(|detail| detail.contains("workspace is unsupported")), + "the server's own message must survive into the error: {:?}", + failure.underlying_message + ); +} + +/// Messages the scripted server received for `method`, in order. +fn notifications(harness: &Harness, method: &str) -> Vec { + harness + .server + .messages() + .into_iter() + .filter(|message| message.get("method").and_then(Value::as_str) == Some(method)) + .collect() +} + +#[test] +fn structured_logging_warnings_keep_source_stage_and_code() { + let harness = Harness::ready(); + let session = harness.session(); + session.log_issue( + "warn", + "processCleanup", + "processCleanupFailed", + EngineLog::ProcessCleanupFailed, + "Cleanup failed", + None, + ); + let event = session + .poll_events() + .unwrap() + .into_iter() + .find(|event| event.kind == "log") + .unwrap(); + assert_eq!(event.level.as_deref(), Some("warn")); + let error = event.error.unwrap(); + assert_eq!(error.code, "processCleanupFailed"); + assert_eq!(error.stage, "processCleanup"); + assert!(error.underlying_message.is_none()); + for (stage, code) in [ + ("stderr", "upstreamStderr"), + ("stderr", "stderrReadFailed"), + ("shutdown", "shutdownTimeout"), + ] { + session.log_issue( + "warning", + stage, + code, + if code == "upstreamStderr" { + EngineLog::StderrReceived + } else if code == "stderrReadFailed" { + EngineLog::StderrReadFailed + } else { + EngineLog::ShutdownTimedOut + }, + "Updated diagnostic wording", + None, + ); + let event = session + .poll_events() + .unwrap() + .into_iter() + .find(|event| event.kind == "log") + .unwrap(); + assert_eq!(event.level.as_deref(), Some("warning")); + let error = event.error.unwrap(); + assert_eq!(error.stage, stage); + assert_eq!(error.code, code); + } + // Ordinary logs must remain unclassified even when their wording matches an issue. + session.log( + "warning", + EngineLog::StderrReceived, + "Language-server stderr", + None, + ); + let event = session + .poll_events() + .unwrap() + .into_iter() + .find(|event| event.kind == "log") + .unwrap(); + assert!(event.error.is_none()); +} + +#[test] +fn lifecycle_catalog_identity_preserves_v1_event_without_extra_log() { + for role in [ + SessionRole::LanguageServer, + SessionRole::Vcs, + SessionRole::Build, + SessionRole::Toolchain, + SessionRole::Backend, + ] { + let harness = Harness::start(|request| { + request.role = role; + request.provider_id = "c++".into(); + request.log_subject = Some("git".into()); + request.plugin_id = Some("io.example.tools".into()); + }); + let session = harness.session(); + session.active.store(false, Ordering::Release); + session.kill_process(); + let mut state = session.state.lock().unwrap(); + state.events.clear(); + let sequence = state.next_sequence; + transition_locked(&session, &mut state, LspLifecycleState::Stopped, None); + assert!(!state.events.iter().any(|e| e.kind == "log")); + let event = state.events.front().unwrap(); + let (id, fields) = EngineLog::SessionUpdated.fact( + &session.log_identity, + &json!({"state": "stopped"}), + None, + ); + assert_eq!(event.event.as_deref(), Some(id.as_str())); + assert_eq!(event.fields.as_ref(), Some(&fields)); + assert_eq!( + event.channel.as_deref(), + Some(session.log_identity.channel().as_str()) + ); + assert_eq!(event.role, Some(role)); + assert_eq!(event.plugin_id.as_deref(), Some("io.example.tools")); + assert_eq!( + event.catalog_level, + Some(lithe_diagnostics::record::LogLevel::Debug) + ); + let mut legacy = serde_json::to_value(event).unwrap(); + for key in [ + "channel", + "pluginId", + "role", + "event", + "fields", + "catalogLevel", + ] { + legacy.as_object_mut().unwrap().remove(key); + } + assert_eq!( + legacy, + json!({"type": "stateChanged", "sequence": sequence, + "providerId": "c++", "sessionId": session.id, "state": "stopped"}) + ); + state.events.clear(); + let error = runtime_error( + &session, + "processCleanupFailed", + "cleanup", + "Engine process cleanup failed", + FailureContext::default(), + ); + let (id, fields) = + EngineLog::ProcessCleanupFailed.fact(&session.log_identity, &Value::Null, Some(&error)); + push_cleanup_fact(&session, &mut state, id, fields, error); + let event = state.events.front().unwrap(); + assert_eq!( + event.channel.as_deref(), + Some(session.log_identity.channel().as_str()) + ); + assert_eq!( + event.catalog_level, + Some(lithe_diagnostics::record::LogLevel::Warn) + ); + assert_eq!(event.kind, "log"); + assert_eq!(event.level.as_deref(), Some("warning")); + assert_eq!( + event.message.as_deref(), + Some("Engine process cleanup failed") + ); + assert!(event.detail.is_none() && event.state.is_none()); + assert_eq!( + event.error.as_ref().map(|error| error.code.as_str()), + Some("processCleanupFailed") + ); + } +} + +#[test] +fn v1_detail_is_independent_of_catalog_fields() { + let harness = Harness::start(|_| {}); + let session = harness.session(); + struct Cleanup(Arc); + impl Drop for Cleanup { + fn drop(&mut self) { + self.0.active.store(false, Ordering::Release); + self.0.kill_process(); + } + } + let _cleanup = Cleanup(session.clone()); + session.poll_events().unwrap(); + let line = " upstream diagnostic "; + session.log_issue( + "warning", + "stderr", + "upstreamStderr", + EngineLog::StderrReceived.with_fields(json!({"diagnostic": line, "truncated": false})), + "Language-server stderr", + Some(line.into()), + ); + let event = session + .poll_events() + .unwrap() + .into_iter() + .find(|e| e.event.as_deref() == Some("lsp.stderr.received")) + .unwrap(); + assert_eq!(event.level.as_deref(), Some("warning")); + assert_eq!(event.message.as_deref(), Some("Language-server stderr")); + assert_eq!(event.detail.as_deref(), Some(line)); + assert_eq!(event.fields.as_ref().unwrap()["diagnostic"], line); + assert_eq!(event.fields.as_ref().unwrap()["truncated"], false); + // A JSON-looking v1 detail cannot supply or overwrite a catalog fact. + session.log( + "info", + EngineLog::StderrReceived.with_fields(json!({"diagnostic": "kept", "truncated": true})), + "Language-server stderr", + Some("{\"diagnostic\":\"overwritten\",\"truncated\":false}".into()), + ); + let event = session.poll_events().unwrap().into_iter().next().unwrap(); + let fields = event.fields.unwrap(); + assert_eq!(fields["diagnostic"], "kept"); + assert_eq!(fields["truncated"], true); +} + +#[test] +fn engine_logging_wire_fixture_preserves_defaults() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../fixtures/diagnostics/engine-logging-v2.json" + )) + .unwrap(); + let request: StartServerRequest = serde_json::from_value(fixture["start"].clone()).unwrap(); + assert_eq!(request.role, SessionRole::Vcs); + assert_eq!( + SessionLogIdentity::new( + request.role, + &request.provider_id, + request.plugin_id.as_deref(), + request.log_subject.as_deref() + ) + .channel(), + "vcs/git" + ); + let mut legacy = fixture["start"].clone(); + legacy.as_object_mut().unwrap().remove("role"); + legacy.as_object_mut().unwrap().remove("logSubject"); + assert_eq!( + serde_json::from_value::(legacy) + .unwrap() + .role, + SessionRole::LanguageServer + ); + let request: SetDetailedDiagnosticsRequest = + serde_json::from_value(fixture["trace"]["payload"].clone()).unwrap(); + assert!(request.enabled); +} + +#[test] +fn detailed_diagnostics_waits_for_initialized_and_keeps_the_latest_request() { + for enabled in [true, false] { + let harness = Harness::start(|_| {}); + let session = harness.session(); + struct Cleanup(Arc); + impl Drop for Cleanup { + fn drop(&mut self) { + self.0.active.store(false, Ordering::Release); + self.0.kill_process(); + } + } + let _cleanup = Cleanup(session.clone()); + session.set_detailed_diagnostics(true).unwrap(); + if !enabled { + session.set_detailed_diagnostics(false).unwrap(); + } + assert!(!harness + .server + .messages() + .iter() + .any(|m| m["method"] == "$/setTrace")); + assert!(!session.lock_state().unwrap().detailed_diagnostics); + + // Drive the real handshake handler synchronously; no reader timing + // or sleep is needed to establish the notification ordering. + let initialize = harness.server.await_request("initialize").unwrap(); + session + .handle_server_message( + json!({ + "jsonrpc":"2.0", "id":initialize, + "result":{"capabilities":ready_capabilities()} + }) + .to_string(), + ) + .unwrap(); + let methods: Vec<_> = harness + .server + .messages() + .into_iter() + .filter_map(|m| m["method"].as_str().map(str::to_owned)) + .collect(); + if enabled { + let initialized = methods.iter().position(|m| m == "initialized").unwrap(); + let trace = methods.iter().position(|m| m == "$/setTrace").unwrap(); + assert!(trace > initialized); + } else { + assert!(!methods.iter().any(|m| m == "$/setTrace")); + } + assert_eq!(session.lock_state().unwrap().detailed_diagnostics, enabled); + session.set_detailed_diagnostics(false).unwrap(); + assert_eq!( + harness.server.messages().last().unwrap()["params"], + json!({"value":"off"}) + ); + } +} + +#[test] +fn upstream_catalog_messages_trace_toggle_and_v1_fields_coexist() { + let harness = Harness::start(|request| { + request.role = SessionRole::Vcs; + request.log_subject = Some("git".into()); + request.plugin_id = Some("io.example.git".into()); + request.provider_id = "opaque:provider:git".into(); + }); + let session = harness.session(); + struct Cleanup(Arc); + impl Drop for Cleanup { + fn drop(&mut self) { + self.0.active.store(false, Ordering::Release); + self.0.kill_process(); + } + } + let _cleanup = Cleanup(session.clone()); + let initialize = harness.server.await_request("initialize").unwrap(); + session + .handle_server_message( + json!({ + "jsonrpc":"2.0", "id":initialize, "result":{"capabilities":{}} + }) + .to_string(), + ) + .unwrap(); + session.poll_events().unwrap(); + session.log( + "warning", + EngineLog::Dropped.with_fields(json!({"count": 7})), + "Engine stderr lines dropped", + Some("count=7".into()), + ); + let dropped = session + .poll_events() + .unwrap() + .into_iter() + .find(|event| event.event.as_deref() == Some("log.dropped")) + .unwrap(); + assert_eq!(dropped.channel.as_deref(), Some("vcs/git")); + assert_eq!(dropped.fields.unwrap()["count"], 7); + for (method, expected) in [ + ("window/logMessage", "vcs.message.warning.logged"), + ("window/showMessage", "vcs.message.warning.shown"), + ] { + session.handle_server_message(json!({"jsonrpc":"2.0", "method":method, "params":{"type":2,"message":"warning token=private-value"}}).to_string()).unwrap(); + let events = session.poll_events().unwrap(); + let event = events + .iter() + .find(|e| e.event.as_deref() == Some(expected)) + .expect("upstream catalog event"); + assert_eq!(event.channel.as_deref(), Some("vcs/git")); + assert_eq!(event.plugin_id.as_deref(), Some("io.example.git")); + assert_eq!(event.role, Some(SessionRole::Vcs)); + assert_eq!( + event.catalog_level, + Some(lithe_diagnostics::record::LogLevel::Warn) + ); + assert_eq!(event.message.as_deref(), Some(method)); + assert!(event.level.is_some() && event.detail.is_some()); + let fields = event.fields.as_ref().unwrap(); + assert_eq!(fields["severity"], "warning"); + assert!(!fields["diagnostic"] + .as_str() + .unwrap() + .contains("private-value")); + } + let trace = json!({"jsonrpc":"2.0", "method":"$/logTrace", "params":{ + "message":"trace token=fixture-secret", "verbose":json!({ + "textDocument":{"text":"private_source_marker"} + }).to_string() + }}) + .to_string(); + session.handle_server_message(trace.clone()).unwrap(); + assert!(!session + .poll_events() + .unwrap() + .iter() + .any(|e| e.event.as_deref() == Some("vcs.trace.received"))); + session.set_detailed_diagnostics(true).unwrap(); + assert_eq!( + harness.server.messages().last().unwrap()["params"], + json!({"value":"verbose"}) + ); + session.handle_server_message(trace.clone()).unwrap(); + let event = session + .poll_events() + .unwrap() + .into_iter() + .find(|e| e.event.as_deref() == Some("vcs.trace.received")) + .unwrap(); + assert_eq!( + event.catalog_level, + Some(lithe_diagnostics::record::LogLevel::Debug) + ); + assert_eq!(event.level.as_deref(), Some("info")); + let fields = event.fields.unwrap(); + assert_eq!(fields["diagnostic"], "trace token="); + let encoded = lithe_diagnostics::record::encode_log_batch( + serde_json::from_value(json!({"validateCatalog":true,"events":[{ + "ts":"2026-11-03T00:00:00.000Z", "seq":1, "level":"DEBUG", + "event":"vcs.trace.received", "msg":"", "fields":fields + }]})) + .unwrap(), + ); + let line = encoded.results[0].line.as_ref().unwrap(); + assert!(!line.contains("private_source_marker")); + assert!(!line.contains("fixture-secret")); + session.set_detailed_diagnostics(false).unwrap(); + assert_eq!( + harness.server.messages().last().unwrap()["params"], + json!({"value":"off"}) + ); + session.handle_server_message(trace).unwrap(); + assert!(!session + .poll_events() + .unwrap() + .iter() + .any(|e| e.event.as_deref() == Some("vcs.trace.received"))); + assert!( + serde_json::from_value::(json!({"sessionId":"s"})).is_err() + ); +} + +#[test] +fn ranged_edits_extend_text_retained_before_the_server_is_ready() { + let mut harness = Harness::start(|request| { + request.provider_id = "gopls".to_string(); + }); + let uri = "file:///workspace/First.java"; + harness.sync(uri, "class First {}"); + let appended = LspDocumentContentChange { + range: Some(LspRange { + start: LspPosition { + line: 0, + utf16_column: 11, + }, + end: LspPosition { + line: 0, + utf16_column: 11, + }, + }), + text: "Renamed".to_string(), + }; + harness + .session() + .sync_document(SyncDocumentRequest { + session_id: harness.session_id.clone(), + uri: uri.to_string(), + language_id: "java".to_string(), + text: String::new(), + content_changes: vec![appended.clone()], + }) + .expect("ranged edits should extend the retained text"); + + harness.server.complete_initialize(ready_capabilities()); + harness.await_state(LspLifecycleState::Ready); + assert!(harness.server.await_notification("textDocument/didOpen")); + let written = String::from_utf8_lossy(&harness.server.written_bytes()).into_owned(); + assert!(written.contains("class FirstRenamed {}")); + + let error = harness + .session() + .sync_document(SyncDocumentRequest { + session_id: harness.session_id.clone(), + uri: "file:///workspace/Unknown.java".to_string(), + language_id: "java".to_string(), + text: String::new(), + content_changes: vec![appended], + }) + .expect_err("edits to an unknown document cannot be resolved"); + assert_eq!(error.details.as_deref(), Some("documentNotSynchronized")); +} + +/// A path descriptor reaches the server with reserved characters escaped. +#[test] +fn native_path_emits_the_canonical_uri_on_the_lsp_wire() { + let harness = Harness::ready(); + let mut payload = json!({"sessionId":harness.session_id, "languageId":"go", "text":"package main", + "uri":{"path":"/workspace/A#B.go","platform":"posix"}}); + lithe_document_uri::resolve_request_paths("lsp.syncDocument", &mut payload).unwrap(); + let request: SyncDocumentRequest = serde_json::from_value(payload).unwrap(); + harness + .session() + .sync_document(request) + .expect("document should synchronize"); + assert!(harness.server.await_notification("textDocument/didOpen")); + let opened = harness + .server + .messages() + .into_iter() + .find(|message| message["method"] == "textDocument/didOpen") + .unwrap(); + assert_eq!( + opened["params"]["textDocument"]["uri"], + "file:///workspace/A%23B.go" + ); +} + +/// Criterion 3: two syncs emit open version 1 then change version 2. +#[test] +fn consecutive_syncs_open_at_version_one_and_change_to_version_two() { + let harness = Harness::ready(); + let uri = "file:///workspace/main.go"; + harness.sync(uri, "package main"); + harness.sync(uri, "package main\nfunc main() {}"); + + let versions: Vec<_> = harness + .server + .messages() + .into_iter() + .filter_map(|message| { + let method = message.get("method")?.as_str()?.to_string(); + let document = message.get("params")?.get("textDocument")?; + let version = document.get("version")?.as_i64()?; + Some((method, version)) + }) + .collect(); + assert_eq!( + versions, + vec![ + ("textDocument/didOpen".to_string(), 1), + ("textDocument/didChange".to_string(), 2) + ] + ); + assert_eq!(harness.snapshot().open_documents[uri].version, 2); +} + +#[test] +fn all_documents_synced_during_initialize_are_opened_when_ready() { + let mut harness = Harness::start(|_| {}); + let first_uri = "file:///workspace/first.go"; + let second_uri = "file:///workspace/second.go"; + harness.sync(first_uri, "package main\nvar first = 1"); + harness.sync(second_uri, "package main\nvar second = 2"); + + harness.server.complete_initialize(ready_capabilities()); + harness.await_state(LspLifecycleState::Ready); + + let opened_uris: Vec<_> = harness + .server + .messages() + .into_iter() + .filter(|message| { + message.get("method").and_then(Value::as_str) == Some("textDocument/didOpen") + }) + .filter_map(|message| { + message + .get("params")? + .get("textDocument")? + .get("uri")? + .as_str() + .map(ToString::to_string) + }) + .collect(); + assert_eq!( + opened_uris, + vec![first_uri.to_string(), second_uri.to_string()] + ); + let snapshot = harness.snapshot(); + assert_eq!(snapshot.open_documents[first_uri].version, 1); + assert_eq!(snapshot.open_documents[second_uri].version, 1); +} + +#[test] +fn workspace_file_changes_queue_until_initialize_and_collapse_watcher_bursts() { + let mut harness = Harness::start(|_| {}); + harness + .session() + .workspace_files_changed(vec![ + WorkspaceFileChange { + uri: "file:///workspace/pom.xml".to_string(), + kind: WorkspaceFileChangeKind::Changed, + }, + WorkspaceFileChange { + uri: "file:///workspace/src/Main.java".to_string(), + kind: WorkspaceFileChangeKind::Created, + }, + WorkspaceFileChange { + uri: "file:///workspace/src/Main.java".to_string(), + kind: WorkspaceFileChangeKind::Changed, + }, + ]) + .expect("watcher events should queue before initialize"); + assert!(!harness + .server + .messages() + .iter() + .any(|message| { message["method"] == "workspace/didChangeWatchedFiles" })); + + harness.server.complete_initialize(ready_capabilities()); + harness.await_state(LspLifecycleState::Ready); + let notification = harness + .server + .messages() + .into_iter() + .find(|message| message["method"] == "workspace/didChangeWatchedFiles") + .expect("queued watcher events should flush after initialize"); + assert_eq!( + notification["params"]["changes"], + json!([ + { "uri": "file:///workspace/pom.xml", "type": 2 }, + { "uri": "file:///workspace/src/Main.java", "type": 2 } + ]) + ); +} + +/// Criterion 4: a crash fails pending operations with `serverExited`. +#[test] +fn a_crash_fails_every_pending_operation_once_with_server_exited() { + let mut harness = Harness::ready(); + let uri = "file:///workspace/main.go"; + harness.sync(uri, "package main"); + let hover = harness.request(LspSemanticOperation::Hover, uri); + let definition = harness.request(LspSemanticOperation::Definition, uri); + + harness.server.exit(Some(134)); + harness.await_state(LspLifecycleState::Failed); + + let failures: Vec<_> = harness + .events + .iter() + .filter(|event| event.kind == "requestCompleted") + .collect(); + assert_eq!( + failures.len(), + 2, + "each pending operation must fail exactly once" + ); + for event in failures { + let error = event.error.as_ref().expect("a crash cannot yield a result"); + assert_eq!(error.code, "serverExited"); + assert_eq!(error.process_exit_code, Some(134)); + } + let completed: Vec<_> = harness + .events + .iter() + .filter_map(|event| event.operation_id.clone()) + .collect(); + assert!(completed.contains(&hover) && completed.contains(&definition)); + assert!(harness.snapshot().pending_operation_ids.is_empty()); +} + +/// Criterion 5: a request deadline removes the pending request. +/// Criterion 6: a late response after that timeout is ignored. +#[test] +fn a_timed_out_request_is_removed_cancelled_and_deaf_to_its_late_response() { + let mut harness = Harness::start(|request| { + request.request_timeout_milliseconds = 30; + }); + harness.server.complete_initialize(ready_capabilities()); + harness.await_state(LspLifecycleState::Ready); + let uri = "file:///workspace/main.go"; + harness.sync(uri, "package main"); + let operation = harness.request(LspSemanticOperation::Hover, uri); + let request_id = harness + .server + .await_request("textDocument/hover") + .expect("the hover request should reach the server"); + + let timeout = harness + .await_event(|event| { + event + .error + .as_ref() + .is_some_and(|error| error.code == "requestTimeout") + }) + .clone(); + assert_eq!(timeout.operation_id.as_deref(), Some(operation.as_str())); + assert!(harness.snapshot().pending_operation_ids.is_empty()); + // The completion event is queued before the cancellation is written, so + // the wire assertion has to wait for the write rather than assume it. + assert!( + harness.server.await_notification("$/cancelRequest"), + "a timed-out request must be cancelled on the wire" + ); + + // The server answers anyway. Nothing may reach the application: the + // operation has already been completed with its timeout error. + harness.server.send(json!({ + "jsonrpc": "2.0", + "id": request_id, + "result": { "contents": "too late" } + })); + thread::sleep(Duration::from_millis(50)); + harness.poll(); + let completions = harness + .events + .iter() + .filter(|event| event.operation_id.as_deref() == Some(operation.as_str())) + .count(); + assert_eq!( + completions, 1, + "a late response must not complete the operation a second time" + ); +} + +/// Criterion 7: responses from an old session cannot affect a restarted one. +#[test] +fn a_restarted_session_ignores_the_previous_session_s_responses() { + // The first session is taken to Ready and then torn down. Its hover + // request id is recorded first, because every session numbers its + // requests from one: an id alone cannot tell two sessions apart, so only + // per-session pending state can reject a foreign response. + let mut first = Harness::ready(); + let uri = "file:///workspace/main.go"; + first.sync(uri, "package main"); + first.request(LspSemanticOperation::Hover, uri); + let stale_id = first + .server + .await_request("textDocument/hover") + .expect("the hover request should reach the server"); + first.server.exit(Some(0)); + first.await_state(LspLifecycleState::Failed); + first.engine.destroy(&first.session_id).unwrap(); + + let mut restarted = Harness::ready(); + restarted.sync(uri, "package main"); + let operation = restarted.request(LspSemanticOperation::Hover, uri); + assert_eq!( + restarted + .server + .await_request("textDocument/hover") + .as_deref(), + Some(stale_id.as_str()), + "the restarted session must reuse the id, or this proves nothing" + ); + + // Delivered to the new session, the id does match a pending request, so + // isolation cannot rest on ids. It rests on the process: the old + // session's reader thread is gone with its process, so its responses + // have no path into the new session at all. + let before = restarted.snapshot(); + first.server.send(json!({ + "jsonrpc": "2.0", + "id": stale_id, + "result": { "contents": "from the dead session" } + })); + thread::sleep(Duration::from_millis(50)); + restarted.poll(); + assert_eq!( + restarted.snapshot().pending_operation_ids, + before.pending_operation_ids, + "the old session's response must not complete the new session's request" + ); + assert!( + !restarted + .events + .iter() + .any(|event| event.kind == "requestCompleted"), + "no operation may complete from a foreign session's traffic" + ); + + // The new session's own response still lands, so the isolation above is + // not merely a dead session. + restarted.server.send(json!({ + "jsonrpc": "2.0", + "id": stale_id, + "result": { "contents": "from the live session" } + })); + let completion = restarted + .await_event(|event| event.kind == "requestCompleted") + .clone(); + assert_eq!(completion.operation_id.as_deref(), Some(operation.as_str())); + assert_eq!( + completion + .result + .as_ref() + .and_then(|result| result.get("hover")?.get("contents")) + .and_then(Value::as_str), + Some("from the live session") + ); +} + +/// A write that fails mid-session is a transport failure, not a silent drop. +#[test] +fn a_broken_stdin_fails_the_session_and_stops_writing() { + let mut harness = Harness::ready(); + let written = harness.server.written_bytes().len(); + harness.server.break_input(); + let error = harness + .session() + .sync_document(SyncDocumentRequest { + session_id: harness.session_id.clone(), + uri: "file:///workspace/main.go".to_string(), + language_id: "go".to_string(), + text: "package main".to_string(), + content_changes: Vec::new(), + }) + .expect_err("a broken pipe must surface to the caller"); + assert!(matches!(error.code, ErrorCode::ProcessFailed)); + + harness.await_state(LspLifecycleState::Failed); + let failure = harness + .events + .iter() + .find_map(|event| event.error.as_ref()) + .expect("the failure should be reported as a runtime error"); + assert_eq!(failure.code, "transportFailed"); + assert_eq!( + harness.server.written_bytes().len(), + written, + "nothing may be written after the pipe breaks" + ); +} + +/// The stopped session's own late response is dropped rather than acted on, +/// which is the same rule seen from the other side of a restart. +#[test] +fn a_response_to_an_already_failed_session_is_logged_and_dropped() { + let mut harness = Harness::ready(); + let uri = "file:///workspace/main.go"; + harness.sync(uri, "package main"); + harness.request(LspSemanticOperation::Hover, uri); + let request_id = harness + .server + .await_request("textDocument/hover") + .expect("the hover request should reach the server"); + + harness.server.exit(Some(1)); + harness.await_state(LspLifecycleState::Failed); + let completions = harness + .events + .iter() + .filter(|event| event.kind == "requestCompleted") + .count(); + assert_eq!(completions, 1, "the crash already completed the operation"); + + harness.server.send(json!({ + "jsonrpc": "2.0", + "id": request_id, + "result": { "contents": "unreachable" } + })); + thread::sleep(Duration::from_millis(50)); + harness.poll(); + assert_eq!( + harness + .events + .iter() + .filter(|event| event.kind == "requestCompleted") + .count(), + completions, + "a response after the terminal state must not complete anything" + ); + assert_eq!(harness.snapshot().state, LspLifecycleState::Failed); +} + +#[test] +fn indexing_policy_withdrawal_serializes_with_a_checked_document_commit() { + assert_policy_withdrawal_serializes_with_checked_commit(false); +} + +#[test] +fn indexing_policy_withdrawal_serializes_with_checked_diagnostics() { + assert_policy_withdrawal_serializes_with_checked_commit(true); +} + +fn assert_policy_withdrawal_serializes_with_checked_commit(diagnostics: bool) { + let workspace = TemporaryWorkspace::recursive("indexing-document-commit"); + let root = workspace.root.canonicalize().unwrap(); + let mut policy = IndexingPolicyFixture::new(root.clone()); + let mut harness = Harness::start(|request| workspace.configure(request)); + harness.server.complete_initialize(ready_capabilities()); + harness.await_state(LspLifecycleState::Ready); + let uri = url::Url::from_file_path(root.join("reactor/hidden.go")) + .unwrap() + .to_string(); + let session = harness.session(); + let (checked_tx, checked_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let (sync_done_tx, sync_done_rx) = std::sync::mpsc::channel(); + let syncing = { + let session = session.clone(); + let uri = uri.clone(); + thread::spawn(move || { + let checked = || { + checked_tx.send(()).unwrap(); + release_rx + .recv_timeout(Duration::from_secs(3)) + .expect("document commit was not released"); + }; + let result = if diagnostics { + session.handle_server_message_after_policy_check(json!({"jsonrpc":"2.0","method":"textDocument/publishDiagnostics","params":{ + "uri":uri,"diagnostics":[{"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":1}},"message":"old diagnostic"}] + }}).to_string(), checked) + } else { + session + .sync_document_after_policy_check( + SyncDocumentRequest { + session_id: session.id.clone(), + uri, + language_id: "java".into(), + text: "class Hidden {}".into(), + content_changes: Vec::new(), + }, + checked, + ) + .map(|_| ()) + }; + let _ = sync_done_tx.send(()); + result + }) + }; + checked_rx + .recv_timeout(Duration::from_secs(3)) + .expect("document eligibility was not reached"); + assert!( + matches!( + session.indexing_roots.try_lock(), + Err(std::sync::TryLockError::WouldBlock) + ), + "eligibility and document publication must retain the policy serialization guard" + ); + policy.exclude(&["reactor"]); + let (update_done_tx, update_done_rx) = std::sync::mpsc::channel(); + let updating = { + let session = session.clone(); + let root = root.clone(); + thread::spawn(move || { + let result = apply_indexing_to_sessions(&root, vec![session]); + let _ = update_done_tx.send(()); + result + }) + }; + release_tx.send(()).unwrap(); + sync_done_rx + .recv_timeout(Duration::from_secs(3)) + .expect("document commit did not finish"); + update_done_rx + .recv_timeout(Duration::from_secs(3)) + .expect("policy update did not finish"); + syncing.join().unwrap().unwrap(); + updating.join().unwrap().unwrap(); + assert!(!harness.snapshot().open_documents.contains_key(&uri)); + assert!(!session + .lock_state() + .unwrap() + .client + .diagnostics + .contains_key(&uri)); + if !diagnostics { + assert!(notifications(&harness, "textDocument/didClose") + .iter() + .any(|message| message["params"]["textDocument"]["uri"] == uri)); + } +} + +#[test] +fn indexing_policy_withdraws_queued_file_changes_before_service_readiness() { + let workspace = TemporaryWorkspace::recursive("indexing-pending-file-changes"); + let root = workspace.root.canonicalize().unwrap(); + let mut policy = IndexingPolicyFixture::new(root.clone()); + let mut harness = Harness::start(|request| workspace.configure(request)); + let hidden = url::Url::from_file_path(root.join("reactor/hidden.go")) + .unwrap() + .to_string(); + let visible = url::Url::from_file_path(root.join("visible.go")) + .unwrap() + .to_string(); + harness + .session() + .workspace_files_changed(vec![ + WorkspaceFileChange { + uri: hidden.clone(), + kind: WorkspaceFileChangeKind::Created, + }, + WorkspaceFileChange { + uri: visible.clone(), + kind: WorkspaceFileChangeKind::Created, + }, + ]) + .unwrap(); + assert_eq!( + harness + .session() + .lock_state() + .unwrap() + .pending_workspace_file_changes + .len(), + 2 + ); + policy.exclude(&["reactor"]); + // The policy is installed, but this independent session has not received + // its withdrawal callback yet. Readiness must recheck queued eligibility. + harness.server.complete_initialize(ready_capabilities()); + harness.await_state(LspLifecycleState::Ready); + let changes = notifications(&harness, "workspace/didChangeWatchedFiles"); + assert!(changes.iter().any(|message| message["params"]["changes"] + .as_array() + .unwrap() + .iter() + .any(|change| change["uri"] == visible))); + assert!(!changes.iter().any(|message| message["params"]["changes"] + .as_array() + .unwrap() + .iter() + .any(|change| change["uri"] == hidden))); +} + +#[test] +fn indexing_startup_cancels_when_policy_changes_during_process_launch() { + const CANCELLED_PLUGIN: &str = "io.example.indexing-cancel-launch"; + struct RetireOnLaunch { + inner: Arc, + root: PathBuf, + revision: Arc, + process: Arc>>>, + } + impl LspProcessLauncher for RetireOnLaunch { + fn launch( + &self, + spec: LspProcessSpec, + ) -> Result { + let process = self.inner.launch(spec)?; + *self.process.lock().unwrap() = Some(process.handle.clone()); + let status = lithe_project::indexing::configure(serde_json::from_value(json!({ + "root": self.root, "manualExcludedDirectories": ["reactor"], "includedDirectories": [], "gitIgnoreEnabled": false + })).unwrap(), crate::indexing_changed)?; + self.revision.store(status.revision, Ordering::Release); + Ok(process) + } + } + struct Cleanup { + root: PathBuf, + revision: Arc, + process: Arc>>>, + } + impl Drop for Cleanup { + fn drop(&mut self) { + if let Some(process) = self.process.lock().unwrap().as_ref() { + process.terminate(); + } + let _ = + lithe_project::indexing::release(lithe_project::indexing::ReleaseIndexingRequest { + root: self.root.to_string_lossy().into_owned(), + owner_id: None, + revision: self.revision.load(Ordering::Acquire), + }); + } + } + for (initially_configured, cleanup_blocked, native_owner) in [ + (false, false, false), + (true, false, false), + (false, true, false), + (true, true, false), + (false, false, true), + (true, false, true), + (false, true, true), + (true, true, true), + ] { + let workspace = TemporaryWorkspace::recursive(&format!( + "indexing-startup-retired-{initially_configured}" + )); + let root = workspace.root.canonicalize().unwrap(); + let installed = if initially_configured { + lithe_project::indexing::configure(serde_json::from_value(json!({ + "root":root,"manualExcludedDirectories":[],"includedDirectories":[],"gitIgnoreEnabled":false + })).unwrap(), crate::indexing_changed).unwrap().revision + } else { + 0 + }; + let cleanup = Cleanup { + root: root.clone(), + revision: Arc::new(AtomicU64::new(installed)), + process: Arc::new(Mutex::new(None)), + }; + let server = ScriptedServer::new(); + server.block_cleanup(cleanup_blocked); + let engine = LspEngine::with_launcher(Arc::new(RetireOnLaunch { + inner: server.launcher(), + root: root.clone(), + revision: cleanup.revision.clone(), + process: cleanup.process.clone(), + })); + let mut request = start_request(&server); + request.provider_id = "python".into(); + request.root_uri = url::Url::from_directory_path(&root).unwrap().to_string(); + request.working_directory = root.to_string_lossy().into_owned(); + request.startup_operation = Some(AttemptHandoff { + plugin_id: CANCELLED_PLUGIN.to_string(), + operation_id: "indexing-cancel-before-registration".to_string(), + elapsed_ms: 5, + }); + let mut registered = None; + let result = if native_owner { + engine.start_server_owned(request, |id| registered = Some(id.to_owned())) + } else { + engine.start_server(request) + }; + assert!(matches!(result.unwrap_err().code, ErrorCode::Cancelled)); + if cleanup_blocked { + let id = if native_owner { + registered.expect("incomplete disposal must reach the native owner") + } else { + engine + .lock_failed_starts() + .unwrap() + .first() + .cloned() + .expect("incomplete wire disposal retains its internal owner") + }; + assert!(engine.destroy(&id).is_err()); + assert!(engine.session(&id).is_ok()); + server.block_cleanup(false); + if native_owner { + engine + .destroy(&id) + .expect("native owner can retry verified disposal"); + } else { + engine + .cleanup_failed_start() + .expect("wire retry confirms disposal"); + assert!(engine.lock_failed_starts().unwrap().is_empty()); + } + } else { + assert!( + registered.is_none(), + "disposed startup needs no native barrier" + ); + } + assert!(engine.lock_sessions().unwrap().is_empty()); + assert!( + server.input_was_closed(), + "the cancelled startup owns process termination" + ); + assert!( + plugin_operations::test_capture::take(CANCELLED_PLUGIN).is_empty(), + "a cancelled start records no plugin operation result" + ); + } +} + +#[test] +fn native_owner_retains_indexing_cancellation_after_registration() { + const CANCELLED_PLUGIN: &str = "io.example.indexing-cancel-registered"; + let workspace = TemporaryWorkspace::recursive("indexing-owned-startup"); + let root = workspace.root.canonicalize().unwrap(); + let mut policy = IndexingPolicyFixture::new(root.clone()); + let server = ScriptedServer::new(); + server.block_cleanup(true); + let engine = LspEngine::with_launcher(server.launcher()); + let mut request = start_request(&server); + request.provider_id = "python".into(); + request.root_uri = url::Url::from_directory_path(&root).unwrap().to_string(); + request.working_directory = root.to_string_lossy().into_owned(); + request.startup_operation = Some(AttemptHandoff { + plugin_id: CANCELLED_PLUGIN.to_string(), + operation_id: "indexing-cancel-after-registration".to_string(), + elapsed_ms: 5, + }); + let mut registered = None; + let result = engine.start_server_owned(request, |id| { + registered = Some(id.to_owned()); + // Revoke between the initial policy check and initialize write. + policy.exclude(&["reactor"]); + }); + assert!(matches!(result.unwrap_err().code, ErrorCode::Cancelled)); + let id = registered.expect("native owner received its session"); + assert!(engine.destroy(&id).is_err()); + assert!(engine.session(&id).is_ok()); + server.block_cleanup(false); + engine + .destroy(&id) + .expect("native owner confirms process disposal"); + assert!(engine.session(&id).is_err()); + assert!( + plugin_operations::test_capture::take(CANCELLED_PLUGIN).is_empty(), + "a cancelled start records no plugin operation result" + ); +} + +#[test] +fn indexing_released_root_allows_a_standalone_provider_without_old_exclusions() { + let workspace = TemporaryWorkspace::recursive("indexing-released-standalone"); + let root = workspace.root.canonicalize().unwrap(); + let root_uri = url::Url::from_directory_path(&root).unwrap().to_string(); + let status = lithe_project::indexing::configure(serde_json::from_value(json!({ + "root": root, "manualExcludedDirectories": ["reactor"], "includedDirectories": [], "gitIgnoreEnabled": false + })).unwrap(), crate::indexing_changed).unwrap(); + let old = lithe_project::indexing::context(&root).unwrap(); + lithe_project::indexing::release(lithe_project::indexing::ReleaseIndexingRequest { + root: root.to_string_lossy().into_owned(), + revision: status.revision, + owner_id: None, + }) + .unwrap(); + assert!( + old.check().is_err(), + "late inventory readers must remain cancelled" + ); + let harness = Harness::start(|request| { + workspace.configure(request); + request.root_uri = root_uri.clone(); + request.initialization_options = + Some(json!({"settings":{"java":{"import":{"exclusions":["**/vendor/**"]}}}})); + }); + let initialize = notifications(&harness, "initialize").pop().unwrap(); + assert_eq!( + initialize["params"]["initializationOptions"]["settings"]["java"]["import"]["exclusions"], + json!(["**/vendor/**"]) + ); + let file_uri = url::Url::from_file_path(root.join("reactor/visible.go")) + .unwrap() + .to_string(); + assert!(!lithe_project::indexing::uri_is_excluded(&root_uri, &file_uri).unwrap()); +} + +#[test] +fn indexing_cold_start_resolves_git_before_provider_initialization() { + let workspace = TemporaryWorkspace::recursive("indexing-cold-start"); + let root = workspace.root.canonicalize().unwrap(); + std::fs::create_dir_all(root.join("ignored")).unwrap(); + std::fs::write(root.join("ignored/hidden.txt"), "hidden").unwrap(); + std::fs::write(root.join(".gitignore"), "ignored/\n").unwrap(); + let git = lithe_git::execute_git_readonly( + &root.to_string_lossy(), + &["init".into(), "--quiet".into()], + None, + ) + .unwrap(); + assert_eq!(git.exit_code, 0, "{}", git.stderr); + let status = lithe_project::indexing::configure(serde_json::from_value(json!({ + "root": root, "manualExcludedDirectories": [], "includedDirectories": [], "gitIgnoreEnabled": true + })).unwrap(), crate::indexing_changed).unwrap(); + let harness = Harness::start(|request| { + workspace.configure(request); + request.root_uri = url::Url::from_directory_path(&root).unwrap().to_string(); + }); + assert!(notifications(&harness, "initialize").pop().is_some()); + for (provider, pointer) in [ + ("go", "/gopls/directoryFilters"), + ("rust", "/rust-analyzer/files/exclude"), + ] { + let value = + crate::scan_settings::configuration(provider, &harness.session().root_uri, None) + .unwrap(); + assert!(value + .pointer(pointer) + .unwrap() + .as_array() + .unwrap() + .iter() + .any(|item| item + == if provider == "go" { + "-ignored" + } else { + "ignored" + })); + } + lithe_project::indexing::release(lithe_project::indexing::ReleaseIndexingRequest { + root: root.to_string_lossy().into_owned(), + revision: status.revision, + owner_id: None, + }) + .unwrap(); +} + +#[test] +fn indexing_change_withdraws_open_working_copies_and_rejects_sync() { + let workspace = TemporaryWorkspace::recursive("indexing-working-copy"); + let root = workspace.root.canonicalize().unwrap(); + let root_uri = url::Url::from_directory_path(&root).unwrap().to_string(); + let mut harness = Harness::start(|request| { + request.root_uri = root_uri.clone(); + request.working_directory = root.to_string_lossy().into_owned(); + }); + harness.server.complete_initialize(ready_capabilities()); + harness.await_state(LspLifecycleState::Ready); + let uri = url::Url::from_file_path(root.join("reactor/main.go")) + .unwrap() + .to_string(); + harness.sync(&uri, "package main"); + let status = lithe_project::indexing::configure( + serde_json::from_value(json!({ + "root":root,"manualExcludedDirectories":["reactor"], + "includedDirectories":[],"gitIgnoreEnabled":false + })) + .unwrap(), + crate::indexing_changed, + ) + .unwrap(); + apply_indexing_to_sessions(&root, vec![harness.session()]).unwrap(); + assert!(!harness + .session() + .lock_state() + .unwrap() + .client + .open_documents + .contains_key(&uri)); + assert!(lithe_project::indexing::uri_is_excluded(&root_uri, &uri).unwrap()); + let session = harness.session(); + assert!(session + .sync_document(SyncDocumentRequest { + session_id: harness.session_id.clone(), + uri: uri.clone(), + language_id: "go".into(), + text: "package main".into(), + content_changes: Vec::new(), + }) + .is_err()); + session.handle_server_message(json!({"jsonrpc":"2.0","method":"textDocument/publishDiagnostics", "params":{ + "uri":uri,"diagnostics":[{"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":1}},"severity":1,"message":"late"}] + }}).to_string()).unwrap(); + assert!(!session + .lock_state() + .unwrap() + .client + .diagnostics + .contains_key(&uri)); + + assert_eq!( + harness.session().indexing_revision.load(Ordering::Acquire), + status.revision + ); + lithe_project::indexing::release(lithe_project::indexing::ReleaseIndexingRequest { + owner_id: None, + root: root.to_string_lossy().into_owned(), + revision: status.revision, + }) + .unwrap(); +} + +/// Criterion 8: diagnostics for a stale document version are ignored. +/// Criterion 9: closing a document clears document and diagnostic state. +#[test] +fn diagnostics_follow_the_current_document_version_and_clear_on_close() { + let mut harness = Harness::ready(); + let uri = "file:///workspace/main.go"; + harness.sync(uri, "package main"); + harness.sync(uri, "package main\nfunc main() {}"); + + harness.server.send(json!({ + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": { + "uri": uri, + "version": 2, + "diagnostics": [{ + "range": { + "start": { "line": 1, "character": 0 }, + "end": { "line": 1, "character": 4 } + }, + "severity": 1, + "message": "current" + }] + } + })); + harness.await_event(|event| { + event.kind == "diagnostics" + && event + .diagnostics + .as_ref() + .is_some_and(|list| list.len() == 1) + }); + assert_eq!(harness.snapshot().diagnostic_versions[uri], 2); + + // Version 1 is behind the open document, so it cannot replace version 2. + harness.server.send(json!({ + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": { "uri": uri, "version": 1, "diagnostics": [] } + })); + thread::sleep(Duration::from_millis(50)); + harness.poll(); + assert_eq!( + harness.snapshot().diagnostic_versions[uri], + 2, + "a stale version must not clear current diagnostics" + ); + + harness.session().close_document(uri).unwrap(); + let snapshot = harness.snapshot(); + assert!(!snapshot.open_documents.contains_key(uri)); + assert!(!snapshot.diagnostic_versions.contains_key(uri)); + assert!( + harness.notification("textDocument/didClose").is_some(), + "the server must be told the document closed" + ); + // Clearing is published so the editor drops its markers, rather than + // leaving them until the next unrelated publish. + let cleared = harness + .poll() + .iter() + .rev() + .find(|event| event.kind == "diagnostics" && event.uri.as_deref() == Some(uri)); + assert_eq!( + cleared + .and_then(|event| event.diagnostics.as_ref()) + .map(Vec::len), + Some(0) + ); +} + +/// A declarative plugin start handed off by the host ends in exactly one Core +/// result: `initialize` success, an exit before it, or nothing for a user stop. +/// Later exits belong to the session, not the start. A plugin API 1 server +/// starts host-owned, so the handoff alone names the plugin; a session owned +/// by a different plugin records nothing. +#[test] +fn declarative_plugin_start_records_one_host_operation_per_attempt() { + const PLUGIN: &str = "io.example.declarative-start"; + const OTHER: &str = "io.example.declarative-start-other"; + let handoff = |operation_id: &'static str, owner: Option<&'static str>| { + move |request: &mut StartServerRequest| { + request.plugin_id = owner.map(str::to_string); + request.startup_operation = Some(AttemptHandoff { + plugin_id: PLUGIN.to_string(), + operation_id: operation_id.to_string(), + elapsed_ms: 5, + }); + } + }; + + let mut ready = Harness::start(handoff("declarative-ready", Some(PLUGIN))); + ready.server.complete_initialize(ready_capabilities()); + ready.await_state(LspLifecycleState::Ready); + ready.server.exit(Some(1)); + ready.await_state(LspLifecycleState::Failed); + + let mut exited = Harness::start(handoff("declarative-exited", None)); + exited.server.exit(Some(2)); + exited.await_state(LspLifecycleState::Failed); + + let mut stopped = Harness::start(handoff("declarative-stopped", Some(PLUGIN))); + stopped.session().stop().unwrap(); + stopped.await_state(LspLifecycleState::Stopped); + + let mut foreign = Harness::start(handoff("declarative-foreign", Some(OTHER))); + foreign.server.exit(Some(3)); + foreign.await_state(LspLifecycleState::Failed); + assert!(plugin_operations::test_capture::take(OTHER).is_empty()); + + let facts = plugin_operations::test_capture::take(PLUGIN); + assert!(facts + .iter() + .all(|fact| std::ptr::eq(fact.contribution, &LANGUAGE_SERVER_START))); + let results: Vec<_> = facts + .into_iter() + .map(|fact| (fact.operation_id, fact.failure)) + .collect(); + assert_eq!( + results, + [ + (Some("declarative-ready".to_string()), None), + ( + Some("declarative-exited".to_string()), + Some(AttemptStage::Exit) + ), + ] + ); +} + +/// startServer failing after spawn is a `launch` failure, and an initialize +/// timeout decided under the state lock is the only result even when the +/// response arrives afterwards. +#[test] +fn declarative_plugin_start_attributes_write_failure_and_timeout() { + const PLUGIN: &str = "io.example.declarative-start-failures"; + let handoff = |operation_id: &str, request: &mut StartServerRequest| { + request.plugin_id = Some(PLUGIN.to_string()); + request.startup_operation = Some(AttemptHandoff { + plugin_id: PLUGIN.to_string(), + operation_id: operation_id.to_string(), + elapsed_ms: 5, + }); + }; + + let broken = ScriptedServer::new(); + broken.break_input(); + let engine = LspEngine::with_launcher(broken.launcher()); + let mut request = start_request(&broken); + handoff("declarative-write-failed", &mut request); + assert!(engine.start_server(request).is_err()); + + let mut timed_out = Harness::start(|request| { + handoff("declarative-timed-out", request); + request.initialize_timeout_milliseconds = 60_000; + }); + timed_out + .session() + .expire_deadlines_at(Instant::now() + Duration::from_secs(61)); + timed_out.await_state(LspLifecycleState::Failed); + timed_out.server.complete_initialize(ready_capabilities()); + + let results: Vec<_> = plugin_operations::test_capture::take(PLUGIN) + .into_iter() + .map(|fact| (fact.operation_id, fact.failure)) + .collect(); + assert_eq!( + results, + [ + ( + Some("declarative-write-failed".to_string()), + Some(AttemptStage::Launch) + ), + ( + Some("declarative-timed-out".to_string()), + Some(AttemptStage::Timeout) + ), + ] + ); +} + +/// Criterion 10: shutdown sends exit after the response, and a shutdown that +/// is never answered force-terminates. +#[test] +fn shutdown_sends_exit_after_the_response_and_force_terminates_on_timeout() { + let mut harness = Harness::ready(); + harness.session().stop().unwrap(); + let shutdown_id = harness + .server + .await_request("shutdown") + .expect("stop should request shutdown"); + assert!( + harness.notification("exit").is_none(), + "exit must not precede the shutdown response" + ); + + harness.server.send(json!({ + "jsonrpc": "2.0", + "id": shutdown_id, + "result": null + })); + assert!( + harness.server.await_notification("exit"), + "exit must follow the shutdown response" + ); + harness.server.exit(Some(0)); + harness.await_state(LspLifecycleState::Stopped); + + let mut silent = Harness::start(|request| { + request.shutdown_timeout_milliseconds = 30; + }); + silent.server.complete_initialize(ready_capabilities()); + silent.await_state(LspLifecycleState::Ready); + silent.session().stop().unwrap(); + silent + .server + .await_request("shutdown") + .expect("stop should request shutdown"); + // No response ever arrives, so the deadline must kill the process + // instead of leaving the session stuck in Stopping. + silent.await_state(LspLifecycleState::Stopped); + silent.await_event(|event| { + event.kind == "log" + && event + .error + .as_ref() + .is_some_and(|error| error.stage == "shutdown" && error.code == "shutdownTimeout") + }); +} + +/// Criterion 11: a malformed `Content-Length` is a transport failure. +#[test] +fn a_malformed_content_length_header_fails_the_session_in_transport() { + let mut harness = Harness::ready(); + harness.server.send_raw(b"Content-Length: banana\r\n\r\n{}"); + harness.await_state(LspLifecycleState::Failed); + + let failure = harness + .events + .iter() + .find_map(|event| event.error.as_ref()) + .expect("bad framing should be reported as a runtime error"); + assert_eq!(failure.code, "transportFailed"); + assert_eq!(failure.stage, "transport"); +} + +/// Criterion 12: a partial frame is retained until it completes. +/// Criterion 13: consecutive frames are handled in order. +#[test] +fn partial_frames_are_buffered_and_consecutive_frames_arrive_in_order() { + let mut harness = Harness::ready(); + let uri = "file:///workspace/main.go"; + harness.sync(uri, "package main"); + + let split = |uri: &str, message: &str| { + let body = json!({ + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": { + "uri": uri, + "version": 1, + "diagnostics": [{ + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 1 } + }, + "severity": 1, + "message": message + }] + } + }) + .to_string(); + format!("Content-Length: {}\r\n\r\n{body}", body.len()) + }; + + let first = split(uri, "first"); + let boundary = first.len() - 12; + harness.server.send_raw(&first.as_bytes()[..boundary]); + thread::sleep(Duration::from_millis(40)); + harness.poll(); + assert!( + !harness + .events + .iter() + .any(|event| event.kind == "diagnostics"), + "an incomplete frame must not be delivered" + ); + + // The tail of the first frame and a whole second frame arrive together, + // which is exactly how a stream coalesces writes. + harness + .server + .send_raw(format!("{}{}", &first[boundary..], split(uri, "second")).as_bytes()); + harness.await_event(|event| { + event + .diagnostics + .as_ref() + .and_then(|list| list.first()) + .is_some_and(|diagnostic| diagnostic.message == "second") + }); + let delivered: Vec<_> = harness + .events + .iter() + .filter(|event| event.kind == "diagnostics") + .filter_map(|event| event.diagnostics.as_ref()) + .filter_map(|list| list.first()) + .map(|diagnostic| diagnostic.message.clone()) + .collect(); + assert_eq!(delivered, vec!["first".to_string(), "second".to_string()]); + assert_eq!(harness.snapshot().state, LspLifecycleState::Ready); +} + +/// Criterion 14: dynamic registration and unregistration change availability. +#[test] +fn features_event_reports_completion_trigger_characters() { + let mut harness = Harness::start(|_| {}); + harness.server.complete_initialize(json!({ + "completionProvider": { + "triggerCharacters": [".", ":", ">"], + "allCommitCharacters": [";"] + } + })); + let event = harness + .await_event(|event| event.kind == "featuresChanged" && event.completion_options.is_some()) + .clone(); + let json = serde_json::to_value(&event).unwrap(); + assert_eq!( + json["completionOptions"], + json!({"triggerCharacters": [".", ":", ">"], "allCommitCharacters": [";"]}) + ); +} + +#[test] +fn dynamic_capability_registration_and_unregistration_change_availability() { + let mut harness = Harness::start(|_| {}); + // Formatting is absent from the static capabilities, so it can only + // become available through dynamic registration. + harness + .server + .complete_initialize(json!({ "hoverProvider": true })); + harness.await_state(LspLifecycleState::Ready); + let uri = "file:///workspace/main.go"; + harness.sync(uri, "package main"); + assert!(harness + .session() + .request( + SemanticRequest { + query: None, + session_id: harness.session_id.clone(), + operation_id: Some("op-formatting".to_string()), + operation: LspSemanticOperation::Formatting, + uri: Some(uri.to_string()), + position: None, + new_name: None, + range: None, + diagnostics: Vec::new(), + completion_context: None, + completion_item: None, + code_action: None, + command: None, + formatting_options: None, + include_declaration: None, + }, + "op-formatting".to_string(), + ) + .is_err()); + + harness.server.send(json!({ + "jsonrpc": "2.0", + "id": "registration-1", + "method": "client/registerCapability", + "params": { + "registrations": [{ + "id": "formatting-1", + "method": "textDocument/formatting", + "registerOptions": {} + }] + } + })); + let registered = harness + .await_event(|event| { + event + .capabilities + .as_ref() + .is_some_and(|names| names.iter().any(|name| name == "formatting")) + }) + .clone(); + assert_eq!(registered.kind, "featuresChanged"); + + harness.server.send(json!({ + "jsonrpc": "2.0", + "id": "registration-2", + "method": "client/unregisterCapability", + "params": { + "unregisterations": [{ + "id": "formatting-1", + "method": "textDocument/formatting" + }] + } + })); + // The initialize handshake also emitted a formatting-free feature set, so + // the withdrawal is only identifiable by coming after the registration. + let registered_at = registered.sequence; + harness.await_event(|event| { + event.kind == "featuresChanged" + && event.sequence > registered_at + && event + .capabilities + .as_ref() + .is_some_and(|names| !names.iter().any(|name| name == "formatting")) + }); + assert!( + harness + .session() + .request( + SemanticRequest { + query: None, + session_id: harness.session_id.clone(), + operation_id: Some("op-formatting-2".to_string()), + operation: LspSemanticOperation::Formatting, + uri: Some(uri.to_string()), + position: None, + new_name: None, + range: None, + diagnostics: Vec::new(), + completion_context: None, + completion_item: None, + code_action: None, + command: None, + formatting_options: None, + include_declaration: None, + }, + "op-formatting-2".to_string(), + ) + .is_err(), + "an unregistered capability must stop being offered" + ); +} + +/// Criterion 15: replacing the workspace stops the old root and clears it. +#[test] +fn replacing_the_workspace_stops_the_old_root_and_clears_its_state() { + let old_server = ScriptedServer::new(); + let engine = LspEngine::with_launcher(old_server.launcher()); + let old = engine.start_server(start_request(&old_server)).unwrap(); + old_server.complete_initialize(ready_capabilities()); + let old_session = engine.session(&old.session_id).unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline + && old_session.snapshot().unwrap().state != LspLifecycleState::Ready + { + thread::sleep(Duration::from_millis(2)); + } + let uri = "file:///workspace/main.go"; + old_session + .sync_document(SyncDocumentRequest { + session_id: old.session_id.clone(), + uri: uri.to_string(), + language_id: "go".to_string(), + text: "package main".to_string(), + content_changes: Vec::new(), + }) + .unwrap(); + old_server.send(json!({ + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": { + "uri": uri, + "version": 1, + "diagnostics": [{ + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 1 } + }, + "severity": 1, + "message": "old root" + }] + } + })); + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline + && !old_session + .snapshot() + .unwrap() + .diagnostic_versions + .contains_key(uri) + { + thread::sleep(Duration::from_millis(2)); + } + + old_session.stop().unwrap(); + let shutdown = old_server.await_request("shutdown").unwrap(); + old_server.send(json!({ "jsonrpc": "2.0", "id": shutdown, "result": null })); + old_server.exit(Some(0)); + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline + && old_session.snapshot().unwrap().state != LspLifecycleState::Stopped + { + thread::sleep(Duration::from_millis(2)); + } + + let stopped = old_session.snapshot().unwrap(); + assert_eq!(stopped.root_uri, "file:///workspace"); + assert_eq!(stopped.state, LspLifecycleState::Stopped); + assert!(stopped.open_documents.is_empty()); + assert!(stopped.diagnostic_versions.is_empty()); + assert!(stopped.pending_operation_ids.is_empty()); + assert!( + old_server.input_was_closed(), + "the old root's stdin must be released" + ); + + // Only a stopped session may be destroyed, and a destroyed one is + // unreachable, so the replacement cannot inherit any of its state. + engine.destroy(&old.session_id).unwrap(); + assert!(engine.session(&old.session_id).is_err()); + assert!(old_session + .sync_document(SyncDocumentRequest { + session_id: old.session_id.clone(), + uri: uri.to_string(), + language_id: "go".to_string(), + text: "package main".to_string(), + content_changes: Vec::new(), + }) + .is_err()); +} + +#[test] +fn semantic_operations_are_protocol_methods_but_never_expose_request_ids() { + assert_eq!( + semantic_method(LspSemanticOperation::Definition), + "textDocument/definition" + ); +} + +#[test] +fn workspace_execute_command_does_not_require_an_open_document() { + let mut harness = Harness::start(|_| {}); + harness.server.complete_initialize(json!({ + "executeCommandProvider": { "commands": ["source.fix"] } + })); + harness.await_state(LspLifecycleState::Ready); + let operation_id = harness.engine.next_operation_id(); + + harness + .session() + .request( + SemanticRequest { + query: None, + session_id: harness.session_id.clone(), + operation_id: Some(operation_id.clone()), + operation: LspSemanticOperation::ExecuteCommand, + uri: None, + position: None, + new_name: None, + range: None, + diagnostics: Vec::new(), + completion_context: None, + completion_item: None, + code_action: None, + command: Some(json!({ + "title": "Apply fix", + "command": "source.fix", + "arguments": [] + })), + formatting_options: None, + include_declaration: None, + }, + operation_id, + ) + .expect("workspace commands should not be gated on an open document"); + + let request = harness + .server + .messages() + .into_iter() + .find(|message| message["method"] == "workspace/executeCommand") + .expect("the command should reach the language server"); + assert_eq!(request["params"]["command"], "source.fix"); + assert!(request["params"].get("textDocument").is_none()); +} + +#[test] +fn document_saves_honor_server_options_and_never_replace_newer_editor_text() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../fixtures/lsp/document-save-v1.json" + ))) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let option = &case["saveOption"]; + let mut harness = Harness::start(|_| {}); + let mut capabilities = ready_capabilities(); + capabilities["textDocumentSync"] = json!({"change": 1, "save": option}); + harness.server.complete_initialize(capabilities); + harness.await_state(LspLifecycleState::Ready); + let uri = case["uri"].as_str().unwrap(); + let saved_text = case["savedText"].as_str().unwrap(); + harness.sync(uri, saved_text); + let saved = || SaveDocumentRequest { + session_id: harness.session_id.clone(), + uri: uri.to_string(), + text: saved_text.to_string(), + }; + let supported = case["sent"].as_bool().unwrap(); + assert_eq!(harness.session().save_document(saved()).unwrap(), supported); + if supported { + let notification = harness + .server + .await_notification_at("textDocument/didSave", 0) + .unwrap(); + assert_eq!(notification["params"]["textDocument"]["uri"], uri); + assert_eq!( + notification["params"].get("text"), + case["includeText"] + .as_bool() + .unwrap() + .then_some(&json!(saved_text)) + ); + } + harness.sync(uri, "newer unsaved edit"); + assert!(!harness.session().save_document(saved()).unwrap()); + let session = harness.session(); + let state = session.lock_state().unwrap(); + assert_eq!( + state.client.open_documents[uri].text.as_ref(), + "newer unsaved edit" + ); + assert_eq!(state.client.open_documents[uri].version, 2); + } +} + +/// Removes a scratch directory when the owning test ends or unwinds. +#[cfg(unix)] +struct ScratchDirectory(PathBuf); + +#[cfg(unix)] +impl Drop for ScratchDirectory { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +// Regression: a server publishes diagnostics under the resolved path of a +// file the host opened through a symbolic link; matching the URIs textually +// dropped every error in such a workspace. +#[cfg(unix)] +#[test] +fn diagnostics_published_under_the_resolved_path_reach_the_linked_document() { + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should follow the Unix epoch") + .as_nanos(); + let scratch = ScratchDirectory(std::env::temp_dir().join(format!( + "lithe-linked-diagnostics-{}-{stamp}", + std::process::id() + ))); + let workspace = scratch.0.join("resolved"); + std::fs::create_dir_all(&workspace).expect("workspace should be creatable"); + std::fs::write(workspace.join("main.go"), "package main").expect("source file"); + let link = scratch.0.join("link"); + std::os::unix::fs::symlink(&workspace, &link).expect("link should be creatable"); + let opened_uri = url::Url::from_file_path(link.join("main.go")) + .expect("linked file converts to a URI") + .to_string(); + let published_uri = url::Url::from_file_path( + workspace + .canonicalize() + .expect("workspace canonicalizes") + .join("main.go"), + ) + .expect("resolved file converts to a URI") + .to_string(); + assert_ne!(opened_uri, published_uri); + + let mut harness = Harness::ready(); + harness.sync(&opened_uri, "package main"); + harness.server.send(json!({ + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": { + "uri": published_uri, + "diagnostics": [{ + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 7 } + }, + "severity": 1, + "message": "reported at the resolved path" + }] + } + })); + let event = harness + .await_event(|event| event.kind == "diagnostics") + .clone(); + assert_eq!(event.uri.as_deref(), Some(opened_uri.as_str())); + assert_eq!(event.diagnostics.map(|list| list.len()), Some(1)); +} + +#[cfg(unix)] +fn open_document(uri: &str) -> (String, LspClientDocument) { + ( + uri.to_string(), + LspClientDocument { + uri: uri.to_string(), + language_id: "go".to_string(), + version: 1, + text: "".into(), + }, + ) +} + +// Servers publish for many files nobody has open. A session whose documents +// were not opened through a link must remember that and leave those +// messages untouched; closing a document must forget what was learned. +#[cfg(unix)] +#[test] +fn diagnostics_for_unlinked_documents_are_left_alone_and_forgotten_on_close() { + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should follow the Unix epoch") + .as_nanos(); + let scratch = ScratchDirectory(std::env::temp_dir().join(format!( + "lithe-unlinked-diagnostics-{}-{stamp}", + std::process::id() + ))); + std::fs::create_dir_all(&scratch.0).expect("scratch should be creatable"); + let real = scratch.0.canonicalize().expect("scratch canonicalizes"); + std::fs::write(real.join("main.go"), "package main").expect("source file"); + std::fs::write(real.join("other.go"), "package main").expect("source file"); + let uri_of = |name: &str| { + url::Url::from_file_path(real.join(name)) + .expect("file converts to a URI") + .to_string() + }; + let message = |uri: &str| { + json!({ + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": { "uri": uri, "diagnostics": [] } + }) + }; + + let documents: BTreeMap<_, _> = [open_document(&uri_of("main.go"))].into(); + let mut linked = LinkedDocumentPaths::default(); + assert_eq!( + diagnostics_addressed_to_open_document( + &documents, + &mut linked, + &message(&uri_of("other.go")) + ), + None + ); + assert!(linked.resolved.is_empty()); + assert!(linked.examined.contains(&uri_of("main.go"))); + + diagnostics_addressed_to_open_document( + &BTreeMap::new(), + &mut linked, + &message(&uri_of("other.go")), + ); + assert!(linked.examined.is_empty()); +} + +#[test] +fn wait_events_returns_queued_events_without_waiting_the_timeout() { + let harness = Harness::start(|_| {}); + let started = Instant::now(); + let events = harness + .session() + .wait_events(Duration::from_secs(2)) + .expect("waiting should succeed"); + assert!( + !events.is_empty(), + "starting a session should enqueue at least one lifecycle event" + ); + assert!( + started.elapsed() < Duration::from_millis(500), + "queued events must not wait out the timeout" + ); +} + +#[test] +fn wait_events_times_out_with_an_empty_queue() { + let mut harness = Harness::ready(); + harness.poll(); + let started = Instant::now(); + let events = harness + .session() + .wait_events(Duration::from_millis(40)) + .expect("waiting should succeed"); + assert!(events.is_empty()); + assert!(started.elapsed() >= Duration::from_millis(30)); +} + +#[test] +fn wait_events_wakes_when_the_session_enqueues_a_lifecycle_event() { + let mut harness = Harness::ready(); + harness.poll(); + let session = harness.session(); + let waiter = thread::spawn({ + let session = Arc::clone(&session); + move || { + session + .wait_events(Duration::from_secs(2)) + .expect("waiting should succeed") + } + }); + thread::sleep(Duration::from_millis(30)); + session.stop().expect("the session should stop"); + let events = waiter.join().expect("the waiter thread should finish"); + assert!( + events.iter().any(|event| event.kind == "stateChanged"), + "stop should wake waiters with a lifecycle event, got {events:?}" + ); +} + +#[test] +fn wait_events_errors_after_terminal_state_events_are_drained() { + let mut harness = Harness::ready(); + harness.poll(); + harness.session().stop().unwrap(); + let shutdown_id = harness + .server + .await_request("shutdown") + .expect("stop should request shutdown"); + harness.server.send(json!({ + "jsonrpc": "2.0", + "id": shutdown_id, + "result": null + })); + assert!( + harness.server.await_notification("exit"), + "exit must follow the shutdown response" + ); + harness.server.exit(Some(0)); + harness.await_state(LspLifecycleState::Stopped); + + // Drain any remaining lifecycle events from the stop transition. + loop { + let events = match harness.session().wait_events(Duration::from_millis(20)) { + Ok(events) => events, + Err(error) => { + assert!(matches!(error.code, ErrorCode::ProcessFailed)); + assert_eq!(error.details.as_deref(), Some("sessionStopped")); + return; + } + }; + if events.is_empty() { + break; + } + } + + let error = harness + .session() + .wait_events(Duration::from_millis(50)) + .expect_err("drained terminal sessions must not return empty Ok"); + assert!(matches!(error.code, ErrorCode::ProcessFailed)); + assert_eq!(error.details.as_deref(), Some("sessionStopped")); +} + +#[test] +fn provider_debug_preparation_retains_progress() { + let mut harness = plugin_backend(json!({"litheBuild":{"version":0}})); + let operation = build_request(&harness, "lithe/build/prepareDebug", json!({}), None).unwrap(); + let request = harness + .server + .await_request("lithe/build/prepareDebug") + .unwrap(); + let wire_id = request.clone(); + assert_eq!( + harness.written_request(&request)["params"]["operationID"], + wire_id + ); + harness.server.send(json!({"jsonrpc":"2.0","method":"lithe/build/progress","params":{"operationID":wire_id,"output":"built"}})); + let progress = harness.await_event(|event| { + event.kind == "requestProgress" && event.operation_id.as_deref() == Some(operation.as_str()) + }); + assert_eq!(progress.result.as_ref().unwrap()["output"], "built"); + harness.server.send(json!({"jsonrpc":"2.0","id":request,"result":{"program":"app/bin/main","arguments":[],"environment":{},"workingDirectory":"app"}})); + let done = harness.await_event(|event| { + event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str()) + }); + assert_eq!( + done.result.as_ref().unwrap()["value"]["program"], + "app/bin/main" + ); +} + +#[test] +fn inline_test_reports_reach_the_provider() { + let mut harness = plugin_backend(json!({"litheBuild":{"version":0}})); + let operation = build_request( + &harness, + "lithe/build/test", + json!({"testResults":true}), + None, + ) + .unwrap(); + let id = harness.server.await_request("lithe/build/test").unwrap(); + let value = json!({"exitCode":1,"output":"failure","testResults":{"format":"litheTestResults","version":0,"tests":[]}}); + harness + .server + .send(json!({"jsonrpc":"2.0","id":id,"result":value})); + let event = harness.await_event(|event| { + event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str()) + }); + assert_eq!(event.result.as_ref().unwrap()["value"], value); +} + +#[test] +fn provider_native_configuration_returns_only_the_projection() { + let mut harness = plugin_backend(json!({"litheBuild":{"version":0}})); + let operation = build_request(&harness, "lithe/build/configuration", json!({}), None).unwrap(); + let id = harness + .server + .await_request("lithe/build/configuration") + .unwrap(); + let result = json!({"format":"litheBuildConfiguration","version":0,"sources":[]}); + harness + .server + .send(json!({"jsonrpc":"2.0","id":id,"result":result})); + let event = harness.await_event(|event| { + event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str()) + }); + assert_eq!(event.result.as_ref().unwrap()["value"], result); +} + +/// Launch options reach the provider unchanged, and only run preparation accepts them. +#[test] +fn provider_run_options_reach_the_wire_for_run_preparation_only() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/plugins/contract/v1/fixtures/build-providers/build-run-options.json" + )) + .unwrap(); + let params = json!({"launchOptions":fixture["launchOptions"]}); + let mut current = plugin_backend(json!({"litheBuild":{"version":0}})); + let error = build_request(¤t, "lithe/build/model", params.clone(), None).unwrap_err(); + assert!(matches!(error.code, ErrorCode::InvalidRequest)); + let operation = build_request(¤t, "lithe/build/prepareRun", params, None).unwrap(); + let id = current + .server + .await_request("lithe/build/prepareRun") + .unwrap(); + let message = current + .server + .messages() + .into_iter() + .find(|message| message["method"] == "lithe/build/prepareRun") + .unwrap(); + assert_eq!(message["params"]["launchOptions"], fixture["launchOptions"]); + current + .server + .send(json!({"jsonrpc":"2.0","id":id,"result":fixture["result"]})); + let event = current.await_event(|event| { + event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str()) + }); + assert_eq!(event.result.as_ref().unwrap()["value"], fixture["result"]); +} + +/// Port ownership reaches the provider and comes back in the raw plan. +#[test] +fn provider_connection_debug_options_reach_the_wire() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/plugins/contract/v1/fixtures/build-providers/build-connection-debug-options.json" + )) + .unwrap(); + let params = json!({"launchOptions":fixture["launchOptions"]}); + let mut current = plugin_backend(json!({"litheBuild":{"version":0}})); + let operation = build_request(¤t, "lithe/build/prepareRun", params, None).unwrap(); + let id = current + .server + .await_request("lithe/build/prepareRun") + .unwrap(); + current + .server + .send(json!({"jsonrpc":"2.0","id":id,"result":fixture["result"]})); + let event = current.await_event(|event| { + event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str()) + }); + assert_eq!(event.result.as_ref().unwrap()["value"], fixture["result"]); +} + +#[test] +fn provider_run_preparation_returns_the_raw_plan() { + let mut harness = plugin_backend(json!({"litheBuild":{"version":0}})); + let operation = build_request(&harness, "lithe/build/prepareRun", json!({}), None).unwrap(); + let id = harness + .server + .await_request("lithe/build/prepareRun") + .unwrap(); + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/plugins/contract/v1/fixtures/build-providers/build-run-preparation.json" + )) + .unwrap(); + harness + .server + .send(json!({"jsonrpc":"2.0","id":id,"result":fixture["result"]})); + let event = harness.await_event(|event| { + event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str()) + }); + assert_eq!(event.result.as_ref().unwrap()["value"], fixture["result"]); +} + +#[test] +fn provider_test_discovery_uses_the_existing_request_lifecycle() { + let mut harness = plugin_backend(json!({"litheBuild":{"version":0}})); + let operation = build_request( + &harness, + "lithe/build/discoverTests", + json!({"root":"."}), + None, + ) + .unwrap(); + let id = harness + .server + .await_request("lithe/build/discoverTests") + .unwrap(); + let result = json!({"format":"litheTestDiscovery","version":0,"tests":[]}); + harness + .server + .send(json!({"jsonrpc":"2.0","id":id,"result":result})); + let event = harness.await_event(|event| { + event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str()) + }); + assert_eq!(event.result.as_ref().unwrap()["value"], result); + assert!(harness.snapshot().pending_operation_ids.is_empty()); +} + +/// Ordered messages use a completed round trip as a barrier: old progress +/// and replies are drained before checking that cancellation stayed final. +#[test] +fn provider_execution_progress_cancel_and_late_results_are_isolated() { + for method in [ + "lithe/build/execute", + "lithe/build/test", + "lithe/build/prepareRun", + ] { + let mut harness = Harness::start(|_| {}); + harness + .server + .complete_initialize(json!({"experimental":{"litheBuild":{"version":0}}})); + harness.await_state(LspLifecycleState::Ready); + let operation = + build_request(&harness, method, json!({"operationID":"spoof"}), None).unwrap(); + let id = harness.server.await_request(method).unwrap(); + assert_eq!(harness.written_request(&id)["params"]["operationID"], id); + harness + .server + .send(json!({"jsonrpc":"2.0","method":"lithe/build/progress", + "params":{"operationID":id,"output":"first","percentage":20}})); + let progress = harness + .await_event(|event| event.kind == "requestProgress") + .clone(); + assert_eq!(progress.operation_id.as_deref(), Some(operation.as_str())); + assert_eq!(progress.result.unwrap()["output"], "first"); + harness.session().cancel_operation(&operation).unwrap(); + let ended = harness + .await_event(|event| { + event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str()) + }) + .clone(); + assert_eq!(ended.error.unwrap().code, "requestCancelled"); + harness + .server + .send(json!({"jsonrpc":"2.0","method":"lithe/build/progress", + "params":{"operationID":id,"output":"late"}})); + harness + .server + .send(json!({"jsonrpc":"2.0","id":id,"result":{"exitCode":0,"output":"late"}})); + let barrier = build_request(&harness, "lithe/build/model", json!({}), None).unwrap(); + let barrier_id = harness.server.await_request("lithe/build/model").unwrap(); + harness + .server + .send(json!({"jsonrpc":"2.0","id":barrier_id,"result":{}})); + harness.await_event(|event| { + event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(barrier.as_str()) + }); + assert_eq!( + harness + .events + .iter() + .filter(|event| event.kind == "requestProgress") + .count(), + 1 + ); + assert_eq!( + harness + .events + .iter() + .filter(|event| event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str())) + .count(), + 1 + ); + } +} + +/// Host operation IDs may be reused; only the current wire request ID +/// may contribute progress to that new call. +#[test] +fn provider_execution_reused_host_ids_reject_old_wire_progress() { + for method in [ + "lithe/build/execute", + "lithe/build/test", + "lithe/build/prepareRun", + ] { + let mut harness = plugin_backend(json!({"litheBuild":{"version":0}})); + let operation = "reused-build".to_string(); + let send = |harness: &Harness| { + harness + .session() + .build_request( + BuildProviderRequest { + session_id: harness.session_id.clone(), + operation_id: Some(operation.clone()), + method: method.to_string(), + params: json!({}), + timeout_milliseconds: None, + }, + operation.clone(), + ) + .unwrap() + }; + send(&harness); + let old = harness.server.await_request(method).unwrap(); + harness.session().cancel_operation(&operation).unwrap(); + harness.await_event(|event| { + event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str()) + }); + send(&harness); + let current = harness.server.await_request_at(method, 1).unwrap(); + assert_ne!(old, current); + for (id, output) in [(old, "old"), (current, "current")] { + harness + .server + .send(json!({"jsonrpc":"2.0","method":"lithe/build/progress", + "params":{"operationID":id,"output":output}})); + } + harness.await_event(|event| { + event.kind == "requestProgress" + && event + .result + .as_ref() + .is_some_and(|result| result["output"] == "current") + }); + assert!(harness + .events + .iter() + .filter(|event| event.kind == "requestProgress") + .all(|event| event.result.as_ref().unwrap()["output"] != "old")); + harness.session().cancel_operation(&operation).unwrap(); + } +} + +#[test] +fn provider_execution_validates_results_and_expires_without_sleeping() { + for method in [ + "lithe/build/execute", + "lithe/build/test", + "lithe/build/prepareRun", + ] { + let mut harness = Harness::start(|_| {}); + harness + .server + .complete_initialize(json!({"experimental":{"litheBuild":{"version":0}}})); + harness.await_state(LspLifecycleState::Ready); + for (index, (reply, code)) in [ + (json!({"exitCode":7,"output":"failed build"}), None), + ( + json!({"exitCode":"bad"}), + Some("plugin.build.invalidResult"), + ), + ] + .into_iter() + .enumerate() + { + let operation = build_request(&harness, method, json!({}), None).unwrap(); + let id = harness.server.await_request_at(method, index).unwrap(); + harness + .server + .send(json!({"jsonrpc":"2.0","id":id,"result":reply})); + let ended = harness + .await_event(|event| { + event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str()) + }) + .clone(); + // Preparation returns raw launch data; the build decoder validates it. + let code = if method == "lithe/build/prepareRun" { + None + } else { + code + }; + assert_eq!(ended.error.as_ref().map(|error| error.code.as_str()), code); + if code.is_none() { + assert_eq!(ended.result.unwrap()["value"], reply); + } + } + let operation = build_request(&harness, method, json!({}), Some(1000)).unwrap(); + harness.server.await_request_at(method, 2).unwrap(); + harness + .session() + .expire_deadlines_at(Instant::now() + Duration::from_secs(2)); + assert_eq!( + harness + .await_event(|event| event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str())) + .error + .as_ref() + .unwrap() + .code, + "requestTimeout" + ); + assert!(harness.snapshot().pending_operation_ids.is_empty()); + } +} + +/// A process that answered the build-provider protocol with version 0. +fn negotiated_build_provider() -> Harness { + let mut harness = Harness::start(|_| {}); + harness.server.complete_initialize(json!({ + "experimental": { "litheBuild": { "version": 0 } } + })); + harness.await_state(LspLifecycleState::Ready); + harness +} + +fn build_request( + harness: &Harness, + method: &str, + params: Value, + timeout_milliseconds: Option, +) -> Result { + let operation_id = harness.engine.next_operation_id(); + harness + .session() + .build_request( + BuildProviderRequest { + session_id: harness.session_id.clone(), + operation_id: Some(operation_id.clone()), + method: method.to_string(), + params, + timeout_milliseconds, + }, + operation_id.clone(), + ) + .map(|()| operation_id) +} + +/// Every session offers the protocol, but one whose process did not +/// choose a version must never receive a build request: a shared +/// language server keeps serving its language without it. +#[test] +fn build_requests_need_a_negotiated_protocol_version() { + let harness = Harness::ready(); + let initialize = harness + .server + .messages() + .into_iter() + .find(|message| message["method"] == "initialize") + .expect("initialize should have been written"); + assert_eq!( + initialize["params"]["capabilities"]["experimental"]["litheBuild"], + json!({ "versions": [0] }) + ); + + let error = build_request(&harness, "lithe/build/model", json!({}), None) + .expect_err("an unnegotiated session must refuse build requests"); + assert!(matches!(error.code, ErrorCode::NotSupported)); + assert!(harness + .server + .messages() + .iter() + .all(|message| message["method"] != "lithe/build/model")); +} + +/// A reply naming a version the host did not offer negotiates nothing, +/// for the build protocol and the plugin-function protocol alike. +#[test] +fn a_reply_naming_an_unoffered_version_negotiates_nothing() { + let harness = plugin_backend(json!({ + "litheBuild": { "version": 1 }, + "litheFunctions": { "version": 1, "functions": [{ "id": "snapshot.refresh" }] } + })); + let error = build_request(&harness, "lithe/build/model", json!({}), None) + .expect_err("version 1 was not offered"); + assert!(matches!(error.code, ErrorCode::NotSupported)); + let error = invoke(&harness, FUNCTION_PLUGIN, "snapshot.refresh", None) + .expect_err("version 1 was not offered"); + assert!(matches!(error.code, ErrorCode::NotSupported)); + assert!(harness.server.messages().iter().all(|message| { + message["method"] != "lithe/build/model" && message["method"] != "lithe/function/invoke" + })); +} + +/// A model request carries the declared shared-input snapshot to the provider verbatim. +#[test] +fn model_inputs_reach_the_provider_verbatim() { + let mut harness = negotiated_build_provider(); + let params = json!({"modelInputs":{"version":0,"revision":7,"inputs":[]}}); + let operation = build_request(&harness, "lithe/build/model", params.clone(), None).unwrap(); + let id = harness + .server + .await_request("lithe/build/model") + .expect("the provider receives its snapshot"); + assert_eq!(harness.written_request(&id)["params"], params); + harness + .server + .send(json!({"jsonrpc":"2.0","id":id,"result":{"projects":[]}})); + harness.await_event(|event| { + event.operation_id.as_deref() == Some(&operation) && event.kind == "requestCompleted" + }); +} + +#[test] +fn a_negotiated_build_request_forwards_the_plan_and_returns_the_reply() { + let mut harness = negotiated_build_provider(); + let params = json!({ + "buildSystemID": "make", + "root": "", + "options": { "offline": true }, + "optionArguments": ["--offline"], + "environment": {} + }); + let operation = + build_request(&harness, "lithe/build/model", params.clone(), None).expect("accepted"); + let request_id = harness + .server + .await_request("lithe/build/model") + .expect("the request should reach the provider"); + assert_eq!(harness.written_request(&request_id)["params"], params); + + // A reply with a `locations` field proves no navigation normalizer + // rewrites the document Core decodes. + let reply = json!({ "format": "litheBuildModel", "version": 0, "locations": [] }); + harness + .server + .send(json!({ "jsonrpc": "2.0", "id": request_id, "result": reply })); + let completed = harness + .await_event(|event| { + event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str()) + }) + .clone(); + assert!(completed.error.is_none()); + assert_eq!(completed.result, Some(json!({ "value": reply }))); + assert!(harness.snapshot().pending_operation_ids.is_empty()); +} + +/// Only the negotiated methods pass, so the build path cannot become a +/// generic JSON-RPC tunnel into a language server. +#[test] +fn build_requests_accept_only_the_protocol_methods() { + let harness = negotiated_build_provider(); + for method in ["workspace/executeCommand", "shutdown"] { + let error = build_request(&harness, method, json!({}), None) + .expect_err("only protocol methods are build requests"); + assert!(matches!(error.code, ErrorCode::InvalidRequest), "{method}"); + } + assert!(harness + .server + .messages() + .iter() + .all(|message| message["method"] != "workspace/executeCommand")); +} + +/// A build request's own deadline replaces the session's request timeout, +/// here shortened, and the timed-out request is cancelled on the wire. +#[test] +fn a_build_request_times_out_on_its_own_deadline() { + let mut harness = negotiated_build_provider(); + let operation = + build_request(&harness, "lithe/build/dependencies", json!({}), Some(30)).expect("accepted"); + harness + .server + .await_request("lithe/build/dependencies") + .expect("the request should reach the provider"); + let timeout = harness + .await_event(|event| event.operation_id.as_deref() == Some(operation.as_str())) + .clone(); + assert_eq!( + timeout.error.as_ref().map(|error| error.code.as_str()), + Some("requestTimeout") + ); + assert!(harness.server.await_notification("$/cancelRequest")); +} + +#[test] +fn keymap_import_negotiation_is_available_only_to_plugin_owned_sessions() { + for plugin_owned in [false, true] { + let mut harness = Harness::start(|request| { + request.keymap_import_providers = vec!["fixture".to_string()]; + if plugin_owned { + request.plugin_id = Some("dev.lithe.fixture.import".to_string()); + } + }); + harness.server.complete_initialize(json!({"experimental": { + "litheKeymapImport": {"version": 0, "providers": ["fixture"]} + }})); + harness.await_state(LspLifecycleState::Ready); + let initialize = harness + .server + .messages() + .into_iter() + .find(|message| message["method"] == "initialize") + .unwrap(); + let offer = initialize["params"]["capabilities"]["experimental"].get("litheKeymapImport"); + assert_eq!(offer.is_some(), plugin_owned); + if plugin_owned { + assert_eq!( + offer, + Some(&json!({"versions": [0], "providers": ["fixture"]})) + ); + } + let event = harness + .events + .iter() + .find(|event| event.kind == "keymapImportProviders"); + assert_eq!(event.is_some(), plugin_owned); + if let Some(event) = event { + assert_eq!(event.result, Some(json!({"providers": ["fixture"]}))); + } + } +} + +#[test] +fn keymap_import_provider_cannot_select_an_undeclared_identity() { + let mut harness = Harness::start(|request| { + request.plugin_id = Some("dev.lithe.fixture.import".to_string()); + request.keymap_import_providers = vec!["fixture".to_string()]; + }); + harness.server.complete_initialize(json!({"experimental": { + "litheKeymapImport": {"version": 0, "providers": ["fixture", "other"]} + }})); + harness.await_state(LspLifecycleState::Ready); + assert!(harness + .events + .iter() + .all(|event| event.kind != "keymapImportProviders")); +} + +#[test] +fn keymap_import_request_only_reaches_its_negotiated_provider() { + let mut harness = Harness::start(|request| { + request.plugin_id = Some("dev.lithe.fixture.import".to_string()); + request.keymap_import_providers = vec!["fixture".to_string()]; + }); + harness.server.complete_initialize(json!({"experimental": { + "litheKeymapImport": {"version": 0, "providers": ["fixture"]} + }})); + harness.await_state(LspLifecycleState::Ready); + let request = |provider: &str| KeymapImportProviderRequest { + session_id: harness.session_id.clone(), + operation_id: None, + provider_id: provider.to_string(), + snapshot_id: "snapshot-1".to_string(), + timeout_milliseconds: Some(1_000), + }; + assert!(harness + .session() + .keymap_import_request(request("other"), "refused".to_string()) + .is_err()); + assert!(harness + .server + .messages() + .iter() + .all(|message| message["method"] != "lithe/keymap/import")); + harness + .session() + .keymap_import_request(request("fixture"), "import-1".to_string()) + .unwrap(); + let id = harness.server.await_request("lithe/keymap/import").unwrap(); + let wire_id: u64 = id.parse().unwrap(); + let sent = harness.written_request(&id); + assert_eq!( + sent["params"], + json!({"providerID": "fixture", "snapshotID": "snapshot-1"}) + ); + let response = json!({"jsonrpc": "2.0", "id": wire_id, "result": {"bindings": []}}); + harness.server.send(response.clone()); + let event = harness.await_event(|event| { + event.kind == "requestCompleted" && event.operation_id.as_deref() == Some("import-1") + }); + assert_eq!( + event.result, + Some(json!({"value": {"id": wire_id, "response": response}})) + ); +} + +#[test] +fn keymap_import_request_rejects_paths_and_can_be_cancelled() { + let mut harness = Harness::start(|request| { + request.plugin_id = Some("dev.lithe.fixture.import".to_string()); + request.keymap_import_providers = vec!["fixture".to_string()]; + }); + harness.server.complete_initialize(json!({"experimental": { + "litheKeymapImport": {"version": 0, "providers": ["fixture"]} + }})); + harness.await_state(LspLifecycleState::Ready); + let mut request = KeymapImportProviderRequest { + session_id: harness.session_id.clone(), + operation_id: None, + provider_id: "fixture".to_string(), + snapshot_id: "../private".to_string(), + timeout_milliseconds: None, + }; + assert!(harness + .session() + .keymap_import_request(request.clone(), "invalid".to_string()) + .is_err()); + assert!(harness + .server + .messages() + .iter() + .all(|message| message["method"] != "lithe/keymap/import")); + request.snapshot_id = "snapshot-1".to_string(); + harness + .session() + .keymap_import_request(request, "import-2".to_string()) + .unwrap(); + harness.server.await_request("lithe/keymap/import").unwrap(); + harness.session().cancel_operation("import-2").unwrap(); + assert!(harness.server.await_notification("$/cancelRequest")); + let event = harness.await_event(|event| { + event.kind == "requestCompleted" && event.operation_id.as_deref() == Some("import-2") + }); + assert_eq!( + event.error.as_ref().map(|error| error.code.as_str()), + Some("requestCancelled") + ); + assert!(event.result.is_none()); +} + +#[test] +fn keymap_import_request_rejects_oversized_provider_replies() { + let mut harness = Harness::start(|request| { + request.plugin_id = Some("dev.lithe.fixture.import".to_string()); + request.keymap_import_providers = vec!["fixture".to_string()]; + }); + harness.server.complete_initialize(json!({"experimental": { + "litheKeymapImport": {"version": 0, "providers": ["fixture"]} + }})); + harness.await_state(LspLifecycleState::Ready); + let request = KeymapImportProviderRequest { + session_id: harness.session_id.clone(), + operation_id: None, + provider_id: "fixture".to_string(), + snapshot_id: "snapshot-1".to_string(), + timeout_milliseconds: Some(5_000), + }; + harness + .session() + .keymap_import_request(request, "import-3".to_string()) + .unwrap(); + let id = harness.server.await_request("lithe/keymap/import").unwrap(); + harness + .server + .send(json!({"jsonrpc": "2.0", "id": id.parse::().unwrap(), + "result": {"data": "x".repeat(lithe_lsp_client::keymap_provider::MAX_REPLY_BYTES)}})); + let event = harness.await_event(|event| { + event.kind == "requestCompleted" && event.operation_id.as_deref() == Some("import-3") + }); + assert_eq!( + event.error.as_ref().map(|error| error.code.as_str()), + Some("plugin.keymapImport.replyTooLarge") + ); + assert!(event.result.is_none()); +} + +// Toolchain providers against a scripted backend. Only a plugin-owned +// session launched with declared providers is offered `litheToolchains`, +// and only a provider its process selected receives `toolchains/query`. + +fn toolchain_backend(answer: Value) -> Harness { + let mut harness = Harness::start(|request| { + request.plugin_id = Some("dev.lithe.fixture.toolchain-provider".to_string()); + request.toolchain_providers = vec!["mise".to_string()]; + }); + harness + .server + .complete_initialize(json!({ "experimental": { "litheToolchains": answer } })); + harness.await_state(LspLifecycleState::Ready); + harness +} + +fn toolchain_query(harness: &Harness, provider: &str) -> Result { + let operation_id = harness.engine.next_operation_id(); + harness + .session() + .toolchain_request( + ToolchainProviderRequest { + session_id: harness.session_id.clone(), + operation_id: Some(operation_id.clone()), + params: json!({ "operationID": "query-1", "generation": 1, "providerID": provider }), + timeout_milliseconds: None, + }, + operation_id.clone(), + ) + .map(|()| operation_id) +} + +fn no_toolchain_query_written(harness: &Harness) -> bool { + harness + .server + .messages() + .iter() + .all(|message| message["method"] != "toolchains/query") +} + +#[test] +fn host_sessions_are_not_offered_the_toolchain_protocol() { + let harness = Harness::start(|request| { + request.toolchain_providers = vec!["mise".to_string()]; + }); + harness.server.complete_initialize(json!({})); + let initialize = harness + .server + .messages() + .into_iter() + .find(|message| message["method"] == "initialize") + .expect("initialize should have been written"); + assert!(initialize["params"]["capabilities"]["experimental"] + .get("litheToolchains") + .is_none()); +} + +#[test] +fn a_query_reaches_only_a_selected_provider_and_returns_the_raw_response() { + let mut harness = toolchain_backend(json!({ "version": 0, "providers": ["mise"] })); + let initialize = harness + .server + .messages() + .into_iter() + .find(|message| message["method"] == "initialize") + .expect("initialize should have been written"); + assert_eq!( + initialize["params"]["capabilities"]["experimental"]["litheToolchains"], + json!({ "versions": [0], "providers": ["mise"] }) + ); + let event = harness + .await_event(|event| event.kind == "toolchainProviders") + .clone(); + assert_eq!(event.result, Some(json!({ "providers": ["mise"] }))); + + let refused = toolchain_query(&harness, "asdf").expect_err("not selected"); + assert_eq!( + refused.details.as_deref(), + Some("plugin.toolchain.providerUnavailable") + ); + assert!(no_toolchain_query_written(&harness)); + + let operation = toolchain_query(&harness, "mise").expect("written"); + let id = harness + .server + .await_request("toolchains/query") + .expect("the query is written"); + let wire_id: u64 = id.parse().expect("a numeric wire ID"); + // Even a provider error stays a raw reply: decodeReply owns its mapping. + let response = json!({ + "jsonrpc": "2.0", "id": wire_id, + "error": { "code": -32073, "message": "untrusted", "data": { "code": "x" } } + }); + harness.server.send(response.clone()); + let event = harness + .await_event(|event| { + event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str()) + }) + .clone(); + assert!(event.error.is_none()); + assert_eq!( + event.result, + Some(json!({ "value": { "id": wire_id, "response": response } })) + ); +} + +#[test] +fn an_undeclared_toolchain_provider_enables_nothing() { + let mut harness = toolchain_backend(json!({ "version": 0, "providers": ["mise", "asdf"] })); + harness.poll(); + assert!(harness + .events + .iter() + .all(|event| event.kind != "toolchainProviders")); + let error = toolchain_query(&harness, "mise").expect_err("refused"); + assert!(matches!(error.code, ErrorCode::NotSupported)); + assert!(no_toolchain_query_written(&harness)); +} + +// VCS providers against a scripted backend. Only a plugin-owned session +// is offered `litheVcs`, and only one whose process answered with a VCS +// ID and every required capability receives requests, each planned by +// Core from an operation name. + +const VCS_PLUGIN: &str = "dev.lithe.fixture.vcs-provider"; + +fn vcs_capabilities() -> Value { + json!({ "status": 0, "baseContent": 0, "checkin": 0, "rollback": 0, "update": 0 }) +} + +fn vcs_backend(answer: Value) -> Harness { + let mut harness = Harness::start(|request| { + request.plugin_id = Some(VCS_PLUGIN.to_string()); + }); + harness + .server + .complete_initialize(json!({ "experimental": { "litheVcs": answer } })); + harness.await_state(LspLifecycleState::Ready); + harness +} + +fn negotiated_vcs_provider() -> Harness { + vcs_backend(json!({ "version": 0, "vcs": "svn", "capabilities": vcs_capabilities() })) +} + +/// A native absolute path on every platform the tests run on. +fn working_copy() -> String { + std::env::temp_dir() + .join("lithe-vcs-working-copy") + .to_string_lossy() + .into_owned() +} + +fn vcs_request( + harness: &Harness, + vcs: &str, + operation: &str, + arguments: Value, +) -> Result { + let operation_id = harness.engine.next_operation_id(); + harness + .session() + .vcs_request( + VcsProviderRequest { + session_id: harness.session_id.clone(), + operation_id: Some(operation_id.clone()), + vcs: vcs.to_string(), + operation: operation.to_string(), + root: working_copy(), + tool: None, + arguments, + timeout_milliseconds: None, + }, + operation_id.clone(), + ) + .map(|()| operation_id) +} + +/// Feature names the session recorded from the provider's answer. +fn vcs_features(harness: &Harness) -> Vec { + let session = harness.session(); + let state = session.lock_state().expect("state"); + state.client.server_capabilities.clone() +} + +fn no_vcs_request_written(harness: &Harness) -> bool { + harness.server.messages().iter().all(|message| { + !message["method"] + .as_str() + .is_some_and(|method| method.starts_with("lithe/vcs/")) + }) +} + +#[test] +fn host_sessions_are_not_offered_the_vcs_protocol() { + let harness = Harness::ready(); + let initialize = harness + .server + .messages() + .into_iter() + .find(|message| message["method"] == "initialize") + .expect("initialize should have been written"); + assert!(initialize["params"]["capabilities"]["experimental"] + .get("litheVcs") + .is_none()); + let error = vcs_request(&harness, "svn", "status", json!({})) + .expect_err("a host session is never a provider"); + assert!(matches!(error.code, ErrorCode::NotSupported)); + assert_eq!(error.details.as_deref(), Some("vcs.provider.unavailable")); + assert!(no_vcs_request_written(&harness)); +} + +/// The negotiated VCS and capabilities reach the host as one event, so +/// it can check the VCS against the manifest and pick the actions to show. +#[test] +fn a_negotiated_provider_is_announced_with_its_capabilities() { + let mut harness = negotiated_vcs_provider(); + let event = harness + .await_event(|event| event.kind == "vcsProvider") + .clone(); + assert_eq!( + event.result, + Some(json!({ "vcs": "svn", "capabilities": vcs_capabilities() })) + ); + assert!(vcs_features(&harness) + .iter() + .any(|feature| feature == "litheVcs")); +} + +/// The host has no side for credentials or console routing: an +/// authentication request is refused so the provider fails with +/// `vcs.authentication.failed`, and an execution report is passed on as a +/// session log entry. +#[test] +fn provider_authentication_is_refused_and_execution_reports_pass_through() { + let mut harness = negotiated_vcs_provider(); + harness.server.send(json!({ + "jsonrpc": "2.0", + "id": "auth-1", + "method": "lithe/vcs/authenticate", + "params": { "realm": " Example" } + })); + assert_eq!(await_reply(&harness, "auth-1")["error"]["code"], -32601); + harness.server.send(json!({ + "jsonrpc": "2.0", + "method": "lithe/vcs/execution", + "params": { "kind": "started", "arguments": ["status", "--xml"] } + })); + let event = harness + .await_event(|event| { + event.kind == "log" && event.message.as_deref() == Some("lithe/vcs/execution") + }) + .clone(); + assert!(event + .detail + .as_deref() + .is_some_and(|detail| detail.contains("--xml"))); +} + +/// An answer without a required capability makes the process a plain +/// plugin session: no announcement, no feature, and no request. +#[test] +fn an_incomplete_vcs_answer_is_not_a_provider() { + let mut harness = vcs_backend(json!({ + "version": 0, "vcs": "svn", "capabilities": { "status": 0, "baseContent": 0 } + })); + harness.poll(); + assert!(harness + .events + .iter() + .all(|event| event.kind != "vcsProvider")); + assert!(vcs_features(&harness) + .iter() + .all(|feature| feature != "litheVcs")); + let error = vcs_request(&harness, "svn", "status", json!({})).expect_err("refused"); + assert_eq!(error.details.as_deref(), Some("vcs.provider.unavailable")); + assert!(no_vcs_request_written(&harness)); +} + +#[test] +fn a_vcs_request_is_planned_from_its_operation_and_returns_the_reply() { + let mut harness = negotiated_vcs_provider(); + let operation = vcs_request( + &harness, + "svn", + "checkin", + json!({ "paths": ["src/b.txt", "src/a.txt"], "message": "Fix" }), + ) + .expect("accepted"); + let request_id = harness + .server + .await_request("lithe/vcs/checkin") + .expect("the request should reach the provider"); + assert_eq!( + harness.written_request(&request_id)["params"], + json!({ + "paths": ["src/a.txt", "src/b.txt"], + "message": "Fix", + "root": { "path": working_copy() }, + "tool": null + }) + ); + let reply = json!({ "revision": { "id": "12", "display": "r12", "kind": "number" } }); + harness + .server + .send(json!({ "jsonrpc": "2.0", "id": request_id, "result": reply })); + let completed = harness + .await_event(|event| { + event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str()) + }) + .clone(); + assert!(completed.error.is_none(), "{:?}", completed.error); + assert_eq!(completed.result, Some(json!({ "value": reply }))); + assert!(harness.snapshot().pending_operation_ids.is_empty()); +} + +/// The host names an operation, never a method, so the session cannot +/// be used as a tunnel; another VCS, an unknown operation, a capability +/// the provider lacks, or a path leaving the root never reach the wire. +#[test] +fn vcs_requests_are_refused_before_they_are_written() { + let harness = negotiated_vcs_provider(); + let error = vcs_request(&harness, "hg", "status", json!({})).expect_err("wrong VCS"); + assert_eq!(error.details.as_deref(), Some("vcs.provider.unavailable")); + let error = vcs_request(&harness, "svn", "shutdown", json!({})).expect_err("no such operation"); + assert!(matches!(error.code, ErrorCode::InvalidRequest)); + let error = vcs_request(&harness, "svn", "annotate", json!({ "path": "a.txt" })) + .expect_err("not negotiated"); + assert!(matches!(error.code, ErrorCode::NotSupported)); + assert_eq!(error.details.as_deref(), Some("vcs.capability.unsupported")); + let error = vcs_request(&harness, "svn", "rollback", json!({ "paths": ["../x"] })) + .expect_err("outside the root"); + assert!(matches!(error.code, ErrorCode::InvalidRequest)); + assert!(no_vcs_request_written(&harness)); +} + +/// A provider may name one of the contract's failure codes; any other +/// code, including one of Lithe's own, becomes the generic failure. +#[test] +fn vcs_failures_keep_only_contract_codes() { + let mut harness = negotiated_vcs_provider(); + for (index, (data, expected)) in [ + (json!({ "code": "vcs.outOfDate" }), "vcs.outOfDate"), + ( + json!({ "code": "plugin.vcs.invalidOutput" }), + "vcs.provider.failed", + ), + ] + .into_iter() + .enumerate() + { + let operation = vcs_request(&harness, "svn", "update", json!({})).expect("sent"); + let request_id = harness + .server + .await_request_at("lithe/vcs/update", index) + .expect("the request should reach the provider"); + harness.server.send(json!({ + "jsonrpc": "2.0", + "id": request_id, + "error": { "code": -32000, "message": "Working copy is out of date.", "data": data } + })); + let error = harness + .await_event(|event| event.operation_id.as_deref() == Some(operation.as_str())) + .error + .clone() + .expect("an error reply fails the request"); + assert_eq!(error.code, expected); + assert_eq!(error.message, "Working copy is out of date."); + } +} + +// Plugin functions against a scripted backend. The scripted server plays +// the plugin process, so every outcome (result, failure, cancellation, +// deadline, late reply, revocation, and compatibility with a backend that +// only speaks the build protocol) is driven at a chosen moment. + +const FUNCTION_PLUGIN: &str = "dev.lithe.fixture.build"; + +/// A session owned by `FUNCTION_PLUGIN` whose backend answered +/// `initialize` with `experimental`. +fn plugin_backend(experimental: Value) -> Harness { + let mut harness = Harness::start(|request| { + request.plugin_id = Some(FUNCTION_PLUGIN.to_string()); + }); + harness + .server + .complete_initialize(json!({ "experimental": experimental })); + harness.await_state(LspLifecycleState::Ready); + harness +} + +fn function_backend() -> Harness { + plugin_backend(json!({ + "litheBuild": { "version": 0 }, + "litheFunctions": { "version": 0, "functions": [{ "id": "snapshot.refresh" }] } + })) +} + +fn invoke( + harness: &Harness, + plugin_id: &str, + function_id: &str, + timeout_milliseconds: Option, +) -> Result { + let operation_id = harness.engine.next_operation_id(); + invoke_with_operation( + harness, + plugin_id, + function_id, + timeout_milliseconds, + operation_id, + ) +} + +/// Starts a call using a host-selected ID so regressions can exercise ID reuse. +fn invoke_with_operation( + harness: &Harness, + plugin_id: &str, + function_id: &str, + timeout_milliseconds: Option, + operation_id: String, +) -> Result { + harness + .session() + .function_request( + PluginFunctionRequest { + session_id: harness.session_id.clone(), + operation_id: Some(operation_id.clone()), + function: lithe_plugin_rpc::FunctionReference { + plugin_id: plugin_id.to_string(), + function_id: function_id.to_string(), + }, + params: json!({ "project": "app" }), + timeout_milliseconds, + context: None, + }, + operation_id.clone(), + None, + ) + .map(|()| operation_id) +} + +/// The engine's reply to a request the scripted backend sent, waited for +/// with a bounded deadline because the reply is written by the reader thread. +fn await_reply(harness: &Harness, id: &str) -> Value { + harness + .server + .await_response(id) + .unwrap_or_else(|| panic!("the engine never answered backend request {id}")) +} + +fn plugin_configured() -> Harness { + let mut harness = Harness::start(|request| { + request.plugin_id = Some("dev.lithe.go".to_string()); + request.workspace_configuration = Some(json!({ + "gopls": { "ui": { "staticcheck": true } }, + "go.dotted": 7 + })); + }); + harness.server.complete_initialize(ready_capabilities()); + harness.await_state(LspLifecycleState::Ready); + harness +} + +#[test] +fn plugin_configuration_answers_sections_and_announces_itself() { + let harness = plugin_configured(); + assert!(harness + .server + .await_notification("workspace/didChangeConfiguration")); + harness.server.send(json!({ + "jsonrpc": "2.0", + "id": "cfg-1", + "method": "workspace/configuration", + "params": { "items": [ + { "section": "gopls" }, + { "section": "gopls.ui.staticcheck" }, + { "section": "go.dotted" }, + { "section": "missing.path" } + ] } + })); + let reply = await_reply(&harness, "cfg-1"); + assert_eq!( + reply["result"], + json!([{ "ui": { "staticcheck": true } }, true, 7, null]) + ); +} + +#[test] +fn plugin_configuration_updates_reach_a_running_server() { + let harness = plugin_configured(); + let response = harness + .session() + .update_plugin_configuration(json!({ "gopls": { "ui": { "staticcheck": false } } })) + .expect("the update should be accepted"); + assert!(response.sent); + let change = harness + .server + .await_notification_at("workspace/didChangeConfiguration", 1) + .expect("the configuration change was never sent"); + assert_eq!( + change["params"]["settings"]["gopls"]["ui"]["staticcheck"], + json!(false) + ); +} + +#[test] +fn host_servers_reject_plugin_configuration() { + let harness = Harness::ready(); + assert!(harness + .session() + .update_plugin_configuration(json!({})) + .is_err()); + assert!(checked_workspace_configuration(Some(json!([1]))).is_err()); +} + +fn completions(harness: &mut Harness, operation: &str) -> Vec { + harness.poll(); + harness + .events + .iter() + .filter(|event| { + event.kind == "requestCompleted" && event.operation_id.as_deref() == Some(operation) + }) + .cloned() + .collect() +} + +#[test] +fn a_plugin_function_is_routed_to_its_plugin_and_returns_its_result() { + let mut harness = function_backend(); + let initialize = harness + .server + .messages() + .into_iter() + .find(|message| message["method"] == "initialize") + .expect("initialize should have been written"); + assert_eq!( + initialize["params"]["capabilities"]["experimental"], + json!({ + "litheBuild": { "versions": [0] }, + "litheFunctions": { "versions": [0] }, + "litheLogging": { "versions": [0] }, + "litheVcs": { "versions": [0] } + }) + ); + + // Another plugin's reference never reaches this process. + let error = invoke( + &harness, + "dev.lithe.fixture.other-build", + "snapshot.refresh", + None, + ) + .expect_err("a foreign plugin is refused"); + assert!(matches!(error.code, ErrorCode::PermissionDenied)); + assert_eq!( + error.details.as_deref(), + Some("plugin.function.wrongPlugin") + ); + let error = invoke(&harness, FUNCTION_PLUGIN, "snapshot.clear", None) + .expect_err("an unregistered function is refused"); + assert_eq!(error.details.as_deref(), Some("plugin.function.unknown")); + assert!(harness + .server + .messages() + .iter() + .all(|message| message["method"] != "lithe/function/invoke")); + + let operation = invoke(&harness, FUNCTION_PLUGIN, "snapshot.refresh", None).expect("sent"); + let request_id = harness + .server + .await_request("lithe/function/invoke") + .expect("the call should reach the backend"); + let wire_operation = harness.written_request(&request_id)["params"]["operationID"].clone(); + assert!(wire_operation.as_str().is_some_and(|id| !id.is_empty())); + assert_eq!( + harness.written_request(&request_id)["params"], + json!({ + "functionID": "snapshot.refresh", + "operationID": wire_operation, + "params": { "project": "app" } + }) + ); + harness.server.send(json!({ + "jsonrpc": "2.0", + "id": request_id, + "result": { "snapshot": 3, "locations": [] } + })); + let completed = harness + .await_event(|event| { + event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str()) + }) + .clone(); + assert!(completed.error.is_none()); + assert_eq!( + completed.result, + Some(json!({ "value": { "snapshot": 3, "locations": [] } })) + ); + assert!(harness.snapshot().pending_operation_ids.is_empty()); +} + +#[test] +fn a_failing_plugin_function_reports_the_stable_and_the_plugin_code() { + let mut harness = function_backend(); + let operation = invoke(&harness, FUNCTION_PLUGIN, "snapshot.refresh", None).expect("sent"); + let request_id = harness + .server + .await_request("lithe/function/invoke") + .expect("the call should reach the backend"); + harness.server.send(json!({ + "jsonrpc": "2.0", + "id": request_id, + "error": { "code": -32000, "message": "Snapshot is locked.", "data": { "code": "build.snapshotLocked" } } + })); + let error = harness + .await_event(|event| event.operation_id.as_deref() == Some(operation.as_str())) + .error + .clone() + .expect("an error reply fails the call"); + assert_eq!(error.code, "plugin.function.failed"); + assert_eq!(error.message, "Snapshot is locked."); + assert_eq!( + error.plugin_error_code.as_deref(), + Some("build.snapshotLocked") + ); +} + +/// Cancellation reaches the backend, ends the call once, and neither a +/// late reply nor a late progress report revives it. +#[test] +fn a_cancelled_plugin_function_ignores_its_late_reply_and_progress() { + let mut harness = function_backend(); + let operation = invoke(&harness, FUNCTION_PLUGIN, "snapshot.refresh", None).expect("sent"); + let request_id = harness + .server + .await_request("lithe/function/invoke") + .expect("the call should reach the backend"); + let wire_operation = harness.written_request(&request_id)["params"]["operationID"].clone(); + harness.server.send(json!({ + "jsonrpc": "2.0", + "method": "lithe/function/progress", + "params": { "operationID": wire_operation, "message": "Reading snapshot", "percentage": 40 } + })); + let progress = harness + .await_event(|event| event.kind == "requestProgress") + .clone(); + assert_eq!(progress.operation_id.as_deref(), Some(operation.as_str())); + assert_eq!( + progress.result, + Some(json!({ "operationID": operation, "message": "Reading snapshot", "percentage": 40 })) + ); + + harness + .session() + .cancel_operation(&operation) + .expect("a running call can be cancelled"); + assert!(harness.server.await_notification("$/cancelRequest")); + harness.server.send(json!({ + "jsonrpc": "2.0", + "method": "lithe/function/progress", + "params": { "operationID": wire_operation, "percentage": 90 } + })); + harness + .server + .send(json!({ "jsonrpc": "2.0", "id": request_id, "result": "too late" })); + // A request the backend sends after the late messages is answered only + // once the reader has processed them, so its reply orders the check. + harness.server.send(json!({ + "jsonrpc": "2.0", + "id": "fence", + "method": "workspace/configuration", + "params": { "items": [] } + })); + await_reply(&harness, "fence"); + + let ended = completions(&mut harness, &operation); + assert_eq!( + ended.len(), + 1, + "a late reply must not complete the call again" + ); + assert_eq!( + ended[0].error.as_ref().map(|error| error.code.as_str()), + Some("requestCancelled") + ); + assert_eq!( + harness + .events + .iter() + .filter(|event| event.kind == "requestProgress") + .count(), + 1, + "progress after cancellation must be dropped" + ); +} + +#[test] +fn a_plugin_function_times_out_on_its_own_deadline() { + let mut harness = function_backend(); + let operation = + invoke(&harness, FUNCTION_PLUGIN, "snapshot.refresh", Some(60_000)).expect("sent"); + harness + .server + .await_request("lithe/function/invoke") + .expect("the call should reach the backend"); + harness + .session() + .expire_deadlines_at(Instant::now() + Duration::from_secs(30)); + assert!(completions(&mut harness, &operation).is_empty()); + assert_eq!( + harness.snapshot().pending_operation_ids, + vec![operation.clone()] + ); + harness + .session() + .expire_deadlines_at(Instant::now() + Duration::from_secs(61)); + let timeout = harness + .await_event(|event| event.operation_id.as_deref() == Some(operation.as_str())) + .clone(); + assert_eq!( + timeout.error.as_ref().map(|error| error.code.as_str()), + Some("requestTimeout") + ); + assert!(harness.server.await_notification("$/cancelRequest")); +} + +/// Registration after `initialize` is all or nothing, and withdrawing a +/// function ends its running calls instead of letting them finish later. +#[test] +fn unregistering_a_running_function_revokes_the_call() { + let mut harness = function_backend(); + harness.server.send(json!({ + "jsonrpc": "2.0", + "id": "register-bad", + "method": "lithe/function/register", + "params": { "functions": [{ "id": "snapshot.clear" }, { "id": "snapshot.refresh" }] } + })); + let refused = await_reply(&harness, "register-bad"); + assert_eq!( + refused["error"]["data"]["code"], + "plugin.function.invalidRegistration" + ); + + harness.server.send(json!({ + "jsonrpc": "2.0", + "id": "register", + "method": "lithe/function/register", + "params": { "functions": [{ "id": "snapshot.clear" }] } + })); + assert_eq!(await_reply(&harness, "register")["result"], Value::Null); + let changed = harness + .await_event(|event| { + event.kind == "functionsChanged" + && event.result.as_ref().is_some_and(|result| { + result["functions"] == json!(["snapshot.clear", "snapshot.refresh"]) + }) + }) + .clone(); + assert_eq!( + changed.result, + Some(json!({ "functions": ["snapshot.clear", "snapshot.refresh"], "removed": [] })) + ); + + let operation = invoke(&harness, FUNCTION_PLUGIN, "snapshot.clear", None).expect("sent"); + let request_id = harness + .server + .await_request("lithe/function/invoke") + .expect("the call should reach the backend"); + harness.server.send(json!({ + "jsonrpc": "2.0", + "id": "unregister", + "method": "lithe/function/unregister", + "params": { "ids": ["snapshot.clear"] } + })); + assert_eq!(await_reply(&harness, "unregister")["result"], Value::Null); + let revoked = harness + .await_event(|event| event.operation_id.as_deref() == Some(operation.as_str())) + .clone(); + assert_eq!( + revoked.error.as_ref().map(|error| error.code.as_str()), + Some("plugin.function.revoked") + ); + // The revocation is published before the cancellation is written, so + // the write is awaited rather than expected to be there already. + let cancel = harness + .server + .await_notification_at("$/cancelRequest", 0) + .expect("the revoked call should be cancelled on the wire"); + assert_eq!(cancel["params"]["id"], request_id); + let error = invoke(&harness, FUNCTION_PLUGIN, "snapshot.clear", None) + .expect_err("a withdrawn function is unknown"); + assert_eq!(error.details.as_deref(), Some("plugin.function.unknown")); + assert!(harness.snapshot().pending_operation_ids.is_empty()); +} + +/// A backend that speaks only the build protocol keeps its build requests; +/// it receives no function calls and its function messages are not +/// mistaken for registrations. +#[test] +fn a_build_only_backend_keeps_working_without_plugin_functions() { + let mut harness = plugin_backend(json!({ "litheBuild": { "version": 0 } })); + let error = invoke(&harness, FUNCTION_PLUGIN, "snapshot.refresh", None) + .expect_err("functions need the negotiated protocol"); + assert!(matches!(error.code, ErrorCode::NotSupported)); + assert_eq!( + error.details.as_deref(), + Some("plugin.function.unavailable") + ); + + harness.server.send(json!({ + "jsonrpc": "2.0", + "id": "register", + "method": "lithe/function/register", + "params": { "functions": [{ "id": "snapshot.refresh" }] } + })); + assert_eq!(await_reply(&harness, "register")["error"]["code"], -32601); + + let operation = build_request(&harness, "lithe/build/model", json!({}), None) + .expect("the build protocol is unaffected"); + let request_id = harness + .server + .await_request("lithe/build/model") + .expect("the build request should reach the backend"); + let reply = json!({ "format": "litheBuildModel", "version": 0 }); + harness + .server + .send(json!({ "jsonrpc": "2.0", "id": request_id, "result": reply })); + let completed = harness + .await_event(|event| event.operation_id.as_deref() == Some(operation.as_str())) + .clone(); + assert_eq!(completed.result, Some(json!({ "value": reply }))); +} + +/// Host-owned sessions neither offer nor accept the plugin-function protocol. +#[test] +fn a_host_owned_session_serves_no_plugin_functions() { + let mut harness = Harness::start(|_| {}); + harness.server.complete_initialize(json!({ + "experimental": { "litheFunctions": { "version": 0, "functions": [{ "id": "snapshot.refresh" }] } } + })); + harness.await_state(LspLifecycleState::Ready); + let initialize = harness + .server + .messages() + .into_iter() + .find(|message| message["method"] == "initialize") + .expect("initialize written"); + assert!(initialize["params"]["capabilities"]["experimental"] + .get("litheFunctions") + .is_none()); + assert!(!harness + .session() + .lock_state() + .expect("session state") + .client + .server_capabilities + .iter() + .any(|feature| feature == "litheFunctions")); + for (id, method, params) in [ + ( + "host-register", + "lithe/function/register", + json!({ "functions": [{ "id": "snapshot.clear" }] }), + ), + ( + "host-unregister", + "lithe/function/unregister", + json!({ "ids": ["snapshot.refresh"] }), + ), + ] { + harness + .server + .send(json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })); + assert_eq!(await_reply(&harness, id)["error"]["code"], -32601); + } + harness.poll(); + assert!(harness + .events + .iter() + .all(|event| event.kind != "functionsChanged")); + let error = invoke(&harness, FUNCTION_PLUGIN, "snapshot.refresh", None) + .expect_err("a session without a plugin is refused"); + assert_eq!( + error.details.as_deref(), + Some("plugin.function.unavailable") + ); +} + +/// Ends the first invocation at a controlled boundary before reusing its host ID. +fn finish_function_for_reuse(harness: &mut Harness, operation: &str, request: &str, ending: &str) { + match ending { + "cancel" => harness + .session() + .cancel_operation(operation) + .expect("cancelled"), + "timeout" => harness + .session() + .expire_deadlines_at(Instant::now() + Duration::from_secs(61)), + "complete" => harness + .server + .send(json!({ "jsonrpc": "2.0", "id": request, "result": "first" })), + "error" => harness.server.send(json!({ "jsonrpc": "2.0", "id": request, "error": { "code": -32000, "message": "first failed" } })), + "revoke" => { + harness.server.send(json!({ + "jsonrpc": "2.0", "id": "withdraw-first", "method": "lithe/function/unregister", + "params": { "ids": ["snapshot.refresh"] } + })); + assert_eq!( + await_reply(harness, "withdraw-first")["result"], + Value::Null + ); + } + _ => panic!("unknown termination scenario"), + } + harness.await_event(|event| { + event.kind == "requestCompleted" && event.operation_id.as_deref() == Some(operation) + }); + if matches!(ending, "cancel" | "timeout" | "revoke") { + assert!(harness.server.await_notification("$/cancelRequest")); + } + if ending == "revoke" { + harness.server.send(json!({ + "jsonrpc": "2.0", "id": "restore-first", "method": "lithe/function/register", + "params": { "functions": [{ "id": "snapshot.refresh" }] } + })); + assert_eq!(await_reply(harness, "restore-first")["result"], Value::Null); + } +} + +#[test] +fn plugin_function_initialization_publishes_the_full_registry() { + for functions in [ + json!([{ "id": "snapshot.refresh" }, { "id": "snapshot.clear" }]), + json!([]), + ] { + let mut harness = + plugin_backend(json!({ "litheFunctions": { "version": 0, "functions": functions } })); + let initialized = harness + .await_event(|event| event.kind == "functionsChanged") + .clone(); + let mut expected: Vec<_> = functions + .as_array() + .expect("descriptors") + .iter() + .map(|item| item["id"].as_str().expect("function identifier")) + .collect(); + expected.sort_unstable(); + assert_eq!( + initialized.result, + Some(json!({ "functions": expected, "removed": [] })) + ); + assert_eq!( + harness + .events + .iter() + .filter(|event| event.kind == "functionsChanged") + .count(), + 1 + ); + } +} + +/// An ended call's response and progress cannot become the next call's result, +/// even when the host chooses the same operation ID after every terminal outcome. +#[test] +fn reused_plugin_function_operation_ids_ignore_old_wire_progress() { + for ending in ["cancel", "timeout", "complete", "error", "revoke"] { + let mut harness = function_backend(); + harness.server.send(json!({ + "jsonrpc": "2.0", "id": "register-second", "method": "lithe/function/register", + "params": { "functions": [{ "id": "snapshot.clear" }] } + })); + assert_eq!( + await_reply(&harness, "register-second")["result"], + Value::Null + ); + let operation = "reusable-function-operation"; + invoke_with_operation( + &harness, + FUNCTION_PLUGIN, + "snapshot.refresh", + Some(60_000), + operation.to_string(), + ) + .expect("first call"); + let first = harness + .server + .await_request("lithe/function/invoke") + .expect("first wire request"); + let old_wire = harness.written_request(&first)["params"]["operationID"].clone(); + finish_function_for_reuse(&mut harness, operation, &first, ending); + invoke_with_operation( + &harness, + FUNCTION_PLUGIN, + "snapshot.clear", + None, + operation.to_string(), + ) + .expect("reused host ID"); + let second = harness + .server + .await_request_at("lithe/function/invoke", 1) + .expect("second wire request"); + let current_wire = harness.written_request(&second)["params"]["operationID"].clone(); + assert_ne!( + old_wire, current_wire, + "wire identity must change after {ending}" + ); + harness.server.send(json!({ + "jsonrpc": "2.0", "id": "withdraw-old-function", "method": "lithe/function/unregister", + "params": { "ids": ["snapshot.refresh"] } + })); + assert_eq!( + await_reply(&harness, "withdraw-old-function")["result"], + Value::Null + ); + assert_eq!( + harness.snapshot().pending_operation_ids, + vec![operation.to_string()] + ); + assert!(harness + .server + .messages() + .iter() + .all(|message| message["method"] != "$/cancelRequest" + || message["params"]["id"] != second)); + harness + .server + .send(json!({ "jsonrpc": "2.0", "id": first, "result": "late" })); + harness.server.send(json!({ "jsonrpc": "2.0", "method": "lithe/function/progress", "params": { "operationID": old_wire, "message": "stale" } })); + harness.server.send(json!({ "jsonrpc": "2.0", "method": "lithe/function/progress", "params": { "operationID": current_wire, "message": "current", "percentage": 50 } })); + let progress = harness + .await_event(|event| event.kind == "requestProgress") + .clone(); + assert_eq!(progress.operation_id.as_deref(), Some(operation)); + assert_eq!( + progress.result, + Some(json!({ "operationID": operation, "message": "current", "percentage": 50 })), + "{ending}" + ); + harness + .server + .send(json!({ "jsonrpc": "2.0", "id": second, "result": "second" })); + harness.await_event(|event| { + event.kind == "requestCompleted" && event.result == Some(json!({ "value": "second" })) + }); + assert_eq!(completions(&mut harness, operation).len(), 2, "{ending}"); + assert_eq!( + harness + .events + .iter() + .filter(|event| event.kind == "requestProgress") + .count(), + 1, + "{ending}" + ); + assert!(harness.snapshot().pending_operation_ids.is_empty()); + } +} + +/// Revocation after a function has ended must never cancel an unrelated +/// build that reused the same host operation ID. +#[test] +fn reused_plugin_function_operation_ids_preserve_build_requests() { + for ending in ["cancel", "timeout", "complete", "error", "revoke"] { + let mut harness = function_backend(); + let operation = "reusable-build-operation"; + invoke_with_operation( + &harness, + FUNCTION_PLUGIN, + "snapshot.refresh", + Some(60_000), + operation.to_string(), + ) + .expect("first call"); + let first = harness + .server + .await_request("lithe/function/invoke") + .expect("function request"); + let old_wire = harness.written_request(&first)["params"]["operationID"].clone(); + finish_function_for_reuse(&mut harness, operation, &first, ending); + harness + .session() + .build_request( + BuildProviderRequest { + session_id: harness.session_id.clone(), + operation_id: Some(operation.to_string()), + method: "lithe/build/model".to_string(), + params: json!({}), + timeout_milliseconds: None, + }, + operation.to_string(), + ) + .expect("build may reuse host operation ID"); + let build = harness + .server + .await_request("lithe/build/model") + .expect("build request"); + harness + .server + .send(json!({ "jsonrpc": "2.0", "id": first, "result": "late" })); + harness.server.send(json!({ "jsonrpc": "2.0", "method": "lithe/function/progress", "params": { "operationID": old_wire, "message": "stale" } })); + harness.server.send(json!({ + "jsonrpc": "2.0", "id": "withdraw-during-build", "method": "lithe/function/unregister", + "params": { "ids": ["snapshot.refresh"] } + })); + assert_eq!( + await_reply(&harness, "withdraw-during-build")["result"], + Value::Null + ); + harness.poll(); + assert!(harness + .events + .iter() + .any(|event| event.kind == "functionsChanged" + && event.result + == Some(json!({ "functions": [], "removed": ["snapshot.refresh"] })))); + assert_eq!( + harness.snapshot().pending_operation_ids, + vec![operation.to_string()], + "{ending}" + ); + assert!(harness + .server + .messages() + .iter() + .all(|message| message["method"] != "$/cancelRequest" + || message["params"]["id"] != build)); + harness + .server + .send(json!({ "jsonrpc": "2.0", "id": build, "result": { "version": 1 } })); + harness.await_event(|event| { + event.kind == "requestCompleted" && event.method.as_deref() == Some("lithe/build/model") + }); + let ended = completions(&mut harness, operation); + assert_eq!(ended.len(), 2, "{ending}"); + assert!(ended[1].error.is_none(), "{ending}: {:?}", ended[1]); + assert!( + harness + .events + .iter() + .all(|event| event.kind != "requestProgress"), + "{ending}" + ); + } +} + +#[test] +fn stopping_a_plugin_function_withdraws_registry_and_ignores_late_messages() { + let mut harness = function_backend(); + let operation = invoke(&harness, FUNCTION_PLUGIN, "snapshot.refresh", None).expect("call"); + let request = harness + .server + .await_request("lithe/function/invoke") + .expect("wire request"); + let wire = harness.written_request(&request)["params"]["operationID"].clone(); + harness.session().stop().expect("shutdown starts"); + let shutdown = harness + .server + .await_request("shutdown") + .expect("shutdown request"); + harness.poll(); + let registry_changes = harness + .events + .iter() + .filter(|event| event.kind == "functionsChanged") + .count(); + assert_eq!( + registry_changes, 2, + "initialize publishes, then stop withdraws" + ); + harness.server.send(json!({ "jsonrpc": "2.0", "method": "lithe/function/progress", "params": { "operationID": wire, "message": "late" } })); + harness + .server + .send(json!({ "jsonrpc": "2.0", "id": request, "result": "late" })); + for (id, method, params) in [ + ( + "late-register", + "lithe/function/register", + json!({ "functions": [{ "id": "snapshot.late" }] }), + ), + ( + "late-unregister", + "lithe/function/unregister", + json!({ "ids": ["snapshot.refresh"] }), + ), + ] { + harness + .server + .send(json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })); + // The reply fences every earlier notification before asserting absence. + assert_eq!(await_reply(&harness, id)["error"]["code"], -32601); + } + harness.poll(); + assert!(harness + .session() + .lock_state() + .expect("session state") + .client + .plugin_functions + .is_empty()); + assert_eq!( + harness + .events + .iter() + .filter(|event| event.kind == "functionsChanged") + .count(), + registry_changes + ); + harness + .server + .send(json!({ "jsonrpc": "2.0", "id": shutdown, "result": null })); + assert!(harness.server.await_notification("exit")); + harness.server.exit(Some(0)); + harness.await_state(LspLifecycleState::Stopped); + assert_eq!(completions(&mut harness, &operation).len(), 1); + assert!(harness + .events + .iter() + .all(|event| event.kind != "requestProgress")); + assert!(harness + .events + .iter() + .any(|event| event.kind == "functionsChanged" + && event.result == Some(json!({ "functions": [], "removed": ["snapshot.refresh"] })))); +} + +#[test] +fn a_plugin_function_process_failure_withdraws_registry_and_completes_calls() { + let mut harness = function_backend(); + let operation = invoke(&harness, FUNCTION_PLUGIN, "snapshot.refresh", None).expect("call"); + harness + .server + .await_request("lithe/function/invoke") + .expect("wire request"); + harness.server.exit(Some(42)); + harness.await_state(LspLifecycleState::Failed); + let ended = completions(&mut harness, &operation); + assert_eq!(ended.len(), 1); + assert!(ended[0].error.is_some()); + assert!(harness.snapshot().pending_operation_ids.is_empty()); + assert!(harness + .events + .iter() + .any(|event| event.kind == "functionsChanged" + && event.result == Some(json!({ "functions": [], "removed": ["snapshot.refresh"] })))); +} + +/// Domain dispatch could route back into the backend that asked, whose +/// handler would wait on itself; a host method the host does not offer is +/// unavailable. +#[test] +fn a_backend_cannot_ask_the_host_to_dispatch_back_into_itself() { + let harness = function_backend(); + for (id, method, code) in [ + ( + "invoke", + "lithe/function/invoke", + "plugin.function.recursiveCall", + ), + ( + "model", + "lithe/build/model", + "plugin.function.recursiveCall", + ), + ( + "process", + "lithe/host/process.run", + "plugin.host.unavailable", + ), + ] { + harness.server.send(json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": {} + })); + let reply = await_reply(&harness, id); + assert_eq!(reply["error"]["data"]["code"], code, "{method}"); + } +} + +fn host_service_backend(host_services: &[&str]) -> Harness { + let host_services: Vec = host_services.iter().map(ToString::to_string).collect(); + let mut harness = Harness::start(move |request| { + request.plugin_id = Some(FUNCTION_PLUGIN.to_string()); + request.host_services = host_services.clone(); + }); + harness.server.complete_initialize(json!({ + "experimental": { "litheFunctions": { "version": 0 } } + })); + harness.await_state(LspLifecycleState::Ready); + harness +} + +fn host_response( + harness: &Harness, + host_request_id: &str, + result: Option, + error: Option<&str>, +) -> Result<(), CoreError> { + // Decoded from the wire shape `lsp.hostResponse` documents, so the + // field spelling is checked with the round trip. + let mut wire = json!({ + "sessionId": harness.session_id, + "hostRequestID": host_request_id, + }); + if let Some(result) = result { + wire["result"] = result; + } + if let Some(message) = error { + wire["error"] = match message.split_once('|') { + Some((code, message)) => json!({ "message": message, "code": code }), + None => json!({ "message": message }), + }; + } + let response: PluginHostResponse = serde_json::from_value(wire).expect("documented wire shape"); + harness.session().host_response(response) +} + +/// A granted credential request reaches the host as `hostRequest`. The +/// backend receives exactly the host's answer under its own ID, and a +/// second answer to the same request is refused instead of reaching a +/// later request. +#[test] +fn a_granted_host_service_request_round_trips_through_the_host() { + let mut harness = host_service_backend(&["credentials"]); + harness.server.send(json!({ + "jsonrpc": "2.0", "id": "read", + "method": "lithe/host/credentials/get", + "params": { "key": "connection.prod" } + })); + let event = harness + .await_event(|event| event.kind == "hostRequest") + .clone(); + assert_eq!(event.method.as_deref(), Some("lithe/host/credentials/get")); + let request = event.result.expect("the request is described"); + assert_eq!(request["service"], "credentials"); + assert_eq!(request["params"], json!({ "key": "connection.prod" })); + let read_id = request["hostRequestID"].as_str().unwrap().to_string(); + host_response( + &harness, + &read_id, + Some(json!({ "secret": "s3cret" })), + None, + ) + .expect("the host answers once"); + assert_eq!( + await_reply(&harness, "read")["result"], + json!({ "secret": "s3cret" }) + ); + assert!(host_response(&harness, &read_id, None, None).is_err()); + + harness.server.send(json!({ + "jsonrpc": "2.0", "id": "write", + "method": "lithe/host/credentials/set", + "params": { "key": "connection.prod", "secret": "s3cret" } + })); + let event = harness + .await_event(|event| { + event.kind == "hostRequest" + && event.method.as_deref() == Some("lithe/host/credentials/set") + }) + .clone(); + let write_id = event.result.unwrap()["hostRequestID"] + .as_str() + .unwrap() + .to_string(); + assert_ne!(write_id, read_id); + host_response(&harness, &write_id, None, Some("The keychain is locked.")) + .expect("the host reports its failure"); + let reply = await_reply(&harness, "write"); + assert_eq!(reply["error"]["data"]["code"], "plugin.host.failed"); + assert_eq!(reply["error"]["message"], "The keychain is locked."); +} + +/// Host services are offered only for the services the plugin's +/// permissions grant; a refusal never reaches the host. +#[test] +fn host_services_need_a_grant() { + let mut harness = host_service_backend(&[]); + harness.server.send(json!({ + "jsonrpc": "2.0", "id": "read", + "method": "lithe/host/credentials/get", + "params": { "key": "connection.prod" } + })); + let reply = await_reply(&harness, "read"); + assert_eq!( + reply["error"]["data"]["code"], + "plugin.host.permissionDenied" + ); + harness.poll(); + assert!(harness + .events + .iter() + .all(|event| event.kind != "hostRequest")); +} + +/// A backend that negotiated no plugin-function protocol is offered no +/// host service and receives no host notification, whatever the plugin's +/// permissions grant. +#[test] +fn a_backend_without_the_function_protocol_gets_no_host_services() { + let mut harness = Harness::start(|request| { + request.plugin_id = Some(FUNCTION_PLUGIN.to_string()); + request.host_services = vec!["credentials".to_string(), "workspace".to_string()]; + }); + harness.server.complete_initialize(json!({ + "experimental": { "litheBuild": { "version": 0 } } + })); + harness.await_state(LspLifecycleState::Ready); + harness.server.send(json!({ + "jsonrpc": "2.0", "id": "read", + "method": "lithe/host/credentials/get", + "params": { "key": "connection.prod" } + })); + let reply = await_reply(&harness, "read"); + assert_eq!(reply["error"]["data"]["code"], "plugin.host.unavailable"); + harness.poll(); + assert!(harness + .events + .iter() + .all(|event| event.kind != "hostRequest")); + let request: PluginHostNotification = serde_json::from_value(json!({ + "sessionId": harness.session_id, + "method": "lithe/host/workspace/didChangeContexts", + "params": { "revision": 2 }, + })) + .expect("documented wire shape"); + assert!(!harness + .session() + .host_notify(request) + .expect("a known notification")); +} + +/// A host that stops answering cannot make a backend grow the session's +/// state: requests beyond the bound fail at once. +#[test] +fn unanswered_host_requests_are_bounded() { + let harness = host_service_backend(&["credentials"]); + let limit = lithe_plugin_rpc::MAX_PENDING_HOST_REQUESTS; + for index in 0..=limit { + harness.server.send(json!({ + "jsonrpc": "2.0", "id": format!("read-{index}"), + "method": "lithe/host/credentials/get", + "params": { "key": "connection.prod" } + })); + } + let reply = await_reply(&harness, &format!("read-{limit}")); + assert_eq!(reply["error"]["code"], -32014); + assert_eq!(reply["error"]["data"]["code"], "plugin.host.limitExceeded"); + assert_eq!( + harness.session().lock_state().unwrap().host_requests.len(), + limit + ); +} + +/// Core answers capability discovery itself, listing only the session's +/// grants; the host never sees the request. +#[test] +fn capability_discovery_is_answered_by_core() { + let mut harness = host_service_backend(&["workspace"]); + harness.server.send(json!({ + "jsonrpc": "2.0", "id": "caps", + "method": "lithe/host/getCapabilities" + })); + let reply = await_reply(&harness, "caps"); + assert_eq!(reply["result"]["protocolVersion"], 0); + assert_eq!(reply["result"]["backendAuthority"], "local"); + assert!(reply["result"]["services"]["workspace"].is_object()); + assert!(reply["result"]["services"]["credentials"].is_null()); + harness.poll(); + assert!(harness + .events + .iter() + .all(|event| event.kind != "hostRequest")); +} + +/// Editor options the host declared at start are reported with the +/// methods they belong to, and only when those methods are granted. +#[test] +fn capability_discovery_reports_granted_editor_options() { + use lithe_plugin_editor::actions::{EditorHostOptions, REGISTER_PROVIDERS}; + let mut harness = Harness::start(|request| { + request.plugin_id = Some(FUNCTION_PLUGIN.to_string()); + request.host_services = vec![REGISTER_PROVIDERS.to_string()]; + request.host_options.editor = Some(EditorHostOptions { + action_icons: vec!["run".into(), "test-failed".into()], + action_source_bytes: 1024 * 1024, + provider_types: vec!["hover".into(), "codeLens".into()], + }); + }); + harness.server.complete_initialize(json!({ + "experimental": { "litheFunctions": { "version": 0 } } + })); + harness.await_state(LspLifecycleState::Ready); + harness.server.send(json!({ + "jsonrpc": "2.0", "id": "caps", + "method": "lithe/host/getCapabilities" + })); + let reply = await_reply(&harness, "caps"); + let editor = &reply["result"]["services"]["editor"]; + assert_eq!(editor["methods"], json!([REGISTER_PROVIDERS])); + assert_eq!( + editor["options"], + json!({ "providerTypes": ["hover", "codeLens"] }) + ); + assert!(reply["result"]["limits"]["editorActionSourceBytes"].is_null()); + harness.poll(); + assert!(harness + .events + .iter() + .all(|event| event.kind != "hostRequest")); +} + +/// Host options belong to a plugin backend and must be ones Core can +/// enforce. +#[test] +fn start_contract_rejects_unenforceable_host_options() { + use lithe_plugin_editor::actions::EditorHostOptions; + let server = ScriptedServer::new(); + let mut request = start_request(&server); + let options = EditorHostOptions { + action_icons: vec!["run".into()], + action_source_bytes: 1024 * 1024, + provider_types: Vec::new(), + }; + request.host_options.editor = Some(options.clone()); + request.plugin_id = None; + assert!(validate_start_request(&request).is_err()); + request.plugin_id = Some(FUNCTION_PLUGIN.to_string()); + assert!(validate_start_request(&request).is_ok()); + for invalid in [ + EditorHostOptions { + action_source_bytes: 2 * 1024 * 1024, + ..options.clone() + }, + EditorHostOptions { + action_icons: vec!["Run Fast".into()], + ..options.clone() + }, + EditorHostOptions { + provider_types: vec!["diagnostics".into()], + ..options.clone() + }, + ] { + request.host_options.editor = Some(invalid); + assert!(validate_start_request(&request).is_err()); + } +} + +/// A field a method does not define is answered as an unsupported option +/// naming it, so a backend can retry without it; the host never sees the +/// request. +#[test] +fn an_unknown_option_is_named_to_the_backend() { + let mut harness = host_service_backend(&["workspace"]); + harness.server.send(json!({ + "jsonrpc": "2.0", "id": "folders", + "method": "lithe/host/workspace/getFolders", + "params": { + "context": { "contextID": "w-1", "generation": 1 }, + "includeHidden": true + } + })); + let reply = await_reply(&harness, "folders"); + assert_eq!(reply["error"]["code"], -32018); + assert_eq!( + reply["error"]["data"]["code"], + "plugin.host.unsupportedOption" + ); + assert_eq!( + reply["error"]["data"]["details"], + json!({ "option": "includeHidden" }) + ); + harness.poll(); + assert!(harness + .events + .iter() + .all(|event| event.kind != "hostRequest")); +} + +/// A host failure's code reaches the backend when it is one a host may +/// report; an unknown code is `plugin.host.failed`. +#[test] +fn typed_host_failures_reach_the_backend() { + let mut harness = host_service_backend(&["workspace"]); + for (id, code, expected, rpc_code) in [ + ( + "a", + "plugin.host.contextExpired", + "plugin.host.contextExpired", + -32011, + ), + ("b", "plugin.function.unknown", "plugin.host.failed", -32603), + ] { + harness.server.send(json!({ + "jsonrpc": "2.0", "id": id, + "method": "lithe/host/workspace/getFolders", + "params": { "context": { "contextID": "w1", "generation": 1 } } + })); + let event = harness + .await_event(|event| event.kind == "hostRequest") + .clone(); + let host_request_id = event.result.unwrap()["hostRequestID"] + .as_str() + .unwrap() + .to_string(); + harness.events.clear(); + host_response( + &harness, + &host_request_id, + None, + Some(&format!("{code}|The workspace closed.")), + ) + .expect("the host reports its failure"); + let reply = await_reply(&harness, id); + assert_eq!(reply["error"]["data"]["code"], expected, "{code}"); + assert_eq!(reply["error"]["code"], rpc_code, "{code}"); + } +} + +/// A host failure's details reach the backend under +/// `error.data.details`, next to the stable code. +#[test] +fn host_failure_details_reach_the_backend() { + let mut harness = host_service_backend(&["workspace"]); + harness.server.send(json!({ + "jsonrpc": "2.0", "id": "folders", + "method": "lithe/host/workspace/getFolders", + "params": { "context": { "contextID": "w1", "generation": 1 } } + })); + let host_request_id = harness + .await_event(|event| event.kind == "hostRequest") + .clone() + .result + .unwrap()["hostRequestID"] + .as_str() + .unwrap() + .to_string(); + let response: PluginHostResponse = serde_json::from_value(json!({ + "sessionId": harness.session_id, + "hostRequestID": host_request_id, + "error": { + "message": "The context changed.", + "code": "plugin.host.contextExpired", + "details": { "generation": 2 } + } + })) + .expect("documented wire shape"); + harness + .session() + .host_response(response) + .expect("the host reports its failure"); + let reply = await_reply(&harness, "folders"); + assert_eq!(reply["error"]["data"]["code"], "plugin.host.contextExpired"); + assert_eq!( + reply["error"]["data"]["details"], + json!({ "generation": 2 }) + ); +} + +/// `lsp.hostNotify` writes an admitted notification to the backend and +/// refuses one the session was not granted. +#[test] +fn host_notifications_reach_the_backend() { + let harness = host_service_backend(&["workspace"]); + let notify = |method: &str, params: Value| { + let request: PluginHostNotification = serde_json::from_value(json!({ + "sessionId": harness.session_id, + "method": method, + "params": params, + })) + .expect("documented wire shape"); + harness.session().host_notify(request) + }; + assert!(notify( + "lithe/host/workspace/didChangeContexts", + json!({ "revision": 2 }) + ) + .expect("an admitted notification")); + assert!(harness + .server + .await_notification("lithe/host/workspace/didChangeContexts")); + assert!(notify("lithe/build/run", json!({})).is_err()); +} + +/// A backend that cancels its own host request is answered at once, the +/// host learns which request to stop, and its late answer is refused. +#[test] +fn a_backend_can_cancel_its_host_request() { + let mut harness = host_service_backend(&["workspace"]); + harness.server.send(json!({ + "jsonrpc": "2.0", "id": "list", + "method": "lithe/host/workspace/listContexts" + })); + let host_request_id = harness + .await_event(|event| event.kind == "hostRequest") + .clone() + .result + .unwrap()["hostRequestID"] + .as_str() + .unwrap() + .to_string(); + harness.server.send(json!({ + "jsonrpc": "2.0", "method": "$/cancelRequest", "params": { "id": "list" } + })); + let cancelled = harness + .await_event(|event| event.kind == "hostRequestCancelled") + .clone(); + assert_eq!( + cancelled.result.unwrap()["hostRequestID"], + json!(host_request_id) + ); + let reply = await_reply(&harness, "list"); + assert_eq!(reply["error"]["data"]["code"], "plugin.host.cancelled"); + assert!(host_response(&harness, &host_request_id, Some(json!({})), None).is_err()); +} + +/// A call's workspace context reaches the backend as `params.context`, +/// and a malformed reference never leaves Core. +#[test] +fn invocations_carry_the_workspace_context() { + let harness = plugin_backend(json!({ + "litheFunctions": { "version": 0, "functions": [{ "id": "snapshot.refresh" }] } + })); + let call = |context: Value| { + let operation_id = harness.engine.next_operation_id(); + harness.session().function_request( + PluginFunctionRequest { + session_id: harness.session_id.clone(), + operation_id: Some(operation_id.clone()), + function: lithe_plugin_rpc::FunctionReference { + plugin_id: FUNCTION_PLUGIN.to_string(), + function_id: "snapshot.refresh".to_string(), + }, + params: json!({}), + timeout_milliseconds: None, + context: Some(context), + }, + operation_id, + None, + ) + }; + let error = call(json!({ "contextID": "w/1", "generation": 1 })) + .expect_err("a malformed context is refused"); + assert_eq!( + error.details.as_deref(), + Some("plugin.function.invalidParams") + ); + call(json!({ "contextID": "w1", "generation": 2 })).expect("sent"); + let request_id = harness + .server + .await_request("lithe/function/invoke") + .expect("the call should reach the backend"); + assert_eq!( + harness.written_request(&request_id)["params"]["context"], + json!({ "contextID": "w1", "generation": 2 }) + ); +} + +/// Requests and notifications of a plugin backend to its own language +/// server (plan API-25). The stub servers are Metals-like (a gate and a +/// command that runs one at a time) and rust-analyzer-like (neither), so +/// no case depends on one language. +mod plugin_owned_server { + use super::*; + + const PLUGIN: &str = "dev.lithe.metals"; + const SERVER: &str = "metals"; + const STATUS: &str = "metals/status"; + + /// The policy of the Metals-like server. `gated` adds the gate that + /// holds requests until the server reports it is ready. + fn metals_policy(gated: bool) -> Value { + let mut policy = json!({ + "serverID": SERVER, + "requestMethods": [ + "textDocument/definition", + "workspace/executeCommand", + "metals/slow", + "metals/didFocus" + ], + "commands": ["build-import", "build-connect"], + "serializedCommands": ["build-import"], + "forwardNotifications": [STATUS], + }); + if gated { + policy["gates"] = json!([{ + "id": "imported", + "until": "notification", + "method": STATUS, + "match": { "text": "ready" } + }]); + } + policy + } + + /// A rust-analyzer-like server: no gate, no serialized command. + fn rust_analyzer_policy() -> Value { + json!({ + "serverID": SERVER, + "requestMethods": ["experimental/runnables"], + }) + } + + fn start(policy: Value) -> Harness { + let mut harness = Harness::start(move |request| { + request.plugin_id = Some(PLUGIN.to_string()); + request.session_policy = + Some(serde_json::from_value(policy).expect("the policy should decode")); + }); + harness.server.complete_initialize(ready_capabilities()); + harness.await_state(LspLifecycleState::Ready); + harness + } + + /// The `lsp.pluginServerRequest` payload the host sends. + fn wire(harness: &Harness, operation_id: &str, method: &str, params: Value) -> Value { + json!({ + "sessionId": harness.session_id, + "operationId": operation_id, + "pluginId": PLUGIN, + "serverID": SERVER, + "method": method, + "params": params, + }) + } + + fn send(harness: &Harness, wire: Value) -> Result<(), CoreError> { + let operation_id = wire["operationId"].as_str().unwrap().to_string(); + let request: PluginServerRequest = + serde_json::from_value(wire).expect("documented wire shape"); + harness + .session() + .plugin_server_request(request, operation_id) + } + + fn call( + harness: &Harness, + operation_id: &str, + method: &str, + params: Value, + ) -> Result<(), CoreError> { + send(harness, wire(harness, operation_id, method, params)) + } + + fn run_command(harness: &Harness, operation_id: &str, command: &str) -> Result<(), CoreError> { + call( + harness, + operation_id, + "workspace/executeCommand", + json!({ "command": command, "arguments": [] }), + ) + } + + fn notify(harness: &Harness, method: &str, params: Value) -> Result { + let request: PluginServerNotify = serde_json::from_value(json!({ + "sessionId": harness.session_id, + "pluginId": PLUGIN, + "serverID": SERVER, + "method": method, + "params": params, + })) + .expect("documented wire shape"); + harness.session().plugin_server_notify(request) + } + + /// The server reports `text` through its status notification. + fn report_status(harness: &Harness, text: &str) { + harness.server.send(json!({ + "jsonrpc": "2.0", + "method": STATUS, + "params": { "text": text } + })); + } + + /// Waits until Core has applied the status notification of `text`. The + /// notification is forwarded to the backend, so the forwarded event + /// proves the engine already processed it. + fn await_status_seen(harness: &mut Harness, text: &str) { + harness.await_event(|event| { + event.kind == "pluginServerNotification" + && event + .result + .as_ref() + .is_some_and(|result| result["params"]["text"] == text) + }); + } + + fn reply(harness: &Harness, request_id: &str, result: Value) { + harness.server.send(json!({ + "jsonrpc": "2.0", "id": request_id, "result": result + })); + } + + fn completed(harness: &mut Harness, operation_id: &str) -> LspRuntimeEvent { + harness + .await_event(|event| { + event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation_id) + }) + .clone() + } + + fn error_code(event: &LspRuntimeEvent) -> &str { + &event.error.as_ref().expect("the request failed").code + } + + fn written(harness: &Harness, method: &str) -> usize { + harness + .server + .messages() + .iter() + .filter(|message| message["method"] == method) + .count() + } + + fn refusal_code(error: CoreError) -> String { + error.details.expect("a refusal names its plugin.host code") + } + + /// The server's result reaches the backend untouched, even for a + /// method whose result Lithe normalizes for its own editor. + #[test] + fn a_declared_request_returns_the_servers_own_result() { + let mut harness = start(metals_policy(false)); + call( + &harness, + "def-1", + "textDocument/definition", + json!({ "textDocument": { "uri": "file:///workspace/A.scala" } }), + ) + .expect("a declared method is sent"); + let request_id = harness + .server + .await_request("textDocument/definition") + .expect("the request should reach the server"); + let location = json!({ + "uri": "jar:file:///cache/lib.jar!/Lib.scala", + "range": { + "start": { "line": 1, "character": 2 }, + "end": { "line": 1, "character": 5 } + } + }); + reply(&harness, &request_id, json!([location])); + + let event = completed(&mut harness, "def-1"); + assert_eq!(event.result, Some(json!({ "value": [location] }))); + assert!(event.error.is_none()); + } + + #[test] + fn a_server_without_gates_or_commands_sends_at_once() { + let mut harness = start(rust_analyzer_policy()); + call(&harness, "run-1", "experimental/runnables", json!({})) + .expect("a declared method is sent"); + let request_id = harness + .server + .await_request("experimental/runnables") + .expect("nothing holds the request"); + reply(&harness, &request_id, json!([])); + assert_eq!( + completed(&mut harness, "run-1").result, + Some(json!({ "value": [] })) + ); + } + + #[test] + fn a_server_error_keeps_its_code_and_message_as_details() { + let mut harness = start(metals_policy(false)); + call(&harness, "slow-1", "metals/slow", json!({})).unwrap(); + let request_id = harness.server.await_request("metals/slow").unwrap(); + harness.server.send(json!({ + "jsonrpc": "2.0", + "id": request_id, + "error": { "code": -32001, "message": "Build server is not connected." } + })); + + let event = completed(&mut harness, "slow-1"); + assert_eq!(error_code(&event), "plugin.host.languageServerError"); + assert_eq!( + event.error.unwrap().details, + Some(json!({ "code": -32001, "message": "Build server is not connected." })) + ); + } + + #[test] + fn a_method_the_plugin_did_not_declare_never_reaches_the_server() { + let harness = start(metals_policy(false)); + let error = call(&harness, "bad-1", "textDocument/rename", json!({})) + .expect_err("an undeclared method is refused"); + assert_eq!(refusal_code(error), "plugin.host.methodNotDeclared"); + + let error = run_command(&harness, "bad-2", "wipe-workspace") + .expect_err("an undeclared command is refused"); + assert_eq!(refusal_code(error), "plugin.host.methodNotDeclared"); + assert_eq!(written(&harness, "textDocument/rename"), 0); + assert_eq!(written(&harness, "workspace/executeCommand"), 0); + } + + /// The host resolves the session, so a call that names another plugin + /// or server, or a session without a policy, is the host's mistake and + /// must not reach any server. + #[test] + fn a_session_of_another_contribution_is_refused() { + let harness = start(metals_policy(false)); + for (plugin_id, server_id) in [("dev.lithe.other", SERVER), (PLUGIN, "other-server")] { + let mut foreign = wire(&harness, "foreign-1", "metals/slow", json!({})); + foreign["pluginId"] = json!(plugin_id); + foreign["serverID"] = json!(server_id); + let error = send(&harness, foreign).expect_err("a foreign contribution is refused"); + assert_eq!(refusal_code(error), "plugin.host.foreignContribution"); + } + assert_eq!(written(&harness, "metals/slow"), 0); + + let plain = Harness::ready(); + let error = call(&plain, "plain-1", "metals/slow", json!({})) + .expect_err("a session without a policy is no plugin server"); + assert_eq!(refusal_code(error), "plugin.host.foreignContribution"); + } + + #[test] + fn requests_wait_for_the_gate_and_are_sent_in_order() { + let mut harness = start(metals_policy(true)); + call(&harness, "first", "metals/slow", json!({ "n": 1 })).unwrap(); + call( + &harness, + "second", + "textDocument/definition", + json!({ "n": 2 }), + ) + .unwrap(); + assert_eq!(written(&harness, "metals/slow"), 0); + + // A status that does not match the gate leaves it closed. + report_status(&harness, "importing"); + await_status_seen(&mut harness, "importing"); + assert_eq!(written(&harness, "metals/slow"), 0); + assert_eq!(written(&harness, "textDocument/definition"), 0); + + report_status(&harness, "ready"); + let first = harness.server.await_request("metals/slow").unwrap(); + let second = harness + .server + .await_request("textDocument/definition") + .unwrap(); + let messages = harness.server.messages(); + let position = |id: &str| messages.iter().position(|message| message["id"] == id); + assert!( + position(&first) < position(&second), + "arrival order is kept" + ); + + reply(&harness, &first, json!("one")); + assert_eq!( + completed(&mut harness, "first").result, + Some(json!({ "value": "one" })) + ); + } + + #[test] + fn a_held_request_can_be_cancelled_without_touching_the_server() { + let mut harness = start(metals_policy(true)); + call(&harness, "held-1", "metals/slow", json!({})).unwrap(); + harness + .session() + .cancel_operation("held-1") + .expect("a held request can be cancelled"); + + let event = completed(&mut harness, "held-1"); + assert_eq!(error_code(&event), "plugin.host.cancelled"); + + // Opening the gate afterwards sends nothing: the request is gone. + report_status(&harness, "ready"); + await_status_seen(&mut harness, "ready"); + assert_eq!(written(&harness, "metals/slow"), 0); + assert_eq!(written(&harness, "$/cancelRequest"), 0); + } + + #[test] + fn a_held_request_times_out_while_it_waits() { + let mut harness = start(metals_policy(true)); + let mut held = wire(&harness, "held-1", "metals/slow", json!({})); + held["timeoutMilliseconds"] = json!(1); + send(&harness, held).unwrap(); + + let event = completed(&mut harness, "held-1"); + assert_eq!(error_code(&event), "plugin.host.timeout"); + assert_eq!(written(&harness, "metals/slow"), 0); + } + + #[test] + fn a_sent_request_times_out_and_is_cancelled_on_the_server() { + let mut harness = start(metals_policy(false)); + let mut slow = wire(&harness, "slow-1", "metals/slow", json!({})); + slow["timeoutMilliseconds"] = json!(1); + send(&harness, slow).unwrap(); + let request_id = harness.server.await_request("metals/slow").unwrap(); + + let event = completed(&mut harness, "slow-1"); + assert_eq!(error_code(&event), "plugin.host.timeout"); + let cancel = harness + .server + .await_notification_at("$/cancelRequest", 0) + .expect("the server is told to stop"); + assert_eq!(cancel["params"]["id"], json!(request_id)); + } + + /// A second request for a serialized command does not start a second + /// run; both finish with the answer of the one that runs. + #[test] + fn a_serialized_command_joins_the_one_that_runs() { + let mut harness = start(metals_policy(false)); + run_command(&harness, "import-1", "build-import").unwrap(); + let request_id = harness + .server + .await_request("workspace/executeCommand") + .unwrap(); + run_command(&harness, "import-2", "build-import").unwrap(); + // A command that is not serialized is independent of the first. + run_command(&harness, "connect-1", "build-connect").unwrap(); + harness + .server + .await_request_at("workspace/executeCommand", 1) + .expect("an independent command is sent"); + assert_eq!(written(&harness, "workspace/executeCommand"), 2); + + reply(&harness, &request_id, json!({ "imported": 3 })); + for operation_id in ["import-1", "import-2"] { + let event = completed(&mut harness, operation_id); + assert_eq!(event.result, Some(json!({ "value": { "imported": 3 } }))); + } + assert!(!harness + .events + .iter() + .any(|event| event.operation_id.as_deref() == Some("connect-1"))); + + // The command can run again once the first run is over. + run_command(&harness, "import-3", "build-import").unwrap(); + harness + .server + .await_request_at("workspace/executeCommand", 2) + .expect("a finished command can run again"); + } + + #[test] + fn cancelling_a_joiner_leaves_the_running_command_alone() { + let mut harness = start(metals_policy(false)); + run_command(&harness, "import-1", "build-import").unwrap(); + let request_id = harness + .server + .await_request("workspace/executeCommand") + .unwrap(); + run_command(&harness, "import-2", "build-import").unwrap(); + harness.session().cancel_operation("import-2").unwrap(); + assert_eq!( + error_code(&completed(&mut harness, "import-2")), + "plugin.host.cancelled" + ); + assert_eq!(written(&harness, "$/cancelRequest"), 0); + + reply(&harness, &request_id, json!(null)); + assert_eq!( + completed(&mut harness, "import-1").result, + Some(json!({ "value": null })) + ); + } + + #[test] + fn only_declared_server_notifications_are_forwarded() { + let mut harness = start(metals_policy(false)); + harness.server.send(json!({ + "jsonrpc": "2.0", + "method": "metals/unlisted", + "params": { "text": "private" } + })); + report_status(&harness, "indexing"); + await_status_seen(&mut harness, "indexing"); + + let forwarded: Vec<_> = harness + .events + .iter() + .filter(|event| event.kind == "pluginServerNotification") + .collect(); + assert_eq!(forwarded.len(), 1); + assert_eq!( + forwarded[0].result, + Some(json!({ + "serverID": SERVER, + "method": STATUS, + "params": { "text": "indexing" } + })) + ); + } + + #[test] + fn a_declared_notification_is_sent_and_a_command_is_not_a_notification() { + let harness = start(metals_policy(false)); + let sent = notify( + &harness, + "metals/didFocus", + json!({ "uri": "file:///A.scala" }), + ); + assert!(sent.expect("a declared notification is sent")); + let message = harness + .server + .await_notification_at("metals/didFocus", 0) + .expect("the server receives it"); + assert_eq!(message["params"], json!({ "uri": "file:///A.scala" })); + + let error = notify(&harness, "workspace/executeCommand", json!({})) + .expect_err("commands run as requests only"); + assert_eq!(refusal_code(error), "plugin.host.methodNotDeclared"); + let error = notify(&harness, "metals/unlisted", json!({})) + .expect_err("an undeclared notification is refused"); + assert_eq!(refusal_code(error), "plugin.host.methodNotDeclared"); + } + + /// New settings replace the stored configuration, so they also answer + /// the server's own `workspace/configuration` requests. + #[test] + fn changed_configuration_goes_through_the_plugin_configuration() { + let harness = start(metals_policy(false)); + let settings = json!({ "metals": { "scalafmt": true } }); + let sent = notify( + &harness, + "workspace/didChangeConfiguration", + json!({ "settings": settings }), + ); + assert!(sent.expect("the change is accepted")); + let change = harness + .server + .await_notification_at("workspace/didChangeConfiguration", 0) + .expect("the server receives the change"); + assert_eq!(change["params"]["settings"], settings); + + let error = notify( + &harness, + "workspace/didChangeConfiguration", + json!({ "settings": 1 }), + ) + .expect_err("settings must be an object"); + assert_eq!(refusal_code(error), "plugin.host.invalidParams"); + } + + #[test] + fn a_stopped_server_fails_what_is_in_flight_and_refuses_new_calls() { + let mut harness = start(metals_policy(false)); + call(&harness, "slow-1", "metals/slow", json!({})).unwrap(); + call(&harness, "focus-1", "metals/didFocus", json!({})).unwrap(); + harness.server.await_request("metals/slow").unwrap(); + harness.server.await_request("metals/didFocus").unwrap(); + + harness.server.exit(Some(1)); + harness.await_state(LspLifecycleState::Failed); + for operation_id in ["slow-1", "focus-1"] { + let event = completed(&mut harness, operation_id); + assert_eq!(error_code(&event), "plugin.host.languageServerUnavailable"); + } + + let error = call(&harness, "late-1", "metals/slow", json!({})) + .expect_err("a stopped server takes no requests"); + assert_eq!(refusal_code(error), "plugin.host.languageServerUnavailable"); + } + + #[test] + fn a_gated_request_still_waiting_fails_when_the_server_stops() { + let mut harness = start(metals_policy(true)); + call(&harness, "held-1", "metals/slow", json!({})).unwrap(); + + harness.server.exit(Some(1)); + harness.await_state(LspLifecycleState::Failed); + let event = completed(&mut harness, "held-1"); + assert_eq!(error_code(&event), "plugin.host.languageServerUnavailable"); + } + + #[test] + fn an_invalid_policy_is_refused_at_start() { + let refused = |plugin_id: Option<&str>, policy: Value| { + let server = ScriptedServer::new(); + let engine = LspEngine::with_launcher(server.launcher()); + let mut request = start_request(&server); + request.plugin_id = plugin_id.map(ToString::to_string); + request.session_policy = Some(serde_json::from_value(policy).unwrap()); + match engine.start_server(request) { + Err(error) => matches!(error.code, ErrorCode::InvalidRequest), + Ok(_) => false, + } + }; + // The server ID is no protocol name. + assert!(refused(Some(PLUGIN), json!({ "serverID": "" }))); + // A serialized command must also be a declared command. + assert!(refused( + Some(PLUGIN), + json!({ "serverID": SERVER, "serializedCommands": ["build-import"] }) + )); + // A host-owned server has no backend, so it cannot have a policy. + assert!(refused(None, metals_policy(false))); + } +} diff --git a/rust/crates/lsp-engine/src/validation.rs b/rust/crates/lsp-engine/src/validation.rs new file mode 100644 index 00000000..dc9dd697 --- /dev/null +++ b/rust/crates/lsp-engine/src/validation.rs @@ -0,0 +1,81 @@ +//! Start-request validation and the errors every entry point shares. + +use super::*; + +pub(crate) fn validate_start_request(request: &StartServerRequest) -> Result<(), CoreError> { + if request.provider_id.trim().is_empty() { + return Err(invalid_field("providerId")); + } + // A host-owned server is no plugin's backend, and a service Core does not + // know could never be forwarded. + if !request.host_services.is_empty() + && (request.plugin_id.is_none() + || request + .host_services + .iter() + .any(|service| !lithe_plugin_rpc::is_host_grant(service))) + { + return Err(invalid_field("hostServices")); + } + if let Some(policy) = &request.session_policy { + policy.validate(request.plugin_id.as_deref())?; + } + if !request.host_options.is_empty() + && (request.plugin_id.is_none() + || request + .host_options + .editor + .as_ref() + .is_some_and(|editor| editor.validate().is_err())) + { + return Err(invalid_field("hostOptions")); + } + 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) +} + +pub(crate) 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(), + } +} + +pub(crate) fn unknown_session(session_id: &str) -> CoreError { + CoreError::new( + ErrorCode::InvalidRequest, + "Unknown language-server session.", + ) + .with_details(session_id) +} + +pub(crate) 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(()) + } +} diff --git a/rust/crates/lsp-policy/Cargo.toml b/rust/crates/lsp-policy/Cargo.toml new file mode 100644 index 00000000..271408bd --- /dev/null +++ b/rust/crates/lsp-policy/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "lithe-lsp-policy" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Deterministic language-tooling decisions both platform hosts apply: document identity, navigation targets, workspace edit plans, completion merging, activation and problems queries" + +[dependencies] +lithe-core-rules = { path = "../core-rules" } +lithe-document-uri = { path = "../document-uri" } +lithe-protocol = { path = "../protocol" } +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0", features = ["preserve_order"] } +url = "2.5" diff --git a/rust/crates/core/src/lsp/host_policy/activation.rs b/rust/crates/lsp-policy/src/activation.rs similarity index 95% rename from rust/crates/core/src/lsp/host_policy/activation.rs rename to rust/crates/lsp-policy/src/activation.rs index 3908b302..2fa43155 100644 --- a/rust/crates/core/src/lsp/host_policy/activation.rs +++ b/rust/crates/lsp-policy/src/activation.rs @@ -10,14 +10,14 @@ //! clock, which keeps the policy deterministic and unaffected by wall-clock //! adjustments. -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use serde_json::Value; /// Consecutive failures after which automatic starts stop until the user retries. -pub(crate) const MAX_CONSECUTIVE_FAILURES: u32 = 3; +pub const MAX_CONSECUTIVE_FAILURES: u32 = 3; /// Minimum delay between a failure and the next automatic start. -pub(crate) const FAILURE_COOLDOWN_MS: u64 = 5_000; +pub const FAILURE_COOLDOWN_MS: u64 = 5_000; #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -75,7 +75,7 @@ struct Response { } /// Executes `lsp.activationPolicy`. -pub(crate) fn policy_command(payload: Value) -> Result { +pub fn policy_command(payload: Value) -> Result { let request: Request = serde_json::from_value(payload).map_err(|error| { CoreError::new( ErrorCode::InvalidRequest, diff --git a/rust/crates/core/src/lsp/host_policy/completion_merge.rs b/rust/crates/lsp-policy/src/completion_merge.rs similarity index 95% rename from rust/crates/core/src/lsp/host_policy/completion_merge.rs rename to rust/crates/lsp-policy/src/completion_merge.rs index 141c6616..d65369a0 100644 --- a/rust/crates/core/src/lsp/host_policy/completion_merge.rs +++ b/rust/crates/lsp-policy/src/completion_merge.rs @@ -15,7 +15,7 @@ //! Only labels cross the boundary. The platform keeps the full items and //! applies the returned indices, which keeps the per-keystroke payload small. -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashSet; @@ -45,7 +45,7 @@ struct Response { } /// Executes `lsp.mergeCompletions`. -pub(crate) fn merge_command(payload: Value) -> Result { +pub fn merge_command(payload: Value) -> Result { let request: Request = serde_json::from_value(payload).map_err(|error| { CoreError::new( ErrorCode::InvalidRequest, diff --git a/rust/crates/core/src/lsp/declaration_or_usages.rs b/rust/crates/lsp-policy/src/declaration_or_usages.rs similarity index 71% rename from rust/crates/core/src/lsp/declaration_or_usages.rs rename to rust/crates/lsp-policy/src/declaration_or_usages.rs index 38324bf2..32b54242 100644 --- a/rust/crates/core/src/lsp/declaration_or_usages.rs +++ b/rust/crates/lsp-policy/src/declaration_or_usages.rs @@ -8,7 +8,7 @@ //! language server for references without the declaration when Core says //! `usages`; Core owns the decision so every host and gesture agree. -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::Deserialize; use serde_json::{json, Value}; @@ -59,7 +59,7 @@ struct Request { /// its range contains the position, end included: an editor resolves the /// identifier left of the caret, so a caret right after a declaration's name /// is still on that declaration. -pub(crate) fn decide_command(payload: Value) -> Result { +pub fn decide_command(payload: Value) -> Result { let request: Request = serde_json::from_value(payload).map_err(|error| { invalid("Invalid declaration-or-usages request").with_details(error.to_string()) })?; @@ -84,32 +84,3 @@ pub(crate) fn decide_command(payload: Value) -> Result { fn invalid(message: &str) -> CoreError { CoreError::new(ErrorCode::InvalidRequest, message) } - -#[cfg(test)] -mod tests { - use serde_json::{json, Value}; - - /// Every shared-fixture case yields its expected data or error code - /// through the public JSON command. - #[test] - fn matches_shared_fixture() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../../fixtures/lsp/declaration-or-usages-v1.json" - )) - .unwrap(); - for case in fixture["cases"].as_array().unwrap() { - let response: Value = serde_json::from_str(&crate::execute_json( - &json!({"command": "lsp.declarationOrUsages", "payload": case["request"]}) - .to_string(), - )) - .unwrap(); - if let Some(code) = case.get("expectedError") { - assert_eq!(response["ok"], false, "{}: {response}", case["name"]); - assert_eq!(&response["error"]["code"], code, "{}", case["name"]); - } else { - assert_eq!(response["ok"], true, "{}: {response}", case["name"]); - assert_eq!(response["data"], case["expected"], "{}", case["name"]); - } - } - } -} diff --git a/rust/crates/core/src/lsp/document_identity.rs b/rust/crates/lsp-policy/src/document_identity.rs similarity index 76% rename from rust/crates/core/src/lsp/document_identity.rs rename to rust/crates/lsp-policy/src/document_identity.rs index 0fb71cb7..9336a798 100644 --- a/rust/crates/core/src/lsp/document_identity.rs +++ b/rust/crates/lsp-policy/src/document_identity.rs @@ -1,10 +1,8 @@ //! Lexical workspace identities for diagnostics; the host owns native file I/O. -use crate::lsp::{ - workspace_edit_plan::native_path, workspace_scope::WorkspaceScope, DocumentPathPlatform, - MAX_PATH_BYTES, -}; -use crate::protocol::{CoreError, ErrorCode}; +use crate::workspace_scope::WorkspaceScope; +use lithe_document_uri::{native_path, DocumentPathPlatform, MAX_PATH_BYTES}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::Deserialize; use serde_json::{json, Value}; use url::Url; @@ -21,7 +19,7 @@ struct Request { /// Resolves one server-supplied identity to a native path and workspace-relative /// display path. Non-file URIs, ambiguous drive-relative paths and outside targets /// return a null document. The result grants no permission to follow symlinks. -pub(crate) fn resolve_command(payload: Value) -> Result { +pub fn resolve_command(payload: Value) -> Result { let request: Request = serde_json::from_value(payload).map_err(|error| { CoreError::new( ErrorCode::InvalidRequest, @@ -106,27 +104,3 @@ pub(crate) fn resolve_command(payload: Value) -> Result { }; Ok(json!({"document": {"path": path, "relativePath": relative}})) } - -#[cfg(test)] -mod tests { - use serde_json::{json, Value}; - - #[test] - fn matches_shared_fixture() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../../fixtures/lsp/document-identity-v1.json" - )) - .unwrap(); - for case in fixture["cases"].as_array().unwrap() { - let response: Value = serde_json::from_str(&crate::execute_json( - &json!({"command":"lsp.documentIdentity", "payload":case["request"]}).to_string(), - )) - .unwrap(); - if let Some(code) = case.get("error") { - assert_eq!(&response["error"]["code"], code, "{case}"); - } else { - assert_eq!(response["data"], case["expected"], "{case}: {response}"); - } - } - } -} diff --git a/rust/crates/core/src/lsp/host_policy/mod.rs b/rust/crates/lsp-policy/src/lib.rs similarity index 51% rename from rust/crates/core/src/lsp/host_policy/mod.rs rename to rust/crates/lsp-policy/src/lib.rs index f30bd06f..ac85eb3f 100644 --- a/rust/crates/core/src/lsp/host_policy/mod.rs +++ b/rust/crates/lsp-policy/src/lib.rs @@ -2,12 +2,14 @@ //! their native language-server sessions and editor presentation. //! //! Every command here is a pure function of the facts the platform submits: -//! no process, document, or clock is owned by Core. Platforms keep the native +//! this crate owns no process, document or clock. Platforms keep the native //! I/O, the per-session storage of returned state, and localization. -pub(crate) mod activation; -pub(crate) mod completion_merge; -pub(crate) mod problems; - -#[cfg(test)] -mod tests; +pub mod activation; +pub mod completion_merge; +pub mod declaration_or_usages; +pub mod document_identity; +pub mod navigation_target; +pub mod problems; +pub mod workspace_edit_plan; +mod workspace_scope; diff --git a/rust/crates/core/src/lsp/navigation_target.rs b/rust/crates/lsp-policy/src/navigation_target.rs similarity index 74% rename from rust/crates/core/src/lsp/navigation_target.rs rename to rust/crates/lsp-policy/src/navigation_target.rs index a140fde0..f2f55db6 100644 --- a/rust/crates/core/src/lsp/navigation_target.rs +++ b/rust/crates/lsp-policy/src/navigation_target.rs @@ -7,8 +7,9 @@ //! at stay closed. Hosts own symlink resolution and the path-to-language //! lookup and pass both as facts; Core owns the decision. -use crate::lsp::{workspace_scope::WorkspaceScope, DocumentPathPlatform}; -use crate::protocol::{CoreError, ErrorCode}; +use crate::workspace_scope::WorkspaceScope; +use lithe_document_uri::DocumentPathPlatform; +use lithe_protocol::{CoreError, ErrorCode}; use serde::Deserialize; use serde_json::{json, Value}; @@ -49,7 +50,7 @@ struct Request { /// real root): an in-workspace symlink to the outside must not skip the /// source-file check, and an outside path to a workspace file still opens /// under its outside path. Missing real paths fall back to the opened ones. -pub(crate) fn classify_command(payload: Value) -> Result { +pub fn classify_command(payload: Value) -> Result { let request: Request = serde_json::from_value(payload).map_err(|error| { CoreError::new( ErrorCode::InvalidRequest, @@ -92,32 +93,3 @@ fn classify(request: &Request) -> Result<&'static str, CoreError> { "rejected" }) } - -#[cfg(test)] -mod tests { - use serde_json::{json, Value}; - - /// Every shared-fixture case yields its expected data or error code - /// through the public JSON command. - #[test] - fn matches_shared_fixture() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../../fixtures/lsp/navigation-target-v1.json" - )) - .unwrap(); - for case in fixture["cases"].as_array().unwrap() { - let response: Value = serde_json::from_str(&crate::execute_json( - &json!({"command": "lsp.classifyNavigationTarget", "payload": case["request"]}) - .to_string(), - )) - .unwrap(); - if let Some(code) = case.get("expectedError") { - assert_eq!(response["ok"], false, "{}: {response}", case["name"]); - assert_eq!(&response["error"]["code"], code, "{}", case["name"]); - } else { - assert_eq!(response["ok"], true, "{}: {response}", case["name"]); - assert_eq!(response["data"], case["expected"], "{}", case["name"]); - } - } - } -} diff --git a/rust/crates/core/src/lsp/host_policy/problems.rs b/rust/crates/lsp-policy/src/problems.rs similarity index 98% rename from rust/crates/core/src/lsp/host_policy/problems.rs rename to rust/crates/lsp-policy/src/problems.rs index b45de7f1..d0854c1b 100644 --- a/rust/crates/core/src/lsp/host_policy/problems.rs +++ b/rust/crates/lsp-policy/src/problems.rs @@ -6,8 +6,8 @@ //! compared with the IDEA natural file-name order that the Project view uses, //! which keeps `File2` before `File10` on both platforms. -use crate::project::file_name_order::FileNameKey; -use crate::protocol::{CoreError, ErrorCode}; +use lithe_core_rules::file_names::FileNameKey; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::cmp::Ordering; @@ -176,7 +176,7 @@ struct Entry<'a> { } /// Executes `lsp.problems.query`. -pub(crate) fn query_command(payload: Value) -> Result { +pub fn query_command(payload: Value) -> Result { let request: Request = serde_json::from_value(payload).map_err(|error| { CoreError::new(ErrorCode::InvalidRequest, "Invalid Problems query request") .with_details(error.to_string()) diff --git a/rust/crates/core/src/lsp/workspace_edit_plan.rs b/rust/crates/lsp-policy/src/workspace_edit_plan.rs similarity index 63% rename from rust/crates/core/src/lsp/workspace_edit_plan.rs rename to rust/crates/lsp-policy/src/workspace_edit_plan.rs index 960e017e..6c1d97e6 100644 --- a/rust/crates/core/src/lsp/workspace_edit_plan.rs +++ b/rust/crates/lsp-policy/src/workspace_edit_plan.rs @@ -8,12 +8,9 @@ //! encoding-preserving writes, editor buffers, stale-input checks and the //! rollback the plan prescribes. -use crate::lsp::{ - percent_decode, - workspace_scope::{comparable, WorkspaceScope}, - DocumentPathPlatform, -}; -use crate::protocol::{CoreError, ErrorCode}; +use crate::workspace_scope::{comparable, WorkspaceScope}; +use lithe_document_uri::{native_path, DocumentPathPlatform}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::Deserialize; use serde_json::{json, Map, Value}; @@ -47,40 +44,6 @@ struct Target { edits: Vec, } -/// Native path named by an LSP document or workspace-edit key; `None` for a -/// non-file URI. Shared with navigation results and diagnostic placement. -/// Core's URI projection keeps the leading slash of `file:///C:/...`; strip it -/// so drive paths parse as Windows paths. -pub(super) fn native_path(key: &str) -> Option { - let path = match key.split_once(':') { - // A one-letter "scheme" is a drive letter, not a URI scheme. - Some((scheme, rest)) - if scheme.len() > 1 - && scheme.starts_with(|c: char| c.is_ascii_alphabetic()) - && scheme - .chars() - .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) => - { - if !scheme.eq_ignore_ascii_case("file") { - return None; - } - let rest = rest.strip_prefix("//").unwrap_or(rest); - // `file:///p` leaves `/p`; `file://host/share` leaves `host/share`. - if rest.starts_with('/') { - percent_decode(rest) - } else { - format!("//{}", percent_decode(rest)) - } - } - _ => key.to_string(), - }; - let bytes = path.as_bytes(); - if bytes.len() >= 3 && bytes[0] == b'/' && bytes[1].is_ascii_alphabetic() && bytes[2] == b':' { - return Some(path[1..].to_string()); - } - Some(path) -} - /// Plans one workspace edit. /// /// Returns `{ rejection, targets }`. On success `rejection` is null and @@ -95,7 +58,7 @@ pub(super) fn native_path(key: &str) -> Option { /// open document returns `rejection: { code: "outsideWorkspace" | /// "readOnlyDocument", path }` and no targets. Unparseable native paths return /// `invalid_request`. -pub(crate) fn plan_command(payload: Value) -> Result { +pub fn plan_command(payload: Value) -> Result { let request: Request = serde_json::from_value(payload).map_err(|error| { CoreError::new( ErrorCode::InvalidRequest, @@ -158,32 +121,3 @@ pub(crate) fn plan_command(payload: Value) -> Result { })).collect::>(), })) } - -#[cfg(test)] -mod tests { - use serde_json::{json, Value}; - - /// Every shared-fixture case yields its expected data or error code - /// through the public JSON command. - #[test] - fn matches_shared_fixture() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../../fixtures/lsp/workspace-edit-plan-v1.json" - )) - .unwrap(); - for case in fixture["cases"].as_array().unwrap() { - let response: Value = serde_json::from_str(&crate::execute_json( - &json!({"command": "lsp.planWorkspaceEdit", "payload": case["request"]}) - .to_string(), - )) - .unwrap(); - if let Some(code) = case.get("expectedError") { - assert_eq!(response["ok"], false, "{}: {response}", case["name"]); - assert_eq!(&response["error"]["code"], code, "{}", case["name"]); - } else { - assert_eq!(response["ok"], true, "{}: {response}", case["name"]); - assert_eq!(response["data"], case["expected"], "{}", case["name"]); - } - } - } -} diff --git a/rust/crates/core/src/lsp/workspace_scope.rs b/rust/crates/lsp-policy/src/workspace_scope.rs similarity index 94% rename from rust/crates/core/src/lsp/workspace_scope.rs rename to rust/crates/lsp-policy/src/workspace_scope.rs index 680eae12..809c1bfc 100644 --- a/rust/crates/core/src/lsp/workspace_scope.rs +++ b/rust/crates/lsp-policy/src/workspace_scope.rs @@ -6,8 +6,8 @@ //! the `lsp.documentUri` rules. Windows comparisons ignore ASCII case, as the //! Windows filesystem does. No filesystem access happens here. -use crate::lsp::DocumentPathPlatform; -use crate::protocol::CoreError; +use lithe_document_uri::{percent_decode, DocumentPathPlatform}; +use lithe_protocol::CoreError; /// One workspace root in canonical URI form. pub(crate) struct WorkspaceScope { @@ -41,7 +41,7 @@ impl WorkspaceScope { // The case-folded prefix has the same byte length as the original URI // prefix because only ASCII letters are folded. let relative = &uri[self.prefix.len()..]; - Ok(Some(crate::lsp::percent_decode(relative))) + Ok(Some(percent_decode(relative))) } } diff --git a/rust/crates/lsp-transport/Cargo.toml b/rust/crates/lsp-transport/Cargo.toml new file mode 100644 index 00000000..e7533e61 --- /dev/null +++ b/rust/crates/lsp-transport/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "lithe-lsp-transport" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Language-server process boundary: local and remote stdio launchers, LSP Content-Length framing and a scripted server for engine tests" + +[features] +# Exposes the scripted language server that lithe-core's engine tests drive. +test-support = [] + +[dependencies] +lithe-diagnostics = { path = "../diagnostics" } +lithe-protocol = { path = "../protocol" } +lithe-remote = { path = "../remote", default-features = false, features = ["client"] } +lithe-workspace = { path = "../workspace" } +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0", features = ["preserve_order"] } + +[target.'cfg(windows)'.dependencies] +lithe-git-host = { path = "../git-host" } diff --git a/rust/crates/core/src/lsp/interface/transport.rs b/rust/crates/lsp-transport/src/framing.rs similarity index 68% rename from rust/crates/core/src/lsp/interface/transport.rs rename to rust/crates/lsp-transport/src/framing.rs index e6ce3d30..746e169a 100644 --- a/rust/crates/core/src/lsp/interface/transport.rs +++ b/rust/crates/lsp-transport/src/framing.rs @@ -1,6 +1,6 @@ //! Bounded encoding and incremental parsing of LSP transport frames. -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; const MAX_HEADER_BYTES: usize = 64 * 1024; @@ -10,6 +10,7 @@ const MAX_MESSAGE_BYTES: usize = 64 * 1024 * 1024; #[serde(rename_all = "camelCase")] /// JSON-RPC message to encode with the LSP content-length framing protocol. pub struct FrameMessageRequest { + /// Serialized JSON-RPC message body. pub message: String, } @@ -17,6 +18,7 @@ pub struct FrameMessageRequest { #[serde(rename_all = "camelCase")] /// Complete transport frame ready to write to a language server. pub struct FrameMessageResponse { + /// `Content-Length` header, blank line and body. pub frame: String, } @@ -24,8 +26,10 @@ pub struct FrameMessageResponse { #[serde(rename_all = "camelCase")] /// Previously buffered bytes and the newest process-output chunk. pub struct ParseServerMessagesRequest { + /// Unconsumed bytes returned by the previous parse. #[serde(default)] pub buffer: Vec, + /// Bytes read from the server since the previous parse. #[serde(default)] pub chunk: Vec, } @@ -34,7 +38,9 @@ pub struct ParseServerMessagesRequest { #[serde(rename_all = "camelCase")] /// Complete JSON-RPC messages plus the unconsumed partial frame. pub struct ParseServerMessagesResponse { + /// Bytes of an incomplete trailing frame, passed back with the next chunk. pub buffer: Vec, + /// Bodies of the complete frames, in arrival order. pub messages: Vec, } /// Encodes one JSON-RPC message with its UTF-8 byte length. @@ -148,4 +154,52 @@ mod tests { }); assert!(result.is_err()); } + + #[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()); + } } diff --git a/rust/crates/lsp-transport/src/lib.rs b/rust/crates/lsp-transport/src/lib.rs new file mode 100644 index 00000000..7f81ba77 --- /dev/null +++ b/rust/crates/lsp-transport/src/lib.rs @@ -0,0 +1,17 @@ +//! The language-server process boundary and its wire framing. +//! +//! The language-server engine reaches a server's process and stdio only through +//! the traits in [`process`]: [`process::SystemProcessLauncher`] starts a local +//! child, or a backend on the active remote workspace host through +//! [`remote_process`]. [`framing`] encodes and incrementally parses the LSP +//! `Content-Length` frames carried over those streams. With the +//! `test-support` feature, `scripted` provides a scripted server so the +//! engine's timing and crash rules can be asserted without a real one. + +pub mod framing; +pub mod process; +pub mod remote_process; +#[cfg(any(test, feature = "test-support"))] +pub mod scripted; + +pub use framing::*; diff --git a/rust/crates/core/src/lsp/interface/process.rs b/rust/crates/lsp-transport/src/process.rs similarity index 96% rename from rust/crates/core/src/lsp/interface/process.rs rename to rust/crates/lsp-transport/src/process.rs index 6c8889c2..05daa73a 100644 --- a/rust/crates/core/src/lsp/interface/process.rs +++ b/rust/crates/lsp-transport/src/process.rs @@ -9,7 +9,7 @@ //! On Windows a server and everything it starts share one Job Object, so a //! runtime a batch launcher starts ends with it and none outlives a crashed host. -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use std::collections::BTreeMap; use std::io::{Read, Write}; #[cfg(target_os = "windows")] @@ -25,9 +25,14 @@ use std::os::windows::process::CommandExt; /// Everything needed to start a language server, after provider adaptation has /// already rewritten the arguments. pub struct LspProcessSpec { + /// Program to start. pub executable: PathBuf, + /// Arguments after the program, one entry per argument. pub arguments: Vec, + /// Directory the process starts in. pub working_directory: PathBuf, + /// Variables added to the inherited environment, or to the login + /// environment on a remote workspace host. pub environment: BTreeMap, } @@ -78,8 +83,11 @@ pub trait LspProcessHandle: Send + Sync { /// A launched process and the two streams the engine reads on its own threads. pub struct LspProcessStreams { + /// Input, exit and disposal control of the process. pub handle: Arc, + /// The server's stdout, carrying framed LSP messages. pub output: Box, + /// The server's stderr, read as log lines. pub errors: Box, } @@ -94,8 +102,8 @@ pub struct SystemProcessLauncher; impl LspProcessLauncher for SystemProcessLauncher { fn launch(&self, spec: LspProcessSpec) -> Result { - if let Some(host) = crate::remote::workspace::current() { - return super::remote_process::launch(host, spec); + if let Some(host) = lithe_workspace::current() { + return crate::remote_process::launch(host, spec); } let mut command = language_server_command(&spec); command @@ -405,7 +413,7 @@ mod tests { } /// Libtest name of [`argv_echo_helper`], which the batch test re-launches. - const ARGV_ECHO_TEST: &str = "lsp::interface::process::tests::argv_echo_helper"; + const ARGV_ECHO_TEST: &str = "process::tests::argv_echo_helper"; /// Separates libtest's own arguments from the arguments under test. const ARGV_ECHO_MARKER: &str = "lithe-argv-echo"; const ARGV_ECHO_EXECUTABLE_ENV: &str = "LITHE_TEST_ARGV_ECHO_EXECUTABLE"; @@ -428,7 +436,7 @@ mod tests { /// Libtest name of [`stdin_hold_helper`], which the termination test /// re-launches from a batch script. - const STDIN_HOLD_TEST: &str = "lsp::interface::process::tests::stdin_hold_helper"; + const STDIN_HOLD_TEST: &str = "process::tests::stdin_hold_helper"; const STDIN_HOLD_ENV: &str = "LITHE_TEST_STDIN_HOLD"; const STDIN_HOLD_READY: &str = "lithe-stdin-hold-ready"; diff --git a/rust/crates/core/src/lsp/interface/remote_process.rs b/rust/crates/lsp-transport/src/remote_process.rs similarity index 98% rename from rust/crates/core/src/lsp/interface/remote_process.rs rename to rust/crates/lsp-transport/src/remote_process.rs index 13e7ac89..1d45a6fe 100644 --- a/rust/crates/core/src/lsp/interface/remote_process.rs +++ b/rust/crates/lsp-transport/src/remote_process.rs @@ -2,12 +2,12 @@ //! The process handle keeps its original connection; changing workspaces never //! redirects a running backend or its cleanup to another host. -use super::process::{LspProcessHandle, LspProcessSpec, LspProcessStreams}; -use crate::protocol::{cancellation, CoreError, ErrorCode}; -use crate::remote::sessions::{request_error, Connection}; -use crate::remote::workspace::Host; +use crate::process::{LspProcessHandle, LspProcessSpec, LspProcessStreams}; +use lithe_protocol::{cancellation, CoreError, ErrorCode}; use lithe_remote::client::session::{ClientError, Event}; use lithe_remote::protocol::methods::{self, EnvironmentSource, OutputStream, SpawnParams}; +use lithe_workspace::sessions::{request_error, Connection}; +use lithe_workspace::Host; use serde_json::json; use serde_json::Value; use std::collections::VecDeque; @@ -26,7 +26,7 @@ const INPUT_BYTES: usize = 8 * 1024 * 1024; /// Rewrites protocol-owned URI fields, including WorkspaceEdit map keys. Text, /// snippets, hover contents and arbitrary plugin function values stay verbatim. -pub(crate) fn map_message( +pub fn map_message( host: &Host, message: &str, outbound: bool, @@ -313,9 +313,9 @@ impl RemoteProcess { if !matches!(&error, ClientError::Remote(remote) if remote.code == lithe_remote::protocol::ErrorCode::UnknownResource) && !self.connection.session.is_closed() { - crate::diagnostics::core_events::cleanup_failed( + lithe_diagnostics::core_events::cleanup_failed( "remote.process.cleanup_failed", - &crate::diagnostics::core_events::client_failure_category(&error), + &error.failure_category(), ); } } @@ -444,7 +444,7 @@ impl LspProcessHandle for RemoteProcess { } } -pub(super) fn launch(host: Host, spec: LspProcessSpec) -> Result { +pub(crate) fn launch(host: Host, spec: LspProcessSpec) -> Result { let connection = host.connection.clone(); let id = connection.session.allocate_id(); let events = connection.session.subscribe("process", id); diff --git a/rust/crates/core/src/lsp/interface/scripted.rs b/rust/crates/lsp-transport/src/scripted.rs similarity index 97% rename from rust/crates/core/src/lsp/interface/scripted.rs rename to rust/crates/lsp-transport/src/scripted.rs index 4982c79d..09993968 100644 --- a/rust/crates/core/src/lsp/interface/scripted.rs +++ b/rust/crates/lsp-transport/src/scripted.rs @@ -7,10 +7,8 @@ //! test deliver bytes and exits at chosen moments, so each rule is asserted //! directly rather than approximated. -#![cfg(test)] - -use super::process::{LspProcessHandle, LspProcessLauncher, LspProcessSpec, LspProcessStreams}; -use crate::protocol::{CoreError, ErrorCode}; +use crate::process::{LspProcessHandle, LspProcessLauncher, LspProcessSpec, LspProcessStreams}; +use lithe_protocol::{CoreError, ErrorCode}; use serde_json::Value; use std::io::Read; use std::sync::atomic::{AtomicBool, Ordering}; @@ -52,7 +50,14 @@ struct ScriptedInputPause { release_requested: bool, } +impl Default for ScriptedServer { + fn default() -> Self { + Self::new() + } +} + impl ScriptedServer { + /// A server that has not been launched and has written nothing. pub fn new() -> Self { Self { shared: Arc::new(ScriptedShared { @@ -229,6 +234,7 @@ impl ScriptedServer { self.shared.ready.notify_all(); } + /// True once the engine closed the server's stdin. pub fn input_was_closed(&self) -> bool { self.shared.input_closed.load(Ordering::Acquire) } diff --git a/rust/crates/plugin-backend/Cargo.toml b/rust/crates/plugin-backend/Cargo.toml new file mode 100644 index 00000000..87dcb5a8 --- /dev/null +++ b/rust/crates/plugin-backend/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "lithe-plugin-backend" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "The plugin backend a manifest declares: one long-lived process per plugin, its triggers and the host services it may request" + +[dependencies] +lithe-plugin-model = { path = "../plugin-model" } +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0", features = ["preserve_order"] } diff --git a/rust/crates/core/src/plugins/backend.rs b/rust/crates/plugin-backend/src/lib.rs similarity index 76% rename from rust/crates/core/src/plugins/backend.rs rename to rust/crates/plugin-backend/src/lib.rs index e8f8f178..d560f619 100644 --- a/rust/crates/core/src/plugins/backend.rs +++ b/rust/crates/plugin-backend/src/lib.rs @@ -3,7 +3,7 @@ //! //! A build provider or VCS provider exists for one domain contract and one //! workspace. A backend serves only the plugin itself: its web pages call the -//! backend's [plugin functions](crate::lsp::interface::plugin_function), and +//! backend's plugin functions, and //! the backend may ask the host for the primitive services its permissions //! grant, such as private credential storage or workspace contexts. The host //! keeps one process per plugin on the machine that runs Lithe, and ends it @@ -23,48 +23,53 @@ //! Note: the decisions are recorded in .agents/notes/implemented/architecture/2026-10-24-database-provider-plugin.md //! and .agents/notes/implemented/architecture/2026-10-30-plugin-backend-workspace-context.md -use super::declarative::{invalid, ServerProcess, ServerRole, Validation}; -use super::settings::DeclaredSettings; +use lithe_plugin_model::process::{ServerProcess, ServerRole}; +use lithe_plugin_model::settings::DeclaredSettings; +use lithe_plugin_model::{invalid, Validation}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; /// The plugin's own backend process. #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct BackendDeclaration { +pub struct BackendDeclaration { + /// Launch fields shared with every plugin server process. #[serde(flatten)] - pub(crate) process: ServerProcess, + pub process: ServerProcess, } /// Primitive host services a backend may request, each granted by one /// manifest permission. -pub(crate) const HOST_SERVICE_CREDENTIALS: &str = "credentials"; +pub const HOST_SERVICE_CREDENTIALS: &str = "credentials"; /// Workspace contexts and their folders (`workspace.metadata`). -pub(crate) const HOST_SERVICE_WORKSPACE: &str = "workspace"; +pub const HOST_SERVICE_WORKSPACE: &str = "workspace"; /// Activation event of a backend that starts once a workspace is open. It /// names no ID: the backend is one process for every workspace. -pub(crate) const ON_WORKSPACE_OPEN: &str = "onWorkspaceOpen"; +pub const ON_WORKSPACE_OPEN: &str = "onWorkspaceOpen"; /// What backend validation needs from the rest of the manifest. -pub(crate) struct DeclarationContext<'a> { - pub(crate) declared_settings: &'a DeclaredSettings, +pub struct DeclarationContext<'a> { + /// The plugin's declared settings, which launch templates may reference. + pub declared_settings: &'a DeclaredSettings, /// Whether a tool window of the plugin shows web content, whose pages /// call the backend. - pub(crate) has_web_window: bool, + pub has_web_window: bool, /// Whether the plugin declares a UI command, whose `onCommand` event /// starts the backend. - pub(crate) has_command: bool, + pub has_command: bool, /// Whether the manifest lists `onWorkspaceOpen`. - pub(crate) activates_on_workspace_open: bool, + pub activates_on_workspace_open: bool, /// A provider capability may start this backend for a host-owned query. - pub(crate) provider_query_trigger: bool, + pub provider_query_trigger: bool, /// The tool-definition IDs from `requiresTools`. - pub(crate) runtime_tools: crate::plugins::declarative::RuntimeTools<'a>, + pub runtime_tools: lithe_plugin_model::process::RuntimeTools<'a>, } impl BackendDeclaration { - pub(crate) fn validate(&self, context: &DeclarationContext<'_>) -> Validation<()> { + /// Rejects a backend that nothing can start, launch fields the backend + /// role does not allow, and `${workspaceStateDir}`. + pub fn validate(&self, context: &DeclarationContext<'_>) -> Validation<()> { // A backend nothing can start would never run: it needs a page, a // command, a workspace event, or a provider query trigger. if !context.has_web_window @@ -96,7 +101,7 @@ impl BackendDeclaration { } /// Install-review entries: the backend process with the runtimes it needs. -pub(crate) fn programs(backend: &BackendDeclaration) -> Vec { +pub fn programs(backend: &BackendDeclaration) -> Vec { let mut programs = vec![backend.process.program(ServerRole::Backend)]; programs.extend(backend.process.runtime_programs()); programs @@ -105,6 +110,6 @@ pub(crate) fn programs(backend: &BackendDeclaration) -> Vec { /// The `backend` member of a launch plan: the `backend` capability version /// the manifest requires and the host services the session may request, /// which the host passes to `lsp.startServer` unchanged. -pub(crate) fn plan_descriptor(version: u32, host_services: &[&str]) -> Value { +pub fn plan_descriptor(version: u32, host_services: &[&str]) -> Value { json!({ "hostServices": host_services, "version": version }) } diff --git a/rust/crates/plugin-content-providers/Cargo.toml b/rust/crates/plugin-content-providers/Cargo.toml new file mode 100644 index 00000000..0c1d7a71 --- /dev/null +++ b/rust/crates/plugin-content-providers/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "lithe-plugin-content-providers" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Read-only documents a plugin backend serves as a URI scheme, and the checks on each answer" + +[dependencies] +lithe-plugin-model = { path = "../plugin-model" } +lithe-protocol = { path = "../protocol" } +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0", features = ["preserve_order"] } diff --git a/rust/crates/core/src/plugins/content_providers.rs b/rust/crates/plugin-content-providers/src/lib.rs similarity index 97% rename from rust/crates/core/src/plugins/content_providers.rs rename to rust/crates/plugin-content-providers/src/lib.rs index 24aafafe..95b018a1 100644 --- a/rust/crates/core/src/plugins/content_providers.rs +++ b/rust/crates/plugin-content-providers/src/lib.rs @@ -5,8 +5,8 @@ //! to and shows the answer read-only. Core checks that answer, so every host //! opens the same text under the same limit and refuses the same shapes. -use super::declarative::decode_payload; -use crate::protocol::{CoreError, ErrorCode}; +use lithe_plugin_model::decode_payload; +use lithe_protocol::{CoreError, ErrorCode}; use serde::Deserialize; use serde_json::{json, Value}; diff --git a/rust/crates/plugin-editor/Cargo.toml b/rust/crates/plugin-editor/Cargo.toml new file mode 100644 index 00000000..ffcd19a0 --- /dev/null +++ b/rust/crates/plugin-editor/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "lithe-plugin-editor" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Editor contributions of plugins: host-rendered editor actions and the language-feature providers a backend registers" + +[dependencies] +lithe-plugin-model = { path = "../plugin-model" } +lithe-protocol = { path = "../protocol" } +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0", features = ["preserve_order"] } diff --git a/rust/crates/core/src/plugins/editor_actions.rs b/rust/crates/plugin-editor/src/actions.rs similarity index 98% rename from rust/crates/core/src/plugins/editor_actions.rs rename to rust/crates/plugin-editor/src/actions.rs index 097f25c7..239062b9 100644 --- a/rust/crates/core/src/plugins/editor_actions.rs +++ b/rust/crates/plugin-editor/src/actions.rs @@ -4,12 +4,12 @@ //! their business facts; hosts own document versions, session leases, icon //! resources and clicks. Collected actions are checked against the icons and //! source bound the host declared in -//! [`EditorHostOptions`](crate::plugins::editor_actions::EditorHostOptions); an +//! [`EditorHostOptions`]; an //! action may carry alternatives, and a clicked action may return a reveal //! result. -use crate::lsp::interface::plugin_function::is_function_id; -use crate::protocol::{CoreError, ErrorCode}; +use lithe_plugin_model::is_function_id; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::BTreeSet; @@ -23,7 +23,9 @@ const MAX_ALTERNATIVES: usize = 4; const MAX_REVEAL_LOCATIONS: usize = 100; const MAX_URI_BYTES: usize = 4096; const REGISTER_ACTIONS: &str = "lithe/host/editor/registerActions"; -pub(crate) const REGISTER_PROVIDERS: &str = "lithe/host/editor/registerProviders"; +/// Host method that registers editor providers, reported among the granted +/// host methods. +pub const REGISTER_PROVIDERS: &str = "lithe/host/editor/registerProviders"; /// Editor presentation a host declares at `lsp.startServer.hostOptions.editor`. /// Core reports it to sessions granted the matching editor methods through @@ -38,7 +40,7 @@ pub struct EditorHostOptions { /// Largest document, in UTF-8 bytes, the host collects actions for. pub action_source_bytes: usize, /// Provider types `lithe/host/editor/registerProviders` accepts on this - /// host; a subset of [`super::editor_providers::PROVIDER_TYPES`]. + /// host; a subset of [`crate::providers::PROVIDER_TYPES`]. #[serde(default)] pub provider_types: Vec, } @@ -63,7 +65,7 @@ impl EditorHostOptions { || !self .provider_types .iter() - .all(|kind| super::editor_providers::PROVIDER_TYPES.contains(&kind.as_str())) + .all(|kind| crate::providers::PROVIDER_TYPES.contains(&kind.as_str())) { return Err(invalid("Unknown editor provider type in host options.")); } @@ -73,7 +75,7 @@ impl EditorHostOptions { /// Adds `services.editor.options` and `limits.editorActionSourceBytes` /// to a `getCapabilities` answer, each only when the session is granted /// the editor method it describes. - pub(crate) fn report(&self, answer: &mut Value, granted: &BTreeSet) { + pub fn report(&self, answer: &mut Value, granted: &BTreeSet) { let actions = granted.contains(REGISTER_ACTIONS); let providers = granted.contains(REGISTER_PROVIDERS) && !self.provider_types.is_empty(); if !actions && !providers { diff --git a/rust/crates/plugin-editor/src/lib.rs b/rust/crates/plugin-editor/src/lib.rs new file mode 100644 index 00000000..e57391c9 --- /dev/null +++ b/rust/crates/plugin-editor/src/lib.rs @@ -0,0 +1,9 @@ +//! Editor contributions of plugins. +//! +//! [`actions`] covers the independent, host-rendered editor actions of ordinary +//! tool plugins; [`providers`] covers the language-feature providers a plugin +//! backend registers through `lithe/host/editor/registerProviders`. Core checks +//! declarations and results; hosts own documents, calls and presentation. + +pub mod actions; +pub mod providers; diff --git a/rust/crates/core/src/plugins/editor_providers.rs b/rust/crates/plugin-editor/src/providers.rs similarity index 99% rename from rust/crates/core/src/plugins/editor_providers.rs rename to rust/crates/plugin-editor/src/providers.rs index 53d9de79..ba4fff9e 100644 --- a/rust/crates/core/src/plugins/editor_providers.rs +++ b/rust/crates/plugin-editor/src/providers.rs @@ -8,9 +8,9 @@ //! never interprets selectors, function names or result payloads beyond //! their shape, so no tool or language has a branch here. -use super::editor_actions::{normalize_names, uri, Location, LspPosition, LspRange}; -use crate::lsp::interface::plugin_function::is_function_id; -use crate::protocol::{CoreError, ErrorCode}; +use crate::actions::{normalize_names, uri, Location, LspPosition, LspRange}; +use lithe_plugin_model::is_function_id; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; use std::collections::BTreeSet; diff --git a/rust/crates/plugin-function-relay/Cargo.toml b/rust/crates/plugin-function-relay/Cargo.toml new file mode 100644 index 00000000..fe129fda --- /dev/null +++ b/rust/crates/plugin-function-relay/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "lithe-plugin-function-relay" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Cross-plugin function relay rules: who may call which exported function, and the calls in flight" + +[dependencies] +lithe-plugin-functions = { path = "../plugin-functions" } +lithe-plugin-model = { path = "../plugin-model" } +lithe-plugin-rpc = { path = "../plugin-rpc" } +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0", features = ["preserve_order"] } diff --git a/rust/crates/core/src/lsp/interface/function_relay.rs b/rust/crates/plugin-function-relay/src/lib.rs similarity index 95% rename from rust/crates/core/src/lsp/interface/function_relay.rs rename to rust/crates/plugin-function-relay/src/lib.rs index 1014cf9a..d594bd20 100644 --- a/rust/crates/core/src/lsp/interface/function_relay.rs +++ b/rust/crates/plugin-function-relay/src/lib.rs @@ -21,8 +21,9 @@ //! The rules live in the plugin contract //! (`shared/plugins/contract/v1/README.md`, "Plugin functions"). -use super::plugin_function::{codes, is_function_id, is_plugin_id}; -use crate::plugins::plugin_functions::KNOWN_CONTEXTS; +use lithe_plugin_functions::KNOWN_CONTEXTS; +use lithe_plugin_model::is_function_id; +use lithe_plugin_rpc::{codes, is_plugin_id}; use serde::Deserialize; use serde_json::{json, Value}; use std::collections::{BTreeMap, BTreeSet}; @@ -61,7 +62,7 @@ pub(crate) const RUNNING_GRACE: Duration = Duration::from_secs(2); /// Operation IDs of relayed calls start with this. The engine keeps them out /// of the callee session's host events, because no host operation waits for /// them: the answer goes to the calling plugin. -pub(crate) const OPERATION_PREFIX: &str = "relay-call-"; +pub const OPERATION_PREFIX: &str = "relay-call-"; /// Most plugins one snapshot may list. Guards the table, not the product. const MAX_SNAPSHOT_PLUGINS: usize = 4096; @@ -70,7 +71,9 @@ const MAX_SNAPSHOT_PLUGINS: usize = 4096; #[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub enum ContextAuthority { + /// The context runs on this machine. Local, + /// The context runs on a remote host. Remote, } @@ -79,26 +82,26 @@ pub enum ContextAuthority { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct RelayContext { #[serde(rename = "contextID")] - pub context_id: String, - pub generation: u64, - pub authority: ContextAuthority, + pub(crate) context_id: String, + pub(crate) generation: u64, + pub(crate) authority: ContextAuthority, } /// A function an enabled plugin exports. #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct RelayExport { - pub id: String, - pub version: u32, - pub context: String, + pub(crate) id: String, + pub(crate) version: u32, + pub(crate) context: String, } /// A function an enabled plugin declared it calls. #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct RelayCall { - pub id: String, - pub version: u32, + pub(crate) id: String, + pub(crate) version: u32, } /// What the relay needs to know about one enabled plugin. @@ -106,11 +109,11 @@ pub struct RelayCall { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct RelayPlugin { #[serde(rename = "pluginID")] - pub plugin_id: String, + pub(crate) plugin_id: String, #[serde(default)] - pub exports: Vec, + pub(crate) exports: Vec, #[serde(default)] - pub calls: Vec, + pub(crate) calls: Vec, } /// The host's complete view: the enabled plugins and the live workspace @@ -121,23 +124,26 @@ pub struct RelayPlugin { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct RelaySnapshot { #[serde(default)] - pub plugins: Vec, + pub(crate) plugins: Vec, #[serde(default)] - pub contexts: Vec, + pub(crate) contexts: Vec, } /// Why a relayed call (or a discovery) did not succeed. `code` is the stable /// `plugin.function.*` or `plugin.host.*` code the calling plugin branches on. #[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct RelayFailure { +pub struct RelayFailure { + /// Stable `plugin.function.*` or `plugin.host.*` code. pub code: &'static str, + /// Message for the calling plugin. pub message: String, /// The callee's own code (`error.data.code`) when its function failed. pub plugin_error_code: Option, } impl RelayFailure { - pub(crate) fn new(code: &'static str, message: &str) -> Self { + /// A failure without a callee code. + pub fn new(code: &'static str, message: &str) -> Self { Self { code, message: message.to_string(), @@ -149,7 +155,7 @@ impl RelayFailure { /// Which call a new request was made while serving, as the engine resolved /// the request's `parentOperationID`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum CallParent { +pub enum CallParent { /// The request named no parent: every relayed call the caller's session /// is running counts as one. Unstated, @@ -163,17 +169,22 @@ pub(crate) enum CallParent { } /// One request of a plugin to call an exported function. -pub(crate) struct RelayRequest { +pub struct RelayRequest { + /// Session of the calling plugin's backend. pub caller_session: String, + /// Plugin ID of the caller. pub caller_plugin: String, /// JSON-RPC ID of the request, echoed in the answer. pub rpc_id: Value, + /// Plugin ID that exports the function. pub callee_plugin: String, + /// Exported function to call. pub function_id: String, /// A JSON object. pub params: Value, /// `{ contextID, generation }`. pub context: Value, + /// Requested timeout; `None` takes the default. Capped at the relay maximum. pub timeout: Option, /// The call this request was made while serving; decides the new call's /// plugin chain and which ended call cancels it. @@ -181,14 +192,17 @@ pub(crate) struct RelayRequest { } /// How the callee's side of a call ended. -pub(crate) enum CalleeOutcome { +pub enum CalleeOutcome { /// The function returned this value. Succeeded(Value), /// The call ended without a value; `code` is the engine's request code, /// such as `plugin.function.failed` or `requestTimeout`. Failed { + /// Engine request code. code: String, + /// Failure message. message: String, + /// The callee's own code (`error.data.code`), if it sent one. plugin_error_code: Option, }, } @@ -196,36 +210,54 @@ pub(crate) enum CalleeOutcome { /// Work the engine must do after a state change. Every action runs outside /// all session locks. #[derive(Debug, Clone, PartialEq)] -pub(crate) enum RelayAction { +pub enum RelayAction { /// Answer the calling plugin's request. Reply { + /// Session of the calling plugin. session_id: String, + /// JSON-RPC ID of the caller's request. rpc_id: Value, + /// Value or failure to answer with. outcome: Result, }, /// Tell the callee session to stop one running call. CancelCallee { + /// Session of the callee. session_id: String, + /// Operation the callee runs the call as. operation_id: String, }, /// Find the callee's backend and send it the call. - Dispatch { call_id: u64 }, + Dispatch { + /// The admitted call. + call_id: u64, + }, } /// Everything the engine needs to send one call to the callee's backend. -pub(crate) struct DispatchPlan { +pub struct DispatchPlan { + /// Session of the calling plugin's backend. pub caller_session: String, + /// Plugin ID of the caller. pub caller_plugin: String, + /// Plugin ID that exports the function. pub callee_plugin: String, + /// Exported function to call. pub function_id: String, + /// Call parameters, a JSON object. pub params: Value, + /// `{ contextID, generation }` the call runs in. pub context: Value, + /// Time left before the call's deadline. pub remaining: Duration, } /// Tells the caller's session that the callee's backend must be started. -pub(crate) struct StartNotice { +pub struct StartNotice { + /// Session of the calling plugin, which announces that the callee's + /// backend must start. pub caller_session: String, + /// Plugin whose backend must start. pub callee_plugin: String, } @@ -265,7 +297,7 @@ struct Call { /// The relay's tables. See the module comment. #[derive(Default)] -pub(crate) struct FunctionRelay { +pub struct FunctionRelay { plugins: BTreeMap, contexts: BTreeMap, calls: BTreeMap, @@ -274,10 +306,7 @@ pub(crate) struct FunctionRelay { impl FunctionRelay { /// Replaces the snapshot and ends the calls it no longer allows. - pub(crate) fn configure( - &mut self, - snapshot: RelaySnapshot, - ) -> Result, &'static str> { + pub fn configure(&mut self, snapshot: RelaySnapshot) -> Result, &'static str> { check_snapshot(&snapshot)?; self.plugins = snapshot .plugins @@ -301,11 +330,7 @@ impl FunctionRelay { /// Answers `lithe/host/functions/providers`: the enabled plugins that /// export `function_id`, sorted by plugin ID, with the contract version /// each exports so a mismatch with the caller's declaration is visible. - pub(crate) fn providers( - &self, - caller_plugin: &str, - function_id: &str, - ) -> Result { + pub fn providers(&self, caller_plugin: &str, function_id: &str) -> Result { self.declared_version(caller_plugin, function_id)?; let providers: Vec = self .plugins @@ -322,7 +347,7 @@ impl FunctionRelay { /// Checks one call request. The checks run in a fixed order, so the same /// request always reports the same reason. On success the call is /// recorded and the returned actions ask the engine to dispatch it. - pub(crate) fn admit( + pub fn admit( &mut self, request: RelayRequest, now: Instant, @@ -409,7 +434,7 @@ impl FunctionRelay { /// What the engine needs to send the call, or `None` when it already /// ended. - pub(crate) fn dispatch_plan(&self, call_id: u64, now: Instant) -> Option { + pub fn dispatch_plan(&self, call_id: u64, now: Instant) -> Option { let call = self.calls.get(&call_id)?; Some(DispatchPlan { caller_session: call.caller_session.clone(), @@ -424,7 +449,7 @@ impl FunctionRelay { /// Records that no ready backend serves the call yet. Returns the notice /// for the host to start one, only the first time the call waits. - pub(crate) fn wait_for_provider(&mut self, call_id: u64, now: Instant) -> Option { + pub fn wait_for_provider(&mut self, call_id: u64, now: Instant) -> Option { let call = self.calls.get_mut(&call_id)?; call.phase = Phase::WaitingForProvider; if call.give_up_at.is_some() { @@ -441,7 +466,7 @@ impl FunctionRelay { /// the request is written, so an answer that beats the caller of this /// method still finds its call. Returns false when the call already /// ended. - pub(crate) fn started(&mut self, call_id: u64, session_id: &str, operation_id: &str) -> bool { + pub fn started(&mut self, call_id: u64, session_id: &str, operation_id: &str) -> bool { let Some(call) = self.calls.get_mut(&call_id) else { return false; }; @@ -454,24 +479,20 @@ impl FunctionRelay { /// Whether the call exists and its callee session has it. The engine asks /// before writing the request, to skip a call that ended while it queued. - pub(crate) fn is_running(&self, call_id: u64) -> bool { + pub fn is_running(&self, call_id: u64) -> bool { self.calls .get(&call_id) .is_some_and(|call| matches!(call.phase, Phase::Running { .. })) } /// The call could not be sent to the callee's backend. - pub(crate) fn dispatch_failed( - &mut self, - call_id: u64, - failure: RelayFailure, - ) -> Vec { + pub fn dispatch_failed(&mut self, call_id: u64, failure: RelayFailure) -> Vec { self.end_call(call_id, Some(Err(failure)), false) } /// The callee's session finished an operation. Anything that is not a /// running relayed call returns no actions. - pub(crate) fn callee_finished( + pub fn callee_finished( &mut self, session_id: &str, operation_id: &str, @@ -485,7 +506,7 @@ impl FunctionRelay { /// The calling plugin withdrew its request with `$/cancelRequest`. /// Returns `None` when the request names no relayed call. - pub(crate) fn cancel_from_caller( + pub fn cancel_from_caller( &mut self, session_id: &str, rpc_id: &Value, @@ -505,7 +526,7 @@ impl FunctionRelay { /// A session stopped or failed. Calls it made end silently, because /// nobody is left to answer, and their callees are cancelled. Calls it /// was serving fail for their callers. - pub(crate) fn session_ended(&mut self, session_id: &str) -> Vec { + pub fn session_ended(&mut self, session_id: &str) -> Vec { let made: Vec = self .calls .values() @@ -536,7 +557,7 @@ impl FunctionRelay { /// A backend of `plugin_id` became ready or registered functions: calls /// that waited for it are dispatched again. - pub(crate) fn provider_changed(&mut self, plugin_id: &str) -> Vec { + pub fn provider_changed(&mut self, plugin_id: &str) -> Vec { self.calls .values_mut() .filter(|call| { @@ -555,7 +576,7 @@ impl FunctionRelay { /// timeout normally answers first; the relay ends it only `RUNNING_GRACE` /// after the deadline, so a callee that never answers cannot leave the /// call open, and it cancels the callee. - pub(crate) fn expire(&mut self, now: Instant) -> Vec { + pub fn expire(&mut self, now: Instant) -> Vec { let mut due = Vec::new(); for call in self.calls.values() { let running = matches!(call.phase, Phase::Running { .. }); @@ -660,13 +681,13 @@ impl FunctionRelay { let named = self .calls .get(&call_id) - .filter(|call| served_here(*call)) + .filter(|call| served_here(call)) .ok_or_else(not_running)?; parents.insert(named.id); chain.extend(named.chain.iter().cloned()); } CallParent::Unstated => { - for served in self.calls.values().filter(|call| served_here(*call)) { + for served in self.calls.values().filter(|call| served_here(call)) { parents.insert(served.id); for plugin in &served.chain { if !chain.contains(plugin) { diff --git a/rust/crates/plugin-functions/Cargo.toml b/rust/crates/plugin-functions/Cargo.toml new file mode 100644 index 00000000..9e9abf9b --- /dev/null +++ b/rust/crates/plugin-functions/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "lithe-plugin-functions" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "pluginFunctions of a plugin manifest: functions a plugin exports to other plugins and the exported functions it calls" + +[dependencies] +lithe-plugin-model = { path = "../plugin-model" } +serde = { version = "1.0", features = ["derive"] } diff --git a/rust/crates/core/src/plugins/plugin_functions.rs b/rust/crates/plugin-functions/src/lib.rs similarity index 95% rename from rust/crates/core/src/plugins/plugin_functions.rs rename to rust/crates/plugin-functions/src/lib.rs index d73c7149..57958e35 100644 --- a/rust/crates/core/src/plugins/plugin_functions.rs +++ b/rust/crates/plugin-functions/src/lib.rs @@ -17,13 +17,13 @@ //! without a context the host knows is rejected, so a function that nobody //! described the acceptable context of is never callable across plugins. //! -//! The relay itself lives in `crate::lsp::interface::function_relay`. +//! The relay itself lives in the language-server runtime of `lithe-core`. //! //! Note: the decision is recorded in .agents/notes/implemented/architecture/2026-10-20-plugin-function-contract.md //! and .agents/notes/proposed/architecture/2026-10-25-plugin-dependencies-and-exports.md -use super::declarative::{invalid, Validation}; -use crate::lsp::interface::plugin_function::is_function_id; +use lithe_plugin_model::is_function_id; +use lithe_plugin_model::{invalid, Validation}; use serde::{Deserialize, Serialize}; use std::collections::BTreeSet; @@ -39,7 +39,7 @@ pub(crate) const MAX_CONTRACT_VERSION: u32 = 1000; pub(crate) const CONTEXT_WORKSPACE: &str = "workspace"; /// Every calling context this Core build knows. -pub(crate) const KNOWN_CONTEXTS: &[&str] = &[CONTEXT_WORKSPACE]; +pub const KNOWN_CONTEXTS: &[&str] = &[CONTEXT_WORKSPACE]; /// A function this plugin offers to other plugins. #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] @@ -66,7 +66,7 @@ pub(crate) struct CallDeclaration { /// The `pluginFunctions` section. #[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PluginFunctionsDeclaration { +pub struct PluginFunctionsDeclaration { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub(crate) exports: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -76,12 +76,12 @@ pub(crate) struct PluginFunctionsDeclaration { impl PluginFunctionsDeclaration { /// Whether the plugin exports at least one function, which lets the host /// start its backend when another plugin calls it. - pub(crate) fn exports_any(&self) -> bool { + pub fn exports_any(&self) -> bool { !self.exports.is_empty() } /// Whether the plugin may call exported functions of other plugins. - pub(crate) fn calls_any(&self) -> bool { + pub fn calls_any(&self) -> bool { !self.calls.is_empty() } } @@ -90,7 +90,7 @@ impl PluginFunctionsDeclaration { /// declaration always plans the same way. The caller has already checked that /// the plugin declares a backend, the only process that registers functions /// for the protocol that carries relayed calls. -pub(crate) fn validate(section: &mut PluginFunctionsDeclaration) -> Validation<()> { +pub fn validate(section: &mut PluginFunctionsDeclaration) -> Validation<()> { if section.exports.is_empty() && section.calls.is_empty() { return Err(invalid( "pluginFunctions", diff --git a/rust/crates/plugin-grammar/Cargo.toml b/rust/crates/plugin-grammar/Cargo.toml new file mode 100644 index 00000000..af3366b2 --- /dev/null +++ b/rust/crates/plugin-grammar/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "lithe-plugin-grammar" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Declarative syntax highlighting of plugin languages: grammar declarations and the TextMate grammar decoder" + +[dependencies] +lithe-plugin-model = { path = "../plugin-model" } +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0", features = ["preserve_order"] } diff --git a/rust/crates/core/src/plugins/grammar.rs b/rust/crates/plugin-grammar/src/lib.rs similarity index 95% rename from rust/crates/core/src/plugins/grammar.rs rename to rust/crates/plugin-grammar/src/lib.rs index 2853bf2d..e02a84e7 100644 --- a/rust/crates/core/src/plugins/grammar.rs +++ b/rust/crates/plugin-grammar/src/lib.rs @@ -20,7 +20,7 @@ //! //! Note: the decision is recorded in .agents/notes/implemented/architecture/2026-10-31-plugin-editor-extension-apis.md -use super::declarative::{invalid, is_safe_relative_path, PluginRejection, Validation}; +use lithe_plugin_model::{invalid, is_safe_relative_path, PluginRejection, Validation}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -49,7 +49,7 @@ const MAX_DELIMITER_BYTES: usize = 16; /// Engine that runs a grammar. #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] -pub(crate) enum GrammarKind { +pub enum GrammarKind { /// A TextMate grammar as JSON, the `.tmLanguage.json` files of VS Code. Textmate, } @@ -57,8 +57,9 @@ pub(crate) enum GrammarKind { /// `languages[].grammar`: the engine and the package file it reads. #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct GrammarDeclaration { - pub(crate) kind: GrammarKind, +pub struct GrammarDeclaration { + /// Engine that runs the grammar file. + pub kind: GrammarKind, /// Package-relative grammar file, e.g. `syntaxes/http.tmLanguage.json`. pub(crate) path: String, } @@ -66,7 +67,7 @@ pub(crate) struct GrammarDeclaration { /// `languages[].configuration`: comment and bracket behavior of the editor. #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct LanguageConfiguration { +pub struct LanguageConfiguration { #[serde(default, skip_serializing_if = "Option::is_none")] line_comment: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -78,7 +79,9 @@ pub(crate) struct LanguageConfiguration { } impl GrammarDeclaration { - pub(crate) fn validate(&self, field: &str) -> Validation<()> { + /// Rejects a grammar path that leaves the plugin package; `field` names + /// the declaration in the error. + pub fn validate(&self, field: &str) -> Validation<()> { if !is_safe_relative_path(&self.path) || self.path.contains(':') { return Err(invalid( format!("{field}.path"), @@ -90,7 +93,9 @@ impl GrammarDeclaration { } impl LanguageConfiguration { - pub(crate) fn validate(&self, field: &str) -> Validation<()> { + /// Rejects empty, overlong or control-character delimiters and too many + /// bracket pairs; `field` names the declaration in the error. + pub fn validate(&self, field: &str) -> Validation<()> { let delimiter = |value: &str| { !value.is_empty() && value.len() <= MAX_DELIMITER_BYTES @@ -141,7 +146,7 @@ fn grammar_error(field: impl Into, message: impl Into) -> Plugin /// Validates the text of a TextMate grammar file and returns its `scopeName`. /// Hosts register the original text, which the editor's engine parses itself. -pub(crate) fn decode_textmate(text: &str) -> Validation { +pub fn decode_textmate(text: &str) -> Validation { if text.len() > MAX_GRAMMAR_BYTES { return Err(grammar_error( "grammar", diff --git a/rust/crates/plugin-keymap-import/Cargo.toml b/rust/crates/plugin-keymap-import/Cargo.toml new file mode 100644 index 00000000..5a44a312 --- /dev/null +++ b/rust/crates/plugin-keymap-import/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "lithe-plugin-keymap-import" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Shortcut import providers plugins declare: declarations, source plans and validation of normalized provider replies" + +[dependencies] +lithe-plugin-model = { path = "../plugin-model" } +lithe-protocol = { path = "../protocol" } +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0", features = ["preserve_order"] } diff --git a/rust/crates/core/src/plugins/keymap_import.rs b/rust/crates/plugin-keymap-import/src/lib.rs similarity index 62% rename from rust/crates/core/src/plugins/keymap_import.rs rename to rust/crates/plugin-keymap-import/src/lib.rs index c68c782a..18bac30d 100644 --- a/rust/crates/core/src/plugins/keymap_import.rs +++ b/rust/crates/plugin-keymap-import/src/lib.rs @@ -1,28 +1,31 @@ //! Shared validation for normalized shortcut import provider replies. -use crate::protocol::{CoreError, ErrorCode}; +use lithe_plugin_model::PluginManifest; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::{BTreeMap, BTreeSet}; /// Maximum number of import providers one plugin may declare or negotiate. -pub(crate) const MAX_PROVIDERS: usize = 8; +pub const MAX_PROVIDERS: usize = 8; const MAX_BYTES: usize = 1024 * 1024; const MAX_COMMANDS: usize = 4096; const MAX_KEYS_PER_COMMAND: usize = 8; const MAX_INPUT_SELECTORS: usize = 16; const MAX_RECOGNITION_PROBES: usize = 16; const MAX_INPUT_FILES: usize = 128; -pub(crate) const MAX_INPUT_FILE_BYTES: usize = 1_048_576; +/// Largest declared input file a provider may read. +pub const MAX_INPUT_FILE_BYTES: usize = 1_048_576; const MAX_INPUT_TOTAL_BYTES: usize = 8_388_608; -pub(crate) const MAX_INPUT_CHUNK_BYTES: usize = 131_072; +/// Largest chunk of an input file one host read returns. +pub const MAX_INPUT_CHUNK_BYTES: usize = 131_072; const MAX_DIRECTORY_ENTRIES: usize = 4_096; const MAX_DIRECTORY_MATCHES: usize = 64; #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] /// One plugin-owned importer; the identifier is local to its package. -pub(crate) struct ProviderDeclaration { +pub struct ProviderDeclaration { id: String, display_name: String, recognition: Vec, @@ -83,7 +86,7 @@ fn valid_selector(selector: &InputSelector) -> bool { } /// Plugin-local provider IDs are opaque and cannot be paths. -pub(crate) fn valid_provider_id(id: &str) -> bool { +pub fn valid_provider_id(id: &str) -> bool { id.len() <= 64 && id.as_bytes().first().is_some_and(u8::is_ascii_lowercase) && id @@ -92,9 +95,9 @@ pub(crate) fn valid_provider_id(id: &str) -> bool { } /// Validates and orders provider declarations without knowing their source formats. -pub(crate) fn validate_declarations( +pub fn validate_declarations( providers: &mut [ProviderDeclaration], -) -> super::declarative::Validation<()> { +) -> lithe_plugin_model::Validation<()> { let mut ids = BTreeSet::new(); for provider in providers.iter() { if !valid_provider_id(&provider.id) @@ -107,7 +110,7 @@ pub(crate) fn validate_declarations( || provider.inputs.is_empty() || provider.inputs.len() > MAX_INPUT_SELECTORS { - return Err(super::declarative::invalid( + return Err(lithe_plugin_model::invalid( "keymapImportProviders", "Invalid or duplicate shortcut import provider.", )); @@ -135,7 +138,7 @@ pub(crate) fn validate_declarations( && components .iter() .position(|part| part == "*") - .map_or(true, |index| index + 1 == components.len()) + .is_none_or(|index| index + 1 == components.len()) && components.iter().all(|part| { part == "*" || (!part.contains('/') && safe_relative_path(part)) }) @@ -143,7 +146,7 @@ pub(crate) fn validate_declarations( }; !valid || !probes.insert(probe) }) { - return Err(super::declarative::invalid( + return Err(lithe_plugin_model::invalid( "keymapImportProviders.recognition", "Invalid or duplicate shortcut source recognition probe.", )); @@ -154,7 +157,7 @@ pub(crate) fn validate_declarations( .iter() .any(|input| !valid_selector(input) || !inputs.insert(input)) { - return Err(super::declarative::invalid( + return Err(lithe_plugin_model::invalid( "keymapImportProviders.inputs", "Invalid or duplicate shortcut source selector.", )); @@ -244,7 +247,7 @@ fn supported_key(key: &str) -> bool { /// Validates an untrusted provider reply against the host's current action IDs. /// This does not inspect source formats, resolve conditions or apply the bindings. -pub(crate) fn decode_command(payload: Value) -> Result { +pub fn decode_command(payload: Value) -> Result { if serde_json::to_vec(&payload).map_err(|_| invalid())?.len() > MAX_BYTES { return Err(invalid()); } @@ -310,7 +313,7 @@ struct CurrentBindings { /// Plans only the keys displaced by an import; existing custom owners and /// duplicate imported claims are conflicts. The host applies the result only /// after rechecking the current settings and source revisions. -pub(crate) fn plan_bindings_command(payload: Value) -> Result { +pub fn plan_bindings_command(payload: Value) -> Result { if serde_json::to_vec(&payload).map_err(|_| invalid())?.len() > MAX_BYTES { return Err(invalid()); } @@ -412,9 +415,16 @@ struct SourcePlanRequest { provider_id: String, } +/// What a source plan reads from one plugin manifest that the plugin host +/// validated; the host's manifest type implements it. +pub trait KeymapImportManifest: PluginManifest { + /// The manifest's validated `keymapImportProviders`. + fn keymap_import_providers(&self) -> &[ProviderDeclaration]; +} + /// Plans the declared inputs for a plugin-local import provider. The caller /// supplies a previously accepted manifest; this operation also validates it. -pub(crate) fn plan_sources_command(payload: Value) -> Result { +pub fn plan_sources_command(payload: Value) -> Result { let bad_plan = || { CoreError::new( ErrorCode::InvalidRequest, @@ -428,17 +438,13 @@ pub(crate) fn plan_sources_command(payload: Value) -> Result { if !valid_provider_id(&request.provider_id) { return Err(bad_plan()); } - let manifest = - super::declarative::validate_manifest_value(request.manifest).map_err(|_| bad_plan())?; - let manifest = serde_json::to_value(manifest).map_err(|_| bad_plan())?; - let provider = manifest["keymapImportProviders"] - .as_array() - .and_then(|providers| { - providers - .iter() - .find(|provider| provider["id"] == request.provider_id) - }) + let manifest = M::validate(request.manifest).map_err(|_| bad_plan())?; + let provider = manifest + .keymap_import_providers() + .iter() + .find(|provider| provider.id == request.provider_id) .ok_or_else(bad_plan)?; + let provider = serde_json::to_value(provider).map_err(|_| bad_plan())?; Ok(serde_json::json!({ "inputs": provider["inputs"], "recognition": provider["recognition"], @@ -453,33 +459,13 @@ pub(crate) fn plan_sources_command(payload: Value) -> Result { #[cfg(test)] mod tests { - use super::{decode_command, plan_sources_command}; + use super::decode_command; use serde_json::{json, Value}; - #[test] - fn import_binding_plans_follow_shared_fixtures() { - let fixtures: Value = serde_json::from_str(include_str!( - "../../../../fixtures/keymap/import-plan-v1.json" - )) - .unwrap(); - for case in fixtures["cases"].as_array().unwrap() { - let request = json!({"id": "fixture", "command": "keymap.planImportBindings", "payload": case["request"]}); - let response: Value = - serde_json::from_str(&crate::execute_json(&request.to_string())).unwrap(); - if let Some(expected) = case.get("expected") { - assert_eq!(response["ok"], true, "{}: {response}", case["name"]); - assert_eq!(&response["data"], expected, "{}", case["name"]); - } else { - assert_eq!(response["ok"], false, "{}", case["name"]); - assert!(response.get("data").is_none()); - } - } - } - #[test] fn normalized_import_provider_replies_follow_shared_fixtures() { let fixtures: Value = serde_json::from_str(include_str!( - "../../../../fixtures/keymap/import-provider-v1.json" + "../../../fixtures/keymap/import-provider-v1.json" )) .unwrap(); for case in fixtures["cases"].as_array().unwrap() { @@ -497,31 +483,6 @@ mod tests { } } - #[test] - fn dispatcher_returns_the_validated_reply_without_partial_results() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../../fixtures/keymap/import-provider-v1.json" - )) - .unwrap(); - for case in fixture["cases"].as_array().unwrap() { - let request = json!({"id": "fixture", "command": "keymap.decodeImport", "payload": case["request"]}); - let response: Value = - serde_json::from_str(&crate::execute_json(&request.to_string())).unwrap(); - assert_eq!(response["id"], "fixture"); - match case.get("expected") { - Some(expected) => { - assert_eq!(response["ok"], true, "{}", case["name"]); - assert_eq!(&response["data"], expected, "{}", case["name"]); - } - None => { - assert_eq!(response["ok"], false, "{}", case["name"]); - assert_eq!(response["error"]["code"], "invalid_request"); - assert!(response.get("data").is_none()); - } - } - } - } - #[test] fn provider_reply_limits_are_checked_before_application() { let keys: Vec = (1..=9) @@ -545,139 +506,4 @@ mod tests { .is_err() ); } - - #[test] - fn import_provider_manifest_is_generic_bounded_and_permission_checked() { - let plugin: Value = serde_json::from_str(include_str!( - "../../../../../shared/plugins/contract/v1/fixtures/keymap-import/keymap-import-provider.plugin.json" - )) - .unwrap(); - let validate = crate::plugins::declarative::validate_manifest_value; - assert!(validate(plugin.clone()).is_ok()); - let host_without_importers = json!({"backend": [0]}); - let host_with_importers = json!({"backend": [0], "keymapImportProviders": [0]}); - let command = crate::plugins::declarative::validate_command; - let rejected = command(json!({ - "manifest": plugin, - "hostCapabilities": host_without_importers - })) - .unwrap(); - assert_eq!(rejected["status"], "rejected"); - assert_eq!(rejected["error"]["code"], "plugin.api.unsupported"); - let accepted = command(json!({ - "manifest": plugin, - "hostCapabilities": host_with_importers - })) - .unwrap(); - assert_eq!(accepted["status"], "ok"); - - let mut duplicate = plugin.clone(); - duplicate["keymapImportProviders"][1]["id"] = json!("first"); - assert!(validate(duplicate).is_err()); - let mut malformed = plugin.clone(); - malformed["keymapImportProviders"][0]["id"] = json!("../private"); - assert!(validate(malformed).is_err()); - let mut traversal = plugin.clone(); - traversal["keymapImportProviders"][0]["inputs"][0]["path"] = json!("../credentials"); - assert!(validate(traversal).is_err()); - let mut wildcard = plugin.clone(); - wildcard["keymapImportProviders"][1]["inputs"][1]["extension"] = json!("*"); - assert!(validate(wildcard).is_err()); - let mut duplicate_inputs = plugin.clone(); - duplicate_inputs["keymapImportProviders"][0]["inputs"] = json!([ - {"kind": "file", "path": "bindings.json"}, - {"kind": "file", "path": "bindings.json"} - ]); - assert!(validate(duplicate_inputs).is_err()); - let mut no_recognition = plugin.clone(); - no_recognition["keymapImportProviders"][0]["recognition"] = json!([]); - assert!(validate(no_recognition).is_err()); - let mut duplicate_recognition = plugin.clone(); - duplicate_recognition["keymapImportProviders"][0]["recognition"] = json!([ - {"kind": "file", "path": "bindings.json"}, {"kind": "file", "path": "bindings.json"} - ]); - assert!(validate(duplicate_recognition).is_err()); - let mut bad_root = plugin.clone(); - bad_root["keymapImportProviders"][0]["recognition"][1]["components"] = - json!(["..", "User"]); - assert!(validate(bad_root).is_err()); - let mut bad_pattern = plugin.clone(); - bad_pattern["keymapImportProviders"][0]["recognition"][1]["components"] = - json!(["First*", "User"]); - assert!(validate(bad_pattern).is_err()); - let mut too_broad = plugin.clone(); - too_broad["keymapImportProviders"][0]["recognition"][1]["components"] = json!(["*", "*"]); - assert!(validate(too_broad).is_err()); - let mut unanchored = plugin.clone(); - unanchored["keymapImportProviders"][0]["recognition"][1]["components"] = - json!(["*", "User"]); - assert!(validate(unanchored).is_err()); - let mut international = plugin.clone(); - international["keymapImportProviders"][0]["displayName"] = json!("编".repeat(64)); - assert!(validate(international.clone()).is_ok()); - international["keymapImportProviders"][0]["displayName"] = json!("编".repeat(65)); - assert!(validate(international).is_err()); - let mut no_grant = plugin.clone(); - no_grant["permissions"] = json!(["backend.launch"]); - assert_eq!( - validate(no_grant).unwrap_err().code, - "plugin.permission.undeclared" - ); - let mut no_source_grant = plugin.clone(); - no_source_grant["permissions"] = json!(["backend.launch", "keymaps.provide"]); - assert_eq!( - validate(no_source_grant).unwrap_err().code, - "plugin.permission.undeclared" - ); - let mut unknown_backend_version = plugin.clone(); - unknown_backend_version["requires"]["backend"] = json!(1); - assert_eq!( - validate(unknown_backend_version).unwrap_err().code, - "plugin.api.unsupported" - ); - let mut unsafe_launch = plugin.clone(); - unsafe_launch["backend"]["launch"] = - json!({"kind": "runtime", "tool": "node", "entry": "main.mjs"}); - assert!(validate(unsafe_launch).is_err()); - let mut too_many = plugin; - too_many["keymapImportProviders"] = json!((0..9) - .map(|i| json!({"id": format!("provider-{i}"), "displayName": "Provider"})) - .collect::>()); - assert!(validate(too_many).is_err()); - } - - #[test] - fn source_plans_only_expose_inputs_of_the_requested_provider() { - let plugin: Value = serde_json::from_str(include_str!( - "../../../../../shared/plugins/contract/v1/fixtures/keymap-import/keymap-import-provider.plugin.json" - )) - .unwrap(); - let fixture: Value = serde_json::from_str(include_str!( - "../../../../fixtures/keymap/source-plans-v1.json" - )) - .unwrap(); - for case in fixture["cases"].as_array().unwrap() { - let actual = - plan_sources_command(json!({"manifest": plugin, "providerID": case["providerID"]})); - if case["expected"].is_null() { - assert!(actual.is_err()); - } else { - let actual = actual.unwrap(); - assert_eq!(actual["inputs"], case["expected"]["inputs"]); - assert_eq!(actual["recognition"], case["expected"]["recognition"]); - assert_eq!(actual["limits"], fixture["limits"]); - } - } - let mut invalid = plugin; - invalid["keymapImportProviders"][0]["inputs"][0]["path"] = json!("../credentials"); - assert!( - plan_sources_command(json!({"manifest": invalid, "providerID": "second"})).is_err() - ); - let mut invalid: Value = serde_json::from_str(include_str!( - "../../../../../shared/plugins/contract/v1/fixtures/keymap-import/keymap-import-provider.plugin.json" - )) - .unwrap(); - invalid["keymapImportProviders"][1]["recognition"][0]["path"] = json!("../private"); - assert!(plan_sources_command(json!({"manifest": invalid, "providerID": "first"})).is_err()); - } } diff --git a/rust/crates/plugin-language-servers/Cargo.toml b/rust/crates/plugin-language-servers/Cargo.toml new file mode 100644 index 00000000..579fae5c --- /dev/null +++ b/rust/crates/plugin-language-servers/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "lithe-plugin-language-servers" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Plugin-owned language server sessions: declared requests, notifications, commands, state retention and launch resolution" + +[dependencies] +lithe-plugin-model = { path = "../plugin-model" } +lithe-protocol = { path = "../protocol" } +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0", features = ["preserve_order"] } diff --git a/rust/crates/core/src/plugins/language_server_sessions.rs b/rust/crates/plugin-language-servers/src/lib.rs similarity index 93% rename from rust/crates/core/src/plugins/language_server_sessions.rs rename to rust/crates/plugin-language-servers/src/lib.rs index affece0a..4e1c0a0b 100644 --- a/rust/crates/core/src/plugins/language_server_sessions.rs +++ b/rust/crates/plugin-language-servers/src/lib.rs @@ -13,24 +13,20 @@ //! state directories to prune. The host calls the function, creates the //! directories, and starts the process; it never interprets the answer. -use super::declarative::{ +use lithe_plugin_model::{ decode_payload, invalid, is_safe_relative_path, PluginRejection, Validation, }; -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; use std::collections::{BTreeMap, BTreeSet}; -/// How long the host waits for a launch function by default, in -/// milliseconds; a manifest may choose a value within the bounds below. -pub(crate) const RESOLVE_TIMEOUT_MILLISECONDS: u64 = 30_000; -const MIN_RESOLVE_TIMEOUT_MILLISECONDS: u64 = 1_000; -const MAX_RESOLVE_TIMEOUT_MILLISECONDS: u64 = 120_000; - /// How long a forwarded request may take when the backend names no timeout, -/// and the longest timeout it may name, in milliseconds. -pub(crate) const DEFAULT_REQUEST_TIMEOUT_MILLISECONDS: u64 = 60_000; -pub(crate) const MAX_REQUEST_TIMEOUT_MILLISECONDS: u64 = 300_000; +/// in milliseconds. +pub const DEFAULT_REQUEST_TIMEOUT_MILLISECONDS: u64 = 60_000; +/// The longest timeout a backend may name for a forwarded request, in +/// milliseconds. +pub const MAX_REQUEST_TIMEOUT_MILLISECONDS: u64 = 300_000; /// Most request or notification methods one server may expose to its /// backend. Bounded so the policy stays a short list the engine scans. @@ -58,10 +54,10 @@ const MAX_GATE_MATCH_BYTES: usize = 4 * 1024; const MAX_WRITABLE_PATHS: usize = 32; /// The request that carries server commands. -pub(crate) const EXECUTE_COMMAND: &str = "workspace/executeCommand"; +pub const EXECUTE_COMMAND: &str = "workspace/executeCommand"; /// The notification that replaces the server's settings. A backend may /// always send it, because only the backend knows the server's settings. -pub(crate) const DID_CHANGE_CONFIGURATION: &str = "workspace/didChangeConfiguration"; +pub const DID_CHANGE_CONFIGURATION: &str = "workspace/didChangeConfiguration"; /// Methods the host sends itself to keep the session and the open documents /// consistent. A backend sending one of them would desynchronize the host's @@ -91,7 +87,7 @@ pub(crate) fn is_host_owned_method(method: &str) -> bool { /// drive declares none of them. #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] -pub(crate) struct SessionDeclaration { +pub struct SessionDeclaration { /// Requests and notifications the backend may send to the server. #[serde(default, skip_serializing_if = "Option::is_none")] request_methods: Option>, @@ -154,15 +150,17 @@ impl Retention { #[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub enum RemoteSupport { + /// The server runs next to a remote workspace like a local one. #[default] Supported, + /// The server only runs for local workspaces. Unsupported, } impl SessionDeclaration { /// Whether the backend may talk to the server, which needs the /// `languageServer.request` permission. - pub(crate) fn exposes_server(&self) -> bool { + pub fn exposes_server(&self) -> bool { self.request_methods.as_ref().is_some_and(|m| !m.is_empty()) || self .forward_notifications @@ -171,7 +169,7 @@ impl SessionDeclaration { } /// Checks the session fields of the server at `field`. - pub(crate) fn validate(&self, field: &str) -> Validation<()> { + pub fn validate(&self, field: &str) -> Validation<()> { let methods = unique_names( self.request_methods.as_deref(), &format!("{field}.requestMethods"), @@ -233,7 +231,7 @@ impl SessionDeclaration { /// The session policy the LSP engine enforces for this server, in the /// shape `lsp.startServer` accepts as `sessionPolicy`. Lists are sorted /// so equal declarations give equal policies. - pub(crate) fn policy(&self, server_id: &str) -> Value { + pub fn policy(&self, server_id: &str) -> Value { let sorted = |values: &Option>| { values .iter() @@ -287,7 +285,7 @@ fn unique_names<'a>( } /// A method or command name: short text without whitespace or controls. -pub(crate) fn is_protocol_name(value: &str) -> bool { +pub fn is_protocol_name(value: &str) -> bool { !value.is_empty() && value.len() <= MAX_NAME_BYTES && !value @@ -295,29 +293,17 @@ pub(crate) fn is_protocol_name(value: &str) -> bool { .any(|character| character.is_whitespace() || character.is_control()) } -/// Checks `launch.timeoutMilliseconds` of a backend launch. -pub(crate) fn validate_resolve_timeout(value: Option, field: &str) -> Validation<()> { - match value { - Some(milliseconds) - if !(MIN_RESOLVE_TIMEOUT_MILLISECONDS..=MAX_RESOLVE_TIMEOUT_MILLISECONDS) - .contains(&milliseconds) => - { - Err(invalid( - field, - "timeoutMilliseconds must be between 1000 and 120000.", - )) - } - _ => Ok(()), - } -} - /// Writable host directories of one start, each already trimmed of a /// trailing separator. Every path a launch answer may write lies in one. -pub(crate) struct LaunchDirectories<'a> { - pub(crate) state: &'a str, - pub(crate) workspace_state: &'a str, - pub(crate) log: &'a str, - pub(crate) temporary: &'a str, +pub struct LaunchDirectories<'a> { + /// `stateDirectory`: kept for the plugin's server until uninstall. + pub state: &'a str, + /// `workspaceStateDirectory`: the server's state of this workspace. + pub workspace_state: &'a str, + /// `logDirectory`: this session's log storage of the plugin. + pub log: &'a str, + /// `temporaryDirectory`: scratch space of this start. + pub temporary: &'a str, } impl LaunchDirectories<'_> { @@ -350,7 +336,7 @@ fn is_within(path: &str, directory: &str) -> bool { /// Params of the launch function, apart from `context` and `tools`, which /// the host attaches: the server, the machine, the writable directories, /// and the plugin's effective settings. -pub(crate) fn resolve_params( +pub fn resolve_params( server_id: &str, architecture: &str, directories: &LaunchDirectories<'_>, @@ -400,11 +386,14 @@ enum AnsweredExecutable { #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct Gate { - pub(crate) id: String, + /// Name of the gate, unique within one session declaration. + pub id: String, until: GateCondition, - pub(crate) method: String, + /// Server notification that opens the gate. + pub method: String, + /// Fields the notification parameters must contain, compared by value. #[serde(rename = "match")] - pub(crate) pattern: Map, + pub pattern: Map, } /// What opens a gate. Only a server notification does today. @@ -425,7 +414,7 @@ fn serialized_bytes(value: &Value) -> usize { /// Decodes and bounds a launch answer. The result is the start plan the /// host runs, in the shape of a declarative launch plan, plus the gates /// that join the session policy. -pub(crate) fn decode_answer( +pub fn decode_answer( answer: Value, directories: &LaunchDirectories<'_>, tool_ids: &BTreeSet<&str>, @@ -539,7 +528,7 @@ pub(crate) fn decode_answer( /// Whether a notification's params satisfy a gate's match object: every /// member of the pattern is present with an equal value, and nested /// objects match the same way. -pub(crate) fn matches_pattern(params: &Value, pattern: &Map) -> bool { +pub fn matches_pattern(params: &Value, pattern: &Map) -> bool { let Some(params) = params.as_object() else { return pattern.is_empty(); }; diff --git a/rust/crates/plugin-manifest/Cargo.toml b/rust/crates/plugin-manifest/Cargo.toml new file mode 100644 index 00000000..bf73e7eb --- /dev/null +++ b/rust/crates/plugin-manifest/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "lithe-plugin-manifest" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Declarative plugin manifests: validation, normalization and the run, test, debug, settings and language-server plans derived from them" + +[dependencies] +lithe-build = { path = "../build" } +lithe-debug = { path = "../debug" } +lithe-plugin-backend = { path = "../plugin-backend" } +lithe-plugin-functions = { path = "../plugin-functions" } +lithe-plugin-grammar = { path = "../plugin-grammar" } +lithe-plugin-keymap-import = { path = "../plugin-keymap-import" } +lithe-plugin-language-servers = { path = "../plugin-language-servers" } +lithe-plugin-model = { path = "../plugin-model" } +lithe-plugin-native-ui = { path = "../plugin-native-ui" } +lithe-plugin-output-links = { path = "../plugin-output-links" } +lithe-plugin-rpc = { path = "../plugin-rpc" } +lithe-project-generators = { path = "../project-generators" } +lithe-protocol = { path = "../protocol" } +lithe-toolchain = { path = "../toolchain" } +lithe-vcs = { path = "../vcs" } +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0", features = ["preserve_order"] } diff --git a/rust/crates/plugin-manifest/src/commands.rs b/rust/crates/plugin-manifest/src/commands.rs new file mode 100644 index 00000000..225bd2b8 --- /dev/null +++ b/rust/crates/plugin-manifest/src/commands.rs @@ -0,0 +1,213 @@ +//! Command entry points that decode a payload and run validation, run and test plans +//! or a language-server launch plan. + +use super::*; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ValidatePayload { + manifest: Value, + /// Capability versions the host runs. When sent, a manifest that + /// requires anything else is refused. + #[serde(default)] + host_capabilities: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct PlanPayload { + manifest: Value, + #[serde(rename = "languageID")] + language_id: String, + request: Request, +} + +/// A launch plan names the process by the language whose server it is, by +/// the build system whose provider it is, or by the VCS it provides. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct LaunchPlanPayload { + manifest: Value, + #[serde(default, rename = "languageID")] + language_id: Option, + #[serde(default, rename = "buildSystemID")] + build_system_id: Option, + #[serde(default, rename = "vcsProviderID")] + vcs_provider_id: Option, + /// Selects the plugin's backend. + #[serde(default)] + backend: bool, + #[serde(default, rename = "debugAdapterID")] + debug_adapter_id: Option, + request: LanguageServerLaunchPlanRequest, +} + +enum LaunchSelection<'a> { + Language(&'a str), + BuildSystem(&'a str), + VcsProvider(&'a str), + Backend, + DebugAdapter(&'a str), +} + +/// `plugins.declarative.validate`: returns `{status: "ok", plugin}` with the +/// normalized manifest, or `{status: "rejected", error}`. +pub fn validate_command(payload: Value) -> Result { + let payload: ValidatePayload = decode_payload(payload, "plugins.declarative.validate")?; + Ok(outcome(validate_manifest_value(payload.manifest).and_then( + |manifest| { + if let Some(host) = &payload.host_capabilities { + capabilities::check_hosted(manifest.requires(), host)?; + } + serde_json::to_value(manifest) + .map(|value| ("plugin", value)) + .map_err(|error| PluginRejection::new("plugin.manifest.invalid", error.to_string())) + }, + ))) +} + +/// `plugins.declarative.runPlan`: expands the run template for one file. +pub fn run_plan_command(payload: Value) -> Result { + let payload: PlanPayload = + decode_payload(payload, "plugins.declarative.runPlan")?; + Ok(outcome(validate_manifest_value(payload.manifest).and_then( + |manifest| { + run_plan(&manifest, &payload.language_id, &payload.request).map(|plan| ("plan", plan)) + }, + ))) +} + +/// `plugins.declarative.discoverTests`: lists workspace and file test items. +pub fn discover_tests_command(payload: Value) -> Result { + let payload: PlanPayload = + decode_payload(payload, "plugins.declarative.discoverTests")?; + Ok(outcome(validate_manifest_value(payload.manifest).and_then( + |manifest| { + discover_tests(&manifest, &payload.language_id, &payload.request) + .map(|items| ("items", items)) + }, + ))) +} + +/// `plugins.declarative.testPlan`: expands the test template for one scope. +pub fn test_plan_command(payload: Value) -> Result { + let payload: PlanPayload = + decode_payload(payload, "plugins.declarative.testPlan")?; + Ok(outcome(validate_manifest_value(payload.manifest).and_then( + |manifest| { + test_plan(&manifest, &payload.language_id, &payload.request).map(|plan| ("plan", plan)) + }, + ))) +} + +/// `plugins.declarative.languageServerLaunchPlan`: expands a language +/// server's launch declaration with the host's directories and architecture. +pub fn language_server_launch_plan_command(payload: Value) -> Result { + const COMMAND: &str = "plugins.declarative.languageServerLaunchPlan"; + let payload: LaunchPlanPayload = decode_payload(payload, COMMAND)?; + let selected = match ( + &payload.language_id, + &payload.build_system_id, + &payload.vcs_provider_id, + payload.backend, + &payload.debug_adapter_id, + ) { + (Some(language_id), None, None, false, None) => LaunchSelection::Language(language_id), + (None, Some(build_system_id), None, false, None) => { + LaunchSelection::BuildSystem(build_system_id) + } + (None, None, Some(vcs_id), false, None) => LaunchSelection::VcsProvider(vcs_id), + (None, None, None, true, None) => LaunchSelection::Backend, + (None, None, None, false, Some(adapter_id)) => LaunchSelection::DebugAdapter(adapter_id), + _ => { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + format!("Invalid {COMMAND} payload"), + ) + .with_details( + "Name exactly one of languageID, buildSystemID, vcsProviderID, backend, and debugAdapterID.", + )) + } + }; + Ok(outcome(validate_manifest_value(payload.manifest).and_then( + |manifest| { + match selected { + LaunchSelection::Language(language_id) => { + language_server_launch_plan(&manifest, language_id, &payload.request) + } + LaunchSelection::BuildSystem(build_system_id) => { + build_provider_launch_plan(&manifest, build_system_id, &payload.request) + } + LaunchSelection::VcsProvider(vcs_id) => { + vcs_provider_launch_plan(&manifest, vcs_id, &payload.request) + } + LaunchSelection::Backend => backend_launch_plan(&manifest, &payload.request), + LaunchSelection::DebugAdapter(adapter_id) => { + debug_adapter_launch_plan(&manifest, adapter_id, &payload.request) + } + } + .map(|plan| ("plan", plan)) + }, + ))) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct GrammarPayload { + manifest: Value, + #[serde(rename = "languageID")] + language_id: String, + /// Text of the grammar file the host read from the package. + grammar: String, +} + +/// `plugins.declarative.decodeGrammar`: validates the grammar file of one +/// language and returns what the editor registers for it. +pub fn decode_grammar_command(payload: Value) -> Result { + let payload: GrammarPayload = decode_payload(payload, "plugins.declarative.decodeGrammar")?; + Ok(outcome(validate_manifest_value(payload.manifest).and_then( + |manifest| { + let language = manifest + .languages + .iter() + .find(|language| language.id == payload.language_id) + .filter(|language| language.grammar.is_some() || language.configuration.is_some()) + .ok_or_else(|| { + PluginRejection::new( + "plugin.grammar.unavailable", + format!( + "The plugin declares no grammar for {}.", + payload.language_id + ), + ) + })?; + let grammar = language + .grammar + .as_ref() + .map(|grammar| match grammar.kind { + // The editor's engine parses the original text; Core only + // proves that it can and names the scope it registers. + lithe_plugin_grammar::GrammarKind::Textmate => { + lithe_plugin_grammar::decode_textmate(&payload.grammar).map(|scope_name| { + json!({ + "kind": grammar.kind, + "scopeName": scope_name, + "source": payload.grammar, + }) + }) + } + }) + .transpose()?; + Ok(( + "language", + json!({ + "id": language.id, + "fileExtensions": language.file_extensions, + "fileNames": language.file_names, + "grammar": grammar, + "configuration": language.configuration, + }), + )) + }, + ))) +} diff --git a/rust/crates/plugin-manifest/src/declarations.rs b/rust/crates/plugin-manifest/src/declarations.rs new file mode 100644 index 00000000..954b74ca --- /dev/null +++ b/rust/crates/plugin-manifest/src/declarations.rs @@ -0,0 +1,348 @@ +//! Normalization of language, language-server and execution-provider declarations, +//! with their executable and argument checks. + +use super::*; + +impl LanguageDeclaration { + pub(crate) fn normalize(&mut self, index: usize) -> Validation<()> { + let field = format!("languages[{index}]"); + if let Some(grammar) = &self.grammar { + grammar.validate(&format!("{field}.grammar"))?; + } + if let Some(configuration) = &self.configuration { + configuration.validate(&format!("{field}.configuration"))?; + } + let id = self.id.trim().to_lowercase(); + if id.is_empty() + || id != self.id + || !id.chars().all(|character| { + character.is_ascii_lowercase() + || character.is_ascii_digit() + || "+-_".contains(character) + }) + { + return Err(invalid( + format!("{field}.id"), + "The language ID must be lowercase.", + )); + } + if self.display_name.trim().is_empty() { + return Err(invalid( + format!("{field}.displayName"), + "The language display name is empty.", + )); + } + self.file_extensions = normalized_extensions(&self.file_extensions); + self.file_names = normalized_names(&self.file_names); + self.project_file_names = normalized_names(&self.project_file_names); + if self.file_extensions.is_empty() + && self.file_names.is_empty() + && self.project_file_names.is_empty() + { + return Err(invalid( + field, + "The language declares no file extensions or file names.", + )); + } + if self + .file_extensions + .iter() + .chain(&self.file_names) + .chain(&self.project_file_names) + .any(|name| name.contains('/') || name.contains('\\')) + { + return Err(invalid( + field, + "Language file names must not contain path separators.", + )); + } + if let Some(dependencies) = &mut self.dependencies { + dependencies.management_file_names = + normalized_names(&dependencies.management_file_names); + dependencies.project_dependency_paths = + sorted_unique(dependencies.project_dependency_paths.clone()); + dependencies.project_binary_paths = + sorted_unique(dependencies.project_binary_paths.clone()); + if dependencies + .management_file_names + .iter() + .chain(&dependencies.project_dependency_paths) + .chain(&dependencies.project_binary_paths) + .any(|path| !is_safe_relative_path(path)) + { + return Err(invalid( + format!("{field}.dependencies"), + "Dependency paths must stay inside the project.", + )); + } + } + Ok(()) + } +} + +impl LanguageServerDeclaration { + pub(crate) fn validate( + &self, + field: &str, + declared_settings: &DeclaredSettings, + runtime_tools: RuntimeTools<'_>, + ) -> Validation<()> { + self.process.validate( + field, + declared_settings, + ServerRole::LanguageServer, + runtime_tools, + )?; + self.session.validate(field)?; + if self.process.is_backend_launch() && self.host_integration.is_some() { + return Err(invalid( + format!("{field}.hostIntegration"), + "A language server started through the plugin backend has no host integration.", + )); + } + if let Some(configuration) = &self.workspace_configuration { + let field = format!("{field}.workspaceConfiguration"); + if !configuration.is_object() { + return Err(invalid(field, "workspaceConfiguration is a JSON object.")); + } + validate_launch_value( + configuration, + &declared_settings.for_configuration(), + &field, + )?; + // The host re-expands it on every change without a launch, so + // only setting values may vary. + if mentions_launch_directory(configuration) { + return Err(invalid_template( + field, + "workspaceConfiguration may only use ${setting:} variables.", + )); + } + } + if let Some(identifier) = &self.language_identifier { + if identifier.trim().is_empty() { + return Err(invalid( + format!("{field}.languageIdentifier"), + "The LSP language identifier is empty.", + )); + } + } + if let Some(integration) = &self.host_integration { + let field = format!("{field}.hostIntegration.id"); + let message = match integration.get("id").and_then(Value::as_str) { + Some(id) => format!("The host has no integration named {id}."), + None => "The host has no language-server integration.".to_owned(), + }; + return Err(PluginRejection::at( + "plugin.integration.unsupported", + field, + message, + )); + } + Ok(()) + } +} + +impl ExecutionProviderDeclaration { + pub(crate) fn normalize(&mut self, field: &str) -> Validation<()> { + if self.run.is_none() && self.test.is_none() { + return Err(invalid( + field.to_string(), + "The execution provider declares neither run nor test.", + )); + } + if let Some(run) = &self.run { + validate_executable(&run.executable, &format!("{field}.run.executable"))?; + validate_arguments( + &run.arguments, + TemplateContext::Run, + &format!("{field}.run.arguments"), + )?; + } + if let Some(test) = &mut self.test { + let test_field = format!("{field}.test"); + if !is_identifier(&test.framework_id) { + return Err(invalid( + format!("{test_field}.frameworkID"), + "The test framework ID is invalid.", + )); + } + if test.workspace_label.trim().is_empty() { + return Err(invalid( + format!("{test_field}.workspaceLabel"), + "The workspace test label is empty.", + )); + } + test.project_markers = normalized_names(&test.project_markers); + test.test_file_patterns = normalized_names(&test.test_file_patterns); + if test.project_markers.is_empty() + || test.project_markers.iter().any(|name| name.contains('/')) + { + return Err(invalid( + format!("{test_field}.projectMarkers"), + "Project markers must be file names.", + )); + } + if test.test_file_patterns.is_empty() + || test.test_file_patterns.iter().any(|pattern| { + pattern.contains('/') || pattern.contains('?') || pattern.contains('[') + }) + { + return Err(invalid( + format!("{test_field}.testFilePatterns"), + "Test file patterns must be file names using only *.", + )); + } + validate_executable(&test.executable, &format!("{test_field}.executable"))?; + validate_arguments( + &test.workspace_arguments, + TemplateContext::TestWorkspace, + &format!("{test_field}.workspaceArguments"), + )?; + validate_arguments( + &test.file_arguments, + TemplateContext::TestFile, + &format!("{test_field}.fileArguments"), + )?; + validate_arguments( + &test.test_case_arguments, + TemplateContext::TestCase, + &format!("{test_field}.testCaseArguments"), + )?; + let names_test = test.test_case_arguments.iter().any(|argument| { + parse_template(argument.value()).is_some_and(|parts| { + parts.iter().any(|part| { + matches!( + part, + TemplatePart::Variable( + TemplateVariable::TestName | TemplateVariable::TestNameRegex + ) + ) + }) + }) + }); + if !names_test { + return Err(invalid_template( + format!("{test_field}.testCaseArguments"), + "The test case command must reference the test name.", + )); + } + } + Ok(()) + } +} + +fn validate_executable(executable: &ExecutableDeclaration, field: &str) -> Validation<()> { + match (&executable.command, &executable.toolchain) { + (Some(command), None) if is_bare_command(command) => Ok(()), + (None, Some(toolchain)) if is_identifier(toolchain) => Ok(()), + _ => Err(invalid( + field.to_string(), + "The executable must be exactly one bare command or toolchain ID.", + )), + } +} + +fn validate_arguments( + arguments: &[ArgumentTemplate], + context: TemplateContext, + field: &str, +) -> Validation<()> { + for (index, argument) in arguments.iter().enumerate() { + let element = format!("{field}[{index}]"); + let value = argument.value(); + if value.contains('\0') { + return Err(invalid_template(element, "Arguments must not contain NUL.")); + } + let Some(parts) = parse_template(value) else { + return Err(invalid_template( + element, + "The argument uses an unknown or unterminated template variable.", + )); + }; + let condition = argument.condition(); + if condition.is_some() && context != TemplateContext::TestCase { + return Err(invalid_template( + element, + "Conditional arguments are only allowed in test case commands.", + )); + } + for part in &parts { + let TemplatePart::Variable(variable) = part else { + continue; + }; + if !context.allows(*variable) { + return Err(invalid_template( + element, + "The argument uses a variable that is not available in this command.", + )); + } + if *variable == TemplateVariable::UserArguments && parts.len() != 1 { + return Err(invalid_template( + element, + "${userArguments} must be a whole argument.", + )); + } + // A test case may run without a selected file, so file variables + // must be guarded instead of expanding to an empty argument. + if context == TemplateContext::TestCase + && variable.needs_file() + && condition != Some(ArgumentCondition::FileSelected) + { + return Err(invalid_template( + element, + "File variables in a test case command require \"when\": \"fileSelected\".", + )); + } + } + } + Ok(()) +} + +// MARK: Plans + +/// Whether any string in a JSON value names a variable other than a setting. +fn mentions_launch_directory(value: &Value) -> bool { + match value { + Value::String(text) => parse_template_with(text, LaunchVariable::parse) + .unwrap_or_default() + .iter() + .any(|part| { + matches!(part, TemplatePart::Variable(variable) if !matches!(variable, LaunchVariable::Setting(_))) + }), + Value::Array(items) => items.iter().any(mentions_launch_directory), + Value::Object(members) => members.values().any(mentions_launch_directory), + _ => false, + } +} + +/// Trims and lowercases complete file names and markers. A leading `.` is +/// part of the name (`.toolrc`, `.env.example`) and is kept. +fn normalized_names(values: &[String]) -> Vec { + sorted_unique( + values + .iter() + .filter_map(|value| { + let name = value.trim().to_lowercase(); + (!name.is_empty()).then_some(name) + }) + .collect(), + ) +} + +/// Normalizes file extensions, where a leading `.` is optional notation: +/// `.php` and `php` both declare the `php` extension. +fn normalized_extensions(values: &[String]) -> Vec { + let names = normalized_names(values); + sorted_unique( + names + .iter() + .filter_map(|name| { + let extension = name.strip_prefix('.').unwrap_or(name); + (!extension.is_empty()).then(|| extension.to_string()) + }) + .collect(), + ) +} + +// MARK: Command entry points diff --git a/rust/crates/plugin-manifest/src/launch.rs b/rust/crates/plugin-manifest/src/launch.rs new file mode 100644 index 00000000..78c2e30e --- /dev/null +++ b/rust/crates/plugin-manifest/src/launch.rs @@ -0,0 +1,522 @@ +//! Language-server launch plans: request layers, launch values and requirements, and +//! the expansion of each server process. + +use super::*; + +/// Host facts substituted into a language server's launch templates. The +/// host owns every directory; Core only joins text and never touches disk. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LanguageServerLaunchPlanRequest { + /// Absolute root of the installed plugin package. + plugin_directory: String, + /// Absolute writable directory the host keeps for this plugin's server. + state_directory: String, + /// Absolute writable directory for the server's state of this workspace. + /// A host without a workspace, such as one starting a plugin backend, + /// omits it; a launch that names it then has no usable plan. + #[serde(default)] + workspace_state_directory: Option, + /// Absolute current-session `plugins//files` directory. The host creates it + /// outside installed resources; Core never infers a writable path. + #[serde(default)] + log_directory: Option, + /// Absolute writable scratch directory for one start of the server. Only + /// a launch the backend resolves needs it. + #[serde(default)] + temporary_directory: Option, + /// Host CPU architecture, one of `KNOWN_ARCHITECTURES`. + architecture: String, + /// Values the user stored for the plugin's settings, by setting ID. A + /// missing or no longer acceptable value falls back to the default. + #[serde(default)] + settings: BTreeMap, + /// The current workspace's overrides of project-scoped settings. + #[serde(default)] + project_settings: BTreeMap, +} + +impl LanguageServerLaunchPlanRequest { + fn layers(&self) -> SettingLayers<'_> { + SettingLayers { + global: &self.settings, + project: &self.project_settings, + } + } + + /// The four writable directories a backend-resolved launch receives. + /// Each is required, because the answer may name paths in any of them. + fn backend_launch_directories( + &self, + ) -> Validation> { + fn required<'a>(value: Option<&'a String>, name: &str) -> Validation<&'a str> { + match value { + Some(directory) => launch_directory(directory, name), + None => Err(PluginRejection::new( + "plugin.launch.invalidRequest", + format!("The launch request has no usable {name}."), + )), + } + } + Ok(lithe_plugin_language_servers::LaunchDirectories { + state: launch_directory(&self.state_directory, "stateDirectory")?, + workspace_state: required( + self.workspace_state_directory.as_ref(), + "workspaceStateDirectory", + )?, + log: required(self.log_directory.as_ref(), "logDirectory")?, + temporary: required(self.temporary_directory.as_ref(), "temporaryDirectory")?, + }) + } +} + +/// Validated substitution values of one launch plan. +struct LaunchValues<'a> { + plugin_directory: &'a str, + state_directory: &'a str, + workspace_state_directory: &'a str, + log_directory: &'a str, + architecture: &'a str, + /// Effective value of every declared setting. + settings: &'a BTreeMap, +} + +/// What a launch plan needs from the host besides the command line. +#[derive(Default)] +struct LaunchRequirements { + /// Package-relative files or directories the templates point at. + package_paths: BTreeSet, + uses_state_directory: bool, + uses_workspace_state_directory: bool, + uses_log_directory: bool, +} + +pub(crate) fn language_server_launch_plan( + manifest: &DeclarativePluginManifest, + language_id: &str, + request: &LanguageServerLaunchPlanRequest, +) -> Validation { + let server = manifest + .language_servers + .iter() + .find(|server| server.language_id == language_id) + .ok_or_else(|| { + PluginRejection::new( + "plugin.launch.unavailable", + format!("The plugin does not provide a language server for {language_id}."), + ) + })?; + let mut plan = match &server.process.launch { + LanguageServerLaunch::Backend { + function, + timeout_milliseconds, + } => backend_resolved_launch_plan( + manifest, + &server.language_id, + function, + *timeout_milliseconds, + request, + )?, + _ => server_launch_plan(manifest, &server.process, request)?, + }; + if let Some(configuration) = &server.workspace_configuration { + plan["workspaceConfiguration"] = expand_configuration( + configuration, + &manifest.effective_settings(request.layers()), + ); + } + // The engine enforces the session fields. + plan["sessionPolicy"] = server.session.policy(&server.language_id); + Ok(plan) +} + +/// The first step of a language server whose backend resolves its launch: +/// the function to call and its params. The host calls +/// the function and passes the answer to `plugins.languageServers.decodeLaunch`, +/// whose plan it then starts like any other. +fn backend_resolved_launch_plan( + manifest: &DeclarativePluginManifest, + server_id: &str, + function: &str, + timeout_milliseconds: Option, + request: &LanguageServerLaunchPlanRequest, +) -> Validation { + let directories = request.backend_launch_directories()?; + if !KNOWN_ARCHITECTURES.contains(&request.architecture.as_str()) { + return Err(PluginRejection::new( + "plugin.launch.invalidRequest", + format!( + "The architecture {} is not supported.", + request.architecture + ), + )); + } + let settings = manifest.effective_settings(request.layers()); + let mut plan = json!({ + "owner": {"pluginID":manifest.id,"version":manifest.version}, + "launch": { + "kind": "backend", + "function": function, + "timeoutMilliseconds": timeout_milliseconds + .unwrap_or(lithe_plugin_model::process::RESOLVE_TIMEOUT_MILLISECONDS), + }, + "resolveParams": lithe_plugin_language_servers::resolve_params( + server_id, + &request.architecture, + &directories, + &settings, + ), + "arguments": [], + "validationArguments": [], + "runtimeRequirements": [], + "requiredDirectories": ["state", "workspaceState", "log", "temporary"], + "requiredPackagePaths": [], + }); + if !settings.is_empty() { + plan["settings"] = json!(settings); + } + Ok(plan) +} + +/// `plugins.languageServers.decodeLaunch`: checks a launch function's answer +/// against the server's declaration and the directories of this start, and +/// returns the plan the host starts. A server whose launch is not resolved +/// by the backend is refused, so a host never runs an answer it did not ask +/// for. +pub fn decode_language_server_launch_command(payload: Value) -> Result { + const COMMAND: &str = "plugins.languageServers.decodeLaunch"; + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Payload { + manifest: Value, + #[serde(rename = "languageID")] + language_id: String, + request: LanguageServerLaunchPlanRequest, + result: Value, + } + let payload: Payload = decode_payload(payload, COMMAND)?; + Ok(outcome(validate_manifest_value(payload.manifest).and_then( + |manifest| { + let server = manifest + .language_servers + .iter() + .find(|server| server.language_id == payload.language_id) + .ok_or_else(|| { + PluginRejection::new( + "plugin.launch.unavailable", + format!( + "The plugin does not provide a language server for {}.", + payload.language_id + ), + ) + })?; + if !server.process.is_backend_launch() { + return Err(PluginRejection::new( + "plugin.launch.notBackend", + format!( + "The language server for {} is not launched by the plugin backend.", + payload.language_id + ), + )); + } + let directories = payload.request.backend_launch_directories()?; + let tool_ids: BTreeSet<&str> = manifest + .tool_definitions + .iter() + .filter_map(|definition| definition["id"].as_str()) + .collect(); + let (mut plan, gates) = lithe_plugin_language_servers::decode_answer( + payload.result, + &directories, + &tool_ids, + )?; + let settings = manifest.effective_settings(payload.request.layers()); + if !settings.is_empty() { + plan["settings"] = json!(settings); + } + if plan.get("workspaceConfiguration").is_none() { + if let Some(configuration) = &server.workspace_configuration { + plan["workspaceConfiguration"] = expand_configuration(configuration, &settings); + } + } + let mut policy = server.session.policy(&server.language_id); + policy["gates"] = json!(gates); + plan["sessionPolicy"] = policy; + Ok(("plan", plan)) + }, + ))) +} + +/// Expands a validated `workspaceConfiguration` with effective setting +/// values. A string that is exactly one setting keeps the setting's JSON type. +pub(crate) fn expand_configuration(value: &Value, settings: &BTreeMap) -> Value { + match value { + Value::String(text) => { + if let Some(id) = sole_setting_reference(text) { + return settings.get(&id).cloned().unwrap_or(Value::Null); + } + let parts = parse_template_with(text, LaunchVariable::parse).unwrap_or_default(); + Value::String( + parts + .into_iter() + .map(|part| match part { + TemplatePart::Text(text) => text, + TemplatePart::Variable(LaunchVariable::Setting(id)) => { + lithe_plugin_model::settings::setting_text(settings.get(&id)) + } + TemplatePart::Variable(_) => String::new(), + }) + .collect(), + ) + } + Value::Array(items) => Value::Array( + items + .iter() + .map(|item| expand_configuration(item, settings)) + .collect(), + ), + Value::Object(members) => Value::Object( + members + .iter() + .map(|(key, item)| (key.clone(), expand_configuration(item, settings))) + .collect(), + ), + other => other.clone(), + } +} + +pub(crate) fn server_launch_plan( + manifest: &DeclarativePluginManifest, + process: &ServerProcess, + request: &LanguageServerLaunchPlanRequest, +) -> Validation { + let effective_settings = manifest.effective_settings(request.layers()); + let values = LaunchValues { + plugin_directory: launch_directory(&request.plugin_directory, "pluginDirectory")?, + state_directory: launch_directory(&request.state_directory, "stateDirectory")?, + workspace_state_directory: match &request.workspace_state_directory { + Some(directory) => launch_directory(directory, "workspaceStateDirectory")?, + None if process.uses_workspace_state_directory() => { + return Err(PluginRejection::new( + "plugin.launch.invalidRequest", + "The launch request has no usable workspaceStateDirectory.", + )) + } + None => "", + }, + log_directory: match request.log_directory.as_deref() { + Some(directory) => { + let directory = launch_directory(directory, "logDirectory")?; + // Recognize absolute native paths on both platforms without using + // the build machine's Path::is_absolute semantics for the other OS. + let absolute = directory.starts_with('/') + || directory.starts_with("\\\\") + || (directory.as_bytes().get(1) == Some(&b':') + && directory + .as_bytes() + .first() + .is_some_and(u8::is_ascii_alphabetic) + && directory + .as_bytes() + .get(2) + .is_some_and(|b| b"/\\".contains(b))); + if !absolute { + return Err(PluginRejection::new( + "plugin.launch.invalidRequest", + "The launch request has no usable absolute logDirectory.", + )); + } + directory + } + None => "", + }, + architecture: &request.architecture, + settings: &effective_settings, + }; + if !KNOWN_ARCHITECTURES.contains(&values.architecture) { + return Err(PluginRejection::new( + "plugin.launch.invalidRequest", + format!("The architecture {} is not supported.", values.architecture), + )); + } + + let mut requirements = LaunchRequirements::default(); + let expand_all = |templates: &[String], requirements: &mut LaunchRequirements| { + templates + .iter() + .map(|value| expand_launch_template(value, &values, requirements)) + .collect::>() + }; + + let launch = match &process.launch { + // A tool definition owns how its executable starts an entry, so the + // plan hands the host the parts for `toolchains.instantiateLaunch`. + LanguageServerLaunch::Runtime { + tool, + minimum_version, + packaged_runtime, + runtime_arguments, + entry, + } => { + requirements.package_paths.insert(entry.clone()); + let mut launch = json!({ + "kind": "tool", + "tool": tool, + "entry": format!("{}/{entry}", values.plugin_directory), + "runtimeArguments": expand_all(runtime_arguments, &mut requirements), + }); + if let Some(version) = minimum_version { + launch["minimumVersion"] = json!(version); + } + if let Some(path) = packaged_runtime { + let path = path.replace("${arch}", values.architecture); + requirements.package_paths.insert(path.clone()); + launch["packagedRuntime"] = json!(path); + } + launch + } + LanguageServerLaunch::Packaged { path } => { + requirements.package_paths.insert(path.clone()); + json!({ "kind": "packaged", "path": path }) + } + LanguageServerLaunch::SystemCommand { commands } => { + json!({ "kind": "systemCommand", "commands": commands }) + } + // Validation admits a backend launch only for a language server, + // whose plan `backend_resolved_launch_plan` builds instead. + LanguageServerLaunch::Backend { .. } => { + return Err(PluginRejection::new( + "plugin.launch.unavailable", + "This process is launched by the plugin backend.", + )); + } + }; + let arguments = expand_all(&process.arguments, &mut requirements); + let validation_arguments = expand_all(&process.validation_arguments, &mut requirements); + + let mut plan = json!({ + "owner": {"pluginID":manifest.id,"version":manifest.version}, + "launch": launch, + "arguments": arguments, + "validationArguments": validation_arguments, + "runtimeRequirements": process.runtime_requirements(), + }); + if let Some(options) = &process.initialization_options { + plan["initializationOptions"] = expand_launch_value(options, &values, &mut requirements); + } + let mut directories = Vec::new(); + if requirements.uses_state_directory { + directories.push("state"); + } + if requirements.uses_workspace_state_directory { + directories.push("workspaceState"); + } + if !effective_settings.is_empty() { + plan["settings"] = json!(effective_settings); + } + if requirements.uses_log_directory { + if values.log_directory.is_empty() { + return Err(PluginRejection::new( + "plugin.launch.invalidRequest", + "The launch request has no usable logDirectory.", + )); + } + directories.push("log"); + } + plan["requiredDirectories"] = json!(directories); + plan["requiredPackagePaths"] = json!(requirements.package_paths); + Ok(plan) +} + +/// Accepts a host directory as substitution text. A trailing separator is +/// dropped so `${pluginDir}/x` never yields a doubled separator. +fn launch_directory<'a>(value: &'a str, name: &str) -> Validation<&'a str> { + let trimmed = value.trim_end_matches(['/', '\\']); + if trimmed.is_empty() || trimmed.contains('\0') { + return Err(PluginRejection::new( + "plugin.launch.invalidRequest", + format!("The launch request has no usable {name}."), + )); + } + Ok(trimmed) +} + +/// The setting ID when the whole template is exactly one `${setting:}`. +/// Such a value keeps the setting's JSON type, so an LSP option can receive a +/// number or a boolean rather than its text. +fn sole_setting_reference(template: &str) -> Option { + match parse_template_with(template, LaunchVariable::parse)?.as_slice() { + [TemplatePart::Variable(LaunchVariable::Setting(id))] => Some(id.clone()), + _ => None, + } +} + +fn expand_launch_value( + value: &Value, + values: &LaunchValues<'_>, + requirements: &mut LaunchRequirements, +) -> Value { + match value { + Value::String(text) => sole_setting_reference(text) + .and_then(|id| values.settings.get(&id).cloned()) + .unwrap_or_else(|| Value::String(expand_launch_template(text, values, requirements))), + Value::Array(items) => Value::Array( + items + .iter() + .map(|item| expand_launch_value(item, values, requirements)) + .collect(), + ), + Value::Object(members) => Value::Object( + members + .iter() + .map(|(key, item)| (key.clone(), expand_launch_value(item, values, requirements))) + .collect(), + ), + other => other.clone(), + } +} + +fn expand_launch_template( + value: &str, + values: &LaunchValues<'_>, + requirements: &mut LaunchRequirements, +) -> String { + // Validation guarantees the template parses and that the text after + // `${pluginDir}` is a `/`-rooted path inside the package. + let parts = parse_template_with(value, LaunchVariable::parse).unwrap_or_default(); + let mut expanded = String::new(); + let mut package_path_start = None; + for part in parts { + match part { + TemplatePart::Text(text) => expanded.push_str(&text), + TemplatePart::Variable(LaunchVariable::PluginDir) => { + expanded.push_str(values.plugin_directory); + package_path_start = Some(expanded.len() + 1); + } + TemplatePart::Variable(LaunchVariable::StateDir) => { + requirements.uses_state_directory = true; + expanded.push_str(values.state_directory); + } + TemplatePart::Variable(LaunchVariable::WorkspaceStateDir) => { + requirements.uses_workspace_state_directory = true; + expanded.push_str(values.workspace_state_directory); + } + TemplatePart::Variable(LaunchVariable::LogDir) => { + requirements.uses_log_directory = true; + expanded.push_str(values.log_directory); + } + TemplatePart::Variable(LaunchVariable::Arch) => expanded.push_str(values.architecture), + TemplatePart::Variable(LaunchVariable::Setting(id)) => { + expanded.push_str(&lithe_plugin_model::settings::setting_text( + values.settings.get(&id), + )); + } + } + } + if let Some(start) = package_path_start { + requirements + .package_paths + .insert(expanded[start..].trim_end_matches('/').to_string()); + } + expanded +} diff --git a/rust/crates/plugin-manifest/src/lib.rs b/rust/crates/plugin-manifest/src/lib.rs new file mode 100644 index 00000000..89abef8c --- /dev/null +++ b/rust/crates/plugin-manifest/src/lib.rs @@ -0,0 +1,61 @@ +//! Declarative plugin contract: manifest validation and deterministic +//! expansion of run/test command templates and language-server launch plans. +//! +//! A declarative plugin carries no host-loaded code. The host reads its +//! `plugin.json`, asks this module to validate and normalize it, and launches +//! the declared external processes itself. Both products must produce the +//! same outcome for the fixtures in `shared/plugins/contract/v1/`. +//! +//! Content problems in a manifest or request are reported as `rejected` +//! outcomes with a stable `plugin.*` code instead of Core failures, so hosts +//! can show them next to the affected plugin. Only a malformed command +//! payload is a Core `invalid_request` failure. + +use lithe_plugin_model::capabilities::{self, HostCapabilities, Requires}; +use lithe_plugin_model::process::{ + collect_setting_references, validate_launch_value, LanguageServerLaunch, LaunchVariable, + RuntimeTools, ServerProcess, ServerRole, +}; +use lithe_plugin_model::settings::{ + DeclaredSettings, ResolvedSetting, SettingAction, SettingDeclaration, SettingLayers, + MAX_SETTINGS, MAX_SETTING_ACTIONS, +}; +use lithe_plugin_model::{ + decode_payload, invalid, invalid_template, is_bare_command, is_identifier, + is_official_namespace, is_plugin_id, is_safe_relative_path, is_semantic_version, outcome, + parse_template_with, regex_literal, sorted_unique, PluginRejection, TemplatePart, UIIcon, + Validation, +}; +use lithe_protocol::{CoreError, ErrorCode}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet}; + +mod commands; +mod declarations; +mod launch; +mod manifest; +mod permissions; +mod provider_launch; +mod providers; +mod run_plans; +mod settings; +mod settings_surfaces; +mod templates; +mod ui; +mod validation; + +pub use commands::*; +pub use launch::*; +pub use manifest::*; +use permissions::*; +pub use provider_launch::*; +pub use run_plans::*; +pub use settings::*; +pub use settings_surfaces::{ + settings_catalog_command, settings_delivery_command, settings_navigation_command, +}; +use templates::*; +pub use validation::*; + +pub use lithe_plugin_model::capabilities::SUPPORTED_PLUGIN_API_VERSIONS; diff --git a/rust/crates/plugin-manifest/src/manifest.rs b/rust/crates/plugin-manifest/src/manifest.rs new file mode 100644 index 00000000..6fd4fce9 --- /dev/null +++ b/rust/crates/plugin-manifest/src/manifest.rs @@ -0,0 +1,468 @@ +//! The manifest model: `DeclarativePluginManifest`, the declarations it carries and +//! its accessors. + +use super::*; + +/// `plugin.json` schema version that identifies a declarative manifest. +pub const DECLARATIVE_MANIFEST_SCHEMA_VERSION: u32 = 0; + +/// Most tool definitions one plugin may require. +pub(crate) const MAX_REQUIRED_TOOLS: usize = 32; + +/// CPU architectures a host may report for `${arch}`. +pub(crate) const KNOWN_ARCHITECTURES: &[&str] = &["arm64", "x86_64"]; + +/// Root of a declarative `plugin.json`. Unknown fields are refused so a newer +/// manifest feature cannot be silently ignored by an older host. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DeclarativePluginManifest { + schema_version: u32, + pub(crate) id: String, + pub(crate) display_name: String, + pub(crate) version: String, + #[serde(rename = "pluginAPIVersion")] + plugin_api_version: u32, + /// Capability versions the manifest was written against; see + /// `lithe_plugin_model::capabilities`. + pub(crate) requires: Requires, + pub(crate) vendor: PluginVendor, + #[serde(default)] + pub(crate) permissions: Vec, + #[serde(default)] + pub(crate) activation_events: Vec, + #[serde(default)] + pub(crate) languages: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) ui: Option, + #[serde(default)] + pub(crate) language_servers: Vec, + #[serde(default)] + pub(crate) execution_providers: Vec, + /// User-adjustable options of the plugin; see `lithe_plugin_model::settings`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) settings: Vec, + /// Buttons on settings pages that invoke the plugin's functions. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) setting_actions: Vec, + /// Project-level build tools the plugin describes. The shape and its + /// validation live in `lithe_build`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) build_systems: Vec, + /// Version control systems the plugin implements. The shape and its + /// validation live in `lithe_vcs`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) vcs_providers: Vec, + /// Debug adapters the plugin implements. The shape and its validation + /// live in `lithe_debug::adapters`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) debug_adapters: Vec, + /// Entries of the New Project window. The shape and its validation live + /// in `lithe_project_generators`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) project_generators: Vec, + /// The plugin's own process serving its pages; see `lithe_plugin_backend`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) backend: Option, + /// Tool policies owned by plugins, never a host-maintained definition table. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) tool_definitions: Vec, + /// Fact providers served by the plugin's existing backend process. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) toolchain_providers: Vec, + /// Independent shortcut importers implemented by this plugin's backend. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) keymap_import_providers: Vec, + /// Tool-definition IDs the plugin's processes and Run requests name; + /// hosts resolve them through plugin-owned toolchains. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) requires_tools: Vec, + /// Patterns that turn console output into links; see + /// `lithe_plugin_output_links`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) output_link_matchers: Vec, + /// Functions this plugin exports to other plugins and the exported + /// functions it calls; see `lithe_plugin_functions`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) plugin_functions: Option, +} + +/// UI supplied by plugins. The host owns every window shell: docking, title, +/// activity bar, order, visibility, layout and lifecycle. A window body is +/// text, web content that the host loads from the package into a sandboxed +/// web view inside that shell, or host-rendered native content. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct UIContributions { + #[serde(default)] + pub(crate) tool_windows: Vec, + #[serde(default)] + pub(crate) commands: Vec, +} + +/// A stable tool-window identity and plain-text content rendered by the host. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct UIToolWindow { + pub(crate) id: String, + pub(crate) title: String, + /// Initial dock only; saved user layout takes precedence. + placement: UIDock, + /// Sort rank within the dock; ties fall back to the identifier. + #[serde(default)] + pub(crate) order: i32, + /// Plain text shown in the window body; the host never interprets markup. + /// Exactly one of `text` and `web`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) text: Option, + /// The plugin's own page shown in the window body. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) web: Option, + /// Host-rendered tree/list content. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) native: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) icon: Option, +} + +/// A page from the plugin package. The host serves only package files to it, +/// grants no network, navigation or storage, and talks to it through the +/// plugin web bridge, which reaches only this plugin's own declarations. +/// +/// Note: the decision is recorded in .agents/notes/implemented/architecture/2026-10-18-plugin-web-content-in-host-windows.md +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct UIWebContent { + /// Package-relative HTML entry, e.g. `ui/index.html`. + pub(crate) entry: String, +} + +/// Edge of the workbench where a tool window first docks. +#[derive(Debug, Clone, Copy, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +enum UIDock { + Left, + Bottom, + Right, +} + +/// The initial controlled action opens one of this plugin's own windows. +/// Commands cannot invoke arbitrary host actions or execute a process. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct UICommand { + pub(crate) id: String, + pub(crate) title: String, + /// Absent for a command the plugin backend handles. + #[serde( + default, + rename = "toolWindowID", + skip_serializing_if = "Option::is_none" + )] + pub(crate) tool_window_id: Option, + #[serde(default)] + pub(crate) order: i32, + pub(crate) placements: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) icon: Option, +} + +/// Host surface that may list a plugin command. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum UICommandPlacement { + CommandPalette, + Toolbar, + Menu, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PluginVendor { + pub(crate) id: String, + pub(crate) display_name: String, + pub(crate) signature_requirement: SignatureRequirement, + /// Optional trust for the host's sealed built-in copy only. Installation + /// from every other origin still requires the primary publisher signature. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) bundled_signature_requirement: Option, +} + +/// How a package proves its origin. `sameTeamAsHost` is only honored by a +/// host for packages shipped inside its own signed installation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum SignatureRequirement { + SameTeamAsHost, + PublisherPackage, + /// Community package without a Lithe signature. Its integrity comes from + /// the marketplace index checksum and its trust from the user's explicit + /// confirmation, so the official namespace can never use it. + None, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct LanguageDeclaration { + pub(crate) id: String, + pub(crate) display_name: String, + #[serde(default)] + pub(crate) file_extensions: Vec, + #[serde(default)] + pub(crate) file_names: Vec, + #[serde(default)] + pub(crate) project_file_names: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) dependencies: Option, + /// Syntax highlighting from a package file; see `lithe_plugin_grammar`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) grammar: Option, + /// Comment and bracket behavior of the editor. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) configuration: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct DependencyDeclaration { + #[serde(default)] + pub(crate) management_file_names: Vec, + #[serde(default)] + pub(crate) project_dependency_paths: Vec, + #[serde(default)] + pub(crate) project_binary_paths: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct LanguageServerDeclaration { + #[serde(rename = "languageID")] + pub(crate) language_id: String, + /// LSP `languageId` sent for documents; defaults to the language ID. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) language_identifier: Option, + #[serde(flatten)] + pub(crate) process: ServerProcess, + /// Reserved. Core implements no host integration: a language server's + /// run, test and debug support comes from the plugin's own backend and + /// providers, so any value is refused with `plugin.integration.unsupported` + /// instead of loading without it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) host_integration: Option, + /// Settings the server reads through `workspace/configuration` and + /// receives again in `workspace/didChangeConfiguration` whenever one of + /// the settings it names changes. String values are templates like + /// `initializationOptions`, and may also name settings whose `effect` + /// is `immediate`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) workspace_configuration: Option, + /// Fields that govern what the plugin backend may send to its own server + /// and how the host keeps the server's state. + #[serde(flatten)] + pub(crate) session: lithe_plugin_language_servers::SessionDeclaration, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ExecutionProviderDeclaration { + #[serde(rename = "languageID")] + pub(crate) language_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) run: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) test: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct RunTemplate { + pub(crate) executable: ExecutableDeclaration, + pub(crate) arguments: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct TestTemplate { + #[serde(rename = "frameworkID")] + pub(crate) framework_id: String, + pub(crate) workspace_label: String, + pub(crate) project_markers: Vec, + pub(crate) test_file_patterns: Vec, + pub(crate) executable: ExecutableDeclaration, + pub(crate) workspace_arguments: Vec, + pub(crate) file_arguments: Vec, + pub(crate) test_case_arguments: Vec, +} + +/// Exactly one of a bare command resolved on the host's tool path or a +/// host-owned project toolchain ID such as `project-go`. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ExecutableDeclaration { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) command: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) toolchain: Option, +} + +/// One argv element. A conditional element is emitted only when the request +/// selects (or does not select) a file. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(untagged)] +pub(crate) enum ArgumentTemplate { + Literal(String), + Conditional { + value: String, + when: ArgumentCondition, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum ArgumentCondition { + FileSelected, + NoFileSelected, +} + +impl DeclarativePluginManifest { + /// Stable plugin ID. + pub fn id(&self) -> &str { + &self.id + } + + /// Package-relative HTML entry of this plugin's web tool window + /// `window_id`, or `None` when the plugin declares no such web window. + pub fn web_window_entry(&self, window_id: &str) -> Option<&str> { + self.ui + .as_ref()? + .tool_windows + .iter() + .find(|window| window.id == window_id)? + .web + .as_ref() + .map(|web| web.entry.as_str()) + } + + /// Plugin version, `major.minor.patch`. + pub fn version(&self) -> &str { + &self.version + } + + /// The version of `capability` the manifest requires. Validation has + /// already checked that every used capability is listed. + pub(crate) fn capability_version(&self, capability: &str) -> u32 { + self.requires.get(capability).copied().unwrap_or_default() + } + + /// Normalized, sorted permission list. + pub fn permissions(&self) -> &[String] { + &self.permissions + } + + /// Capability versions the manifest requires. + pub fn requires(&self) -> &Requires { + &self.requires + } + + /// Host services the plugin's backend may request, sorted; empty without + /// a backend. Each is granted by one permission. + pub(crate) fn backend_host_services(&self) -> Vec<&'static str> { + let mut services = Vec::new(); + if self.backend.is_none() { + return services; + } + if self.permissions.iter().any(|p| p == PERMISSION_CREDENTIALS) { + services.push(lithe_plugin_backend::HOST_SERVICE_CREDENTIALS); + } + if self + .permissions + .iter() + .any(|p| p == PERMISSION_WORKSPACE_METADATA) + { + services.push(lithe_plugin_backend::HOST_SERVICE_WORKSPACE); + } + for permission in &self.permissions { + services.extend(lithe_plugin_rpc::methods_for_permission(permission)); + } + // Calling another plugin's exported function takes no permission: the + // declaration, and the receiver's own export, are the consent. The + // service lets the backend ask Core to find and call a provider. + if self + .plugin_functions + .as_ref() + .is_some_and(lithe_plugin_functions::PluginFunctionsDeclaration::calls_any) + { + services.push(lithe_plugin_rpc::HOST_SERVICE_FUNCTIONS); + } + services.sort_unstable(); + services.dedup(); + services + } +} + +impl DeclarativePluginManifest { + /// Every program the host may start on this plugin's behalf, in a stable + /// order. Each entry carries everything that decides what actually runs: + /// the executable plus its arguments, the language server's validation + /// probe, and the run/test argument templates. An update that changes any + /// of these, even with the same executable, is a new program and must be + /// approved again. + pub fn programs(&self) -> Vec { + let mut programs = Vec::new(); + for server in &self.language_servers { + let mut program = server.process.program(ServerRole::LanguageServer); + program["languageID"] = json!(server.language_id); + programs.push(program); + programs.extend(server.process.runtime_programs()); + } + for provider in &self.execution_providers { + if let Some(run) = &provider.run { + programs.push(json!({ + "kind": "run", + "languageID": provider.language_id, + "executable": run.executable, + "arguments": run.arguments, + })); + } + if let Some(test) = &provider.test { + programs.push(json!({ + "kind": "test", + "languageID": provider.language_id, + "executable": test.executable, + "workspaceArguments": test.workspace_arguments, + "fileArguments": test.file_arguments, + "testCaseArguments": test.test_case_arguments, + })); + } + } + for build_system in &self.build_systems { + programs.extend(lithe_build::programs(build_system)); + } + for provider in &self.vcs_providers { + programs.extend(lithe_vcs::programs(provider)); + } + for adapter in &self.debug_adapters { + programs.extend(lithe_debug::adapters::programs(adapter)); + } + for generator in &self.project_generators { + programs.extend(lithe_project_generators::programs(generator)); + } + if let Some(backend) = &self.backend { + programs.extend(lithe_plugin_backend::programs(backend)); + } + // Order by kind, then language or tool, then the full serialized + // value as a total tie-breaker; duplicates (two servers needing the + // same runtime) collapse to one entry. + programs.sort_by_key(|program| { + let text = |key: &str| program[key].as_str().unwrap_or_default().to_string(); + ( + text("kind"), + text("languageID"), + text("tool"), + program.to_string(), + ) + }); + programs.dedup(); + programs + } +} diff --git a/rust/crates/plugin-manifest/src/permissions.rs b/rust/crates/plugin-manifest/src/permissions.rs new file mode 100644 index 00000000..ebe19969 --- /dev/null +++ b/rust/crates/plugin-manifest/src/permissions.rs @@ -0,0 +1,261 @@ +//! Permission names, the set the contract knows, and the checks that tie each +//! declared contribution to its permission. + +use super::*; + +const PERMISSION_LANGUAGE_SERVER: &str = "languageServer.launch"; + +const PERMISSION_RUN: &str = "process.run"; + +const PERMISSION_TEST: &str = "process.test"; + +const PERMISSION_UI: &str = "ui.contribute"; + +/// Running the plugin's own scripts in a host-managed web view. +const PERMISSION_UI_WEB: &str = "ui.web"; + +/// Running a declared build system's executable: model, task, test, and +/// dependency commands. +const PERMISSION_BUILD: &str = "process.build"; + +/// Letting a VCS provider change the working copy and contact its server. +const PERMISSION_VCS: &str = "process.vcs"; + +/// Creating projects: writing template files, running scaffolding tools, +/// and downloading project archives. +const PERMISSION_PROJECT_GENERATE: &str = "project.generate"; + +/// Starting the plugin's own backend process. +const PERMISSION_BACKEND: &str = "backend.launch"; + +/// Supplying facts that can change the executable selected for a defined tool. +const PERMISSION_TOOLCHAINS: &str = "toolchains.provide"; + +/// Providing normalized shortcuts during an explicit import. +const PERMISSION_KEYMAPS: &str = "keymaps.provide"; + +/// Reading declared files from the explicitly selected import source. +const PERMISSION_KEYMAP_SOURCES: &str = "keymaps.readSelectedSources"; + +/// Letting the backend keep secrets in the host's credential store, private +/// to the plugin. +pub(crate) const PERMISSION_CREDENTIALS: &str = "credentials.store"; + +/// Letting the backend list the open workspaces and read their folders and +/// its per-workspace state directory. +pub(crate) const PERMISSION_WORKSPACE_METADATA: &str = "workspace.metadata"; + +/// Starting a debug session the plugin's adapter serves. +const PERMISSION_DEBUG_LAUNCH: &str = "debug.launch"; + +/// Attaching the plugin's adapter to a running process. +const PERMISSION_DEBUG_ATTACH: &str = "debug.attach"; + +/// Letting the backend start a debug session on one of its own adapters +/// (`lithe/host/debug/start`), a host-method permission of the backend. +const PERMISSION_DEBUG_START: &str = "debug.start"; + +const KNOWN_PERMISSIONS: &[&str] = &[ + PERMISSION_BACKEND, + PERMISSION_BUILD, + PERMISSION_CREDENTIALS, + PERMISSION_DEBUG_ATTACH, + PERMISSION_DEBUG_LAUNCH, + PERMISSION_LANGUAGE_SERVER, + PERMISSION_KEYMAPS, + PERMISSION_KEYMAP_SOURCES, + PERMISSION_PROJECT_GENERATE, + PERMISSION_RUN, + PERMISSION_TEST, + PERMISSION_TOOLCHAINS, + PERMISSION_UI, + PERMISSION_UI_WEB, + PERMISSION_VCS, + PERMISSION_WORKSPACE_METADATA, +]; + +impl DeclarativePluginManifest { + pub(crate) fn validate_permissions(&mut self) -> Validation<()> { + for permission in &self.permissions { + if !KNOWN_PERMISSIONS.contains(&permission.as_str()) + && !lithe_plugin_rpc::HOST_PERMISSIONS.contains(&permission.as_str()) + { + return Err(PluginRejection::at( + "plugin.permission.unknown", + "permissions", + format!("The plugin requests an unknown permission: {permission}."), + )); + } + } + self.permissions = sorted_unique(std::mem::take(&mut self.permissions)); + let mut required = BTreeSet::new(); + if let Some(ui) = &self.ui { + required.insert(PERMISSION_UI); + if ui.tool_windows.iter().any(|window| window.web.is_some()) { + required.insert(PERMISSION_UI_WEB); + } + } + // A provider process of its own is a server process like a language + // server, so it needs the same consent. + if !self.language_servers.is_empty() + || !self.vcs_providers.is_empty() + || self + .build_systems + .iter() + .any(lithe_build::BuildSystemDeclaration::starts_own_provider) + { + required.insert(PERMISSION_LANGUAGE_SERVER); + } + for provider in &self.execution_providers { + if provider.run.is_some() { + required.insert(PERMISSION_RUN); + } + if provider.test.is_some() { + required.insert(PERMISSION_TEST); + } + } + if !self.build_systems.is_empty() { + required.insert(PERMISSION_BUILD); + } + if !self.toolchain_providers.is_empty() { + required.insert(PERMISSION_TOOLCHAINS); + } else if self + .permissions + .iter() + .any(|permission| permission == PERMISSION_TOOLCHAINS) + { + return Err(invalid( + "permissions", + "toolchains.provide requires a toolchain provider.", + )); + } + if !self.keymap_import_providers.is_empty() { + required.insert(PERMISSION_KEYMAPS); + required.insert(PERMISSION_KEYMAP_SOURCES); + } else if self.permissions.iter().any(|permission| { + permission == PERMISSION_KEYMAPS || permission == PERMISSION_KEYMAP_SOURCES + }) { + return Err(invalid( + "permissions", + "Shortcut import permissions require a shortcut import provider.", + )); + } + if !self.vcs_providers.is_empty() { + required.insert(PERMISSION_VCS); + } + if !self.debug_adapters.is_empty() { + required.insert(PERMISSION_DEBUG_LAUNCH); + } + if lithe_debug::adapters::serves_attach(&self.debug_adapters) { + required.insert(PERMISSION_DEBUG_ATTACH); + } else if self + .permissions + .iter() + .any(|p| p == PERMISSION_DEBUG_ATTACH) + { + return Err(invalid( + "permissions", + "debug.attach is used by an adapter that serves attach; declare one or remove the permission.", + )); + } + if !self.project_generators.is_empty() { + required.insert(PERMISSION_PROJECT_GENERATE); + } + if self.backend.is_some() { + required.insert(PERMISSION_BACKEND); + } else if self.permissions.iter().any(|p| p == PERMISSION_CREDENTIALS) { + // Only a backend can use the store; listing the permission + // without one would show the user a grant nothing exercises. + return Err(invalid( + "permissions", + "credentials.store is used by a plugin backend; declare one or remove the permission.", + )); + } + // Only the plugin backend receives workspace contexts, so the grant + // the user reviewed would otherwise never be exercised. + if self + .permissions + .iter() + .any(|p| p == PERMISSION_WORKSPACE_METADATA) + && self.backend.is_none() + { + return Err(invalid( + "permissions", + "workspace.metadata is used by the plugin backend; declare one or remove the permission.", + )); + } + if self + .permissions + .iter() + .any(|p| lithe_plugin_rpc::HOST_PERMISSIONS.contains(&p.as_str())) + && self.backend.is_none() + { + return Err(invalid( + "permissions", + "Host API permissions require the plugin backend.", + )); + } + // Actions and providers both read the live document they annotate. + if self + .permissions + .iter() + .any(|permission| permission == "editor.actions" || permission == "editor.providers") + && !self + .permissions + .iter() + .any(|permission| permission == "documents.read") + { + return Err(invalid( + "permissions", + "Editor actions and providers require documents.read for live snapshots.", + )); + } + // `lithe/host/debug/start` names one of the plugin's own adapters, so + // the grant is useless without one. + if self + .permissions + .iter() + .any(|permission| permission == PERMISSION_DEBUG_START) + && self.debug_adapters.is_empty() + { + return Err(invalid( + "permissions", + "debug.start starts the plugin's own debug adapters; declare one or remove the permission.", + )); + } + // The backend reaches only the servers whose session fields expose + // them; the grant without one would show the user a capability + // nothing exercises. + let language_server_request = lithe_plugin_rpc::PERMISSION_LANGUAGE_SERVER_REQUEST; + if self + .language_servers + .iter() + .any(|server| server.session.exposes_server()) + { + required.insert(language_server_request); + } else if self + .permissions + .iter() + .any(|permission| permission == language_server_request) + { + return Err(invalid( + "permissions", + "languageServer.request is used by a server that lists requestMethods or forwardNotifications; declare one or remove the permission.", + )); + } + for permission in required { + if !self + .permissions + .iter() + .any(|declared| declared == permission) + { + return Err(PluginRejection::at( + "plugin.permission.undeclared", + "permissions", + format!("The plugin uses {permission} without declaring the permission."), + )); + } + } + Ok(()) + } +} diff --git a/rust/crates/plugin-manifest/src/provider_launch.rs b/rust/crates/plugin-manifest/src/provider_launch.rs new file mode 100644 index 00000000..7b1a82fa --- /dev/null +++ b/rust/crates/plugin-manifest/src/provider_launch.rs @@ -0,0 +1,296 @@ +//! Launch plans of the backends behind build, VCS and debug providers, and the debug +//! adapters' start arguments. + +use super::*; + +/// The launch plan of a build system's provider. A provider that shares a +/// language server gets that server's plan; `buildProvider.languageServer` +/// then tells the host to send build requests to the session it already +/// keeps for the language instead of starting a second process. +pub(crate) fn build_provider_launch_plan( + manifest: &DeclarativePluginManifest, + build_system_id: &str, + request: &LanguageServerLaunchPlanRequest, +) -> Validation { + let unavailable = |message: String| PluginRejection::new("plugin.launch.unavailable", message); + let build_system = manifest + .build_systems + .iter() + .find(|build_system| build_system.id == build_system_id) + .ok_or_else(|| { + unavailable(format!( + "The plugin declares no build system {build_system_id}." + )) + })?; + let provider = build_system.provider.as_ref().ok_or_else(|| { + unavailable(format!( + "The build system {build_system_id} has no provider process." + )) + })?; + let mut description = json!({ + "buildSystemID": build_system.id, + "executesProjectCode": provider.executes_project_code(), + "mayUseNetwork": provider.may_use_network(), + }); + let mut plan = match provider.process() { + lithe_build::ProviderProcess::LanguageServer(language_id) => { + description["languageServer"] = json!(language_id); + language_server_launch_plan(manifest, language_id, request)? + } + lithe_build::ProviderProcess::Own(process) => { + server_launch_plan(manifest, process, request)? + } + }; + plan["buildProvider"] = description; + Ok(plan) +} + +/// The launch plan of a VCS provider. `vcsProvider` repeats what the host +/// needs to resolve the client tool and to check the provider's answer in +/// `initialize`, which must name the same VCS. +pub(crate) fn vcs_provider_launch_plan( + manifest: &DeclarativePluginManifest, + vcs_id: &str, + request: &LanguageServerLaunchPlanRequest, +) -> Validation { + let provider = manifest + .vcs_providers + .iter() + .find(|provider| provider.id == vcs_id) + .ok_or_else(|| { + PluginRejection::new( + "plugin.launch.unavailable", + format!("The plugin declares no VCS provider {vcs_id}."), + ) + })?; + let mut plan = server_launch_plan(manifest, &provider.process, request)?; + plan["vcsProvider"] = provider.descriptor(&manifest.id); + Ok(plan) +} + +/// The launch plan of the plugin's backend. `backend.hostServices` lists the +/// host services its permissions grant, which the host passes to +/// `lsp.startServer` so Core admits only those requests. +pub(crate) fn backend_launch_plan( + manifest: &DeclarativePluginManifest, + request: &LanguageServerLaunchPlanRequest, +) -> Validation { + let backend = manifest.backend.as_ref().ok_or_else(|| { + PluginRejection::new( + "plugin.launch.unavailable", + "The plugin declares no backend.", + ) + })?; + let mut plan = server_launch_plan(manifest, &backend.process, request)?; + plan["backend"] = lithe_plugin_backend::plan_descriptor( + manifest.capability_version(capabilities::BACKEND), + &manifest.backend_host_services(), + ); + Ok(plan) +} + +/// The launch plan of a debug adapter. `debugAdapter` repeats what the host +/// needs besides the process: the transport and what to show. An adapter on +/// the runtime Run selected has no process of its own to plan yet. An adapter +/// inside the plugin's language server plans that server, as a build provider +/// on a shared server does. +pub(crate) fn debug_adapter_launch_plan( + manifest: &DeclarativePluginManifest, + adapter_id: &str, + request: &LanguageServerLaunchPlanRequest, +) -> Validation { + use lithe_debug::adapters::DebugProcess; + let adapter = manifest + .debug_adapters + .iter() + .find(|adapter| adapter.id == adapter_id) + .ok_or_else(|| { + PluginRejection::new( + "plugin.launch.unavailable", + format!("The plugin declares no debug adapter {adapter_id}."), + ) + })?; + let mut plan = match &adapter.launch.process { + Some(DebugProcess::Server(process)) => server_launch_plan(manifest, process, request)?, + Some(DebugProcess::SelectedRuntime(_)) => { + return Err(PluginRejection::new( + "plugin.launch.unavailable", + format!("The debug adapter {adapter_id} starts on the selected runtime."), + )); + } + None => { + let language_id = adapter + .launch + .endpoint + .as_ref() + .and_then(|endpoint| endpoint.language_id.as_deref()) + .unwrap_or_default(); + hosting_language_server_plan(manifest, language_id, request)? + } + }; + plan["debugAdapter"] = adapter.plan_descriptor(&manifest.id); + Ok(plan) +} + +/// The plan of the language server a `tcpLanguageServer` adapter runs in. +/// The server's own session starts it; this plan only names the server the +/// adapter belongs to. A server the backend launches is +/// asked by that session with the scratch directories of its own start, so +/// here its plan names the function and demands no directory. +fn hosting_language_server_plan( + manifest: &DeclarativePluginManifest, + language_id: &str, + request: &LanguageServerLaunchPlanRequest, +) -> Validation { + let server = manifest + .language_servers + .iter() + .find(|server| server.language_id == language_id) + .ok_or_else(|| { + PluginRejection::new( + "plugin.launch.unavailable", + format!("The plugin does not provide a language server for {language_id}."), + ) + })?; + match &server.process.launch { + LanguageServerLaunch::Backend { + function, + timeout_milliseconds, + } => Ok(json!({ + "launch": { + "kind": "backend", + "function": function, + "timeoutMilliseconds": timeout_milliseconds + .unwrap_or(lithe_plugin_model::process::RESOLVE_TIMEOUT_MILLISECONDS), + }, + "arguments": [], + "validationArguments": [], + "runtimeRequirements": [], + "requiredDirectories": [], + "requiredPackagePaths": [], + })), + _ => language_server_launch_plan(manifest, language_id, request), + } +} + +/// `debug.adapters.startArguments`: checks one start against the adapter's +/// declaration and returns the DAP arguments its configuration defaults and +/// target bindings give. +pub fn debug_start_arguments_command(payload: Value) -> Result { + const COMMAND: &str = "debug.adapters.startArguments"; + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Payload { + manifest: Value, + #[serde(rename = "adapterID")] + adapter_id: String, + #[serde(flatten)] + start: lithe_debug::adapters::StartRequest, + } + let payload: Payload = decode_payload(payload, COMMAND)?; + let started = validate_manifest_value(payload.manifest).and_then(|manifest| { + let adapter = manifest + .debug_adapters + .iter() + .find(|adapter| adapter.id == payload.adapter_id) + .ok_or_else(|| { + PluginRejection::new( + "plugin.launch.unavailable", + format!( + "The plugin declares no debug adapter {}.", + payload.adapter_id + ), + ) + })?; + lithe_debug::adapters::start_arguments(adapter, &payload.start) + }); + // `connection` is present only for a remote target, so a host that never + // attaches to one can ignore it. + let connection = started + .as_ref() + .ok() + .and_then(|started| started.connection.clone()); + let mut result = + outcome(started.map(|started| ("arguments", Value::Object(started.arguments)))); + if let Some(connection) = connection { + result["connection"] = json!(connection); + } + Ok(result) +} + +/// `debug.adapters.executablePlan`: where a host looks for the program a +/// launch debugs, when the adapter's configuration binds `program` to +/// `target.executable`. `plan` is `null` for an adapter with no such lookup. +/// The host owns the file system, so it checks `configured` and then each +/// candidate; Core only says what to look at and in which order. +pub fn debug_executable_plan_command(payload: Value) -> Result { + const COMMAND: &str = "debug.adapters.executablePlan"; + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Payload { + manifest: Value, + #[serde(rename = "adapterID")] + adapter_id: String, + #[serde(default)] + settings: BTreeMap, + #[serde(default)] + project_settings: BTreeMap, + /// Absolute root of the open project. + #[serde(default)] + workspace: Option, + /// Absolute path of the source file being debugged, when there is one. + #[serde(default)] + target_path: Option, + } + let payload: Payload = decode_payload(payload, COMMAND)?; + Ok(outcome(validate_manifest_value(payload.manifest).and_then( + |manifest| { + let adapter = manifest + .debug_adapters + .iter() + .find(|adapter| adapter.id == payload.adapter_id) + .ok_or_else(|| { + PluginRejection::new( + "plugin.launch.unavailable", + format!( + "The plugin declares no debug adapter {}.", + payload.adapter_id + ), + ) + })?; + let Some(lookup) = adapter + .configurations + .as_ref() + .and_then(|configurations| configurations.launch.as_ref()) + .and_then(|configuration| configuration.executable.as_ref()) + else { + return Ok(("plan", Value::Null)); + }; + let layers = SettingLayers { + global: &payload.settings, + project: &payload.project_settings, + }; + let configured = manifest + .resolve_settings(layers) + .into_iter() + .find(|setting| setting.id == lookup.setting) + .and_then(|setting| match &setting.value { + Value::String(path) if !path.is_empty() => { + Some(json!({"path": path, "source": setting.source})) + } + _ => None, + }); + Ok(( + "plan", + json!({ + "setting": lookup.setting, + "configured": configured, + "candidates": lookup.expanded_candidates( + payload.workspace.as_deref(), + payload.target_path.as_deref(), + ), + }), + )) + }, + ))) +} diff --git a/rust/crates/plugin-manifest/src/providers.rs b/rust/crates/plugin-manifest/src/providers.rs new file mode 100644 index 00000000..451c11f0 --- /dev/null +++ b/rust/crates/plugin-manifest/src/providers.rs @@ -0,0 +1,105 @@ +//! Trait implementations through which the domain crates read a validated manifest. + +use super::*; + +impl lithe_plugin_model::PluginManifest for DeclarativePluginManifest { + fn validate(value: Value) -> Validation { + validate_manifest_value(value) + } + + fn id(&self) -> &str { + &self.id + } +} + +impl lithe_plugin_keymap_import::KeymapImportManifest for DeclarativePluginManifest { + fn keymap_import_providers(&self) -> &[lithe_plugin_keymap_import::ProviderDeclaration] { + &self.keymap_import_providers + } +} + +impl lithe_vcs::VcsManifest for DeclarativePluginManifest { + fn vcs_providers(&self) -> &[lithe_vcs::VcsProviderDeclaration] { + &self.vcs_providers + } +} + +impl lithe_build::BuildManifest for DeclarativePluginManifest { + /// Validated build systems, sorted by ID. + fn build_systems(&self) -> &[lithe_build::BuildSystemDeclaration] { + &self.build_systems + } + + /// Tool-definition IDs the plugin may start processes with, sorted. + fn required_tools(&self) -> &[String] { + &self.requires_tools + } + + /// Strict executable selection preserves invalid stored values instead of + /// applying the UI settings resolver's tolerant fallback-to-default rule. + fn executable_setting_value( + &self, + id: &str, + values: &BTreeMap, + ) -> Validation> { + let setting = self + .settings + .iter() + .find(|setting| setting.id == id) + .ok_or_else(|| { + invalid( + "executables.settingID", + "The executable setting is not declared.", + ) + })?; + setting.executable_value(values.get(id)) + } +} + +impl lithe_debug::DebugManifest for DeclarativePluginManifest { + fn debug_adapters(&self) -> &[lithe_debug::adapters::DebugAdapterDeclaration] { + &self.debug_adapters + } +} + +impl lithe_project_generators::GeneratorManifest for DeclarativePluginManifest { + fn display_name(&self) -> &str { + &self.display_name + } + + fn requires(&self) -> &Requires { + &self.requires + } + + fn project_generators(&self) -> &[lithe_project_generators::ProjectGeneratorDeclaration] { + &self.project_generators + } + + fn declares_language(&self, language_id: &str) -> bool { + self.languages + .iter() + .any(|language| language.id == language_id) + } + + fn declares_build_system(&self, build_system_id: &str) -> bool { + self.build_systems + .iter() + .any(|system| system.id == build_system_id) + } + + /// Whether the plugin declares a definition that `tool` names. A + /// definition is addressed by its short ID or as `:`; both + /// spellings count as a declaration here. Whether the host adopts the + /// short ID is its registry's decision: when several enabled plugins define + /// the same short ID, it belongs to none of them. + fn declares_tool(&self, tool: &str) -> bool { + // `Some(local)` when `tool` is spelled `:`. + let local_name = tool + .strip_prefix(self.id.as_str()) + .and_then(|rest| rest.strip_prefix(':')); + self.tool_definitions + .iter() + .filter_map(|definition| definition["id"].as_str()) + .any(|id| tool == id || local_name == Some(id)) + } +} diff --git a/rust/crates/plugin-manifest/src/run_plans.rs b/rust/crates/plugin-manifest/src/run_plans.rs new file mode 100644 index 00000000..ff06d2ae --- /dev/null +++ b/rust/crates/plugin-manifest/src/run_plans.rs @@ -0,0 +1,304 @@ +//! Run and test plans: expanding a provider's run and test templates for a project. + +use super::*; + +/// A run request for one workspace-relative file. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RunPlanRequest { + relative_file_path: String, + #[serde(default)] + arguments: Vec, +} + +/// A test discovery request over the workspace's relative file list. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TestDiscoveryRequest { + relative_project_file_paths: Vec, +} + +/// A test launch request for a workspace, file, or single test case. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TestPlanRequest { + scope: TestScope, + relative_project_file_paths: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +enum TestScope { + Workspace, + File { + #[serde(rename = "relativeFilePath")] + relative_file_path: String, + }, + TestCase { + identifier: String, + #[serde(default, rename = "relativeFilePath")] + relative_file_path: Option, + }, +} + +/// Values substituted into one command template. +struct ExpansionValues<'a> { + file: Option<&'a str>, + test_name: Option<&'a str>, + user_arguments: &'a [String], +} + +pub(crate) fn run_plan( + manifest: &DeclarativePluginManifest, + language_id: &str, + request: &RunPlanRequest, +) -> Validation { + let run = manifest + .execution_provider(language_id) + .and_then(|provider| provider.run.as_ref()) + .ok_or_else(|| unavailable(language_id, "run"))?; + let path = checked_path(&request.relative_file_path)?; + let arguments = expand( + &run.arguments, + &ExpansionValues { + file: Some(&path), + test_name: None, + user_arguments: &request.arguments, + }, + ); + Ok(json!({ + "executable": run.executable, + "arguments": arguments, + "workingDirectory": "." + })) +} + +pub(crate) fn discover_tests( + manifest: &DeclarativePluginManifest, + language_id: &str, + request: &TestDiscoveryRequest, +) -> Validation { + let test = test_template(manifest, language_id)?; + let paths = checked_paths(&request.relative_project_file_paths)?; + if !supports_project(test, &paths) { + return Ok(json!([])); + } + let mut files: Vec<&String> = paths + .iter() + .filter(|path| { + let name = file_name(path).to_lowercase(); + test.test_file_patterns + .iter() + .any(|pattern| matches_pattern(pattern, &name)) + }) + .collect(); + files.sort(); + files.dedup(); + let mut items = vec![json!({ + "id": format!("{language_id}:workspace"), + "label": test.workspace_label, + "kind": "workspace" + })]; + items.extend(files.into_iter().map(|path| { + json!({ + "id": format!("{language_id}:file:{path}"), + "label": path, + "kind": "file", + "relativeFilePath": path + }) + })); + Ok(Value::Array(items)) +} + +pub(crate) fn test_plan( + manifest: &DeclarativePluginManifest, + language_id: &str, + request: &TestPlanRequest, +) -> Validation { + let test = test_template(manifest, language_id)?; + let paths = checked_paths(&request.relative_project_file_paths)?; + if !supports_project(test, &paths) { + return Err(PluginRejection::new( + "plugin.test.unsupportedProject", + format!("The workspace is not a supported {language_id} test project."), + )); + } + let (label, arguments) = match &request.scope { + TestScope::Workspace => ( + test.workspace_label.clone(), + expand( + &test.workspace_arguments, + &ExpansionValues { + file: None, + test_name: None, + user_arguments: &[], + }, + ), + ), + TestScope::File { relative_file_path } => { + let path = checked_path(relative_file_path)?; + let arguments = expand( + &test.file_arguments, + &ExpansionValues { + file: Some(&path), + test_name: None, + user_arguments: &[], + }, + ); + (file_name(&path).to_string(), arguments) + } + TestScope::TestCase { + identifier, + relative_file_path, + } => { + let name = identifier.trim(); + if name.is_empty() || name.contains('\n') || name.contains('\r') { + return Err(PluginRejection::new( + "plugin.test.invalidIdentifier", + "The selected test identifier is invalid.", + )); + } + let path = relative_file_path + .as_deref() + .map(checked_path) + .transpose()?; + let arguments = expand( + &test.test_case_arguments, + &ExpansionValues { + file: path.as_deref(), + test_name: Some(name), + user_arguments: &[], + }, + ); + (name.to_string(), arguments) + } + }; + Ok(json!({ + "label": label, + "frameworkID": test.framework_id, + "executable": test.executable, + "arguments": arguments, + "workingDirectory": "." + })) +} + +fn test_template<'a>( + manifest: &'a DeclarativePluginManifest, + language_id: &str, +) -> Validation<&'a TestTemplate> { + manifest + .execution_provider(language_id) + .and_then(|provider| provider.test.as_ref()) + .ok_or_else(|| unavailable(language_id, "test")) +} + +fn supports_project(test: &TestTemplate, paths: &[String]) -> bool { + paths.iter().any(|path| { + test.project_markers + .contains(&file_name(path).to_lowercase()) + }) +} + +fn expand(arguments: &[ArgumentTemplate], values: &ExpansionValues<'_>) -> Vec { + let mut result = Vec::new(); + for argument in arguments { + match argument.condition() { + Some(ArgumentCondition::FileSelected) if values.file.is_none() => continue, + Some(ArgumentCondition::NoFileSelected) if values.file.is_some() => continue, + _ => {} + } + // Validation guarantees the template parses and that every variable + // it uses has a value in this context. + let parts = parse_template(argument.value()).unwrap_or_default(); + if parts == [TemplatePart::Variable(TemplateVariable::UserArguments)] { + result.extend(values.user_arguments.iter().cloned()); + continue; + } + let mut element = String::new(); + for part in parts { + match part { + TemplatePart::Text(text) => element.push_str(&text), + TemplatePart::Variable(TemplateVariable::File) => { + element.push_str(&command_path(values.file.unwrap_or_default())) + } + TemplatePart::Variable(TemplateVariable::FileDirectory) => { + element.push_str(&directory_argument(values.file.unwrap_or_default())) + } + TemplatePart::Variable(TemplateVariable::TestName) => { + element.push_str(values.test_name.unwrap_or_default()) + } + TemplatePart::Variable(TemplateVariable::TestNameRegex) => { + element.push_str(®ex_literal(values.test_name.unwrap_or_default())) + } + TemplatePart::Variable(TemplateVariable::UserArguments) => {} + } + } + result.push(element); + } + result +} + +/// A file name beginning with `-` would be parsed as an option by most +/// tools, so it is passed as an explicit relative path. +fn command_path(path: &str) -> String { + if path.starts_with('-') { + format!("./{path}") + } else { + path.to_string() + } +} + +/// The selected file's directory as a `./`-relative argument; `.` for a file +/// at the workspace root. +fn directory_argument(path: &str) -> String { + match path.rsplit_once('/') { + Some((directory, _)) if !directory.is_empty() => format!("./{directory}"), + _ => ".".to_string(), + } +} + +fn matches_pattern(pattern: &str, name: &str) -> bool { + let pieces: Vec<&str> = pattern.split('*').collect(); + if pieces.len() == 1 { + return pattern == name; + } + let (first, last) = (pieces[0], pieces[pieces.len() - 1]); + if !name.starts_with(first) || !name[first.len()..].ends_with(last) { + return false; + } + let mut remainder = &name[first.len()..name.len() - last.len()]; + for piece in &pieces[1..pieces.len() - 1] { + match remainder.find(piece) { + Some(index) => remainder = &remainder[index + piece.len()..], + None => return false, + } + } + true +} + +fn checked_path(value: &str) -> Validation { + // Paths are not trimmed: surrounding spaces are part of a real file name. + if is_safe_relative_path(value) { + Ok(value.to_string()) + } else { + Err(PluginRejection::new( + "plugin.path.invalid", + "A path must stay inside the current workspace.", + )) + } +} + +fn checked_paths(values: &[String]) -> Validation> { + values.iter().map(|value| checked_path(value)).collect() +} + +fn unavailable(language_id: &str, operation: &str) -> PluginRejection { + PluginRejection::new( + "plugin.execution.unavailable", + format!("The plugin does not provide {operation} support for {language_id}."), + ) +} + +fn file_name(path: &str) -> &str { + path.rsplit('/').next().unwrap_or(path) +} diff --git a/rust/crates/plugin-manifest/src/settings.rs b/rust/crates/plugin-manifest/src/settings.rs new file mode 100644 index 00000000..43f1006f --- /dev/null +++ b/rust/crates/plugin-manifest/src/settings.rs @@ -0,0 +1,275 @@ +//! Declared settings: validation, resolution against setting layers, and the +//! settings state, action, edit and workspace configuration commands. + +use super::*; + +impl DeclarativePluginManifest { + /// Checks the declared settings, sorts them by ID, and returns the IDs + /// launch templates may refer to. + pub(crate) fn validate_settings(&mut self) -> Validation { + self.validate_setting_actions()?; + if self.settings.is_empty() { + return Ok(DeclaredSettings::default()); + } + if self.settings.len() > MAX_SETTINGS { + return Err(invalid( + "settings", + "The plugin declares too many settings.", + )); + } + let mut ids = BTreeSet::new(); + for (index, setting) in self.settings.iter().enumerate() { + let field = format!("settings[{index}]"); + setting.validate(&field)?; + if !ids.insert(setting.id.clone()) { + return Err(invalid( + format!("{field}.id"), + "A setting is declared more than once.", + )); + } + } + self.settings.sort_by(|left, right| left.id.cmp(&right.id)); + Ok(lithe_plugin_model::settings::declared(&self.settings)) + } + + /// Checks the settings-page actions and sorts them by ID. + fn validate_setting_actions(&mut self) -> Validation<()> { + if self.setting_actions.is_empty() { + return Ok(()); + } + if self.setting_actions.len() > MAX_SETTING_ACTIONS { + return Err(invalid( + "settingActions", + "The plugin declares too many settings actions.", + )); + } + let mut ids = BTreeSet::new(); + for (index, action) in self.setting_actions.iter().enumerate() { + let field = format!("settingActions[{index}]"); + action.validate(&field)?; + if !ids.insert(action.id.clone()) { + return Err(invalid( + format!("{field}.id"), + "An action is declared more than once.", + )); + } + } + self.setting_actions + .sort_by(|left, right| left.id.cmp(&right.id)); + Ok(()) + } +} + +impl DeclarativePluginManifest { + /// Effective value of every declared setting and the layer it came from. + pub(crate) fn resolve_settings(&self, layers: SettingLayers<'_>) -> Vec { + lithe_plugin_model::settings::resolve(&self.settings, layers) + } + + /// Value of every declared setting for one launch; see + /// `lithe_plugin_model::settings::resolve` for the fallback order. + pub(crate) fn effective_settings(&self, layers: SettingLayers<'_>) -> BTreeMap { + lithe_plugin_model::settings::effective_values(&self.settings, layers) + } +} + +/// Stored setting layers of one plugin, as a host sends them. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct SettingsPayload { + manifest: Value, + #[serde(default)] + settings: BTreeMap, + #[serde(default)] + project_settings: BTreeMap, + /// Names the language server whose `workspaceConfiguration` to expand. + #[serde(default, rename = "languageID")] + language_id: Option, +} + +impl SettingsPayload { + fn layers(&self) -> SettingLayers<'_> { + SettingLayers { + global: &self.settings, + project: &self.project_settings, + } + } +} + +/// `plugins.declarative.resolveSettings`: the effective value of every +/// declared setting and the layer it came from (`default`, `global`, or +/// `project`), so hosts show and launch with the same value. +pub fn resolve_settings_command(payload: Value) -> Result { + let payload: SettingsPayload = decode_payload(payload, "plugins.declarative.resolveSettings")?; + Ok(outcome( + validate_manifest_value(payload.manifest.clone()).and_then(|manifest| { + serde_json::to_value(manifest.resolve_settings(payload.layers())) + .map(|settings| ("settings", settings)) + .map_err(|error| PluginRejection::new("plugin.manifest.invalid", error.to_string())) + }), + )) +} + +/// Installed package identity captured by the native registration adapter. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct SettingsOwner { + #[serde(rename = "pluginID")] + pub(crate) plugin_id: String, + pub(crate) version: String, +} + +/// Complete stored setting documents, captured by the native storage adapter. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SettingsStatePayload { + manifest: Value, + /// Optional native registration facts; this is identity binding, not admission. + owner: Option, + /// Missing files are null; an existing empty or malformed file is an error. + global_document: Option, + project_document: Option, +} + +/// `plugins.declarative.settingsState`: checked UI rows and raw declared launch +/// layers. Reading a state neither edits storage nor admits plugin capabilities. +pub fn settings_state_command(payload: Value) -> Result { + let payload: SettingsStatePayload = + decode_payload(payload, "plugins.declarative.settingsState")?; + Ok(outcome(validate_manifest_value(payload.manifest).and_then( + |manifest| { + if payload.owner.as_ref().is_some_and(|owner| { + owner.plugin_id != manifest.id || owner.version != manifest.version + }) { + return Err(PluginRejection::at( + "plugin.settings.ownerChanged", + "owner", + "The manifest does not match the captured installed package identity.", + )); + } + lithe_plugin_model::settings::document_state( + &manifest.id, + &manifest.settings, + &manifest.setting_actions, + payload.global_document.as_deref(), + payload.project_document.as_deref(), + ) + .map(|mut state| { + state["metadata"] = json!({"displayName": manifest.display_name, "version":manifest.version, "vendor":manifest.vendor.display_name}); + ("state", state) + }) + }, + ))) +} + +/// Native facts for one explicitly selected installed setting action. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SettingsActionPayload { + manifest: Value, + owner: SettingsOwner, + #[serde(rename = "actionID")] + action_id: String, + /// Captured by the native confirmation UI, not inferred from saved preferences. + confirmed: bool, + /// Current native enablement choice; a stored draft cannot activate a plugin. + enabled: bool, + /// Describes the requesting window; runtime ownership is checked on invocation. + workspace_available: bool, +} + +/// Plans a declared settings action without starting or admitting its provider. +/// Native callers retain package/project ownership and confirmation checks. +pub fn settings_action_plan_command(payload: Value) -> Result { + let payload: SettingsActionPayload = + decode_payload(payload, "plugins.declarative.settingsActionPlan")?; + Ok(outcome(validate_manifest_value(payload.manifest).and_then( + |manifest| { + if payload.owner.plugin_id != manifest.id || payload.owner.version != manifest.version { + return Err(PluginRejection::at( + "plugin.settings.ownerChanged", + "owner", + "The installed package identity changed before the action was planned.", + )); + } + lithe_plugin_model::settings::plan_action( + &manifest.id, + &manifest.setting_actions, + &payload.action_id, + payload.confirmed, + payload.workspace_available, + payload.enabled, + ) + .map(|mut plan| { + plan["version"] = json!(manifest.version); + ("plan", plan) + }) + }, + ))) +} + +/// One layer and one atomic edit batch; the host owns its captured byte baseline. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SettingsEditPayload { + manifest: Value, + scope: lithe_plugin_model::settings::SettingScope, + document: Option, + edits: Vec, +} + +/// `plugins.declarative.editSettings`: validates all edits and returns complete +/// text, deletion eligibility and required effects, without performing any I/O. +pub fn edit_settings_command(payload: Value) -> Result { + let payload: SettingsEditPayload = decode_payload(payload, "plugins.declarative.editSettings")?; + Ok(outcome(validate_manifest_value(payload.manifest).and_then( + |manifest| { + lithe_plugin_model::settings::edit_document( + &manifest.id, + &manifest.settings, + payload.scope, + payload.document.as_deref(), + payload.edits, + ) + .map(|edit| ("edit", edit)) + }, + ))) +} + +/// `plugins.declarative.workspaceConfiguration`: a language server's +/// `workspaceConfiguration` expanded with the current values, which the host +/// sends to the running server after an `immediate` setting changed. +/// `configuration` is `null` when the server declares none. +pub fn workspace_configuration_command(payload: Value) -> Result { + const COMMAND: &str = "plugins.declarative.workspaceConfiguration"; + let payload: SettingsPayload = decode_payload(payload, COMMAND)?; + let Some(language_id) = payload.language_id.clone() else { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + format!("Invalid {COMMAND} payload"), + ) + .with_details("languageID is required.")); + }; + Ok(outcome( + validate_manifest_value(payload.manifest.clone()).and_then(|manifest| { + let server = manifest + .language_servers + .iter() + .find(|server| server.language_id == language_id) + .ok_or_else(|| { + PluginRejection::new( + "plugin.launch.unavailable", + format!("The plugin does not provide a language server for {language_id}."), + ) + })?; + let configuration = server + .workspace_configuration + .as_ref() + .map(|value| { + expand_configuration(value, &manifest.effective_settings(payload.layers())) + }) + .unwrap_or(Value::Null); + Ok(("configuration", configuration)) + }), + )) +} diff --git a/rust/crates/core/src/plugins/settings_surfaces.rs b/rust/crates/plugin-manifest/src/settings_surfaces.rs similarity index 97% rename from rust/crates/core/src/plugins/settings_surfaces.rs rename to rust/crates/plugin-manifest/src/settings_surfaces.rs index d0bb37c5..eb3dac8e 100644 --- a/rust/crates/core/src/plugins/settings_surfaces.rs +++ b/rust/crates/plugin-manifest/src/settings_surfaces.rs @@ -2,8 +2,9 @@ //! Native adapters supply installed identities and directory equality facts; //! these commands never discover files, subscribe windows or launch plugins. -use super::{decode_payload, outcome, validate_manifest_value, SettingsOwner}; -use crate::{plugins::declarative::PluginRejection, protocol::CoreError}; +use crate::{validate_manifest_value, SettingsOwner}; +use lithe_plugin_model::{decode_payload, outcome, PluginRejection}; +use lithe_protocol::CoreError; use serde::Deserialize; use serde_json::{json, Value}; use std::collections::{BTreeMap, BTreeSet}; @@ -80,7 +81,7 @@ pub fn settings_catalog_command(payload: Value) -> Result { } let mut pages = BTreeMap::::new(); for (id, (manifest, _)) in manifests { - for page in crate::plugins::settings::page_sections( + for page in lithe_plugin_model::settings::page_sections( &manifest.settings, &manifest.setting_actions, ) { diff --git a/rust/crates/plugin-manifest/src/templates.rs b/rust/crates/plugin-manifest/src/templates.rs new file mode 100644 index 00000000..1d1e2bda --- /dev/null +++ b/rust/crates/plugin-manifest/src/templates.rs @@ -0,0 +1,73 @@ +//! Command templates: their variables, the contexts that allow each variable, and +//! parsing. + +use super::*; + +/// Template variables. `${userArguments}` expands to zero or more elements; +/// the others substitute into a single element. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TemplateVariable { + File, + FileDirectory, + TestName, + TestNameRegex, + UserArguments, +} + +impl TemplateVariable { + fn parse(name: &str) -> Option { + match name { + "file" => Some(Self::File), + "fileDirectory" => Some(Self::FileDirectory), + "testName" => Some(Self::TestName), + "testName:regex" => Some(Self::TestNameRegex), + "userArguments" => Some(Self::UserArguments), + _ => None, + } + } + + pub(crate) fn needs_file(self) -> bool { + matches!(self, Self::File | Self::FileDirectory) + } +} + +/// Which command template an argument list belongs to; each allows a +/// different set of variables. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TemplateContext { + Run, + TestWorkspace, + TestFile, + TestCase, +} + +impl TemplateContext { + pub(crate) fn allows(self, variable: TemplateVariable) -> bool { + use TemplateVariable::*; + match self { + Self::Run => matches!(variable, File | FileDirectory | UserArguments), + Self::TestWorkspace => false, + Self::TestFile => matches!(variable, File | FileDirectory), + Self::TestCase => matches!(variable, File | FileDirectory | TestName | TestNameRegex), + } + } +} + +pub(crate) fn parse_template(value: &str) -> Option>> { + parse_template_with(value, TemplateVariable::parse) +} + +impl ArgumentTemplate { + pub(crate) fn value(&self) -> &str { + match self { + Self::Literal(value) | Self::Conditional { value, .. } => value, + } + } + + pub(crate) fn condition(&self) -> Option { + match self { + Self::Literal(_) => None, + Self::Conditional { when, .. } => Some(*when), + } + } +} diff --git a/rust/crates/plugin-manifest/src/ui.rs b/rust/crates/plugin-manifest/src/ui.rs new file mode 100644 index 00000000..8672278e --- /dev/null +++ b/rust/crates/plugin-manifest/src/ui.rs @@ -0,0 +1,151 @@ +//! Validation of UI contributions: tool windows, web content, docks and commands. + +use super::*; + +impl DeclarativePluginManifest { + pub(crate) fn validate_ui(&mut self) -> Validation<()> { + let Some(ui) = &mut self.ui else { + return Ok(()); + }; + if (ui.tool_windows.is_empty() && ui.commands.is_empty()) + || ui.tool_windows.len() > 64 + || ui.commands.len() > 128 + { + return Err(invalid( + "ui", + "UI declares a tool window or a command, with at most 64 tool windows and 128 commands.", + )); + } + let prefix = format!("{}.", self.id); + let mut ids = BTreeSet::new(); + let mut windows = BTreeSet::new(); + let mut action_ids = BTreeSet::new(); + for (index, window) in ui.tool_windows.iter().enumerate() { + if let Some(icon) = &window.icon { + icon.validate()?; + } + validate_window_body(window, index)?; + if let Some(source) = window + .native + .as_ref() + .and_then(|native| native.source.as_ref()) + { + if !self + .build_systems + .iter() + .any(|system| system.id == source.build_system_id) + { + return Err(invalid( + "ui.toolWindows.native.source", + "The native source must belong to this plugin's build systems.", + )); + } + } + if !is_plugin_id(&window.id) + || !window.id.starts_with(&prefix) + || window.title.trim().is_empty() + || window.title.len() > 256 + || !ids.insert(window.id.clone()) + { + return Err(invalid( + "ui.toolWindows", + "Invalid, duplicate or unowned tool-window ID, title or text.", + )); + } + windows.insert(window.id.clone()); + action_ids.insert(format!("{}.toggle", window.id)); + } + for command in &mut ui.commands { + if let Some(icon) = &command.icon { + icon.validate()?; + } + if !is_plugin_id(&command.id) + || !command.id.starts_with(&prefix) + || command.title.trim().is_empty() + || command.title.len() > 256 + || !ids.insert(command.id.clone()) + || !action_ids.insert(command.id.clone()) + || match &command.tool_window_id { + Some(id) => !windows.contains(id), + // A command without a window runs on the plugin backend. + None => self.backend.is_none(), + } + || command.placements.is_empty() + { + return Err(invalid( + "ui.commands", + "Invalid or duplicate command, placement or tool-window reference.", + )); + } + command.placements.sort(); + command.placements.dedup(); + } + // Placement entries share one command action, but their contribution + // IDs must not shadow an explicitly declared window or another action. + for command in &ui.commands { + for placement in &command.placements { + let suffix = match placement { + UICommandPlacement::CommandPalette => "commandPalette", + UICommandPlacement::Toolbar => "toolbar", + UICommandPlacement::Menu => "menu", + }; + if !ids.insert(format!("{}.{suffix}", command.id)) { + return Err(invalid( + "ui.commands", + "A command placement shadows another UI identity.", + )); + } + } + } + ui.tool_windows + .sort_by(|a, b| (a.order, &a.id).cmp(&(b.order, &b.id))); + ui.commands + .sort_by(|a, b| (a.order, &a.id).cmp(&(b.order, &b.id))); + Ok(()) + } +} + +/// A window body is literal text, one package HTML page, or host-rendered +/// native content. +fn validate_window_body(window: &UIToolWindow, index: usize) -> Validation<()> { + let field = format!("ui.toolWindows[{index}]"); + if let Some(native) = &window.native { + if window.text.is_some() || window.web.is_some() { + return Err(invalid( + &field, + "Native content cannot coexist with text or web.", + )); + } + return native + .validate() + .map_err(|message| invalid(&field, message)); + } + match (&window.text, &window.web) { + (Some(text), None) if text.len() <= 65536 => Ok(()), + (Some(_), None) => Err(invalid( + format!("{field}.text"), + "The window text is too long.", + )), + (None, Some(web)) => { + let entry = &web.entry; + let valid = is_safe_relative_path(entry) + && entry.len() <= 256 + && !entry.contains(':') + && !entry.chars().any(char::is_control) + && entry.split('/').all(|part| !part.is_empty() && part != ".") + && entry.ends_with(".html"); + if valid { + Ok(()) + } else { + Err(invalid( + format!("{field}.web.entry"), + "The web entry must be a package-relative .html file.", + )) + } + } + _ => Err(invalid( + field, + "A tool window shows exactly one of text and web content.", + )), + } +} diff --git a/rust/crates/plugin-manifest/src/validation.rs b/rust/crates/plugin-manifest/src/validation.rs new file mode 100644 index 00000000..2e54b6f5 --- /dev/null +++ b/rust/crates/plugin-manifest/src/validation.rs @@ -0,0 +1,542 @@ +//! Manifest validation: the outcome entry points and the checks that span the whole +//! manifest. + +use super::*; + +/// Validates a raw manifest value and returns its normalized form as JSON, the +/// shape [`lithe_toolchain::ManifestValidator`] gives toolchain policy. +pub fn validated_manifest_json(value: Value) -> Validation { + let manifest = validate_manifest_value(value)?; + serde_json::to_value(manifest) + .map_err(|_| invalid("manifest", "The validated manifest cannot be encoded.")) +} + +/// Validates a raw manifest value and returns its normalized form. +pub fn validate_manifest_value(value: Value) -> Validation { + // Check the version gates first so a future schema reports the precise + // reason instead of an unknown-field decoding failure. + if let Some(version) = value.get("schemaVersion").and_then(Value::as_u64) { + if version != u64::from(DECLARATIVE_MANIFEST_SCHEMA_VERSION) { + return Err(PluginRejection::at( + "plugin.schema.unsupported", + "schemaVersion", + format!("Plugin manifest schema {version} is not supported."), + )); + } + } + if let Some(version) = value.get("pluginAPIVersion").and_then(Value::as_u64) { + if !SUPPORTED_PLUGIN_API_VERSIONS + .iter() + .any(|supported| u64::from(*supported) == version) + { + return Err(PluginRejection::at( + "plugin.api.unsupported", + "pluginAPIVersion", + format!("Plugin API {version} is not supported by this version of Lithe."), + )); + } + capabilities::check_manifest_requires(&value)?; + } + for (field, limit) in [ + ("toolDefinitions", 32), + ("toolchainProviders", 8), + ( + "keymapImportProviders", + lithe_plugin_keymap_import::MAX_PROVIDERS, + ), + ] { + if value.get(field).is_some_and(|section| { + !section + .as_array() + .is_some_and(|items| !items.is_empty() && items.len() <= limit) + }) { + return Err(invalid( + field, + "A declared provider section must be nonempty and bounded.", + )); + } + } + let manifest: DeclarativePluginManifest = serde_json::from_value(value).map_err(|error| { + PluginRejection::new( + "plugin.manifest.invalid", + format!("The plugin manifest is invalid: {error}"), + ) + })?; + manifest.validated() +} + +impl DeclarativePluginManifest { + fn validated(mut self) -> Validation { + if !is_plugin_id(&self.id) { + return Err(invalid( + "id", + "The plugin ID must use lowercase letters, digits, dots, and hyphens.", + )); + } + if !is_semantic_version(&self.version) { + return Err(invalid( + "version", + "The plugin version must use major.minor.patch.", + )); + } + if self.display_name.trim().is_empty() { + return Err(invalid("displayName", "The plugin display name is empty.")); + } + if self.vendor.id.trim().is_empty() || self.vendor.display_name.trim().is_empty() { + return Err(invalid("vendor", "The plugin vendor is incomplete.")); + } + if self.vendor.signature_requirement == SignatureRequirement::None + && is_official_namespace(&self.id) + { + return Err(invalid( + "vendor.signatureRequirement", + "Official plugin IDs must carry a Lithe signature.", + )); + } + if let Some(bundled) = self.vendor.bundled_signature_requirement { + if self.vendor.signature_requirement != SignatureRequirement::PublisherPackage + || bundled != SignatureRequirement::SameTeamAsHost + || !is_official_namespace(&self.id) + { + return Err(invalid( + "vendor.bundledSignatureRequirement", + "Bundled host-seal trust requires an official plugin ID and publisherPackage for installed copies.", + )); + } + } + capabilities::check_used(&self.requires, &self.used_capabilities())?; + if self.languages.is_empty() + && self.ui.is_none() + && self.build_systems.is_empty() + && self.vcs_providers.is_empty() + && self.debug_adapters.is_empty() + && self.project_generators.is_empty() + && self.tool_definitions.is_empty() + && self.toolchain_providers.is_empty() + && self.keymap_import_providers.is_empty() + && self.output_link_matchers.is_empty() + // A backend may be the whole plugin: it starts on a workspace. + && self.backend.is_none() + { + return Err(invalid("languages", "The plugin declares no languages.")); + } + + let mut language_ids = BTreeSet::new(); + for (index, language) in self.languages.iter_mut().enumerate() { + language.normalize(index)?; + if !language_ids.insert(language.id.clone()) { + return Err(invalid( + format!("languages[{index}].id"), + "A language is declared more than once.", + )); + } + } + self.languages.sort_by(|left, right| left.id.cmp(&right.id)); + + let declared_settings = self.validate_settings()?; + let required_tools = self.validate_required_tools()?; + let runtime_tools = RuntimeTools::new(&required_tools); + lithe_plugin_output_links::validate_declarations( + &mut self.output_link_matchers, + self.backend.is_some(), + )?; + + let mut server_languages = BTreeSet::new(); + let mut referenced_immediate_settings = BTreeSet::new(); + for (index, server) in self.language_servers.iter_mut().enumerate() { + let field = format!("languageServers[{index}]"); + if !language_ids.contains(&server.language_id) { + return Err(invalid( + format!("{field}.languageID"), + "The language server references an undeclared language.", + )); + } + if !server_languages.insert(server.language_id.clone()) { + return Err(invalid( + format!("{field}.languageID"), + "A language declares more than one language server.", + )); + } + if let Some(configuration) = &server.workspace_configuration { + collect_setting_references(configuration, &mut referenced_immediate_settings); + } + server.validate(&field, &declared_settings, runtime_tools)?; + } + if let Some(id) = + declared_settings.first_unreferenced_immediate(&referenced_immediate_settings) + { + return Err(invalid( + "settings", + format!("The immediate setting {id} must be used by workspaceConfiguration."), + )); + } + self.language_servers + .sort_by(|left, right| left.language_id.cmp(&right.language_id)); + + let mut execution_languages = BTreeSet::new(); + for (index, provider) in self.execution_providers.iter_mut().enumerate() { + let field = format!("executionProviders[{index}]"); + if !language_ids.contains(&provider.language_id) { + return Err(invalid( + format!("{field}.languageID"), + "The execution provider references an undeclared language.", + )); + } + if !execution_languages.insert(provider.language_id.clone()) { + return Err(invalid( + format!("{field}.languageID"), + "A language declares more than one execution provider.", + )); + } + provider.normalize(&field)?; + } + self.execution_providers + .sort_by(|left, right| left.language_id.cmp(&right.language_id)); + + if self.language_servers.is_empty() + && self.execution_providers.is_empty() + && self.ui.is_none() + && self.build_systems.is_empty() + && self.vcs_providers.is_empty() + && self.debug_adapters.is_empty() + && self.project_generators.is_empty() + && self.tool_definitions.is_empty() + && self.toolchain_providers.is_empty() + && self.keymap_import_providers.is_empty() + // Console links alone are a complete plugin: Core matches them. + && self.output_link_matchers.is_empty() + && self.backend.is_none() + // A grammar alone is a complete highlighting plugin. + && !self + .languages + .iter() + .any(|language| language.grammar.is_some() || language.configuration.is_some()) + { + return Err(invalid( + "languages", + "The plugin declares no language server or execution provider.", + )); + } + self.validate_ui()?; + lithe_project_generators::validate_declarations(&self.project_generators)?; + lithe_build::validate_declarations( + &mut self.build_systems, + &lithe_build::DeclarationContext { + debug_adapters: &self.debug_adapters, + declared_settings: &declared_settings, + language_servers: &server_languages, + runtime_tools, + }, + )?; + lithe_vcs::validate_declarations( + &mut self.vcs_providers, + &lithe_vcs::DeclarationContext { + declared_settings: &declared_settings, + runtime_tools, + }, + )?; + self.validate_debug_adapters(&declared_settings, runtime_tools)?; + self.validate_backend_launches()?; + self.validate_toolchains()?; + if !self.keymap_import_providers.is_empty() { + lithe_plugin_keymap_import::validate_declarations(&mut self.keymap_import_providers)?; + self.validate_packaged_provider_backend()?; + } + self.validate_plugin_functions()?; + if let Some(backend) = &self.backend { + backend.validate(&lithe_plugin_backend::DeclarationContext { + declared_settings: &declared_settings, + has_web_window: self + .ui + .as_ref() + .is_some_and(|ui| ui.tool_windows.iter().any(|window| window.web.is_some())), + has_command: self.ui.as_ref().is_some_and(|ui| !ui.commands.is_empty()), + activates_on_workspace_open: self + .activation_events + .iter() + .any(|event| event == lithe_plugin_backend::ON_WORKSPACE_OPEN), + // A language server the backend launches starts the backend + // when the host asks it for the server's command line; an + // exported function starts it when another plugin calls it. + // Neither needs a page, command or workspace event. + provider_query_trigger: !self.toolchain_providers.is_empty() + || !self.keymap_import_providers.is_empty() + || self + .language_servers + .iter() + .any(|server| server.process.is_backend_launch()) + || self.plugin_functions.as_ref().is_some_and( + lithe_plugin_functions::PluginFunctionsDeclaration::exports_any, + ), + runtime_tools, + })?; + } + self.validate_permissions()?; + self.validate_activation_events()?; + Ok(self) + } + + /// Capabilities whose sections the manifest declares. Launches, tool + /// references, and executable candidates belong to the base contract and + /// are never listed. + fn used_capabilities(&self) -> BTreeSet<&'static str> { + let sections = [ + ( + capabilities::PACKAGE_TRUST, + self.vendor.bundled_signature_requirement.is_some(), + ), + (capabilities::LANGUAGES, !self.languages.is_empty()), + ( + capabilities::LANGUAGE_SERVERS, + !self.language_servers.is_empty(), + ), + ( + capabilities::EXECUTION_PROVIDERS, + !self.execution_providers.is_empty(), + ), + ( + capabilities::SETTINGS, + !self.settings.is_empty() || !self.setting_actions.is_empty(), + ), + (capabilities::UI, self.ui.is_some()), + (capabilities::BUILD_SYSTEMS, !self.build_systems.is_empty()), + ( + capabilities::PROJECT_GENERATORS, + !self.project_generators.is_empty(), + ), + ( + capabilities::BUILD_PROVIDERS, + self.build_systems + .iter() + .any(|system| system.provider.is_some()), + ), + ( + capabilities::BUILD_MODEL_INPUTS, + self.build_systems.iter().any(|system| { + system.model_sharing.is_some() || !system.model_inputs.is_empty() + }), + ), + (capabilities::VCS_PROVIDERS, !self.vcs_providers.is_empty()), + ( + capabilities::DEBUG_ADAPTERS, + !self.debug_adapters.is_empty(), + ), + (capabilities::BACKEND, self.backend.is_some()), + ( + capabilities::TOOL_DEFINITIONS, + !self.tool_definitions.is_empty(), + ), + ( + capabilities::TOOLCHAIN_PROVIDERS, + !self.toolchain_providers.is_empty(), + ), + ( + capabilities::KEYMAP_IMPORT_PROVIDERS, + !self.keymap_import_providers.is_empty(), + ), + ( + capabilities::OUTPUT_LINK_MATCHERS, + !self.output_link_matchers.is_empty(), + ), + ( + capabilities::PLUGIN_FUNCTIONS, + self.plugin_functions.is_some(), + ), + ]; + sections + .into_iter() + .filter_map(|(capability, used)| used.then_some(capability)) + .collect() + } + + /// Checks the debug adapters against the plugin's settings, language + /// servers, and `requiresTools`. + fn validate_debug_adapters( + &mut self, + declared_settings: &DeclaredSettings, + runtime_tools: RuntimeTools<'_>, + ) -> Validation<()> { + let language_servers: BTreeSet = self + .language_servers + .iter() + .map(|server| server.language_id.clone()) + .collect(); + lithe_debug::adapters::validate_declarations( + &mut self.debug_adapters, + &lithe_debug::adapters::DeclarationContext { + declared_settings, + language_servers: &language_servers, + runtime_tools, + }, + ) + } + + /// A language server the plugin backend launches asks the backend for its + /// command line, so the manifest must declare that backend. + fn validate_backend_launches(&self) -> Validation<()> { + if self.backend.is_none() + && self + .language_servers + .iter() + .any(|server| server.process.is_backend_launch()) + { + return Err(invalid( + "backend", + "A language server launched by the plugin backend requires the backend section.", + )); + } + Ok(()) + } + + fn validate_toolchains(&self) -> Validation<()> { + if self.tool_definitions.is_empty() && self.toolchain_providers.is_empty() { + return Ok(()); + } + lithe_toolchain::definitions::validate_declarations( + &self.id, + &self.tool_definitions, + &self.toolchain_providers, + )?; + if !self.toolchain_providers.is_empty() { + self.validate_packaged_provider_backend()?; + } + Ok(()) + } + + /// Checks `pluginFunctions`: only the plugin backend registers functions + /// for the protocol that relays calls. + fn validate_plugin_functions(&mut self) -> Validation<()> { + let Some(section) = self.plugin_functions.as_mut() else { + return Ok(()); + }; + if self.backend.is_none() { + return Err(invalid( + "pluginFunctions", + "Exported and called plugin functions need the plugin backend.", + )); + } + lithe_plugin_functions::validate(section) + } + + /// Host-queried providers must start without relying on the discovery they provide. + fn validate_packaged_provider_backend(&self) -> Validation<()> { + let backend = self + .backend + .as_ref() + .ok_or_else(|| invalid("backend", "A provider requires the plugin backend."))?; + let value = serde_json::to_value(backend) + .map_err(|_| invalid("backend", "The backend cannot be encoded."))?; + let launch = &value["launch"]; + let packaged = launch["kind"] == "packaged" + || (launch["kind"] == "runtime" && launch["packagedRuntime"].as_str().is_some()); + if !packaged + || value["runtimeDependencies"] + .as_array() + .is_some_and(|values| !values.is_empty()) + { + return Err(invalid("backend.launch", "Provider backends must bootstrap from packaged executables or packaged runtimes without discovered dependencies.")); + } + Ok(()) + } +} + +impl DeclarativePluginManifest { + fn validate_activation_events(&mut self) -> Validation<()> { + let mut expected = BTreeSet::new(); + for server in &self.language_servers { + expected.insert(format!("onLanguage:{}", server.language_id)); + } + for provider in &self.execution_providers { + if provider.run.is_some() { + expected.insert(format!("onRun:{}", provider.language_id)); + } + if provider.test.is_some() { + expected.insert(format!("onTest:{}", provider.language_id)); + } + } + for build_system in &self.build_systems { + expected.insert(lithe_build::activation_event(build_system)); + } + for provider in &self.vcs_providers { + expected.insert(lithe_vcs::activation_event(provider)); + } + for adapter in &self.debug_adapters { + expected.insert(lithe_debug::adapters::activation_event(adapter)); + } + if let Some(ui) = &self.ui { + for window in &ui.tool_windows { + expected.insert(format!("onView:{}", window.id)); + } + for command in &ui.commands { + expected.insert(format!("onCommand:{}", command.id)); + } + } + self.activation_events = sorted_unique(std::mem::take(&mut self.activation_events)); + let mut declared: BTreeSet = self.activation_events.iter().cloned().collect(); + // The backend may start once a workspace opens. The event is optional + // because pages and commands can start it too, so it is checked here + // and kept out of the exact provider match below. + if self.backend.is_some() { + declared.remove(lithe_plugin_backend::ON_WORKSPACE_OPEN); + } + // Every provider needs an event, and every event needs a provider: + // an event without one would promise activation the host cannot do. + if let Some(missing) = expected.difference(&declared).next() { + return Err(PluginRejection::at( + "plugin.activation.invalid", + "activationEvents", + format!("The plugin must declare the activation event {missing}."), + )); + } + if let Some(extra) = declared.difference(&expected).next() { + return Err(PluginRejection::at( + "plugin.activation.invalid", + "activationEvents", + format!("The activation event {extra} has no matching provider."), + )); + } + Ok(()) + } + + pub(crate) fn execution_provider( + &self, + language_id: &str, + ) -> Option<&ExecutionProviderDeclaration> { + self.execution_providers + .iter() + .find(|provider| provider.language_id == language_id) + } +} + +impl DeclarativePluginManifest { + /// Checks `requiresTools` and returns its IDs. Every tool a process names + /// is one of them. + fn validate_required_tools(&mut self) -> Validation> { + if self.requires_tools.is_empty() { + return Ok(BTreeSet::new()); + } + if self.requires_tools.len() > MAX_REQUIRED_TOOLS { + return Err(invalid( + "requiresTools", + format!("A plugin requires at most {MAX_REQUIRED_TOOLS} tools."), + )); + } + let mut tools = BTreeSet::new(); + for (index, tool) in self.requires_tools.iter().enumerate() { + if !lithe_plugin_model::is_tool_id(tool) { + return Err(invalid( + format!("requiresTools[{index}]"), + "A required tool is a short or plugin-qualified tool-definition ID.", + )); + } + if !tools.insert(tool.clone()) { + return Err(invalid( + format!("requiresTools[{index}]"), + "A tool is required more than once.", + )); + } + } + self.requires_tools = tools.iter().cloned().collect(); + Ok(tools) + } +} diff --git a/rust/crates/plugin-marketplace/Cargo.toml b/rust/crates/plugin-marketplace/Cargo.toml new file mode 100644 index 00000000..c3a3c4f8 --- /dev/null +++ b/rust/crates/plugin-marketplace/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "lithe-plugin-marketplace" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Plugin marketplace indexes: validation, platform and plugin API filtering, source URL resolution and install reviews" + +[dependencies] +lithe-plugin-manifest = { path = "../plugin-manifest" } +lithe-plugin-model = { path = "../plugin-model" } +lithe-protocol = { path = "../protocol" } +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0", features = ["preserve_order"] } +url = "2.5" diff --git a/rust/crates/core/src/plugins/marketplace.rs b/rust/crates/plugin-marketplace/src/lib.rs similarity index 97% rename from rust/crates/core/src/plugins/marketplace.rs rename to rust/crates/plugin-marketplace/src/lib.rs index 4592295c..82871557 100644 --- a/rust/crates/core/src/plugins/marketplace.rs +++ b/rust/crates/plugin-marketplace/src/lib.rs @@ -8,12 +8,13 @@ //! start, so macOS and Windows present the same catalog and the same trust //! prompts. The contract lives in `shared/plugins/contract/v1/`. -use super::capabilities::{self, HostCapabilities, Requires}; -use super::declarative::{ - is_official_namespace, is_plugin_id, is_semantic_version, validate_manifest_value, - PluginRejection, Validation, SUPPORTED_PLUGIN_API_VERSIONS, +use lithe_plugin_manifest::{validate_manifest_value, SUPPORTED_PLUGIN_API_VERSIONS}; +use lithe_plugin_model::capabilities::{self, HostCapabilities, Requires}; +use lithe_plugin_model::{ + decode_payload, is_official_namespace, is_plugin_id, is_semantic_version, PluginRejection, + Validation, }; -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::CoreError; use serde::Deserialize; use serde_json::{json, Value}; use std::collections::{BTreeMap, BTreeSet}; @@ -784,19 +785,6 @@ struct InstallReviewPayload { host_capabilities: Option, } -fn decode_payload Deserialize<'de>>( - payload: Value, - command: &str, -) -> Result { - serde_json::from_value(payload).map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - format!("Invalid {command} payload"), - ) - .with_details(error.to_string()) - }) -} - fn outcome(key: &str, result: Validation) -> Value { match result { Ok(value) => { diff --git a/rust/crates/plugin-model/Cargo.toml b/rust/crates/plugin-model/Cargo.toml new file mode 100644 index 00000000..1e8cf0fc --- /dev/null +++ b/rust/crates/plugin-model/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "lithe-plugin-model" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Shared vocabulary of declarative Lithe plugin manifests: rejections, identifiers, versions, templates, icons, executables, settings and the processes a plugin declares" + +[dependencies] +lithe-protocol = { path = "../protocol" } +regex = "1.11" +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0", features = ["preserve_order"] } diff --git a/rust/crates/core/src/plugins/capabilities.rs b/rust/crates/plugin-model/src/capabilities.rs similarity index 77% rename from rust/crates/core/src/plugins/capabilities.rs rename to rust/crates/plugin-model/src/capabilities.rs index 50bc05ef..2237ca99 100644 --- a/rust/crates/core/src/plugins/capabilities.rs +++ b/rust/crates/plugin-model/src/capabilities.rs @@ -21,7 +21,7 @@ //! `litheFunctions` is `backend`, `litheVcs` is `vcsProviders`, the toolchain //! protocol is `toolchainProviders`, the keymap protocol is //! `keymapImportProviders`, and `litheLogging` is `pluginAPIVersion`. Use -//! [`versions`] and `SUPPORTED_PLUGIN_API_VERSIONS` for the versions this +//! `versions` and `SUPPORTED_PLUGIN_API_VERSIONS` for the versions this //! Core build speaks. //! //! `requires` is checked before the sections are decoded, so a Core build @@ -31,67 +31,72 @@ //! //! Note: the decision is recorded in .agents/notes/implemented/architecture/2026-11-11-plugin-contract-v2-baseline.md -use super::declarative::{PluginRejection, Validation}; +use crate::{PluginRejection, Validation}; use serde_json::Value; use std::collections::{BTreeMap, BTreeSet}; +/// Base-contract versions (`pluginAPIVersion`) this Core build can host. The +/// version is independent of the Lithe application version, and a later one +/// is appended to this list. +pub const SUPPORTED_PLUGIN_API_VERSIONS: &[u32] = &[0]; + /// `languages[]`: file types and project markers, a TextMate `grammar`, and an -/// editing `configuration`; see `super::grammar`. -pub(crate) const LANGUAGES: &str = "languages"; +/// editing `configuration`; see `lithe_plugin_grammar`. +pub const LANGUAGES: &str = "languages"; /// `languageServers[]`: the launch, `workspaceConfiguration`, and the session /// fields a plugin backend uses to talk to its own server (`requestMethods`, /// `commands`, `serializedCommands`, `forwardNotifications`, `state`, and /// `remote`). -pub(crate) const LANGUAGE_SERVERS: &str = "languageServers"; +pub const LANGUAGE_SERVERS: &str = "languageServers"; /// `vendor.bundledSignatureRequirement`: origin-scoped host-seal trust for a /// package whose independently installed versions require a publisher signature. -pub(crate) const PACKAGE_TRUST: &str = "packageTrust"; +pub const PACKAGE_TRUST: &str = "packageTrust"; /// `executionProviders[]`: run and test templates. -pub(crate) const EXECUTION_PROVIDERS: &str = "executionProviders"; +pub const EXECUTION_PROVIDERS: &str = "executionProviders"; /// `settings[]`: scopes, effects, settings pages, and the multiSelect, text, -/// file, and directory types; see `super::settings`. -pub(crate) const SETTINGS: &str = "settings"; +/// file, and directory types; see [`crate::settings`]. +pub const SETTINGS: &str = "settings"; /// `ui`: tool windows with text, web, or host-rendered native content, and /// commands. -pub(crate) const UI: &str = "ui"; +pub const UI: &str = "ui"; /// Declarative build detection, models, and command plans. -pub(crate) const BUILD_SYSTEMS: &str = "buildSystems"; +pub const BUILD_SYSTEMS: &str = "buildSystems"; /// `buildSystems[].provider`, whether it owns or shares a language server process. -pub(crate) const BUILD_PROVIDERS: &str = "buildProviders"; +pub const BUILD_PROVIDERS: &str = "buildProviders"; /// Optional public model snapshots between enabled build providers. -pub(crate) const BUILD_MODEL_INPUTS: &str = "buildModelInputs"; +pub const BUILD_MODEL_INPUTS: &str = "buildModelInputs"; /// `vcsProviders[]`: version control systems implemented by a plugin -/// process; see `crate::vcs`. -pub(crate) const VCS_PROVIDERS: &str = "vcsProviders"; +/// process; see `lithe_vcs`. +pub const VCS_PROVIDERS: &str = "vcsProviders"; -/// `backend`: the plugin's own long-lived process; see `super::backend`. It +/// `backend`: the plugin's own long-lived process; see `lithe_plugin_backend`. It /// may start without a page (`onWorkspaceOpen`, a plugin command) and may be /// granted the `workspace` host service; it speaks the plugin-function /// protocol (`litheFunctions`). -pub(crate) const BACKEND: &str = "backend"; +pub const BACKEND: &str = "backend"; /// `projectGenerators[]`: entries of the New Project window. A field selects /// installations of any plugin-defined tool with its `tool` field. -pub(crate) const PROJECT_GENERATORS: &str = "projectGenerators"; +pub const PROJECT_GENERATORS: &str = "projectGenerators"; /// `debugAdapters[]`: debug adapters implemented by a plugin process, with /// declared adapter `features`, `defaults`, and argument and environment -/// bindings; see `crate::debug::adapters`. -pub(crate) const DEBUG_ADAPTERS: &str = "debugAdapters"; +/// bindings; see `lithe_debug::adapters`. +pub const DEBUG_ADAPTERS: &str = "debugAdapters"; /// Independent providers that translate external shortcut settings. -pub(crate) const KEYMAP_IMPORT_PROVIDERS: &str = "keymapImportProviders"; +pub const KEYMAP_IMPORT_PROVIDERS: &str = "keymapImportProviders"; /// `pluginFunctions`: functions exported to, and called from, other plugins; -/// see `super::plugin_functions`. -pub(crate) const PLUGIN_FUNCTIONS: &str = "pluginFunctions"; +/// see `lithe_plugin_functions`. +pub const PLUGIN_FUNCTIONS: &str = "pluginFunctions"; /// `toolDefinitions[]`: plugin-owned tool identities, discovery policy, and /// executable candidates. -pub(crate) const TOOL_DEFINITIONS: &str = "toolDefinitions"; +pub const TOOL_DEFINITIONS: &str = "toolDefinitions"; /// Backend facts that can influence the executable selected for a defined tool. -pub(crate) const TOOLCHAIN_PROVIDERS: &str = "toolchainProviders"; +pub const TOOLCHAIN_PROVIDERS: &str = "toolchainProviders"; /// `outputLinkMatchers[]`: patterns that turn console output into links; see -/// `crate::plugins::output_links`. -pub(crate) const OUTPUT_LINK_MATCHERS: &str = "outputLinkMatchers"; +/// `lithe_plugin_output_links`. +pub const OUTPUT_LINK_MATCHERS: &str = "outputLinkMatchers"; /// Capability versions this Core build validates and plans. A later version /// of a capability is appended to its list. @@ -120,14 +125,14 @@ pub const SUPPORTED_CAPABILITIES: &[(&str, &[u32])] = &[ const MAX_CAPABILITY_ID: usize = 64; /// Capability ID to the contract version a plugin was written against. -pub(crate) type Requires = BTreeMap; +pub type Requires = BTreeMap; /// Capability ID to the versions a host implements, as the host sends them. -pub(crate) type HostCapabilities = BTreeMap>; +pub type HostCapabilities = BTreeMap>; /// Checks a raw manifest's `requires` before its sections are decoded. Every /// manifest must declare it. -pub(crate) fn check_manifest_requires(manifest: &Value) -> Validation<()> { +pub fn check_manifest_requires(manifest: &Value) -> Validation<()> { let Some(requires) = manifest.get("requires") else { return Err(PluginRejection::at( "plugin.manifest.invalid", @@ -151,11 +156,7 @@ pub(crate) fn check_manifest_requires(manifest: &Value) -> Validation<()> { /// Reads a `requires` object. IDs are checked for shape only, so an index /// can list a plugin for a capability this Core build does not know yet. -pub(crate) fn parse_requires( - value: &Value, - field: &str, - code: &'static str, -) -> Validation { +pub fn parse_requires(value: &Value, field: &str, code: &'static str) -> Validation { let Value::Object(entries) = value else { return Err(PluginRejection::at( code, @@ -198,7 +199,7 @@ pub(crate) fn parse_requires( /// Checks that `requires` lists exactly the capabilities a decoded manifest /// uses. A missing entry would let a host that lacks the capability accept /// the plugin; an extra one would hide it from hosts it could run on. -pub(crate) fn check_used(requires: &Requires, used: &BTreeSet<&'static str>) -> Validation<()> { +pub fn check_used(requires: &Requires, used: &BTreeSet<&'static str>) -> Validation<()> { if let Some(missing) = used .iter() .find(|capability| !requires.contains_key(**capability)) @@ -225,12 +226,12 @@ pub(crate) fn check_used(requires: &Requires, used: &BTreeSet<&'static str>) -> /// Whether a host can run every required capability. A host that sends no /// capabilities runs no plugin, and a capability this Core build cannot /// validate is never hosted, whatever the host claims. -pub(crate) fn hosts(requires: &Requires, host: Option<&HostCapabilities>) -> bool { +pub fn hosts(requires: &Requires, host: Option<&HostCapabilities>) -> bool { first_unhosted(requires, host).is_none() } /// Rejects a manifest that requires a capability version the host lacks. -pub(crate) fn check_hosted(requires: &Requires, host: &HostCapabilities) -> Validation<()> { +pub fn check_hosted(requires: &Requires, host: &HostCapabilities) -> Validation<()> { match first_unhosted(requires, Some(host)) { Some((capability, version)) => Err(PluginRejection::at( "plugin.api.unsupported", @@ -258,7 +259,7 @@ fn first_unhosted<'a>( /// Versions of `capability` this Core build validates and plans; empty when /// the capability is unknown. -pub(crate) fn versions(capability: &str) -> &'static [u32] { +pub fn versions(capability: &str) -> &'static [u32] { SUPPORTED_CAPABILITIES .iter() .find(|(id, _)| *id == capability) @@ -276,3 +277,16 @@ fn is_capability_id(value: &str) -> bool { && chars.next().is_some_and(|first| first.is_ascii_lowercase()) && chars.all(|rest| rest.is_ascii_alphanumeric()) } + +/// The versions a Lithe runtime protocol offers in `initialize`. A runtime +/// protocol has no version of its own: it speaks the version of the contract +/// that owns it, the capability version of the manifest section that declares +/// the provider (or the plugin API version for plugin logging). The host +/// therefore offers exactly the versions of that contract it implements, and a +/// reply counts as negotiated only when it names one of them. +pub fn offered_versions(contract_versions: &[u32]) -> Vec { + contract_versions + .iter() + .map(|version| u64::from(*version)) + .collect() +} diff --git a/rust/crates/core/src/plugins/executables.rs b/rust/crates/plugin-model/src/executables.rs similarity index 84% rename from rust/crates/core/src/plugins/executables.rs rename to rust/crates/plugin-model/src/executables.rs index a86690e9..e529e085 100644 --- a/rust/crates/core/src/plugins/executables.rs +++ b/rust/crates/plugin-model/src/executables.rs @@ -1,7 +1,8 @@ //! Shared executable candidate declarations and deterministic ordering. //! Native discovery, trust and probing remain injected host responsibilities. -use super::declarative::{invalid, is_identifier, Validation}; +use crate::names::is_identifier; +use crate::rejection::{invalid, Validation}; use serde::{Deserialize, Deserializer, Serialize}; use serde_json::Value; use std::collections::{BTreeMap, BTreeSet}; @@ -9,9 +10,10 @@ use std::collections::{BTreeMap, BTreeSet}; /// A source plus optional ordering constraints within one plugin-owned set. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] -pub(crate) struct ExecutableCandidate { +pub struct ExecutableCandidate { + /// The kind of place the executable is found. #[serde(flatten)] - pub(crate) source: ExecutableSource, + pub source: ExecutableSource, #[serde(skip_serializing_if = "Option::is_none")] pub(crate) id: Option, /// Higher priorities run first; absent means 0. Ties use array order. @@ -21,8 +23,9 @@ pub(crate) struct ExecutableCandidate { pub(crate) insert_before: Option, #[serde(skip_serializing_if = "Option::is_none")] pub(crate) insert_after: Option, + /// Operating systems the candidate applies to; empty means every one. #[serde(skip_serializing_if = "Vec::is_empty")] - pub(crate) platforms: Vec, + pub platforms: Vec, } impl<'de> Deserialize<'de> for ExecutableCandidate { @@ -79,29 +82,42 @@ impl<'de> Deserialize<'de> for ExecutableCandidate { rename_all_fields = "camelCase", deny_unknown_fields )] -pub(crate) enum ExecutableSource { +pub enum ExecutableSource { + /// A wrapper inside the project root; it always runs project code. ProjectFile { + /// Wrapper path, relative to the project root. path: String, + /// Further project files that must exist beside the wrapper. #[serde(default, skip_serializing_if = "Vec::is_empty")] requires: Vec, }, + /// A path below a directory named by an environment variable the + /// manifest lists in `environmentInputs`. Environment { + /// Environment variable holding the directory. variable: String, + /// Executable path below that directory. path: String, }, + /// A bare command name found on `PATH`. Command { + /// Bare command name. command: String, }, + /// A declared runtime dependency itself. Runtime { + /// Tool-definition ID of the runtime. runtime: String, }, /// Global file preference; empty skips, any invalid nonempty value fails. Setting { + /// ID of the global file setting. #[serde(rename = "settingID")] setting_id: String, }, /// Existing host toolchain registry, using the instance's canonical local selection. Toolchain { + /// Tool-definition ID whose selected instance is used. #[serde(rename = "toolchainID")] toolchain_id: String, }, @@ -110,18 +126,18 @@ pub(crate) enum ExecutableSource { /// Operating system filter, applied after validating the complete candidate set. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] #[serde(rename_all = "lowercase")] -pub(crate) enum Platform { +pub enum Platform { + /// macOS. Macos, + /// Linux. Linux, + /// Windows. Windows, } /// Stable topological sort: explicit edges win, otherwise higher priority then /// original array index wins. No cross-set anchors or arbitrary host code. -pub(crate) fn ordered_indices( - candidates: &[ExecutableCandidate], - field: &str, -) -> Validation> { +pub fn ordered_indices(candidates: &[ExecutableCandidate], field: &str) -> Validation> { let mut ids = BTreeMap::new(); for (index, candidate) in candidates.iter().enumerate() { if let Some(id) = &candidate.id { diff --git a/rust/crates/plugin-model/src/icon.rs b/rust/crates/plugin-model/src/icon.rs new file mode 100644 index 00000000..fd608c2e --- /dev/null +++ b/rust/crates/plugin-model/src/icon.rs @@ -0,0 +1,91 @@ +//! Icons a manifest may name: a host icon ID or package SVG/PNG resources. + +use crate::names::is_safe_relative_path; +use crate::rejection::{invalid, Validation}; +use serde::{Deserialize, Serialize}; + +/// Icon identity is portable data; hosts own asset resolution, sizing and states. +/// Custom assets are package-relative and are decoded only by the platform adapter. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct UIIcon { + #[serde(default, skip_serializing_if = "Option::is_none")] + host: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + dark_path: Option, + #[serde(default)] + mode: UIIconMode, +} + +/// How the host paints an icon resource. +#[derive(Debug, Clone, Copy, Default, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum UIIconMode { + /// Tinted with the current theme foreground, like built-in icons. + #[default] + Template, + /// Drawn with its own colors, untinted. + Original, +} + +impl UIIcon { + /// Native actions reuse the host vocabulary, without package resource loading. + pub fn is_valid_host_icon(&self) -> bool { + self.host.is_some() && self.validate().is_ok() + } + + /// Checks the icon as the manifest's `ui.icon`. + pub fn validate(&self) -> Validation<()> { + self.validate_at("ui.icon") + } + + /// Validates the icon and reports a failure at `field`; build systems + /// reuse the UI icon vocabulary for their tool windows and toolbar toggles. + pub fn validate_at(&self, field: &str) -> Validation<()> { + const HOST_IDS: &[&str] = &[ + "tool-window", + "plugin", + "database", + "services", + "terminal", + "search", + "refresh", + "reset", + "run", + "debug", + "settings", + "add", + "remove", + "git", + "problems", + ]; + let valid_path = |path: &str| { + is_safe_relative_path(path) + && path.len() <= 256 + && !path.contains(':') + && !path.chars().any(char::is_control) + && path.split('/').all(|part| !part.is_empty() && part != ".") + && (path.ends_with(".svg") || path.ends_with(".png")) + }; + let valid = match (&self.host, &self.path) { + (Some(host), None) => { + HOST_IDS.contains(&host.as_str()) + && self.dark_path.is_none() + && self.mode == UIIconMode::Template + } + (None, Some(path)) => { + valid_path(path) && self.dark_path.as_deref().is_none_or(valid_path) + } + _ => false, + }; + if !valid { + return Err(invalid( + field, + "An icon must name a supported host icon or safe package SVG/PNG resources.", + )); + } + Ok(()) + } +} diff --git a/rust/crates/core/src/plugins/installed.rs b/rust/crates/plugin-model/src/installed.rs similarity index 70% rename from rust/crates/core/src/plugins/installed.rs rename to rust/crates/plugin-model/src/installed.rs index 59604425..18b4c850 100644 --- a/rust/crates/core/src/plugins/installed.rs +++ b/rust/crates/plugin-model/src/installed.rs @@ -3,8 +3,8 @@ //! Core reuses its existing compatibility version comparator. The host keeps //! paths and maps the returned indices back to the native records it supplied. -use super::PluginVersion; -use crate::protocol::{CoreError, ErrorCode}; +use crate::version::PluginVersion; +use lithe_protocol::{CoreError, ErrorCode}; use serde::Deserialize; use serde_json::{json, Value}; use std::collections::{BTreeMap, BTreeSet}; @@ -56,7 +56,7 @@ pub fn select_command(payload: Value) -> Result { rejected.push(json!({"index":index,"code":"plugin.installed.invalidIdentity"})); continue; } - let Some(version) = super::declarative::is_semantic_version(&entry.version) + let Some(version) = crate::is_semantic_version(&entry.version) .then(|| PluginVersion::parse(&entry.version)) .flatten() else { @@ -81,35 +81,3 @@ pub fn select_command(payload: Value) -> Result { .collect(); Ok(json!({"selected":selected,"rejected":rejected,"maximumEntries":MAX_INSTALLED_ENTRIES})) } - -#[cfg(test)] -mod tests { - use serde_json::{json, Value}; - - #[test] - fn shared_installed_version_fixture_uses_the_existing_numeric_comparator() { - let fixture: Value = serde_json::from_str(include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../fixtures/plugins/installed-versions.json" - ))) - .unwrap(); - for case in fixture["cases"].as_array().unwrap() { - let response: Value = serde_json::from_str(&crate::execute_json(&json!({ - "command":"plugins.installed.select", "payload":{ - "entries":case["entries"], "includeDisabled":case["includeDisabled"].as_bool().unwrap_or(false) - } - }).to_string())).unwrap(); - assert_eq!(response["ok"], true, "case {}: {response}", case["name"]); - assert_eq!( - response["data"]["selected"], case["selected"], - "case {}", - case["name"] - ); - assert_eq!( - response["data"]["rejected"], case["rejected"], - "case {}", - case["name"] - ); - } - } -} diff --git a/rust/crates/plugin-model/src/lib.rs b/rust/crates/plugin-model/src/lib.rs new file mode 100644 index 00000000..87d853df --- /dev/null +++ b/rust/crates/plugin-model/src/lib.rs @@ -0,0 +1,39 @@ +//! The vocabulary every part of a declarative plugin manifest shares. +//! +//! Language servers, build systems, VCS providers, debug adapters, tool +//! definitions and project generators each validate their own manifest +//! section, but they refuse content the same way ([`PluginRejection`]), check +//! the same identifier alphabets ([`is_plugin_id`], [`is_safe_relative_path`]), +//! parse the same `${...}` templates ([`parse_template_with`]), and declare +//! the same [`settings`], [`executables`] and server [`process`]es, and are +//! gated by the same [`capabilities`]. This crate holds that vocabulary so +//! each domain can live in its own crate; assembling a whole manifest stays +//! with the plugin host in `lithe-core`. +//! +//! Note: the contract is `shared/plugins/contract/v1/`; both products must +//! produce the same outcome for its fixtures. + +pub mod capabilities; +pub mod executables; +mod icon; +pub mod installed; +mod manifest; +mod names; +pub mod process; +mod rejection; +pub mod settings; +mod template; +mod version; + +pub use icon::UIIcon; +pub use manifest::PluginManifest; +pub use names::{ + is_bare_command, is_function_id, is_identifier, is_local_tool_id, is_official_namespace, + is_plugin_id, is_safe_relative_path, is_semantic_version, is_tool_id, is_tool_namespace, + sorted_unique, MAX_FUNCTION_ID_BYTES, OFFICIAL_PLUGIN_NAMESPACE, +}; +pub use rejection::{ + decode_payload, invalid, invalid_template, outcome, PluginRejection, Validation, +}; +pub use template::{parse_template_with, regex_literal, TemplatePart}; +pub use version::{reported_version, validate_minimum_version, version_components, PluginVersion}; diff --git a/rust/crates/plugin-model/src/manifest.rs b/rust/crates/plugin-model/src/manifest.rs new file mode 100644 index 00000000..9f87ca02 --- /dev/null +++ b/rust/crates/plugin-model/src/manifest.rs @@ -0,0 +1,16 @@ +//! The plugin host's validated manifest, as a domain crate sees it. + +use crate::Validation; +use serde_json::Value; + +/// A whole `plugin.json` that the plugin host validated. A domain crate reads +/// its own section through a narrower trait built on this one, so it can +/// validate a manifest it receives without depending on the host that +/// assembles every section. +pub trait PluginManifest: Sized { + /// Validates a raw manifest value: every section and every rule that + /// spans sections. + fn validate(value: Value) -> Validation; + /// The plugin ID. + fn id(&self) -> &str; +} diff --git a/rust/crates/plugin-model/src/names.rs b/rust/crates/plugin-model/src/names.rs new file mode 100644 index 00000000..071c9bcd --- /dev/null +++ b/rust/crates/plugin-model/src/names.rs @@ -0,0 +1,121 @@ +//! The identifier alphabets of the manifest contract: plugin, function and +//! short IDs, package-relative paths, and bare command names. + +use std::collections::BTreeSet; + +/// ID prefix reserved to plugins published and signed by Lithe. +pub const OFFICIAL_PLUGIN_NAMESPACE: &str = "dev.lithe"; + +/// A package-relative path: not absolute, no `..` component, no backslash or NUL. +pub fn is_safe_relative_path(value: &str) -> bool { + !value.is_empty() + && !value.starts_with('/') + && !value.contains('\0') + && !value.contains('\\') + && !value.split('/').any(|component| component == "..") +} + +/// A bare command name the host looks up on `PATH`: no path separator, whitespace or NUL. +pub fn is_bare_command(value: &str) -> bool { + !value.is_empty() + && value != "." + && value != ".." + && !value.contains('/') + && !value.contains('\\') + && !value.contains('\0') + && !value.chars().any(char::is_whitespace) +} + +/// A short identifier: lowercase letters, digits and `-`. +pub fn is_identifier(value: &str) -> bool { + !value.is_empty() + && value.chars().all(|character| { + character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-' + }) +} + +/// True for IDs reserved to Lithe's official plugins (`dev.lithe` and +/// `dev.lithe.*`). Third-party sources and unsigned packages may not use it. +pub fn is_official_namespace(id: &str) -> bool { + id == OFFICIAL_PLUGIN_NAMESPACE || id.starts_with(&format!("{OFFICIAL_PLUGIN_NAMESPACE}.")) +} + +/// The manifest's plugin ID alphabet: lowercase letters, digits, `.` and `-`. +pub fn is_plugin_id(value: &str) -> bool { + !value.is_empty() + && value != "." + && value != ".." + && value.chars().all(|character| { + character.is_ascii_lowercase() || character.is_ascii_digit() || ".-".contains(character) + }) +} + +/// Exactly three dot-separated decimal components, such as `1.2.3`. +pub fn is_semantic_version(value: &str) -> bool { + let parts: Vec<&str> = value.split('.').collect(); + parts.len() == 3 + && parts.iter().all(|part| { + !part.is_empty() && part.chars().all(|character| character.is_ascii_digit()) + }) +} + +/// The values in ascending order without duplicates. +pub fn sorted_unique(values: Vec) -> Vec { + values + .into_iter() + .collect::>() + .into_iter() + .collect() +} + +/// Longest function or command ID a plugin may declare, in bytes. +pub const MAX_FUNCTION_ID_BYTES: usize = 128; + +/// Dot-separated lowerCamelCase segments, for example `snapshot.refresh`. +pub fn is_function_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_FUNCTION_ID_BYTES + && value.split('.').all(|segment| { + let mut characters = segment.chars(); + characters + .next() + .is_some_and(|first| first.is_ascii_lowercase()) + && characters.all(|character| character.is_ascii_alphanumeric()) + }) +} + +/// Longest tool ID, namespace and separator included, in bytes. +const MAX_TOOL_ID_BYTES: usize = 192; + +/// A tool ID: a short [local ID](is_local_tool_id), or `:` +/// with a [namespace](is_tool_namespace) such as a plugin ID. It says nothing +/// about which plugin defines the tool; that is the host registry's decision. +pub fn is_tool_id(value: &str) -> bool { + value.len() <= MAX_TOOL_ID_BYTES + && match value.split_once(':') { + Some((namespace, local)) => is_tool_namespace(namespace) && is_local_tool_id(local), + None => is_local_tool_id(value), + } +} + +/// The namespace of a qualified tool or provider ID: non-empty `.`- or +/// `-`-separated parts of lowercase letters and digits. +pub fn is_tool_namespace(value: &str) -> bool { + !value.is_empty() + && value.split(['.', '-']).all(|part| { + !part.is_empty() + && part + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) + }) +} + +/// The local part of a tool or provider ID: at most 64 bytes, a lowercase +/// letter first, then lowercase letters, digits and `-`. +pub fn is_local_tool_id(value: &str) -> bool { + value.len() <= 64 + && value.as_bytes().first().is_some_and(u8::is_ascii_lowercase) + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') +} diff --git a/rust/crates/plugin-model/src/process.rs b/rust/crates/plugin-model/src/process.rs new file mode 100644 index 00000000..98a7cb06 --- /dev/null +++ b/rust/crates/plugin-model/src/process.rs @@ -0,0 +1,822 @@ +//! Processes a plugin declares for the host to start: language servers, +//! build and VCS providers, plugin backends and debug adapters share one +//! launch declaration, one validation, and one install-review entry. +//! +//! Launch templates name host-owned locations (`${pluginDir}`, +//! `${stateDir}`, `${workspaceStateDir}`, `${logDir}`, `${arch}`) and plugin +//! settings (`${setting:}`), so a package never embeds a machine path. + +use crate::names::{is_bare_command, is_function_id, is_safe_relative_path}; +use crate::rejection::{invalid, invalid_template, PluginRejection, Validation}; +use crate::settings::{is_setting_identifier, DeclaredSettings}; +use crate::template::{parse_template_with, TemplatePart}; +use crate::version::validate_minimum_version; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::BTreeSet; + +/// Prefix of a launch template variable that names a plugin setting. +pub(crate) const SETTING_VARIABLE_PREFIX: &str = "setting:"; + +/// How the host starts a process that speaks the LSP base protocol. A +/// language server and a build-system provider are the same kind of process, +/// so both declare it with these fields and share their validation, install +/// review entries, and launch plan. Flattened into its owner, which rejects +/// unknown fields. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ServerProcess { + /// Where the executable comes from. + pub launch: LanguageServerLaunch, + /// Arguments after the executable; launch templates are expanded. + #[serde(default)] + pub arguments: Vec, + /// Arguments of a probe that must exit successfully before the host + /// trusts a candidate executable; empty means no probe. + #[serde(default)] + pub validation_arguments: Vec, + #[serde(default)] + runtime_dependencies: Vec, + /// LSP `initializationOptions` sent verbatim after template expansion of + /// its string values. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub initialization_options: Option, +} + +/// What a server process is for, which names it in rejection messages. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ServerRole { + /// A language server. + LanguageServer, + /// A build-system provider. + BuildProvider, + /// A VCS provider. + VcsProvider, + /// The plugin backend. + Backend, + /// A debug adapter. + DebugAdapter, +} + +/// Tools a server process may name for its launch and dependencies: the +/// tool-definition IDs the manifest lists in `requiresTools`. +#[derive(Debug, Clone, Copy)] +pub struct RuntimeTools<'a>(&'a BTreeSet); + +impl<'a> RuntimeTools<'a> { + /// The tools a manifest lists in `requiresTools`. + pub fn new(tools: &'a BTreeSet) -> Self { + Self(tools) + } + + /// Checks that one tool a process names is listed in `requiresTools`. + pub fn check(self, tool: &str, field: String) -> Validation<()> { + if self.0.contains(tool) { + Ok(()) + } else { + Err(PluginRejection::at( + "plugin.runtime.undeclared", + field, + format!("The tool {tool} is not listed in requiresTools."), + )) + } + } +} + +impl ServerRole { + fn noun(self) -> &'static str { + match self { + Self::LanguageServer => "language server", + Self::BuildProvider => "build provider", + Self::VcsProvider => "VCS provider", + Self::Backend => "plugin backend", + Self::DebugAdapter => "debug adapter", + } + } +} + +/// Where the language-server executable comes from. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)] +pub enum LanguageServerLaunch { + /// An executable inside the plugin package, relative to the package root. + Packaged { + /// Executable path, relative to the package root. + path: String, + }, + /// Bare command names the host resolves through its tool discovery. + SystemCommand { + /// Command names in preference order. + commands: Vec, + }, + /// A packaged entry file started by a host-discovered runtime, so the + /// package needs no launcher script. + Runtime { + /// Tool-definition ID, listed in `requiresTools`, whose launch + /// template starts the entry. + tool: String, + /// Lowest acceptable runtime version, for example `21` or `18.17`. + #[serde( + default, + rename = "minimumVersion", + skip_serializing_if = "Option::is_none" + )] + minimum_version: Option, + /// Runtime executable shipped inside the package, relative to its + /// root; it may vary by `${arch}`. When set, the host starts exactly + /// this file instead of discovering the tool on the machine. + #[serde( + default, + rename = "packagedRuntime", + skip_serializing_if = "Option::is_none" + )] + packaged_runtime: Option, + /// Runtime options placed before the entry. + #[serde(default, rename = "runtimeArguments")] + runtime_arguments: Vec, + /// Entry file inside the package, such as a script for `node`. + entry: String, + }, + /// The plugin backend resolves the command line each time the server + /// starts: the host calls `function` and passes its answer to + /// `plugins.languageServers.decodeLaunch`. Language servers only. + Backend { + /// ID of a function the plugin backend registers. + function: String, + /// How long the host waits for the function, within + /// `RESOLVE_TIMEOUT_MILLISECONDS`. + #[serde( + default, + rename = "timeoutMilliseconds", + skip_serializing_if = "Option::is_none" + )] + timeout_milliseconds: Option, + }, +} + +/// A tool the server process needs at run time, named by its tool-definition ID. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RuntimeDependency { + tool: String, +} + +/// Variables of language-server launch templates. They name host-owned +/// locations, so a package never embeds a machine path. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LaunchVariable { + /// Root of the installed, read-only plugin package. + PluginDir, + /// Writable directory the host keeps for this plugin's server and removes + /// when the plugin is uninstalled. + StateDir, + /// Writable directory for this server's state of the current workspace. + WorkspaceStateDir, + /// Absolute session plugin files directory supplied by the writable storage adapter. + LogDir, + /// Host CPU architecture, one of `KNOWN_ARCHITECTURES`. + Arch, + /// Current value of the plugin setting with this ID. + Setting(String), +} + +impl LaunchVariable { + /// The launch variable a `${name}` template names; `None` for an unknown name. + pub fn parse(name: &str) -> Option { + match name { + "pluginDir" => Some(Self::PluginDir), + "stateDir" => Some(Self::StateDir), + "workspaceStateDir" => Some(Self::WorkspaceStateDir), + "logDir" => Some(Self::LogDir), + "arch" => Some(Self::Arch), + _ => name + .strip_prefix(SETTING_VARIABLE_PREFIX) + .filter(|id| is_setting_identifier(id)) + .map(|id| Self::Setting(id.to_string())), + } + } +} + +/// Which launch value a template belongs to. The validation probe runs before +/// any workspace is open, so it cannot name state directories. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum LaunchTemplateContext { + Start, + ValidationProbe, +} + +impl ServerProcess { + /// Package files the launch starts, as (launch field, package-relative + /// path): a `packaged` executable, or a `runtime` entry and its shipped + /// runtime. A `${arch}` runtime path varies per machine, so only the + /// installer can resolve it and it is left out. + pub fn packaged_files(&self) -> Vec<(&'static str, &str)> { + match &self.launch { + LanguageServerLaunch::Packaged { path } => vec![("path", path)], + LanguageServerLaunch::Runtime { + entry, + packaged_runtime, + .. + } => { + let mut files = vec![("entry", entry.as_str())]; + if let Some(runtime) = packaged_runtime + .as_deref() + .filter(|path| !path.contains("${")) + { + files.push(("packagedRuntime", runtime)); + } + files + } + LanguageServerLaunch::SystemCommand { .. } | LanguageServerLaunch::Backend { .. } => { + Vec::new() + } + } + } + + /// Whether the plugin backend resolves the command line at start. + pub fn is_backend_launch(&self) -> bool { + matches!(self.launch, LanguageServerLaunch::Backend { .. }) + } + + /// Checks the launch, its arguments and probe, the initialization + /// options, and the runtime dependencies. Arguments and options are + /// launch templates. + pub fn validate( + &self, + field: &str, + declared_settings: &DeclaredSettings, + role: ServerRole, + runtime_tools: RuntimeTools<'_>, + ) -> Validation<()> { + let noun = role.noun(); + match &self.launch { + LanguageServerLaunch::Packaged { path } => { + if !is_safe_relative_path(path) { + return Err(invalid( + format!("{field}.launch.path"), + format!("The packaged {noun} must stay inside the plugin package."), + )); + } + } + LanguageServerLaunch::SystemCommand { commands } => { + if commands.is_empty() || commands.iter().any(|command| !is_bare_command(command)) { + return Err(invalid( + format!("{field}.launch.commands"), + "System commands must be bare executable names.", + )); + } + } + LanguageServerLaunch::Runtime { + tool, + minimum_version, + packaged_runtime, + runtime_arguments, + entry, + } => { + runtime_tools.check(tool, format!("{field}.launch.tool"))?; + if let Some(version) = minimum_version { + validate_minimum_version(version, &format!("{field}.launch.minimumVersion"))?; + } + if let Some(path) = packaged_runtime { + let runtime_field = format!("{field}.launch.packagedRuntime"); + if !is_packaged_runtime_path(path) { + return Err(invalid( + runtime_field, + "The packaged runtime must be a path inside the plugin package that varies only by ${arch}.", + )); + } + // The plugin ships the runtime it needs, so there is no + // discovered candidate whose version could be checked. + if minimum_version.is_some() { + return Err(invalid( + runtime_field, + "A packaged runtime cannot also state a minimum version.", + )); + } + } + if !is_safe_relative_path(entry) { + return Err(invalid( + format!("{field}.launch.entry"), + format!("The {noun} entry must stay inside the plugin package."), + )); + } + validate_launch_templates( + runtime_arguments, + LaunchTemplateContext::Start, + declared_settings, + &format!("{field}.launch.runtimeArguments"), + )?; + // The host's probe of a runtime is its version check; a second + // probe would have no defined command line. + if !self.validation_arguments.is_empty() { + return Err(invalid( + format!("{field}.validationArguments"), + "A runtime launch is verified by its minimum version, not by a probe.", + )); + } + } + LanguageServerLaunch::Backend { + function, + timeout_milliseconds, + } => { + return self.validate_backend_launch(field, function, *timeout_milliseconds, role); + } + } + if self + .arguments + .iter() + .chain(&self.validation_arguments) + .any(|argument| argument.contains('\0')) + { + return Err(invalid( + format!("{field}.arguments"), + format!("{} arguments must not contain NUL.", capitalized(noun)), + )); + } + validate_launch_templates( + &self.arguments, + LaunchTemplateContext::Start, + declared_settings, + &format!("{field}.arguments"), + )?; + validate_launch_templates( + &self.validation_arguments, + LaunchTemplateContext::ValidationProbe, + declared_settings, + &format!("{field}.validationArguments"), + )?; + if let Some(options) = &self.initialization_options { + let options_field = format!("{field}.initializationOptions"); + if !options.is_object() { + return Err(invalid( + options_field, + "initializationOptions must be a JSON object.", + )); + } + validate_launch_value(options, declared_settings, &options_field)?; + } + for dependency in &self.runtime_dependencies { + let dependencies_field = format!("{field}.runtimeDependencies"); + runtime_tools.check(&dependency.tool, dependencies_field.clone())?; + // The launch runtime is already discovered for the process, and + // only the launch declaration can state its version requirement. + if matches!(&self.launch, LanguageServerLaunch::Runtime { tool, .. } if *tool == dependency.tool) + { + return Err(invalid( + dependencies_field, + "The launch runtime must not be repeated as a runtime dependency.", + )); + } + } + Ok(()) + } + + /// Checks a launch the plugin backend resolves. Only a language server + /// may use it, and its command line, probe, options, and runtimes all + /// come from the backend's answer, so the manifest declares none of them. + fn validate_backend_launch( + &self, + field: &str, + function: &str, + timeout_milliseconds: Option, + role: ServerRole, + ) -> Validation<()> { + if role != ServerRole::LanguageServer { + return Err(invalid( + format!("{field}.launch.kind"), + format!( + "A {} cannot be started through the plugin backend.", + role.noun() + ), + )); + } + if !is_function_id(function) { + return Err(invalid( + format!("{field}.launch.function"), + "The launch function must be a plugin function ID.", + )); + } + validate_resolve_timeout( + timeout_milliseconds, + &format!("{field}.launch.timeoutMilliseconds"), + )?; + let declared = [ + ("arguments", !self.arguments.is_empty()), + ("validationArguments", !self.validation_arguments.is_empty()), + ("runtimeDependencies", !self.runtime_dependencies.is_empty()), + ( + "initializationOptions", + self.initialization_options.is_some(), + ), + ]; + if let Some((name, _)) = declared.iter().find(|(_, present)| *present) { + return Err(invalid( + format!("{field}.{name}"), + "A backend launch takes its command line and options from the backend's answer.", + )); + } + Ok(()) + } + + /// Install-review entry of the process, without its owner's ID: the + /// executable, its arguments and probe, and the initialization options. + pub fn program(&self, role: ServerRole) -> Value { + let suffix = match role { + ServerRole::LanguageServer => "LanguageServer", + ServerRole::BuildProvider => "BuildProvider", + ServerRole::VcsProvider => "VcsProvider", + ServerRole::Backend => "Backend", + ServerRole::DebugAdapter => "DebugAdapter", + }; + let mut program = match &self.launch { + LanguageServerLaunch::Packaged { path } => json!({ + "kind": format!("packaged{suffix}"), + "path": path, + "arguments": self.arguments, + "validationArguments": self.validation_arguments, + }), + LanguageServerLaunch::SystemCommand { commands } => json!({ + "kind": format!("system{suffix}"), + "commands": commands, + "arguments": self.arguments, + "validationArguments": self.validation_arguments, + }), + LanguageServerLaunch::Runtime { + tool, + packaged_runtime, + runtime_arguments, + entry, + .. + } => json!({ + "kind": format!("runtime{suffix}"), + "tool": tool, + "packagedRuntime": packaged_runtime, + "entry": entry, + "runtimeArguments": runtime_arguments, + "arguments": self.arguments, + "validationArguments": self.validation_arguments, + }), + // The backend chooses the command line when the server starts, + // so the review shows the function that will choose it. + LanguageServerLaunch::Backend { + function, + timeout_milliseconds, + } => json!({ + "kind": format!("backend{suffix}"), + "function": function, + "timeoutMilliseconds": timeout_milliseconds + .unwrap_or(RESOLVE_TIMEOUT_MILLISECONDS), + }), + }; + // Initialization options can name code the server loads (bundles or + // extensions, for example), so changing them is a new program too. + if let Some(options) = &self.initialization_options { + program["initializationOptions"] = options.clone(); + } + program + } + + /// Whether any start template names `${workspaceStateDir}`, which only + /// a process bound to one workspace can be given. + pub fn uses_workspace_state_directory(&self) -> bool { + let runtime_arguments = match &self.launch { + LanguageServerLaunch::Runtime { + runtime_arguments, .. + } => runtime_arguments.as_slice(), + _ => &[], + }; + let mentions = |text: &str| { + parse_template_with(text, LaunchVariable::parse) + .unwrap_or_default() + .iter() + .any(|part| { + matches!( + part, + TemplatePart::Variable(LaunchVariable::WorkspaceStateDir) + ) + }) + }; + fn strings<'a>(value: &'a Value, out: &mut Vec<&'a str>) { + match value { + Value::String(text) => out.push(text), + Value::Array(items) => items.iter().for_each(|item| strings(item, out)), + Value::Object(members) => members.values().for_each(|item| strings(item, out)), + _ => {} + } + } + let mut option_strings = Vec::new(); + if let Some(options) = &self.initialization_options { + strings(options, &mut option_strings); + } + self.arguments + .iter() + .chain(runtime_arguments) + .map(String::as_str) + .chain(option_strings) + .any(mentions) + } + + /// Install-review entries of the runtimes the host discovers for it. + pub fn runtime_programs(&self) -> Vec { + self.runtime_requirements() + .into_iter() + .map(|mut runtime| { + runtime["kind"] = json!("runtime"); + runtime + }) + .collect() + } + + /// Runtimes the host must discover for this process, sorted by tool. The + /// launch runtime comes first in declaration terms but is merged here so + /// hosts resolve every tool the same way. Every entry names a tool + /// definition, so every entry carries `kind: "tool"`. + pub fn runtime_requirements(&self) -> Vec { + let mut requirements: Vec<(&str, Option<&str>)> = self + .runtime_dependencies + .iter() + .map(|dependency| (dependency.tool.as_str(), None)) + .collect(); + // A packaged runtime is part of the plugin, not a host requirement. + if let LanguageServerLaunch::Runtime { + tool, + minimum_version, + packaged_runtime: None, + .. + } = &self.launch + { + requirements.push((tool.as_str(), minimum_version.as_deref())); + } + requirements.sort(); + requirements.dedup(); + requirements + .into_iter() + .map(|(tool, minimum_version)| { + let mut requirement = json!({ "tool": tool, "kind": "tool" }); + if let Some(version) = minimum_version { + requirement["minimumVersion"] = json!(version); + } + requirement + }) + .collect() + } +} + +fn capitalized(text: &str) -> String { + let mut characters = text.chars(); + characters + .next() + .map(|first| first.to_uppercase().chain(characters).collect()) + .unwrap_or_default() +} + +/// A packaged runtime path is package-relative text whose only variable is +/// `${arch}`, so one package can carry a runtime per architecture. +fn is_packaged_runtime_path(value: &str) -> bool { + let Some(parts) = parse_template_with(value, LaunchVariable::parse) else { + return false; + }; + parts.iter().all(|part| { + matches!( + part, + TemplatePart::Text(_) | TemplatePart::Variable(LaunchVariable::Arch) + ) + }) && is_safe_relative_path(&value.replace("${arch}", "arch")) +} + +/// Checks the start and probe arguments of a process that has no launch of +/// its own to validate, such as a debug adapter on the selected runtime. +pub fn validate_process_arguments( + arguments: &[String], + validation_arguments: &[String], + declared_settings: &DeclaredSettings, + field: &str, +) -> Validation<()> { + if arguments + .iter() + .chain(validation_arguments) + .any(|argument| argument.contains('\0')) + { + return Err(invalid( + format!("{field}.arguments"), + "Process arguments must not contain NUL.", + )); + } + validate_launch_templates( + arguments, + LaunchTemplateContext::Start, + declared_settings, + &format!("{field}.arguments"), + )?; + validate_launch_templates( + validation_arguments, + LaunchTemplateContext::ValidationProbe, + declared_settings, + &format!("{field}.validationArguments"), + ) +} + +pub(crate) fn validate_launch_templates( + values: &[String], + context: LaunchTemplateContext, + declared_settings: &DeclaredSettings, + field: &str, +) -> Validation<()> { + for (index, value) in values.iter().enumerate() { + validate_launch_template( + value, + context, + declared_settings, + &format!("{field}[{index}]"), + )?; + } + Ok(()) +} + +/// Collects setting IDs referenced by every template string in a JSON value. +pub fn collect_setting_references(value: &Value, references: &mut BTreeSet) { + match value { + Value::String(text) => { + if let Some(parts) = parse_template_with(text, LaunchVariable::parse) { + for part in parts { + if let TemplatePart::Variable(LaunchVariable::Setting(id)) = part { + references.insert(id); + } + } + } + } + Value::Array(items) => { + for item in items { + collect_setting_references(item, references); + } + } + Value::Object(members) => { + for item in members.values() { + collect_setting_references(item, references); + } + } + _ => {} + } +} + +/// Checks every string inside a JSON value as a start-time launch template. +pub fn validate_launch_value( + value: &Value, + declared_settings: &DeclaredSettings, + field: &str, +) -> Validation<()> { + match value { + Value::String(text) => { + validate_launch_template(text, LaunchTemplateContext::Start, declared_settings, field) + } + Value::Array(items) => items.iter().enumerate().try_for_each(|(index, item)| { + validate_launch_value(item, declared_settings, &format!("{field}[{index}]")) + }), + Value::Object(members) => members.iter().try_for_each(|(key, item)| { + validate_launch_value(item, declared_settings, &format!("{field}.{key}")) + }), + _ => Ok(()), + } +} + +pub(crate) fn validate_launch_template( + value: &str, + context: LaunchTemplateContext, + declared_settings: &DeclaredSettings, + field: &str, +) -> Validation<()> { + if value.contains('\0') { + return Err(invalid_template( + field.to_string(), + "Launch values must not contain NUL.", + )); + } + let Some(parts) = parse_template_with(value, LaunchVariable::parse) else { + return Err(invalid_template( + field.to_string(), + "The value uses an unknown or unterminated template variable.", + )); + }; + let mut package_reference = false; + for (index, part) in parts.iter().enumerate() { + match part { + // A setting must exist, may not shape a package path, and is not + // available to the probe, which runs before settings are read. + TemplatePart::Variable(LaunchVariable::Setting(id)) => { + if context == LaunchTemplateContext::ValidationProbe { + return Err(invalid_template( + field.to_string(), + "The validation probe cannot use settings.", + )); + } + if declared_settings.is_immediate(id) { + return Err(invalid_template( + field.to_string(), + format!( + "The setting {id} applies immediately, so only workspaceConfiguration may use it." + ), + )); + } + if !declared_settings.contains(id) { + return Err(invalid_template( + field.to_string(), + format!("The value refers to the undeclared setting {id}."), + )); + } + // Text and paths are a whole value, or the value of one + // `name=` argument, never part of a longer one. + if declared_settings.requires_whole_value(id) { + let whole = match parts.as_slice() { + [_] => true, + [TemplatePart::Text(prefix), _] => prefix.ends_with('='), + _ => false, + }; + if !whole || index + 1 != parts.len() { + return Err(invalid_template( + field.to_string(), + format!( + "The text or path setting {id} must be a whole value or follow a name= prefix." + ), + )); + } + } + if package_reference { + return Err(invalid_template( + field.to_string(), + "A package path may only vary by ${arch}.", + )); + } + } + TemplatePart::Variable( + LaunchVariable::StateDir + | LaunchVariable::WorkspaceStateDir + | LaunchVariable::LogDir, + ) if context == LaunchTemplateContext::ValidationProbe => { + return Err(invalid_template( + field.to_string(), + "The validation probe runs without a workspace and cannot use state directories.", + )); + } + // Everything after `${pluginDir}` is a package path the host + // verifies before launch, so it has to be `/`-rooted text and may + // only vary by architecture. + TemplatePart::Variable(LaunchVariable::PluginDir) => { + let rooted = matches!( + parts.get(index + 1), + Some(TemplatePart::Text(text)) if text.starts_with('/') && text.len() > 1 + ); + if package_reference || !rooted { + return Err(invalid_template( + field.to_string(), + "${pluginDir} must be used once and be followed by a path inside the package.", + )); + } + package_reference = true; + } + TemplatePart::Variable(variable) + if package_reference && *variable != LaunchVariable::Arch => + { + return Err(invalid_template( + field.to_string(), + "A package path may only vary by ${arch}.", + )); + } + TemplatePart::Text(text) + if package_reference + && (text.contains('\\') + || text.split('/').any(|component| component == "..")) => + { + return Err(invalid_template( + field.to_string(), + "A package path must stay inside the plugin package.", + )); + } + _ => {} + } + } + Ok(()) +} + +/// How long the host waits for a launch function by default, in +/// milliseconds; a manifest may choose a value within the bounds below. +pub const RESOLVE_TIMEOUT_MILLISECONDS: u64 = 30_000; + +pub(crate) const MIN_RESOLVE_TIMEOUT_MILLISECONDS: u64 = 1_000; + +pub(crate) const MAX_RESOLVE_TIMEOUT_MILLISECONDS: u64 = 120_000; + +/// Checks `launch.timeoutMilliseconds` of a backend launch. +pub fn validate_resolve_timeout(value: Option, field: &str) -> Validation<()> { + match value { + Some(milliseconds) + if !(MIN_RESOLVE_TIMEOUT_MILLISECONDS..=MAX_RESOLVE_TIMEOUT_MILLISECONDS) + .contains(&milliseconds) => + { + Err(invalid( + field, + "timeoutMilliseconds must be between 1000 and 120000.", + )) + } + _ => Ok(()), + } +} diff --git a/rust/crates/plugin-model/src/rejection.rs b/rust/crates/plugin-model/src/rejection.rs new file mode 100644 index 00000000..090a2a2b --- /dev/null +++ b/rust/crates/plugin-model/src/rejection.rs @@ -0,0 +1,94 @@ +//! How a manifest or plan request is refused: a stable `plugin.*` code with a +//! message and the offending field, and the `ok`/`rejected` outcome every +//! declarative command answers with. +//! +//! Content problems are rejections, not Core failures, so a host can show them +//! next to the plugin. Only a payload that does not decode is a Core +//! `invalid_request` failure ([`decode_payload`]). + +use lithe_protocol::{CoreError, ErrorCode}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +/// Stable reason a declarative manifest or plan request was refused. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PluginRejection { + /// Stable `plugin.*` code listed in the contract README. + pub code: &'static str, + /// User-facing English summary without host paths. + pub message: String, + /// JSON path of the offending manifest field, when one applies. + #[serde(skip_serializing_if = "Option::is_none")] + pub field: Option, +} + +impl PluginRejection { + /// The stable rejection code, such as `plugin.manifest.invalid`. + pub fn code(&self) -> &'static str { + self.code + } + + /// The manifest or request field the rejection points at, if any. + pub fn field(&self) -> Option<&str> { + self.field.as_deref() + } + + /// A rejection that points at no field. + pub fn new(code: &'static str, message: impl Into) -> Self { + Self { + code, + message: message.into(), + field: None, + } + } + + /// A rejection that points at `field`. + pub fn at(code: &'static str, field: impl Into, message: impl Into) -> Self { + Self { + code, + message: message.into(), + field: Some(field.into()), + } + } +} + +/// The result of validating plugin content: the value, or why it was refused. +pub type Validation = Result; + +/// A `plugin.manifest.invalid` rejection at `field`. +pub fn invalid(field: impl Into, message: impl Into) -> PluginRejection { + PluginRejection::at("plugin.manifest.invalid", field, message) +} + +/// A `plugin.template.invalid` rejection at `field`. +pub fn invalid_template(field: impl Into, message: impl Into) -> PluginRejection { + PluginRejection::at("plugin.template.invalid", field, message) +} + +/// Decodes a command payload; a payload that does not match is an invalid request. +pub fn decode_payload Deserialize<'de>>( + payload: Value, + command: &str, +) -> Result { + serde_json::from_value(payload).map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + format!("Invalid {command} payload"), + ) + .with_details(error.to_string()) + }) +} + +/// The command result for a validation: `status: ok` with the value under +/// `key`, or `status: rejected` with the rejection. +pub fn outcome(result: Validation<(&'static str, Value)>) -> Value { + match result { + Ok((key, value)) => { + let mut object = serde_json::Map::new(); + object.insert("status".to_string(), json!("ok")); + object.insert(key.to_string(), value); + Value::Object(object) + } + Err(rejection) => json!({ "status": "rejected", "error": rejection }), + } +} diff --git a/rust/crates/core/src/plugins/settings.rs b/rust/crates/plugin-model/src/settings.rs similarity index 93% rename from rust/crates/core/src/plugins/settings.rs rename to rust/crates/plugin-model/src/settings.rs index a0baecce..2bd8e048 100644 --- a/rust/crates/core/src/plugins/settings.rs +++ b/rust/crates/plugin-model/src/settings.rs @@ -14,19 +14,20 @@ //! //! Note: the decision is recorded in .agents/notes/implemented/architecture/2026-10-21-plugin-settings-surfaces.md -#[path = "settings_documents.rs"] mod documents; -pub(crate) use documents::{document_state, edit_document, SettingsEdit}; -#[path = "settings_pages.rs"] +pub use documents::{document_state, edit_document, SettingsEdit}; mod pages; -pub(crate) use pages::page_sections; +pub use pages::page_sections; -use super::declarative::{invalid, PluginRejection, Validation}; +use crate::rejection::{invalid, PluginRejection, Validation}; use regex::Regex; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::{BTreeMap, BTreeSet}; +/// Most settings one plugin may declare, so its pages stay pages. +pub const MAX_SETTINGS: usize = 32; + /// A whole number within a declared range. const SETTING_INTEGER: &str = "integer"; /// One of a declared list of values. @@ -46,7 +47,7 @@ const MAX_ENUM_OPTIONS: usize = 16; /// Most choices one multiSelect setting may offer. const MAX_MULTI_SELECT_OPTIONS: usize = 32; /// Longest setting ID, option value, title, unit, and section heading. -pub(crate) const MAX_SETTING_TEXT: usize = 120; +pub const MAX_SETTING_TEXT: usize = 120; /// Longest pattern a text setting may declare. const MAX_PATTERN: usize = 256; /// Default and largest `maxLength` of a text setting. @@ -58,7 +59,7 @@ const MAX_PATH_LENGTH: usize = 1024; /// Where a project may replace the value the user chose for every project. #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] -pub(crate) enum SettingScope { +pub enum SettingScope { /// One value for every workspace. Global, /// A workspace may override the global value. @@ -69,7 +70,7 @@ pub(crate) enum SettingScope { /// what to do after a change: nothing, push it, restart, or reload. #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] -pub(crate) enum SettingEffect { +pub enum SettingEffect { /// The next start of the plugin's processes uses it. Restart, /// Pushed to running language servers through `workspaceConfiguration`. @@ -85,24 +86,24 @@ pub(crate) enum SettingEffect { /// declares; the host shows the page while any plugin contributes to it. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) enum SettingPage { - /// Languages & Frameworks › . +pub enum SettingPage { + /// Languages & Frameworks › *language*. #[serde(rename = "languageID")] Language(String), - /// Build Tools › . + /// Build Tools › *build system*. #[serde(rename = "buildSystemID")] BuildSystem(String), } /// A button the host draws on a settings page that invokes one of the same -/// plugin's [plugin functions](crate::lsp::interface::plugin_function). The +/// plugin's plugin functions (see [`crate::is_function_id`]). The /// host owns the button; the plugin owns what the function does, so a /// "Reindex" or "Download sources" action needs no host code. #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct SettingAction { +pub struct SettingAction { /// Identifier, unique among the plugin's actions. - pub(crate) id: String, + pub id: String, /// Button label. title: String, /// Explanation shown next to the button. @@ -125,7 +126,7 @@ pub(crate) struct SettingAction { /// Resolves one declared action without native I/O or runtime admission. Only /// an explicit native confirmation allows a confirmation-required invocation. -pub(crate) fn plan_action( +pub fn plan_action( plugin_id: &str, actions: &[SettingAction], action_id: &str, @@ -161,10 +162,11 @@ pub(crate) fn plan_action( } /// Most actions one plugin may declare. -pub(crate) const MAX_SETTING_ACTIONS: usize = 16; +pub const MAX_SETTING_ACTIONS: usize = 16; impl SettingAction { - pub(crate) fn validate(&self, field: &str) -> Validation<()> { + /// Checks the action as the manifest entry at `field`. + pub fn validate(&self, field: &str) -> Validation<()> { if !is_setting_identifier(&self.id) { return Err(invalid( format!("{field}.id"), @@ -196,7 +198,7 @@ impl SettingAction { if let Some(page) = &self.page { check_page(page, field)?; } - if !crate::lsp::interface::plugin_function::is_function_id(&self.function) { + if !crate::names::is_function_id(&self.function) { return Err(invalid( format!("{field}.function"), "An action names a plugin function ID.", @@ -225,9 +227,9 @@ fn check_page(page: &SettingPage, field: &str) -> Validation<()> { /// it cannot add arguments or select files inside the package. #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct SettingDeclaration { +pub struct SettingDeclaration { /// Identifier used in `${setting:}` and as the stored key. - pub(crate) id: String, + pub id: String, /// One of the `SETTING_*` kinds. #[serde(rename = "type")] kind: String, @@ -287,7 +289,7 @@ struct SettingOption { /// Setting IDs a template may name, with the restrictions their kinds carry. #[derive(Debug, Clone, Default)] -pub(crate) struct DeclaredSettings { +pub struct DeclaredSettings { /// Global paths that may explicitly select a build executable. executables: BTreeSet, /// Settings a launch reads when it starts. @@ -303,42 +305,40 @@ pub(crate) struct DeclaredSettings { impl DeclaredSettings { /// Whether the global file setting may select the build executable. - pub(crate) fn is_executable(&self, id: &str) -> bool { + pub fn is_executable(&self, id: &str) -> bool { self.executables.contains(id) } /// Whether the setting holds the path of one file, so a declaration can /// read the file the user chose. - pub(crate) fn is_file(&self, id: &str) -> bool { + pub fn is_file(&self, id: &str) -> bool { self.files.contains(id) } /// Whether a start-time template may name the setting. - pub(crate) fn contains(&self, id: &str) -> bool { + pub fn contains(&self, id: &str) -> bool { self.launch.contains(id) } /// Whether the setting exists but only applies through /// `workspaceConfiguration`. - pub(crate) fn is_immediate(&self, id: &str) -> bool { + pub fn is_immediate(&self, id: &str) -> bool { self.immediate.contains(id) } /// Whether a template must use the setting as a whole value. - pub(crate) fn requires_whole_value(&self, id: &str) -> bool { + pub fn requires_whole_value(&self, id: &str) -> bool { self.whole_value.contains(id) } - pub(crate) fn first_unreferenced_immediate( - &self, - references: &BTreeSet, - ) -> Option { + /// An `immediate` setting that none of `references` names, if any. + pub fn first_unreferenced_immediate(&self, references: &BTreeSet) -> Option { self.immediate.difference(references).next().cloned() } /// The settings `workspaceConfiguration` may name: every one, since /// the host re-expands it whenever a value changes. - pub(crate) fn for_configuration(&self) -> Self { + pub fn for_configuration(&self) -> Self { let mut all = self.clone(); all.launch.extend(self.immediate.iter().cloned()); all.immediate.clear(); @@ -349,34 +349,40 @@ impl DeclaredSettings { /// Layer the effective value of a setting came from. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] -pub(crate) enum SettingSource { +pub enum SettingSource { + /// The declared default. Default, + /// The user's global value. Global, + /// The project's value. Project, } /// Effective value of one setting and the layer it came from. #[derive(Debug, Clone, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] -pub(crate) struct ResolvedSetting { - pub(crate) id: String, - pub(crate) value: Value, - pub(crate) source: SettingSource, +pub struct ResolvedSetting { + /// Setting ID. + pub id: String, + /// Effective value. + pub value: Value, + /// Layer the value came from. + pub source: SettingSource, } /// Values a host stored for one plugin's settings. #[derive(Debug, Clone, Copy)] -pub(crate) struct SettingLayers<'a> { +pub struct SettingLayers<'a> { /// The user's value for every workspace, by setting ID. - pub(crate) global: &'a BTreeMap, + pub global: &'a BTreeMap, /// The current workspace's overrides, by setting ID. - pub(crate) project: &'a BTreeMap, + pub project: &'a BTreeMap, } impl SettingDeclaration { /// Unlike display/configuration settings, an explicit invalid executable /// value must terminate resolution rather than silently choose a default. - pub(crate) fn executable_value(&self, stored: Option<&Value>) -> Validation> { + pub fn executable_value(&self, stored: Option<&Value>) -> Validation> { let value = stored.unwrap_or(&self.default); let valid = self.accepts(value) && value.as_str().is_some_and(|path| { @@ -422,7 +428,7 @@ impl SettingDeclaration { } /// Whether `value` is a value this setting can hold. - pub(crate) fn accepts(&self, value: &Value) -> bool { + pub fn accepts(&self, value: &Value) -> bool { match self.kind.as_str() { SETTING_INTEGER => value.as_i64().is_some_and(|number| { self.minimum.is_none_or(|minimum| number >= minimum) @@ -459,7 +465,7 @@ impl SettingDeclaration { } /// Checks the declaration against the `settings` capability contract. - pub(crate) fn validate(&self, field: &str) -> Validation<()> { + pub fn validate(&self, field: &str) -> Validation<()> { if !is_setting_identifier(&self.id) { return Err(invalid( format!("{field}.id"), @@ -661,7 +667,7 @@ impl SettingDeclaration { } /// Checks a manifest's settings and returns what templates may name. -pub(crate) fn declared(settings: &[SettingDeclaration]) -> DeclaredSettings { +pub fn declared(settings: &[SettingDeclaration]) -> DeclaredSettings { let mut declared = DeclaredSettings::default(); for setting in settings { if setting.kind == SETTING_FILE { @@ -692,10 +698,7 @@ pub(crate) fn declared(settings: &[SettingDeclaration]) -> DeclaredSettings { /// the setting allows one, then the global value, then the default. A stored /// value the declaration no longer accepts is skipped, because a plugin /// update may narrow a range and a launch must not fail for that. -pub(crate) fn resolve( - settings: &[SettingDeclaration], - layers: SettingLayers<'_>, -) -> Vec { +pub fn resolve(settings: &[SettingDeclaration], layers: SettingLayers<'_>) -> Vec { settings .iter() .map(|setting| { @@ -723,7 +726,7 @@ pub(crate) fn resolve( } /// Effective values by setting ID, as launch templates read them. -pub(crate) fn effective_values( +pub fn effective_values( settings: &[SettingDeclaration], layers: SettingLayers<'_>, ) -> BTreeMap { @@ -735,7 +738,7 @@ pub(crate) fn effective_values( /// Text a setting contributes inside a longer template: the number, /// `true`/`false`, the text, or the chosen options joined by commas. -pub(crate) fn setting_text(value: Option<&Value>) -> String { +pub fn setting_text(value: Option<&Value>) -> String { match value { Some(Value::String(text)) => text.clone(), Some(Value::Array(items)) => items @@ -748,7 +751,9 @@ pub(crate) fn setting_text(value: Option<&Value>) -> String { } } -pub(crate) fn is_setting_identifier(value: &str) -> bool { +/// A setting ID: a lowercase letter followed by ASCII letters and digits, +/// within the text bound. +pub fn is_setting_identifier(value: &str) -> bool { let mut characters = value.chars(); characters .next() @@ -950,7 +955,3 @@ mod tests { assert!(wrong.validate("settings[0]").is_err()); } } - -#[cfg(test)] -#[path = "settings_document_tests.rs"] -mod document_tests; diff --git a/rust/crates/core/src/plugins/settings_documents.rs b/rust/crates/plugin-model/src/settings/documents.rs similarity index 97% rename from rust/crates/core/src/plugins/settings_documents.rs rename to rust/crates/plugin-model/src/settings/documents.rs index e41975ac..c8f9a7cb 100644 --- a/rust/crates/core/src/plugins/settings_documents.rs +++ b/rust/crates/plugin-model/src/settings/documents.rs @@ -6,8 +6,9 @@ use super::{ resolve, SettingAction, SettingDeclaration, SettingEffect, SettingLayers, SettingScope, }; -use crate::plugins::declarative::{is_plugin_id, PluginRejection, Validation}; -use crate::protocol::json_document::{parse_unique_json, UniqueMap}; +use crate::names::is_plugin_id; +use crate::rejection::{PluginRejection, Validation}; +use lithe_protocol::json_document::{parse_unique_json, UniqueMap}; use serde::Deserialize; use serde_json::{json, Value}; use std::collections::{BTreeMap, BTreeSet}; @@ -19,7 +20,7 @@ const MAX_DOCUMENT_BYTES: usize = 1024 * 1024; /// override and exposes inheritance. #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] -pub(crate) struct SettingsEdit { +pub struct SettingsEdit { /// Declaration identifier, unique within the complete edit batch. pub(crate) id: String, /// A declaration-validated value, or null to remove only this layer. @@ -65,7 +66,7 @@ fn read_document(content: Option<&str>, field: &str) -> Validation { /// Projects declared rows and raw launch layers from complete native documents. /// Unknown settings remain in storage but are not sent across the UI boundary. -pub(crate) fn document_state( +pub fn document_state( plugin: &str, declarations: &[SettingDeclaration], actions: &[SettingAction], @@ -176,7 +177,7 @@ fn editor_input(declaration: &SettingDeclaration, value: &Value) -> Value { /// Validates the entire batch before editing one layer. Siblings and dormant /// values survive; the host receives text to publish, never an I/O instruction. -pub(crate) fn edit_document( +pub fn edit_document( plugin: &str, declarations: &[SettingDeclaration], scope: SettingScope, @@ -184,7 +185,7 @@ pub(crate) fn edit_document( edits: Vec, ) -> Validation { let mut document = read_document(content, "document")?; - if edits.is_empty() || edits.len() > crate::plugins::declarative::MAX_SETTINGS { + if edits.is_empty() || edits.len() > super::MAX_SETTINGS { return Err(PluginRejection::at( "plugin.settings.invalidEdit", "edits", diff --git a/rust/crates/core/src/plugins/settings_pages.rs b/rust/crates/plugin-model/src/settings/pages.rs similarity index 83% rename from rust/crates/core/src/plugins/settings_pages.rs rename to rust/crates/plugin-model/src/settings/pages.rs index 06e9c31d..ab91286a 100644 --- a/rust/crates/core/src/plugins/settings_pages.rs +++ b/rust/crates/plugin-model/src/settings/pages.rs @@ -4,11 +4,13 @@ use super::{SettingAction, SettingDeclaration, SettingPage}; use serde_json::{json, Value}; use std::collections::BTreeMap; +/// Row and action indices placed in one section. +type SectionIndices = (Vec, Vec); + /// Groups only explicitly placed rows and actions. Unplaced declarations remain /// on the plugin's own page; an action alone is sufficient to create a page. -pub(crate) fn page_sections(rows: &[SettingDeclaration], actions: &[SettingAction]) -> Vec { - let mut pages: BTreeMap, Vec)>> = - BTreeMap::new(); +pub fn page_sections(rows: &[SettingDeclaration], actions: &[SettingAction]) -> Vec { + let mut pages: BTreeMap> = BTreeMap::new(); for (index, row) in rows.iter().enumerate() { if let Some(page) = &row.page { pages diff --git a/rust/crates/plugin-model/src/template.rs b/rust/crates/plugin-model/src/template.rs new file mode 100644 index 00000000..0b19afda --- /dev/null +++ b/rust/crates/plugin-model/src/template.rs @@ -0,0 +1,50 @@ +//! The `${...}` template syntax shared by every template family: launch, +//! run and test, build-system and project-generator templates each parse it +//! with their own variable names. + +/// One piece of a parsed `${...}` template: literal text or a variable of the +/// template family that parsed it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TemplatePart { + /// Literal text. + Text(String), + /// A variable of the template family. + Variable(Variable), +} + +/// Splits a template into text and variables, using `parse` for the variable +/// names of one template family. `None` for an unknown or unterminated +/// variable. Build-system templates share this parser with plugin templates. +pub fn parse_template_with( + value: &str, + parse: fn(&str) -> Option, +) -> Option>> { + let mut parts = Vec::new(); + let mut rest = value; + while let Some(start) = rest.find("${") { + if start > 0 { + parts.push(TemplatePart::Text(rest[..start].to_string())); + } + let after = &rest[start + 2..]; + let end = after.find('}')?; + parts.push(TemplatePart::Variable(parse(&after[..end])?)); + rest = &after[end + 1..]; + } + if !rest.is_empty() { + parts.push(TemplatePart::Text(rest.to_string())); + } + Some(parts) +} + +/// Escapes regular-expression metacharacters so a test name matches only +/// literally, in both PCRE (PHPUnit) and RE2 (Go) syntax. +pub fn regex_literal(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for character in value.chars() { + if "\\*?+[](){}^$|.".contains(character) { + escaped.push('\\'); + } + escaped.push(character); + } + escaped +} diff --git a/rust/crates/plugin-model/src/version.rs b/rust/crates/plugin-model/src/version.rs new file mode 100644 index 00000000..9c7e2c5c --- /dev/null +++ b/rust/crates/plugin-model/src/version.rs @@ -0,0 +1,76 @@ +//! Numeric versions in manifests and in a runtime's version output. + +use crate::rejection::{invalid, Validation}; + +/// A minimum version is one to three dot-separated numbers; hosts compare it +/// numerically against the leading components of the runtime's own version. +pub fn validate_minimum_version(value: &str, field: &str) -> Validation<()> { + let parts: Vec<&str> = value.split('.').collect(); + let numeric = parts.iter().all(|part| { + !part.is_empty() + && part.len() <= 9 + && part.chars().all(|character| character.is_ascii_digit()) + }); + if numeric && parts.len() <= 3 { + Ok(()) + } else { + Err(invalid( + field.to_string(), + "A minimum version must be one to three dot-separated numbers.", + )) + } +} + +/// The numeric components of a dotted version; non-numeric components are skipped. +pub fn version_components(value: &str) -> Vec { + value + .split('.') + .filter_map(|part| part.parse().ok()) + .collect() +} + +/// First dotted number in a version probe's output, at most three +/// components: `20.11.0` from `v20.11.0`, `1.22.4` from +/// `go version go1.22.4 darwin/arm64`. A tool whose output needs more than +/// this is checked through its tool definition instead. +pub fn reported_version(output: &str) -> Option> { + let line = output + .lines() + .find(|line| line.contains(|character: char| character.is_ascii_digit()))?; + let start = line.find(|character: char| character.is_ascii_digit())?; + let text: String = line[start..] + .chars() + .take_while(|character| character.is_ascii_digit() || *character == '.') + .collect(); + // A sentence-ending period is not a component. + text.split('.') + .take_while(|part| !part.is_empty()) + .take(3) + .map(|part| part.parse().ok()) + .collect() +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +/// Strict three-component semantic version used for compatibility comparisons. +pub struct PluginVersion { + /// Breaking-change component. + pub major: u32, + /// Backward-compatible feature component. + pub minor: u32, + /// Backward-compatible fix component. + pub patch: u32, +} + +impl PluginVersion { + /// Parses exactly `major.minor.patch`; prerelease tags and missing parts are + /// rejected because the manifest contract does not define their ordering. + pub fn parse(value: &str) -> Option { + let mut parts = value.split('.'); + let version = Self { + major: parts.next()?.parse().ok()?, + minor: parts.next()?.parse().ok()?, + patch: parts.next()?.parse().ok()?, + }; + parts.next().is_none().then_some(version) + } +} diff --git a/rust/crates/plugin-native-ui/Cargo.toml b/rust/crates/plugin-native-ui/Cargo.toml new file mode 100644 index 00000000..23c5621f --- /dev/null +++ b/rust/crates/plugin-native-ui/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "lithe-plugin-native-ui" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Versioned native plugin presentation: bounded view data, stable identities and named actions" + +[dependencies] +lithe-plugin-model = { path = "../plugin-model" } +lithe-protocol = { path = "../protocol" } +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0", features = ["preserve_order"] } diff --git a/rust/crates/core/src/plugins/native_ui.rs b/rust/crates/plugin-native-ui/src/lib.rs similarity index 96% rename from rust/crates/core/src/plugins/native_ui.rs rename to rust/crates/plugin-native-ui/src/lib.rs index fb172426..63b35788 100644 --- a/rust/crates/core/src/plugins/native_ui.rs +++ b/rust/crates/plugin-native-ui/src/lib.rs @@ -8,7 +8,7 @@ use std::collections::BTreeSet; /// A host-rendered tree, list or form; styling is deliberately absent from the contract. #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(super) struct NativeUI { +pub struct NativeUI { schema_version: u32, id: String, kind: Kind, @@ -19,8 +19,9 @@ pub(super) struct NativeUI { buttons: Vec, #[serde(skip_serializing_if = "Option::is_none")] layout: Option, + /// Plugin function that supplies the rows, when the host fetches them. #[serde(skip_serializing_if = "Option::is_none")] - pub(super) source: Option, + pub source: Option, actions: Vec, toolbar: Vec, context_menu: Vec, @@ -142,9 +143,10 @@ struct FormButton { /// Existing plugin function that returns `{ nodes }` for a detected build instance. #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(super) struct Source { +pub struct Source { + /// Build system whose detected instances the function serves. #[serde(rename = "buildSystemID")] - pub(super) build_system_id: String, + pub build_system_id: String, function: String, } @@ -155,7 +157,7 @@ struct Action { id: String, title: String, #[serde(skip_serializing_if = "Option::is_none")] - icon: Option, + icon: Option, kind: ActionKind, #[serde(skip_serializing_if = "Option::is_none")] function: Option, @@ -183,7 +185,8 @@ fn valid_info(value: Option<&str>) -> bool { } impl NativeUI { - pub(super) fn validate(&self) -> Result<(), &'static str> { + /// Checks the bounds and cross-references the contract promises the host. + pub fn validate(&self) -> Result<(), &'static str> { if self.schema_version != 0 || !identifier(&self.id) || self.actions.len() > 64 { return Err("Native UI requires schemaVersion 0, a stable ID and at most 64 actions."); } @@ -388,9 +391,9 @@ impl NativeUI { /// Validates a refreshed node set with the same identity and budget rules as /// the initial declaration. Platform renderers never maintain a second validator. -pub(crate) fn validate_data_command( +pub fn validate_data_command( payload: serde_json::Value, -) -> Result { +) -> Result { use serde_json::{json, Value}; #[derive(Deserialize)] #[serde(deny_unknown_fields)] @@ -404,10 +407,7 @@ pub(crate) fn validate_data_command( nodes: Vec, } let mut payload: Payload = serde_json::from_value(payload).map_err(|error| { - crate::protocol::CoreError::new( - crate::protocol::ErrorCode::InvalidRequest, - error.to_string(), - ) + lithe_protocol::CoreError::new(lithe_protocol::ErrorCode::InvalidRequest, error.to_string()) })?; let validate = || -> Result { if serde_json::to_vec(&payload.data) diff --git a/rust/crates/plugin-output-links/Cargo.toml b/rust/crates/plugin-output-links/Cargo.toml new file mode 100644 index 00000000..1d3fefd4 --- /dev/null +++ b/rust/crates/plugin-output-links/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "lithe-plugin-output-links" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Output link matchers plugins declare, console-line matching and resolution to workspace locations" + +[dependencies] +lithe-document-uri = { path = "../document-uri" } +lithe-plugin-model = { path = "../plugin-model" } +lithe-protocol = { path = "../protocol" } +lithe-toolchain = { path = "../toolchain" } +regex = "1.11" +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0", features = ["preserve_order"] } diff --git a/rust/crates/core/src/plugins/output_links.rs b/rust/crates/plugin-output-links/src/lib.rs similarity index 96% rename from rust/crates/core/src/plugins/output_links.rs rename to rust/crates/plugin-output-links/src/lib.rs index a1bccaa3..8c3520e5 100644 --- a/rust/crates/core/src/plugins/output_links.rs +++ b/rust/crates/plugin-output-links/src/lib.rs @@ -5,8 +5,8 @@ //! console lines, and turns a match into a workspace location, or into the //! groups a plugin backend resolves. A host only shows links and opens them. -use super::declarative::{invalid, Validation}; -use crate::protocol::{CoreError, ErrorCode}; +use lithe_plugin_model::{invalid, Validation}; +use lithe_protocol::{CoreError, ErrorCode}; use regex::{Regex, RegexBuilder}; use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; @@ -41,7 +41,7 @@ const LOCATION_GROUPS: &[&str] = &["path", "line", "column"]; /// One declared matcher. #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct OutputLinkMatcher { +pub struct OutputLinkMatcher { /// Plugin-local ID. pub(crate) id: String, /// A Rust `regex` pattern matched against one console line. @@ -70,7 +70,7 @@ pub(crate) struct OutputLinkResolve { /// Checks every declaration and sorts them by ID. `has_backend_functions` /// says whether the plugin has a `backend` process to call `resolve` on. -pub(crate) fn validate_declarations( +pub fn validate_declarations( matchers: &mut [OutputLinkMatcher], has_backend_functions: bool, ) -> Validation<()> { @@ -102,7 +102,7 @@ pub(crate) fn validate_declarations( "A resolve function needs a backend process.", )); } - if !crate::lsp::interface::plugin_function::is_function_id(&resolve.function) { + if !lithe_plugin_model::is_function_id(&resolve.function) { return Err(invalid( format!("{field}.resolve.function"), "The resolve function is not a valid function ID.", @@ -214,7 +214,7 @@ fn request_error(message: &str) -> CoreError { /// plugin and matcher, and either `location` with `paths` the host opens the /// first existing one of, or `resolve` with the groups the backend function /// receives. Matches overlapping an earlier link on the line are skipped. -pub(crate) fn match_lines(payload: Value) -> Result { +pub fn match_lines(payload: Value) -> Result { let request: MatchRequest = serde_json::from_value(payload).map_err(|error| { request_error("Invalid output link match request.").with_details(error.to_string()) })?; @@ -351,18 +351,18 @@ fn location( let absolute = text.starts_with('/') || (windows && text.as_bytes().get(1) == Some(&b':')); let mut paths = Vec::new(); if absolute { - let path = crate::toolchain::paths::normalize(&text, platform, true)?; + let path = lithe_toolchain::paths::normalize(&text, platform, true)?; if roots .iter() - .any(|root| crate::toolchain::paths::within(&path, root, platform, case_sensitive)) + .any(|root| lithe_toolchain::paths::within(&path, root, platform, case_sensitive)) { paths.push(path); } } else { let relative = text.strip_prefix("./").unwrap_or(&text); for root in roots { - if let Some(path) = crate::toolchain::paths::join(root, relative, platform) { - if crate::toolchain::paths::within(&path, root, platform, case_sensitive) { + if let Some(path) = lithe_toolchain::paths::join(root, relative, platform) { + if lithe_toolchain::paths::within(&path, root, platform, case_sensitive) { paths.push(path); } } @@ -391,7 +391,7 @@ struct ResolutionRequest { /// `outputLinks.decodeResolution`: checks a `resolve` function's reply, /// `{uri, range?}` or `null`, and returns the 1-based location to open, or /// `null` when the backend found nothing. Only `file:` URIs open. -pub(crate) fn decode_resolution(payload: Value) -> Result { +pub fn decode_resolution(payload: Value) -> Result { let request: ResolutionRequest = serde_json::from_value(payload).map_err(|error| { request_error("Invalid output link resolution.").with_details(error.to_string()) })?; @@ -414,7 +414,7 @@ pub(crate) fn decode_resolution(payload: Value) -> Result { let path = uri .strip_prefix("file://") .filter(|path| path.starts_with('/')) - .map(crate::lsp::interface::percent_decode) + .map(lithe_document_uri::percent_decode) .filter(|path| !path.chars().any(char::is_control)) .ok_or_else(|| request_error("A resolve reply names a local file URI."))?; let position = |name: &str| -> Result, CoreError> { diff --git a/rust/crates/plugin-rpc/Cargo.toml b/rust/crates/plugin-rpc/Cargo.toml new file mode 100644 index 00000000..0fc8714a --- /dev/null +++ b/rust/crates/plugin-rpc/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "lithe-plugin-rpc" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Plugin backend JSON-RPC: litheFunctions negotiation, function calls, the host-method and host-notification catalog and its grants" + +[dependencies] +lithe-plugin-keymap-import = { path = "../plugin-keymap-import" } +lithe-plugin-language-servers = { path = "../plugin-language-servers" } +lithe-plugin-model = { path = "../plugin-model" } +lithe-protocol = { path = "../protocol" } +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0", features = ["preserve_order"] } diff --git a/rust/crates/plugin-rpc/src/failures.rs b/rust/crates/plugin-rpc/src/failures.rs new file mode 100644 index 00000000..52b3913c --- /dev/null +++ b/rust/crates/plugin-rpc/src/failures.rs @@ -0,0 +1,186 @@ +//! Host failure codes, bounded details and refusals. + +use super::*; + +/// The JSON-RPC error code of a host-service code. Each code has its own +/// number, so a backend can branch on the number alone: codes with a +/// standard meaning keep the JSON-RPC or LSP number, and the rest take +/// numbers from the server-defined range, after -32000, which backends use +/// for their own failures. +pub fn host_rpc_code(code: &str) -> i64 { + match code { + codes::HOST_UNAVAILABLE => -32601, + codes::HOST_INVALID_PARAMS => -32602, + codes::HOST_CANCELLED => -32800, + codes::HOST_PERMISSION_DENIED => -32010, + codes::HOST_CONTEXT_EXPIRED => -32011, + codes::HOST_AUTHORITY_UNSUPPORTED => -32012, + codes::HOST_VERSION_CONFLICT => -32013, + codes::HOST_LIMIT_EXCEEDED => -32014, + codes::HOST_TIMEOUT => -32015, + codes::HOST_NOT_FOUND => -32016, + codes::HOST_READ_ONLY => -32017, + codes::HOST_UNSUPPORTED_OPTION => -32018, + codes::HOST_PRECONDITION_FAILED => -32019, + codes::HOST_BUSY => -32020, + codes::HOST_RATE_LIMITED => -32021, + codes::HOST_LANGUAGE_SERVER_UNAVAILABLE => -32022, + codes::HOST_LANGUAGE_SERVER_ERROR => -32023, + codes::HOST_METHOD_NOT_DECLARED => -32024, + codes::HOST_TOOL_UNRESOLVED => -32025, + codes::HOST_FOREIGN_CONTRIBUTION => -32026, + codes::NOT_DECLARED => -32027, + codes::NOT_EXPORTED => -32028, + codes::PROVIDER_DISABLED => -32029, + codes::VERSION_MISMATCH => -32030, + codes::CALL_CYCLE => -32065, + codes::DEPTH_EXCEEDED => -32066, + codes::TOO_MANY_CALLS => -32067, + codes::PAYLOAD_TOO_LARGE => -32068, + codes::PROVIDER_UNAVAILABLE => -32069, + // A relayed call's own failure is not a host failure: the caller + // must tell the provider's error from Core's by the number alone. + codes::FAILED => -32070, + codes::INVALID_RESULT => -32071, + _ => -32603, + } +} + +/// The `details` of a host failure a backend receives in +/// `error.data.details`: an object of at most `MAX_HOST_DETAILS_BYTES` +/// serialized, such as the current version of a `versionConflict` or the +/// index of the edit a range error names. Anything else is dropped, so a +/// host cannot inflate the reply or send a value a backend must not parse. +pub fn bounded_host_details(details: Option<&Value>) -> Option { + let details = details?; + if !details.is_object() { + return None; + } + let size = serde_json::to_vec(details).map(|bytes| bytes.len()).ok()?; + (size <= MAX_HOST_DETAILS_BYTES).then(|| details.clone()) +} + +/// The code and JSON-RPC error code a backend receives when the +/// host failed a forwarded request. Only the codes a host may report pass +/// through; anything else, including none, is `plugin.host.failed`, so a +/// host cannot impersonate Core's own refusals of other kinds. +pub fn host_failure(code: Option<&str>) -> (&'static str, i64) { + let code = match code { + Some(codes::HOST_UNAVAILABLE) => codes::HOST_UNAVAILABLE, + Some(codes::HOST_PERMISSION_DENIED) => codes::HOST_PERMISSION_DENIED, + Some(codes::HOST_INVALID_PARAMS) => codes::HOST_INVALID_PARAMS, + Some(codes::HOST_CONTEXT_EXPIRED) => codes::HOST_CONTEXT_EXPIRED, + Some(codes::HOST_AUTHORITY_UNSUPPORTED) => codes::HOST_AUTHORITY_UNSUPPORTED, + Some(codes::HOST_VERSION_CONFLICT) => codes::HOST_VERSION_CONFLICT, + Some(codes::HOST_LIMIT_EXCEEDED) => codes::HOST_LIMIT_EXCEEDED, + Some(codes::HOST_CANCELLED) => codes::HOST_CANCELLED, + Some(codes::HOST_TIMEOUT) => codes::HOST_TIMEOUT, + Some(codes::HOST_NOT_FOUND) => codes::HOST_NOT_FOUND, + Some(codes::HOST_READ_ONLY) => codes::HOST_READ_ONLY, + Some(codes::HOST_UNSUPPORTED_OPTION) => codes::HOST_UNSUPPORTED_OPTION, + Some(codes::HOST_PRECONDITION_FAILED) => codes::HOST_PRECONDITION_FAILED, + Some(codes::HOST_BUSY) => codes::HOST_BUSY, + Some(codes::HOST_RATE_LIMITED) => codes::HOST_RATE_LIMITED, + Some(codes::HOST_LANGUAGE_SERVER_UNAVAILABLE) => codes::HOST_LANGUAGE_SERVER_UNAVAILABLE, + Some(codes::HOST_LANGUAGE_SERVER_ERROR) => codes::HOST_LANGUAGE_SERVER_ERROR, + Some(codes::HOST_METHOD_NOT_DECLARED) => codes::HOST_METHOD_NOT_DECLARED, + Some(codes::HOST_TOOL_UNRESOLVED) => codes::HOST_TOOL_UNRESOLVED, + Some(codes::HOST_FOREIGN_CONTRIBUTION) => codes::HOST_FOREIGN_CONTRIBUTION, + // A backend exit is reported to pages, never to the backend itself. + Some(codes::BACKEND_EXITED) => codes::HOST_FAILED, + _ => codes::HOST_FAILED, + }; + (code, host_rpc_code(code)) +} + +/// Key of a stored secret, also the alphabet of subscription and context +/// IDs. It excludes path separators so a host may use the value in a store +/// item or file name without escaping. +pub(crate) fn is_credential_key(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_CREDENTIAL_KEY_BYTES + && value.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | ':' | '-') + }) +} + +/// Truncates a host's failure message like a backend's. +pub fn bounded_host_message(value: &str) -> String { + bounded_message(value) +} + +/// IDs of a descriptor array, refusing malformed descriptors, duplicates, +/// IDs already in `existing`, and a registry beyond the session limit. +pub(crate) fn descriptor_ids( + descriptors: &Value, + existing: &BTreeSet, +) -> Result, &'static str> { + let descriptors = descriptors + .as_array() + .ok_or("`functions` must be an array.")?; + let mut ids = BTreeSet::new(); + for descriptor in descriptors { + // Unknown descriptor fields are ignored within a version, as for + // every negotiated object, so a backend can add optional metadata. + let id = descriptor + .get("id") + .and_then(Value::as_str) + .filter(|id| is_function_id(id)) + .ok_or("Every function needs a valid `id`.")?; + if existing.contains(id) || !ids.insert(id.to_string()) { + return Err("A function ID is registered twice."); + } + } + if existing.len() + ids.len() > MAX_FUNCTIONS { + return Err("Too many functions are registered."); + } + Ok(ids) +} + +/// The manifest's plugin ID alphabet: lowercase letters, digits, `.` and `-`. +pub fn is_plugin_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_PLUGIN_ID_BYTES + && value.chars().all(|character| { + character.is_ascii_lowercase() + || character.is_ascii_digit() + || character == '.' + || character == '-' + }) +} + +pub(crate) fn is_plugin_error_code(value: &str) -> bool { + let mut characters = value.chars(); + value.len() <= MAX_PLUGIN_ERROR_CODE_BYTES + && characters + .next() + .is_some_and(|first| first.is_ascii_alphabetic()) + && characters.all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-') + }) +} + +/// Truncates at a character boundary so a hostile message cannot inflate +/// every event that carries it. +pub(crate) fn bounded_message(value: &str) -> String { + if value.len() <= MAX_MESSAGE_BYTES { + return value.to_string(); + } + let mut end = MAX_MESSAGE_BYTES; + while !value.is_char_boundary(end) { + end -= 1; + } + value[..end].to_string() +} + +pub(crate) fn invalid_result(message: &str) -> FunctionFailure { + FunctionFailure { + code: codes::INVALID_RESULT, + message: message.to_string(), + plugin_error_code: None, + } +} + +pub(crate) fn refusal(category: ErrorCode, message: &str, code: &str) -> CoreError { + CoreError::new(category, message).with_details(code) +} diff --git a/rust/crates/plugin-rpc/src/functions.rs b/rust/crates/plugin-rpc/src/functions.rs new file mode 100644 index 00000000..0071d537 --- /dev/null +++ b/rust/crates/plugin-rpc/src/functions.rs @@ -0,0 +1,250 @@ +//! Function registration, invocation planning, outcomes and progress. + +use super::*; + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +/// Address of one plugin function: the owning plugin plus its function ID. +/// The host routes by plugin identity first, so a reference can never reach +/// a session of another plugin. +pub struct FunctionReference { + /// Plugin that registered the function. + #[serde(rename = "pluginID")] + pub plugin_id: String, + /// Function ID within that plugin. + #[serde(rename = "functionID")] + pub function_id: String, +} + +/// What a session knows when an invocation is planned. +pub struct FunctionSession<'a> { + /// Plugin that started the session; `None` for host-owned servers. + pub plugin_id: Option<&'a str>, + /// Whether the backend negotiated a supported protocol version. + pub negotiated: bool, + /// Currently registered function IDs. + pub functions: &'a BTreeSet, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +/// Terminal failure of one invocation, before it becomes a runtime error. +pub struct FunctionFailure { + /// Stable contract code of the failure. + pub code: &'static str, + /// Bounded message for the user. + pub message: String, + /// Plugin-defined code from `error.data.code`, for the plugin's own UI. + /// Lithe never interprets it. + pub plugin_error_code: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +/// One accepted progress report. +pub struct FunctionProgress { + /// Operation the report belongs to. + pub operation_id: String, + /// Bounded status text, when the backend sent one. + pub message: Option, + /// Whole percent in `0..=100`. + pub percentage: Option, +} + +/// The client half of the negotiation, placed under `capabilities.experimental`. +pub fn client_experimental_capabilities() -> (&'static str, Value) { + ( + EXPERIMENTAL_KEY, + json!({ "versions": supported_versions() }), + ) +} + +/// The version and functions of a backend that answered a version this +/// client offered, or `None` when the protocol was not negotiated. A +/// malformed function list rejects the whole answer, so a session never +/// starts with half a registry. +pub fn negotiate(capabilities: &Value) -> Option<(u64, BTreeSet)> { + let answer = capabilities + .get("experimental") + .and_then(|experimental| experimental.get(EXPERIMENTAL_KEY))?; + let version = answer.get("version").and_then(Value::as_u64)?; + if !supported_versions().contains(&version) { + return None; + } + let functions = match answer.get("functions") { + None => BTreeSet::new(), + Some(descriptors) => descriptor_ids(descriptors, &BTreeSet::new()).ok()?, + }; + Some((version, functions)) +} + +/// The registry after a `lithe/function/register` request. The request is +/// all or nothing: one malformed, duplicate, or already registered function, +/// or exceeding the session limit, refuses every function in it. +pub fn apply_registration( + current: &BTreeSet, + params: Option<&Value>, +) -> Result, &'static str> { + let descriptors = params + .and_then(|params| params.get("functions")) + .filter(|functions| functions.as_array().is_some_and(|items| !items.is_empty())) + .ok_or("`functions` must be a nonempty array.")?; + let added = descriptor_ids(descriptors, current)?; + let mut registry = current.clone(); + registry.extend(added); + Ok(registry) +} + +/// The registry and the withdrawn IDs after a `lithe/function/unregister` +/// request, all or nothing like registration. +pub fn apply_unregistration( + current: &BTreeSet, + params: Option<&Value>, +) -> Result<(BTreeSet, Vec), &'static str> { + let ids = params + .and_then(|params| params.get("ids")) + .and_then(Value::as_array) + .filter(|ids| !ids.is_empty()) + .ok_or("`ids` must be a nonempty array.")?; + let mut removed = BTreeSet::new(); + for id in ids { + let id = id.as_str().ok_or("Every ID must be a string.")?; + if !current.contains(id) { + return Err("Only registered functions can be unregistered."); + } + if !removed.insert(id.to_string()) { + return Err("An ID is listed twice."); + } + } + let registry = current.difference(&removed).cloned().collect(); + Ok((registry, removed.into_iter().collect())) +} + +/// Params of the `lithe/function/invoke` request for `reference`, or the +/// refusal with its stable code in `details`. Checks run in a fixed order so +/// every host reports the same code for the same request. +pub fn plan_invocation( + session: &FunctionSession<'_>, + reference: &FunctionReference, + operation_id: &str, + params: &Value, +) -> Result { + if !is_plugin_id(&reference.plugin_id) || !is_function_id(&reference.function_id) { + return Err(refusal( + ErrorCode::InvalidRequest, + "The function reference is malformed.", + codes::INVALID_REFERENCE, + )); + } + if !params.is_object() { + return Err(refusal( + ErrorCode::InvalidRequest, + "Function params must be a JSON object.", + codes::INVALID_PARAMS, + )); + } + let Some(owner) = session.plugin_id else { + return Err(refusal( + ErrorCode::NotSupported, + "This session does not belong to a plugin.", + codes::UNAVAILABLE, + )); + }; + if owner != reference.plugin_id { + return Err(refusal( + ErrorCode::PermissionDenied, + "The function belongs to another plugin.", + codes::WRONG_PLUGIN, + )); + } + if !session.negotiated { + return Err(refusal( + ErrorCode::NotSupported, + "The plugin process did not agree on a plugin-function protocol version.", + codes::UNAVAILABLE, + )); + } + if !session.functions.contains(&reference.function_id) { + return Err(refusal( + ErrorCode::NotSupported, + "The plugin has not registered this function.", + codes::UNKNOWN, + )); + } + Ok(json!({ + "functionID": reference.function_id, + "operationID": operation_id, + "params": params + })) +} + +/// Normalizes the backend's JSON-RPC response to an invocation. A success is +/// wrapped as `{ "value": result }` like build-provider replies, so `null` is +/// a valid result distinct from a missing one. +pub fn invocation_outcome(response: &Value) -> Result { + if let Some(error) = response.get("error") { + let message = error + .get("message") + .and_then(Value::as_str) + .map(bounded_message) + .filter(|message| !message.is_empty()) + .unwrap_or_else(|| "The plugin function failed.".to_string()); + let plugin_error_code = error + .get("data") + .and_then(|data| data.get("code")) + .and_then(Value::as_str) + .filter(|code| is_plugin_error_code(code)) + .map(ToString::to_string); + return Err(FunctionFailure { + code: codes::FAILED, + message, + plugin_error_code, + }); + } + let Some(result) = response.get("result") else { + return Err(invalid_result("The plugin function returned no result.")); + }; + // Serialized length is what crosses the C ABI and the host bridge. + if result.to_string().len() > MAX_RESULT_BYTES { + return Err(invalid_result( + "The plugin function returned more data than a function result may hold.", + )); + } + Ok(json!({ "value": result })) +} + +/// A progress report that is well formed, or `None` to ignore it. Whether the +/// operation is still running is the engine's check, not this function's. +pub fn parse_progress(params: Option<&Value>) -> Option { + let params = params?.as_object()?; + let operation_id = params.get("operationID")?.as_str()?.to_string(); + let message = match params.get("message") { + None => None, + Some(message) => Some(bounded_message(message.as_str()?)), + }; + let percentage = match params.get("percentage") { + None => None, + Some(percentage) => Some(percentage.as_u64().filter(|value| *value <= 100)?), + }; + Some(FunctionProgress { + operation_id, + message, + percentage, + }) +} + +/// The stable code for a backend request the host refuses on principle, or +/// `None` for any other method. Domain dispatch can route back into the +/// requesting backend, whose handler would then wait on itself; host +/// capabilities are offered only to a plugin-owned session that negotiated +/// the protocol. +pub fn refused_backend_request(method: &str) -> Option<&'static str> { + if method == INVOKE_METHOD + || method.starts_with("lithe/build/") + || method.starts_with("lithe/keymap/") + { + Some(codes::RECURSIVE_CALL) + } else if method.starts_with("lithe/host/") { + Some(codes::HOST_UNAVAILABLE) + } else { + None + } +} diff --git a/rust/crates/core/src/plugins/host_api.rs b/rust/crates/plugin-rpc/src/host_api.rs similarity index 86% rename from rust/crates/core/src/plugins/host_api.rs rename to rust/crates/plugin-rpc/src/host_api.rs index 54939340..7b1c583e 100644 --- a/rust/crates/core/src/plugins/host_api.rs +++ b/rust/crates/plugin-rpc/src/host_api.rs @@ -4,18 +4,18 @@ //! method table and grant decisions stay with the same Core code that already //! validates backend requests, so a new method cannot widen an old grant. -use crate::lsp::interface::plugin_function::{self, HOST_METHODS, HOST_SERVICE_FUNCTIONS}; -use crate::protocol::{CoreError, ErrorCode}; +use crate::{capabilities_answer, HOST_METHODS, HOST_SERVICE_FUNCTIONS}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::Deserialize; use serde_json::{json, Value}; use std::collections::BTreeSet; /// Describes host methods using the same catalog as backend request routing. -pub(crate) fn describe_command(payload: Value) -> Result { +pub fn describe_command(payload: Value) -> Result { if payload.as_object().is_none_or(|object| !object.is_empty()) { return Err(invalid()); } - let implicit = plugin_function::capabilities_answer(0, &BTreeSet::new()); + let implicit = capabilities_answer(0, &BTreeSet::new()); let methods: Vec = HOST_METHODS .iter() .map(|entry| { @@ -45,7 +45,7 @@ struct Authorization { } /// Reuses Core's capability decisions with the host's current session grants. -pub(crate) fn authorize_command(payload: Value) -> Result { +pub fn authorize_command(payload: Value) -> Result { let input: Authorization = serde_json::from_value(payload).map_err(|_| invalid())?; if input.method.len() > 256 || input.service.len() > 128 @@ -61,7 +61,7 @@ pub(crate) fn authorize_command(payload: Value) -> Result { return Ok(json!({"allowed":false,"code":"plugin.host.unavailable"})); } let grants = input.grants.into_iter().collect(); - let capabilities = plugin_function::capabilities_answer(0, &grants); + let capabilities = capabilities_answer(0, &grants); let allowed = capabilities["services"][&input.service]["methods"] .as_array() .is_some_and(|methods| methods.iter().any(|method| method == &input.method)); @@ -104,10 +104,9 @@ mod tests { #[test] fn authorization_does_not_widen_metadata_grants_or_accept_spoofed_services() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../../fixtures/plugins/host-api-v1.json" - )) - .unwrap(); + let fixture: Value = + serde_json::from_str(include_str!("../../../fixtures/plugins/host-api-v1.json")) + .unwrap(); for case in fixture["authorization"].as_array().unwrap() { assert_eq!( authorize_command(case["input"].clone()).unwrap(), diff --git a/rust/crates/plugin-rpc/src/host_notifications.rs b/rust/crates/plugin-rpc/src/host_notifications.rs new file mode 100644 index 00000000..d28c5505 --- /dev/null +++ b/rust/crates/plugin-rpc/src/host_notifications.rs @@ -0,0 +1,265 @@ +//! Host notifications Core sends to backends. + +use super::*; + +/// Lifecycle of a language server the plugin declares. +pub(crate) const LANGUAGE_SERVER_DID_CHANGE_STATE: &str = + "lithe/host/languageServer/didChangeState"; + +/// A server notification the language server's `forwardNotifications` lists. +pub(crate) const LANGUAGE_SERVER_DID_RECEIVE_NOTIFICATION: &str = + "lithe/host/languageServer/didReceiveNotification"; + +/// How Core checks the params of one host-to-backend notification. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum NotificationParams { + /// Exactly `{ revision }`, a positive integer. + Revision, + /// Exactly `{ subscriptionID, sequence, event }`: the subscription the + /// backend holds, a positive sequence that grows per subscription, and + /// the service's event object. + SubscriptionEvent, + /// `{ commandID, context? }`: one of the plugin's own commands and the + /// workspace context of the window it ran in. + Command, + /// `{ runID, exitCode?, signal? }`: a Run session the backend started + /// ended with an exit status, a signal, or neither when the host lost it. + RunExit, + /// `{ context, serverID, state, generation }`: a lifecycle change of one + /// of the plugin's language servers. + LanguageServerState, + /// `{ context, serverID, generation, method, params? | truncated }`: a + /// forwarded server notification, without params when too large. + LanguageServerNotification, +} + +/// One row of the host-notification table. +pub(crate) struct HostNotification { + pub method: &'static str, + /// Service whose grant admits sending it; implicit services need none. + pub service: &'static str, + /// Host method whose individual grant admits it instead of `service`, + /// for a notification that reports on a method granted per permission. + pub grant: Option<&'static str>, + pub params: NotificationParams, +} + +impl HostNotification { + /// Whether the session's grants admit sending this notification. + pub(crate) fn admitted(&self, granted: &BTreeSet) -> bool { + match self.grant { + Some(method) => granted.contains(method), + None => is_implicit_service(self.service) || holds_service(self.service, granted), + } + } +} + +const WORKSPACE_DID_CHANGE_CONTEXTS: &str = "lithe/host/workspace/didChangeContexts"; + +pub(crate) const SUBSCRIPTIONS_NOTIFY: &str = "lithe/host/subscriptions/notify"; + +const COMMANDS_DID_RUN: &str = "lithe/host/commands/didRun"; + +/// Starts a host-owned Run session; its grant also admits `RUN_DID_EXIT`. +pub(crate) const RUN_START: &str = "lithe/host/run/start"; + +const RUN_DID_EXIT: &str = "lithe/host/run/didExit"; + +/// Host-owned Run sessions a backend starts and stops. +pub(crate) const HOST_SERVICE_RUN: &str = "run"; + +/// Longest opaque run ID a host may hand out. +const MAX_RUN_ID_BYTES: usize = 128; + +/// The plugin's own UI commands reaching its backend. Every negotiated +/// plugin-owned session has it; a command names only the plugin's own IDs. +pub(crate) const HOST_SERVICE_COMMANDS: &str = "commands"; + +/// Every host-to-backend notification the host may send through +/// `lsp.hostNotify`. A notification missing here never reaches a backend, +/// so the host cannot use the channel to call into the backend's own +/// protocol (functions, build or VCS methods). +pub(crate) const HOST_NOTIFICATIONS: &[HostNotification] = &[ + HostNotification { + method: WORKSPACE_DID_CHANGE_CONTEXTS, + service: HOST_SERVICE_WORKSPACE, + grant: None, + params: NotificationParams::Revision, + }, + HostNotification { + method: SUBSCRIPTIONS_NOTIFY, + service: HOST_SERVICE_SUBSCRIPTIONS, + grant: None, + params: NotificationParams::SubscriptionEvent, + }, + HostNotification { + method: COMMANDS_DID_RUN, + service: HOST_SERVICE_COMMANDS, + grant: None, + params: NotificationParams::Command, + }, + HostNotification { + method: RUN_DID_EXIT, + service: HOST_SERVICE_RUN, + grant: Some(RUN_START), + params: NotificationParams::RunExit, + }, + HostNotification { + method: LANGUAGE_SERVER_DID_CHANGE_STATE, + service: HOST_SERVICE_LANGUAGE_SERVER, + grant: None, + params: NotificationParams::LanguageServerState, + }, + HostNotification { + method: LANGUAGE_SERVER_DID_RECEIVE_NOTIFICATION, + service: HOST_SERVICE_LANGUAGE_SERVER, + grant: None, + params: NotificationParams::LanguageServerNotification, + }, +]; + +/// Whether the session holds a service: its aggregate grant, or, for a +/// service granted per method, any of its methods. +fn holds_service(service: &str, granted: &BTreeSet) -> bool { + granted.contains(service) + || HOST_METHODS.iter().any(|entry| { + entry.service == service && entry.permission.is_some() && granted.contains(entry.method) + }) +} + +/// A language server state a `didChangeState` notification may carry. +fn is_language_server_state(value: &str) -> bool { + matches!(value, "starting" | "running" | "stopped" | "crashed") +} + +/// Whether a host notification reaches this session. `Ok(false)` means the +/// backend negotiated no protocol version (`version` is `None`), so the host +/// may skip it; a method outside the table, one whose service the session +/// was not granted, or malformed params are the host's mistake and refused. +pub fn host_notification( + method: &str, + params: &Value, + version: Option, + granted: &BTreeSet, +) -> Result { + let invalid = |message: &str| { + refusal( + ErrorCode::InvalidRequest, + message, + codes::HOST_INVALID_PARAMS, + ) + }; + let Some(entry) = HOST_NOTIFICATIONS + .iter() + .find(|entry| entry.method == method) + else { + return Err(refusal( + ErrorCode::InvalidRequest, + "The host may not send this notification to a plugin process.", + codes::HOST_UNAVAILABLE, + )); + }; + if !entry.admitted(granted) { + return Err(refusal( + ErrorCode::InvalidRequest, + "The session was not granted the service of this notification.", + codes::HOST_PERMISSION_DENIED, + )); + } + let Some(object) = params.as_object() else { + return Err(invalid("Notification params must be an object.")); + }; + if serde_json::to_vec(params).map_or(true, |bytes| bytes.len() > MAX_HOST_NOTIFICATION_BYTES) { + return Err(invalid( + "Notification params exceed the notification bound.", + )); + } + let positive = |key: &str| { + object + .get(key) + .and_then(Value::as_u64) + .is_some_and(|value| value >= 1) + }; + let only = |keys: &[&str]| object.keys().all(|key| keys.contains(&key.as_str())); + let well_formed = match entry.params { + NotificationParams::Revision => only(&["revision"]) && positive("revision"), + NotificationParams::SubscriptionEvent => { + only(&["subscriptionID", "sequence", "event"]) + && object + .get("subscriptionID") + .and_then(Value::as_str) + .is_some_and(is_credential_key) + && positive("sequence") + && object.get("event").is_some_and(Value::is_object) + } + NotificationParams::Command => { + only(&["commandID", "context"]) + && object + .get("commandID") + .and_then(Value::as_str) + .is_some_and(is_command_id) + && object.get("context").is_none_or(is_context_reference) + } + NotificationParams::RunExit => { + only(&["runID", "exitCode", "signal"]) + && object + .get("runID") + .and_then(Value::as_str) + .is_some_and(|id| id.len() <= MAX_RUN_ID_BYTES && is_credential_key(id)) + && object.get("exitCode").is_none_or(|code| { + code.as_i64() + .is_some_and(|code| i32::try_from(code).is_ok()) + }) + && object.get("signal").is_none_or(|signal| { + signal + .as_u64() + .is_some_and(|signal| (1..=127).contains(&signal)) + }) + } + NotificationParams::LanguageServerState => { + only(&["context", "serverID", "state", "generation"]) + && object.get("context").is_some_and(is_context_reference) + && object + .get("serverID") + .and_then(Value::as_str) + .is_some_and(is_protocol_name) + && object + .get("state") + .and_then(Value::as_str) + .is_some_and(is_language_server_state) + && positive("generation") + } + NotificationParams::LanguageServerNotification => { + let truncated = object.get("truncated"); + only(&[ + "context", + "serverID", + "generation", + "method", + "params", + "truncated", + ]) && object.get("context").is_some_and(is_context_reference) + && object + .get("serverID") + .and_then(Value::as_str) + .is_some_and(is_protocol_name) + && object + .get("method") + .and_then(Value::as_str) + .is_some_and(is_protocol_name) + && positive("generation") + // A truncated notification drops its params entirely. + && match truncated { + None => true, + Some(Value::Bool(true)) => !object.contains_key("params"), + Some(_) => false, + } + } + }; + if !well_formed { + return Err(invalid( + "Notification params do not match the notification.", + )); + } + Ok(version.is_some()) +} diff --git a/rust/crates/plugin-rpc/src/host_params.rs b/rust/crates/plugin-rpc/src/host_params.rs new file mode 100644 index 00000000..ceace255 --- /dev/null +++ b/rust/crates/plugin-rpc/src/host_params.rs @@ -0,0 +1,412 @@ +//! Parameter checks of host methods and the context and caller Core attaches. + +use super::*; + +/// A UI command ID as the manifest schema spells it: `[a-z0-9.-]+`. +pub(crate) fn is_command_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_FUNCTION_ID_BYTES + && value.chars().all(|character| { + character.is_ascii_lowercase() + || character.is_ascii_digit() + || matches!(character, '.' | '-') + }) +} + +pub(crate) fn check_host_params( + rule: HostParams, + params: Option<&Value>, +) -> Result<(), HostRequestRefusal> { + let invalid = |message| HostRequestRefusal { + rpc_code: -32602, + code: codes::HOST_INVALID_PARAMS, + message, + details: None, + }; + let empty = serde_json::Map::new(); + let params = match params { + None | Some(Value::Null) if rule == HostParams::Empty => &empty, + Some(Value::Object(params)) => params, + _ => return Err(invalid("Params must be an object.")), + }; + let allowed: &[&str] = match rule { + HostParams::Empty => &[], + HostParams::Credential { with_secret: true } => &["key", "secret"], + HostParams::Credential { with_secret: false } => &["key"], + HostParams::Subscription => &["subscriptionID"], + HostParams::Context => &["context"], + HostParams::FunctionProviders => &["functionID"], + HostParams::FunctionInvoke => &[ + "pluginID", + "functionID", + "context", + "params", + "timeoutMilliseconds", + "parentOperationID", + ], + HostParams::KeymapSnapshot { chunk: false } => &["snapshotID", "providerID"], + HostParams::KeymapSnapshot { chunk: true } => &[ + "snapshotID", + "providerID", + "fileID", + "offset", + "maximumBytes", + ], + HostParams::LanguageServerRequest => &[ + "context", + "serverID", + "method", + "params", + "timeoutMilliseconds", + ], + HostParams::LanguageServerNotify => &["context", "serverID", "method", "params"], + HostParams::LanguageServerStatus => { + &["context", "serverID", "state", "message", "percentage"] + } + HostParams::ContentProvider => &["context", "scheme", "function"], + HostParams::Object => { + return match params.get("context") { + Some(context) if !is_context_reference(context) => { + Err(invalid(CONTEXT_REFERENCE_MESSAGE)) + } + _ => Ok(()), + }; + } + }; + if let Some(option) = params.keys().find(|key| !allowed.contains(&key.as_str())) { + return Err(HostRequestRefusal { + rpc_code: host_rpc_code(codes::HOST_UNSUPPORTED_OPTION), + code: codes::HOST_UNSUPPORTED_OPTION, + message: "Params name an option this host does not support.", + details: Some(json!({ "option": option })), + }); + } + match rule { + HostParams::Credential { with_secret } => { + if !params + .get("key") + .and_then(Value::as_str) + .is_some_and(is_credential_key) + { + return Err(invalid( + "A credential key is 1-128 ASCII letters, digits, '.', '_', ':' or '-'.", + )); + } + if with_secret + && params + .get("secret") + .and_then(Value::as_str) + .is_none_or(|secret| secret.len() > MAX_CREDENTIAL_SECRET_BYTES) + { + return Err(invalid("A secret is a string of at most 16 KiB.")); + } + } + HostParams::Subscription => { + if !params + .get("subscriptionID") + .and_then(Value::as_str) + .is_some_and(is_credential_key) + { + return Err(invalid( + "A subscription ID is 1-128 ASCII letters, digits, '.', '_', ':' or '-'.", + )); + } + } + HostParams::Context => { + if !params.get("context").is_some_and(is_context_reference) { + return Err(invalid(CONTEXT_REFERENCE_MESSAGE)); + } + } + HostParams::FunctionProviders => { + if !params + .get("functionID") + .and_then(Value::as_str) + .is_some_and(is_function_id) + { + return Err(invalid(FUNCTION_ID_MESSAGE)); + } + } + HostParams::FunctionInvoke => check_function_invoke_params(params)?, + HostParams::KeymapSnapshot { chunk } => { + let opaque_id = |key: &str| { + params.get(key).and_then(Value::as_str).is_some_and(|id| { + !id.is_empty() + && id.len() <= 128 + && id + .bytes() + .all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_') + }) + }; + let provider = params + .get("providerID") + .and_then(Value::as_str) + .is_some_and(lithe_plugin_keymap_import::valid_provider_id); + let valid_chunk = !chunk + || (opaque_id("fileID") + && params + .get("offset") + .and_then(Value::as_u64) + .is_some_and(|offset| { + offset <= lithe_plugin_keymap_import::MAX_INPUT_FILE_BYTES as u64 + }) + && params + .get("maximumBytes") + .and_then(Value::as_u64) + .is_some_and(|size| { + size > 0 + && size <= lithe_plugin_keymap_import::MAX_INPUT_CHUNK_BYTES as u64 + })); + if !opaque_id("snapshotID") || !provider || !valid_chunk { + return Err(invalid( + "Snapshot source parameters are malformed or exceed their bounds.", + )); + } + } + HostParams::LanguageServerRequest + | HostParams::LanguageServerNotify + | HostParams::LanguageServerStatus => { + check_language_server_params(rule, params, &invalid)?; + } + HostParams::ContentProvider => { + if !params.get("context").is_some_and(is_context_reference) { + return Err(invalid(CONTEXT_REFERENCE_MESSAGE)); + } + let scheme = params.get("scheme").and_then(Value::as_str); + if !scheme.is_some_and(is_content_scheme) { + return Err(invalid( + "A content scheme is 1-64 lowercase ASCII letters, digits, '+', '-' or '.', starts with a letter, and is not a scheme the host owns.", + )); + } + if !params + .get("function") + .and_then(Value::as_str) + .is_some_and(is_function_id) + { + return Err(invalid( + "The content function must be a plugin function ID.", + )); + } + } + HostParams::Empty | HostParams::Object => {} + } + Ok(()) +} + +/// Checks the params the `languageServer` host methods share: the context, +/// the server, the method, and each method's own fields. Whether the server +/// belongs to the plugin and the method is declared is the engine's answer, +/// since only it holds the session policy. +fn check_language_server_params( + rule: HostParams, + params: &serde_json::Map, + invalid: &dyn Fn(&'static str) -> HostRequestRefusal, +) -> Result<(), HostRequestRefusal> { + if !params.get("context").is_some_and(is_context_reference) { + return Err(invalid(CONTEXT_REFERENCE_MESSAGE)); + } + if !params + .get("serverID") + .and_then(Value::as_str) + .is_some_and(is_protocol_name) + { + return Err(invalid( + "serverID must name one of the plugin's language servers.", + )); + } + if rule == HostParams::LanguageServerStatus { + if !params + .get("state") + .and_then(Value::as_str) + .is_some_and(is_status_state) + { + return Err(invalid( + "state is one of preparing, ready, degraded, or failed.", + )); + } + if params + .get("message") + .is_some_and(|message| !message.is_string()) + { + return Err(invalid("message must be a string.")); + } + if params + .get("percentage") + .is_some_and(|value| value.as_u64().is_none_or(|percentage| percentage > 100)) + { + return Err(invalid("percentage is a whole number from 0 to 100.")); + } + return Ok(()); + } + if !params + .get("method") + .and_then(Value::as_str) + .is_some_and(is_protocol_name) + { + return Err(invalid("method must be a non-empty LSP method name.")); + } + if rule == HostParams::LanguageServerRequest + && params.get("timeoutMilliseconds").is_some_and(|value| { + !value.as_u64().is_some_and(|milliseconds| { + (1..=lithe_plugin_language_servers::MAX_REQUEST_TIMEOUT_MILLISECONDS) + .contains(&milliseconds) + }) + }) + { + return Err(invalid( + "timeoutMilliseconds is a whole number from 1 to 300000.", + )); + } + Ok(()) +} + +const FUNCTION_ID_MESSAGE: &str = + "A function ID is dot-separated lowerCamelCase segments of at most 128 bytes."; + +/// The shape of a `lithe/host/functions/invoke` request. Whether the caller +/// declared the call and the provider exports it is the relay's check, which +/// needs the plugin snapshot. +fn check_function_invoke_params( + params: &serde_json::Map, +) -> Result<(), HostRequestRefusal> { + let invalid = |message| HostRequestRefusal { + rpc_code: -32602, + code: codes::HOST_INVALID_PARAMS, + message, + details: None, + }; + if !params + .get("pluginID") + .and_then(Value::as_str) + .is_some_and(is_plugin_id) + { + return Err(invalid( + "A plugin ID is lowercase letters, digits, '.' and '-'.", + )); + } + if !params + .get("functionID") + .and_then(Value::as_str) + .is_some_and(is_function_id) + { + return Err(invalid(FUNCTION_ID_MESSAGE)); + } + if !params.get("context").is_some_and(is_context_reference) { + return Err(invalid(CONTEXT_REFERENCE_MESSAGE)); + } + if params.get("params").is_some_and(|value| !value.is_object()) { + return Err(invalid("Function params must be a JSON object.")); + } + if params + .get("timeoutMilliseconds") + .is_some_and(|value| value.as_u64().is_none_or(|milliseconds| milliseconds < 1)) + { + return Err(invalid("A timeout is a positive number of milliseconds.")); + } + // Only the shape is checked here. Whether the ID names a call this backend + // is serving needs the session's pending requests, so the relay decides. + if params.get("parentOperationID").is_some_and(|value| { + !value + .as_str() + .is_some_and(|id| (1..=128).contains(&id.len())) + }) { + return Err(invalid( + "A parent operation ID is the operationID of a call this backend is serving, 1-128 bytes.", + )); + } + Ok(()) +} + +/// A preparation state a plugin may report for its language server. +fn is_status_state(value: &str) -> bool { + matches!(value, "preparing" | "ready" | "degraded" | "failed") +} + +/// Schemes the host or the platform resolves itself. A plugin that served +/// one would replace what the user expects the URI to open. +const RESERVED_CONTENT_SCHEMES: &[&str] = &[ + "about", + "blob", + "data", + "file", + "ftp", + "http", + "https", + "javascript", + "lithe", + "mailto", + "untitled", + "ws", + "wss", +]; + +/// A URI scheme a plugin may serve (RFC 3986, lowercase, bounded). +pub(crate) fn is_content_scheme(value: &str) -> bool { + let mut characters = value.chars(); + characters + .next() + .is_some_and(|first| first.is_ascii_lowercase()) + && value.len() <= 64 + && characters.all(|character| { + character.is_ascii_lowercase() + || character.is_ascii_digit() + || matches!(character, '+' | '-' | '.') + }) + && !RESERVED_CONTENT_SCHEMES.contains(&value) +} + +const CONTEXT_REFERENCE_MESSAGE: &str = + "A context is { contextID, generation }: an ID of 1-128 ASCII letters, digits, '.', '_', ':' or '-', and a positive generation."; + +/// `{ contextID, generation }` naming one generation of one workspace +/// context. Only the shape is checked here; whether it is still current is +/// the host's answer (`plugin.host.contextExpired`). +pub(crate) fn is_context_reference(value: &Value) -> bool { + let Some(reference) = value.as_object() else { + return false; + }; + reference.len() == 2 + && reference + .get("contextID") + .and_then(Value::as_str) + .is_some_and(is_credential_key) + && reference + .get("generation") + .and_then(Value::as_u64) + .is_some_and(|generation| generation >= 1) +} + +/// Adds the workspace context the host named to the params of one +/// `lithe/function/invoke`. A malformed reference is refused, and a call +/// without a context is written unchanged. +pub fn attach_context(invoke_params: &mut Value, context: Option<&Value>) -> Result<(), CoreError> { + let Some(context) = context else { + return Ok(()); + }; + if !is_context_reference(context) { + return Err(refusal( + ErrorCode::InvalidRequest, + "The workspace context reference is malformed.", + codes::INVALID_PARAMS, + )); + } + if let Some(params) = invoke_params.as_object_mut() { + params.insert("context".to_string(), context.clone()); + } + Ok(()) +} + +/// Writes the calling plugin into the params of a relayed +/// `lithe/function/invoke` as `caller: { "pluginID": ... }`, a sibling of +/// `functionID`, `operationID`, `params` and `context`. Only Core writes it, +/// from the identity of the session that made the request, so a callee can +/// key state by caller without trusting anything in the params. A call the +/// host itself starts (`caller` is `None`) never carries one: its absence +/// means the host. +pub fn attach_caller(invoke_params: &mut Value, caller: Option<&str>) { + let Some(caller) = caller else { + return; + }; + if let Some(params) = invoke_params.as_object_mut() { + params.insert("caller".to_string(), json!({ "pluginID": caller })); + } +} diff --git a/rust/crates/plugin-rpc/src/host_requests.rs b/rust/crates/plugin-rpc/src/host_requests.rs new file mode 100644 index 00000000..cf766d95 --- /dev/null +++ b/rust/crates/plugin-rpc/src/host_requests.rs @@ -0,0 +1,517 @@ +//! Host methods a backend may call and how Core routes them. + +use super::*; + +/// Why a backend's `lithe/host/*` request is not forwarded to the host. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HostRequestRefusal { + /// JSON-RPC error code of the answer. + pub rpc_code: i64, + /// Stable contract code, sent as `error.data.code`. + pub code: &'static str, + /// Message of the JSON-RPC error. + pub message: &'static str, + /// Sent as `error.data.details`, such as the `option` a host does not + /// support. + pub details: Option, +} + +/// Where an accepted `lithe/host/*` request goes. +#[derive(Debug, Clone, PartialEq)] +pub enum HostRoute { + /// To the host, as a `hostRequest` event of this service. + Forward(&'static str), + /// Answered by Core with this result; the host never sees the request. + Answer(Value), + /// Another plugin's function: Core's relay checks, dispatches, and + /// answers it. The host sees only the start request for a provider whose + /// backend is not running. + Relay, +} + +/// How Core checks the params of one host method before the host sees them. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum HostParams { + /// Absent, `null`, or `{}`. + Empty, + /// `{ key }`, or `{ key, secret }` when `with_secret`. + Credential { with_secret: bool }, + /// `{ subscriptionID }`. + Subscription, + /// Exactly `{ context }`, a well-formed context reference. + Context, + /// Snapshot source listing or one bounded chunk from a selected source. + KeymapSnapshot { chunk: bool }, + /// `{ context, serverID, method, params?, timeoutMilliseconds? }`: one + /// request to the plugin's own language server (plan API-25). + LanguageServerRequest, + /// `{ context, serverID, method, params? }`: one notification to it. + LanguageServerNotify, + /// `{ context, serverID, state, message?, percentage? }`: the plugin's + /// preparation status of the server. + LanguageServerStatus, + /// `{ context, scheme, function }`: the function that supplies the text + /// of a URI scheme (plan API-12). + ContentProvider, + /// Exactly `{ functionID }`, a well-formed function ID. + FunctionProviders, + /// `{ pluginID, functionID, context, params?, timeoutMilliseconds? }`. + FunctionInvoke, + /// Any object whose `context`, when present, is a well-formed + /// reference. The service checks the rest and answers + /// `plugin.host.invalidParams` or, for a field it does not know, + /// `plugin.host.unsupportedOption` itself; for methods whose params + /// Core cannot type without owning their domain. + // No row uses it yet; the file, editor, and window services that follow + // add their rows with it. + #[allow(dead_code)] + Object, +} + +/// One row of the host-method table. +pub(crate) struct HostMethod { + pub method: &'static str, + /// Service whose grant admits the method. + pub service: &'static str, + pub params: HostParams, + /// The method's own grant, or `None` when the service's aggregate grant admits it. + pub permission: Option<&'static str>, +} + +/// Every backend-to-host method Core admits. A method missing here is +/// `plugin.host.unavailable` whatever the host could do, so a new host +/// service adds its rows here, its grant to `GRANTABLE_HOST_SERVICES` and +/// the manifest permission, and its handler to the host's dispatcher. +pub(crate) const HOST_METHODS: &[HostMethod] = &[ + HostMethod { + method: RUN_START, + service: HOST_SERVICE_RUN, + permission: Some("run.start"), + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/run/stop", + service: HOST_SERVICE_RUN, + permission: Some("run.start"), + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/debug/start", + service: "debug", + permission: Some("debug.start"), + params: HostParams::Object, + }, + HostMethod { + method: FUNCTIONS_PROVIDERS, + service: HOST_SERVICE_FUNCTIONS, + permission: None, + params: HostParams::FunctionProviders, + }, + HostMethod { + method: FUNCTIONS_INVOKE, + service: HOST_SERVICE_FUNCTIONS, + permission: None, + params: HostParams::FunctionInvoke, + }, + HostMethod { + method: "lithe/host/keymapImport/listInputs", + service: "keymapImport", + permission: Some("keymaps.readSelectedSources"), + params: HostParams::KeymapSnapshot { chunk: false }, + }, + HostMethod { + method: "lithe/host/keymapImport/readChunk", + service: "keymapImport", + permission: Some("keymaps.readSelectedSources"), + params: HostParams::KeymapSnapshot { chunk: true }, + }, + HostMethod { + method: "lithe/host/terminal/reopen", + service: "terminal", + permission: Some("terminal.create"), + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/terminal/dispose", + service: "terminal", + permission: Some("terminal.create"), + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/terminal/create", + service: "terminal", + permission: Some("terminal.create"), + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/terminal/show", + service: "terminal", + permission: Some("terminal.create"), + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/terminal/close", + service: "terminal", + permission: Some("terminal.create"), + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/terminal/onExit", + service: "terminal", + permission: Some("terminal.create"), + params: HostParams::Object, + }, + HostMethod { + method: CREDENTIALS_GET, + service: HOST_SERVICE_CREDENTIALS, + permission: None, + params: HostParams::Credential { with_secret: false }, + }, + HostMethod { + method: CREDENTIALS_SET, + service: HOST_SERVICE_CREDENTIALS, + permission: None, + params: HostParams::Credential { with_secret: true }, + }, + HostMethod { + method: CREDENTIALS_DELETE, + service: HOST_SERVICE_CREDENTIALS, + permission: None, + params: HostParams::Credential { with_secret: false }, + }, + HostMethod { + method: GET_CAPABILITIES, + service: HOST_SERVICE_HOST, + permission: None, + params: HostParams::Empty, + }, + HostMethod { + method: SUBSCRIPTIONS_DISPOSE, + service: HOST_SERVICE_SUBSCRIPTIONS, + permission: None, + params: HostParams::Subscription, + }, + HostMethod { + method: WORKSPACE_LIST_CONTEXTS, + service: HOST_SERVICE_WORKSPACE, + permission: None, + params: HostParams::Empty, + }, + HostMethod { + method: WORKSPACE_GET_CONTEXT, + service: HOST_SERVICE_WORKSPACE, + permission: None, + params: HostParams::Context, + }, + HostMethod { + method: WORKSPACE_GET_FOLDERS, + service: HOST_SERVICE_WORKSPACE, + permission: None, + params: HostParams::Context, + }, + HostMethod { + method: "lithe/host/workspace/readDirectory", + service: "workspace", + permission: Some("workspace.files.read"), + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/workspace/stat", + service: "workspace", + permission: Some("workspace.files.read"), + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/workspace/readFile", + service: "workspace", + permission: Some("workspace.files.read"), + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/workspace/watch", + service: "workspace", + permission: Some("workspace.files.watch"), + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/workspace/findFiles", + service: "workspace", + permission: Some("workspace.search"), + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/workspace/findText", + service: "workspace", + permission: Some("workspace.search"), + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/documents/list", + service: "documents", + permission: Some("documents.read"), + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/documents/getSnapshot", + service: "documents", + permission: Some("documents.read"), + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/editor/registerActions", + service: "editor", + permission: Some("editor.actions"), + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/editor/registerProviders", + service: "editor", + permission: Some("editor.providers"), + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/editor/getActive", + service: "editor", + permission: Some("documents.read"), + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/editor/getSelections", + service: "editor", + permission: Some("documents.read"), + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/editor/applyEdits", + service: "editor", + permission: Some("editor.edit"), + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/editor/replaceSelections", + service: "editor", + permission: Some("editor.edit"), + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/editor/insertText", + service: "editor", + permission: Some("editor.edit"), + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/editor/open", + service: "editor", + permission: Some("editor.open"), + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/editor/reveal", + service: "editor", + permission: Some("editor.open"), + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/preview/openHTML", + service: "preview", + permission: Some("preview.open"), + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/browser/openExternal", + service: "browser", + permission: Some("browser.open"), + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/window/showInformation", + service: "window", + permission: Some("window.messages"), + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/window/showWarning", + service: "window", + permission: Some("window.messages"), + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/window/showError", + service: "window", + permission: Some("window.messages"), + params: HostParams::Object, + }, + // Plan API-25: the plugin's own language servers. The host resolves + // `{ context, serverID }` to the session and Core's + // `lsp.pluginServerRequest` enforces the declared session policy. + HostMethod { + method: LANGUAGE_SERVER_REQUEST, + service: HOST_SERVICE_LANGUAGE_SERVER, + permission: Some(PERMISSION_LANGUAGE_SERVER_REQUEST), + params: HostParams::LanguageServerRequest, + }, + HostMethod { + method: LANGUAGE_SERVER_NOTIFY, + service: HOST_SERVICE_LANGUAGE_SERVER, + permission: Some(PERMISSION_LANGUAGE_SERVER_REQUEST), + params: HostParams::LanguageServerNotify, + }, + HostMethod { + method: LANGUAGE_SERVER_SET_STATUS, + service: HOST_SERVICE_LANGUAGE_SERVER, + permission: Some(PERMISSION_LANGUAGE_SERVER_REQUEST), + params: HostParams::LanguageServerStatus, + }, + // Plan API-12: read-only text of a URI scheme the backend serves. + HostMethod { + method: DOCUMENTS_REGISTER_CONTENT_PROVIDER, + service: "documents", + permission: Some(PERMISSION_DOCUMENTS_CONTENT_PROVIDER), + params: HostParams::ContentProvider, + }, +]; + +/// Host service of the plugin's own language servers (plan API-25). +pub(crate) const HOST_SERVICE_LANGUAGE_SERVER: &str = "languageServer"; + +/// Forwards one declared request to the plugin's language server. +pub(crate) const LANGUAGE_SERVER_REQUEST: &str = "lithe/host/languageServer/request"; + +/// Forwards one notification to the plugin's language server. +pub(crate) const LANGUAGE_SERVER_NOTIFY: &str = "lithe/host/languageServer/notify"; + +/// Reports the plugin's preparation state of a language server to the user. +pub(crate) const LANGUAGE_SERVER_SET_STATUS: &str = "lithe/host/languageServer/setStatus"; + +/// Registers a backend function as the content provider of a URI scheme. +pub(crate) const DOCUMENTS_REGISTER_CONTENT_PROVIDER: &str = + "lithe/host/documents/registerContentProvider"; + +/// Services every negotiated plugin-owned session has without a permission: +/// they reveal nothing beyond the session's own grants. +pub(crate) fn is_implicit_service(service: &str) -> bool { + service == HOST_SERVICE_HOST + || service == HOST_SERVICE_SUBSCRIPTIONS + || service == HOST_SERVICE_COMMANDS +} + +/// Where a backend request for a host service goes, or why it is refused. +/// `version` is the negotiated protocol version, `None` when the backend did +/// not negotiate one, and `granted` the services the plugin's permissions +/// grant the session. The params are checked here, so the host only ever sees +/// a well-formed request. A field the method does not define is +/// `plugin.host.unsupportedOption` naming it, so a later host can add it as +/// an option without a new version and a backend can tell an option this +/// host lacks from a malformed request. +pub fn host_request( + method: &str, + params: Option<&Value>, + version: Option, + granted: &BTreeSet, +) -> Result { + let entry = version.and_then(|_| HOST_METHODS.iter().find(|entry| entry.method == method)); + let (Some(version), Some(entry)) = (version, entry) else { + return Err(HostRequestRefusal { + rpc_code: -32601, + code: codes::HOST_UNAVAILABLE, + message: "The host offers no such service to this plugin process.", + details: None, + }); + }; + if !is_implicit_service(entry.service) + && !granted.contains(if entry.permission.is_some() { + entry.method + } else { + entry.service + }) + { + return Err(HostRequestRefusal { + rpc_code: host_rpc_code(codes::HOST_PERMISSION_DENIED), + code: codes::HOST_PERMISSION_DENIED, + message: "The plugin does not declare the permission for this host service.", + details: None, + }); + } + if params.is_some_and(|value| value.to_string().len() > MAX_RESULT_BYTES) { + return Err(HostRequestRefusal { + rpc_code: host_rpc_code(codes::HOST_LIMIT_EXCEEDED), + code: codes::HOST_LIMIT_EXCEEDED, + message: "Host request exceeds the byte limit.", + details: None, + }); + } + check_host_params(entry.params, params)?; + if entry.method == GET_CAPABILITIES { + return Ok(HostRoute::Answer(capabilities_answer(version, granted))); + } + if entry.service == HOST_SERVICE_FUNCTIONS { + return Ok(HostRoute::Relay); + } + Ok(HostRoute::Forward(entry.service)) +} + +/// The params the host receives for an accepted request: the backend's own, +/// except that a status message is cut to the bound a progress message has, +/// so a hostile backend cannot inflate every event that carries it. +pub fn forwarded_host_params(method: &str, params: Option<&Value>) -> Value { + let mut forwarded = params.cloned().unwrap_or(Value::Null); + if method == LANGUAGE_SERVER_SET_STATUS { + if let Some(message) = forwarded.get_mut("message") { + if let Some(text) = message.as_str() { + *message = Value::String(bounded_message(text)); + } + } + } + forwarded +} + +/// The answer to `lithe/host/getCapabilities`: the negotiated version, the +/// services and methods this session may call, where the backend runs, and +/// the bounds Core enforces. It lists only what the session is granted, so +/// it reveals nothing about other plugins. `granted` is what the host +/// passed to `lsp.startServer.hostServices`; a host passes only services it +/// serves, so a listed method never answers `plugin.host.unavailable` for +/// want of a handler. +pub(crate) fn capabilities_answer(version: u64, granted: &BTreeSet) -> Value { + let admitted = |service: &str| is_implicit_service(service) || granted.contains(service); + let mut services = serde_json::Map::new(); + let mut add = |service: &str, kind: &str, method: &str| { + let entry = services + .entry(service.to_string()) + .or_insert_with(|| json!({ "methods": [], "notifications": [] })); + if let Some(list) = entry[kind].as_array_mut() { + list.push(json!(method)); + } + }; + for entry in HOST_METHODS.iter().filter(|entry| { + if entry.permission.is_some() { + granted.contains(entry.method) + } else { + admitted(entry.service) + } + }) { + add(entry.service, "methods", entry.method); + } + for entry in HOST_NOTIFICATIONS + .iter() + .filter(|entry| entry.admitted(granted)) + { + add(entry.service, "notifications", entry.method); + } + json!({ + "protocolVersion": version, + // The host starts every plugin backend on the machine that runs + // Lithe, also for remote workspaces. + "backendAuthority": "local", + "services": services, + "limits": { + "pendingHostRequests": MAX_PENDING_HOST_REQUESTS, + "functionResultBytes": MAX_RESULT_BYTES, + "hostRequestBytes": MAX_RESULT_BYTES, + "hostResultBytes": MAX_RESULT_BYTES, + "errorMessageBytes": MAX_MESSAGE_BYTES, + "errorDetailsBytes": MAX_HOST_DETAILS_BYTES, + "notificationBytes": MAX_HOST_NOTIFICATION_BYTES, + }, + }) +} diff --git a/rust/crates/plugin-rpc/src/lib.rs b/rust/crates/plugin-rpc/src/lib.rs new file mode 100644 index 00000000..ef682db8 --- /dev/null +++ b/rust/crates/plugin-rpc/src/lib.rs @@ -0,0 +1,894 @@ +//! Plugin functions: plugin-private actions a backend registers and the host +//! invokes on behalf of the same plugin, next to the typed domain protocols. +//! +//! The protocol version is the `backend` capability version, negotiated in +//! `initialize` under `capabilities.experimental.litheFunctions`, +//! independently of `litheBuild`, so a backend can speak either one. The +//! protocol covers function registration and invocation, the host services of +//! `host_request` (credentials, `lithe/host/getCapabilities`, subscriptions, +//! workspace contexts with a workspace context on invocations, typed host +//! failure codes and details, host notifications), and the cross-plugin +//! function relay (`lithe/host/functions/*`) with the Core-written `caller` +//! of an invocation. Functions never replace a domain +//! operation: a build model, dependency tree, or task still travels over its +//! typed `lithe/build/*` method and Core's plan and decode commands. This +//! crate holds the deterministic rules (identifiers, registration, invocation +//! planning, outcome and progress normalization, and the refusal of requests a +//! backend may not make); the session engine applies them to live traffic. +//! The rules live in the plugin contract +//! (`shared/plugins/contract/v1/README.md`, "Plugin functions"). The +//! [`host_api`] commands expose the same host-method catalog to native +//! dispatchers. + +pub mod host_api; + +use lithe_plugin_language_servers::is_protocol_name; +use lithe_plugin_model::capabilities::{ + offered_versions, versions as capability_versions, BACKEND, +}; +use lithe_plugin_model::{is_function_id, MAX_FUNCTION_ID_BYTES}; +use lithe_protocol::{CoreError, ErrorCode}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::BTreeSet; + +mod failures; +mod functions; +mod host_notifications; +mod host_params; +mod host_requests; + +pub use failures::*; +pub use functions::*; +pub use host_notifications::*; +pub use host_params::*; +pub use host_requests::*; + +/// Key under `capabilities.experimental` on both sides of the exchange. +const EXPERIMENTAL_KEY: &str = "litheFunctions"; + +/// Plugin-function protocol versions this client speaks: the versions of the +/// `backend` capability. +fn supported_versions() -> Vec { + offered_versions(capability_versions(BACKEND)) +} + +/// Feature name recorded in the session's server capabilities once a backend +/// answered a supported version with a valid function list. +pub const FUNCTIONS_FEATURE: &str = "litheFunctions"; + +/// Host-to-backend request that runs one registered function. +pub const INVOKE_METHOD: &str = "lithe/function/invoke"; + +/// Backend-to-host request adding functions after `initialize`. +pub const REGISTER_METHOD: &str = "lithe/function/register"; + +/// Backend-to-host request withdrawing functions; running calls are revoked. +pub const UNREGISTER_METHOD: &str = "lithe/function/unregister"; + +/// Backend-to-host notification reporting progress of one running call. +pub const PROGRESS_METHOD: &str = "lithe/function/progress"; + +/// Upper bound of functions one session may have registered at a time. +const MAX_FUNCTIONS: usize = 256; + +const MAX_PLUGIN_ID_BYTES: usize = 128; + +const MAX_PLUGIN_ERROR_CODE_BYTES: usize = 64; + +/// Bound of a backend-supplied failure or progress message, in UTF-8 bytes. +const MAX_MESSAGE_BYTES: usize = 1024; + +/// Bound of one serialized function result. Larger documents belong in a +/// domain protocol with its own decoder, not in a plugin-private reply. +pub const MAX_RESULT_BYTES: usize = 1 << 20; + +/// Upper bound of one invocation's deadline, matching build-provider requests. +pub const MAX_FUNCTION_TIMEOUT_MS: u64 = 60 * 60_000; + +/// Host service that keeps secrets for the plugin, private to its ID. +pub const HOST_SERVICE_CREDENTIALS: &str = "credentials"; + +/// Host service listing open workspace contexts and their folders, granted +/// by `workspace.metadata` to a `backend` process. +pub const HOST_SERVICE_WORKSPACE: &str = "workspace"; + +/// Capability discovery. Every negotiated plugin-owned session has it; +/// Core answers it without involving the host. +const HOST_SERVICE_HOST: &str = "host"; + +/// Ending subscriptions other services created. Every negotiated +/// plugin-owned session has it, because only the session that holds a +/// subscription can name it. +const HOST_SERVICE_SUBSCRIPTIONS: &str = "subscriptions"; + +/// Calls into the functions other plugins export. The grant comes from the +/// manifest's `requires.pluginFunctions.calls`, not from a permission: the +/// caller's declaration plus the provider's `exports` is the consent, and +/// Core's relay checks both on every call. +pub const HOST_SERVICE_FUNCTIONS: &str = "functions"; + +/// Services granted as a whole by their aggregate grant. Every other host +/// method is granted individually, so adding a method to `workspace` or +/// `editor` never widens an existing permission. +pub(crate) const GRANTABLE_HOST_SERVICES: &[&str] = &[ + HOST_SERVICE_CREDENTIALS, + HOST_SERVICE_WORKSPACE, + HOST_SERVICE_FUNCTIONS, +]; + +/// Whether a launch grant names a service granted as a whole or an implemented +/// host method. +pub fn is_host_grant(grant: &str) -> bool { + GRANTABLE_HOST_SERVICES.contains(&grant) + || HOST_METHODS + .iter() + .any(|entry| entry.method == grant && entry.permission.is_some()) +} + +/// Methods admitted by one manifest permission. The platform intersects these +/// with its registered method handlers before starting the backend. +pub fn methods_for_permission(permission: &str) -> Vec<&'static str> { + HOST_METHODS + .iter() + .filter(|entry| entry.permission == Some(permission)) + .map(|entry| entry.method) + .collect() +} + +/// Permissions for the additive host services. +pub const HOST_PERMISSIONS: &[&str] = &[ + "workspace.files.read", + "workspace.files.watch", + "workspace.search", + "documents.read", + "editor.edit", + "editor.actions", + "editor.providers", + "editor.open", + "preview.open", + "browser.open", + "window.messages", + "terminal.create", + "keymaps.readSelectedSources", + "run.start", + "debug.start", + PERMISSION_LANGUAGE_SERVER_REQUEST, + PERMISSION_DOCUMENTS_CONTENT_PROVIDER, +]; + +/// Permission of the `languageServer` host service (plan API-25): a backend +/// may send requests and notifications to, and report the status of, the +/// language servers its own plugin declares. +pub const PERMISSION_LANGUAGE_SERVER_REQUEST: &str = "languageServer.request"; + +/// Permission of `documents/registerContentProvider` (plan API-12): the +/// backend supplies the read-only text of URIs of a scheme it registers. +pub(crate) const PERMISSION_DOCUMENTS_CONTENT_PROVIDER: &str = "documents.contentProvider"; + +const CREDENTIALS_GET: &str = "lithe/host/credentials/get"; + +const CREDENTIALS_SET: &str = "lithe/host/credentials/set"; + +const CREDENTIALS_DELETE: &str = "lithe/host/credentials/delete"; + +/// Host method that reports the protocol version and granted host services. +pub const GET_CAPABILITIES: &str = "lithe/host/getCapabilities"; + +const SUBSCRIPTIONS_DISPOSE: &str = "lithe/host/subscriptions/dispose"; + +const WORKSPACE_LIST_CONTEXTS: &str = "lithe/host/workspace/listContexts"; + +const WORKSPACE_GET_CONTEXT: &str = "lithe/host/workspace/getContext"; + +const WORKSPACE_GET_FOLDERS: &str = "lithe/host/workspace/getFolders"; + +/// Backend request listing the plugins that export a function it may call. +pub const FUNCTIONS_PROVIDERS: &str = "lithe/host/functions/providers"; + +/// Backend request calling a function another plugin exports. +pub const FUNCTIONS_INVOKE: &str = "lithe/host/functions/invoke"; + +const MAX_CREDENTIAL_KEY_BYTES: usize = 128; + +/// Bound of one stored secret, in UTF-8 bytes; enough for a password, a +/// token, or a private key, not for documents. +pub(crate) const MAX_CREDENTIAL_SECRET_BYTES: usize = 16 * 1024; + +/// Host requests one session may have awaiting the host's answer. +pub const MAX_PENDING_HOST_REQUESTS: usize = 16; + +/// Bound of the serialized `details` object of one host failure; enough +/// for a few numbers and identifiers, not for documents. +pub(crate) const MAX_HOST_DETAILS_BYTES: usize = 4 * 1024; + +/// Bound of the serialized params of one host-to-backend notification, +/// the same as one function result. +pub const MAX_HOST_NOTIFICATION_BYTES: usize = MAX_RESULT_BYTES; + +/// Stable codes of the plugin-function contract. Synchronous refusals carry +/// them in `CoreError::details`; asynchronous outcomes in the runtime error's +/// `code`; refused backend requests in the JSON-RPC error's `data.code`. +pub mod codes { + /// The session cannot serve plugin functions: it belongs to no plugin, or + /// its backend did not negotiate the protocol. + pub const UNAVAILABLE: &str = "plugin.function.unavailable"; + /// The function is not registered on the session. + pub const UNKNOWN: &str = "plugin.function.unknown"; + /// The reference names another plugin than the one owning the session. + pub const WRONG_PLUGIN: &str = "plugin.function.wrongPlugin"; + /// The plugin or function ID of the reference is malformed. + pub const INVALID_REFERENCE: &str = "plugin.function.invalidReference"; + /// Invocation params are not a JSON object. + pub const INVALID_PARAMS: &str = "plugin.function.invalidParams"; + /// A register or unregister request was refused as a whole. + pub const INVALID_REGISTRATION: &str = "plugin.function.invalidRegistration"; + /// The backend answered the invocation with an error. + pub const FAILED: &str = "plugin.function.failed"; + /// The backend's answer has no result or exceeds the result bound. + pub const INVALID_RESULT: &str = "plugin.function.invalidResult"; + /// The function was unregistered while the call was running. + pub const REVOKED: &str = "plugin.function.revoked"; + /// The backend asked the host to dispatch a domain operation or function, + /// which could route back to the backend itself. + pub const RECURSIVE_CALL: &str = "plugin.function.recursiveCall"; + /// The backend asked for a host capability this host does not offer. + pub const HOST_UNAVAILABLE: &str = "plugin.host.unavailable"; + /// The host offers the service, but the plugin's permissions do not + /// grant it to this session. + pub const HOST_PERMISSION_DENIED: &str = "plugin.host.permissionDenied"; + /// A host-service request has malformed or unknown params. + pub const HOST_INVALID_PARAMS: &str = "plugin.host.invalidParams"; + /// The host could not perform the service. + pub const HOST_FAILED: &str = "plugin.host.failed"; + /// The request names a workspace context that was closed or replaced + /// by a newer generation. + pub const HOST_CONTEXT_EXPIRED: &str = "plugin.host.contextExpired"; + /// The context's authority (for example a remote workspace) does not + /// implement the requested file or process operation. The host never + /// falls back to the local placeholder directory. + pub const HOST_AUTHORITY_UNSUPPORTED: &str = "plugin.host.authorityUnsupported"; + /// A versioned write found a newer version than the one it names. + pub const HOST_VERSION_CONFLICT: &str = "plugin.host.versionConflict"; + /// The request exceeds a host limit on size or count. Retrying the same + /// request fails again; rate limits are `HOST_RATE_LIMITED`. + pub const HOST_LIMIT_EXCEEDED: &str = "plugin.host.limitExceeded"; + /// The backend cancelled the request, or the host stopped it. + pub const HOST_CANCELLED: &str = "plugin.host.cancelled"; + /// The host's own deadline for the operation passed. + pub const HOST_TIMEOUT: &str = "plugin.host.timeout"; + /// The resource the request names (a file, a document, a window) does + /// not exist or is no longer open. + pub const HOST_NOT_FOUND: &str = "plugin.host.notFound"; + /// The resource exists but may not be changed. + pub const HOST_READ_ONLY: &str = "plugin.host.readOnly"; + /// The params are well formed, but this host does not implement an + /// option they ask for. + pub const HOST_UNSUPPORTED_OPTION: &str = "plugin.host.unsupportedOption"; + /// The resource is not in the state the operation needs, for example a + /// document with unsaved changes or of another kind; the backend changes + /// the state or the request, not merely retries. + pub const HOST_PRECONDITION_FAILED: &str = "plugin.host.preconditionFailed"; + /// The host cannot serve the request at the moment, for example while + /// the user composes text or an index loads; the same request may + /// succeed later. + pub const HOST_BUSY: &str = "plugin.host.busy"; + /// The plugin made too many requests of this kind recently; the same + /// request may succeed after a while. + pub const HOST_RATE_LIMITED: &str = "plugin.host.rateLimited"; + /// A tool definition the request references resolves to no executable + /// for this workspace; `details` carries `{tool, reason}`. + pub const HOST_TOOL_UNRESOLVED: &str = "plugin.host.toolUnresolved"; + /// The language server a `languageServer` request names is not running + /// in the context: it is starting, stopped, crashed, or not started. + pub const HOST_LANGUAGE_SERVER_UNAVAILABLE: &str = "plugin.host.languageServerUnavailable"; + /// The language server answered a forwarded request with an error; the + /// failure's `details` carry the server's `code` and `message`. + pub const HOST_LANGUAGE_SERVER_ERROR: &str = "plugin.host.languageServerError"; + /// The method or command is not in the language server's declared + /// `requestMethods`, `commands`, or allowed notifications. + pub const HOST_METHOD_NOT_DECLARED: &str = "plugin.host.methodNotDeclared"; + /// The request names a contribution, such as a language server, that + /// another plugin owns. + pub const HOST_FOREIGN_CONTRIBUTION: &str = "plugin.host.foreignContribution"; + /// A page's call or a host operation ended because the backend process + /// exited. Reported by hosts to pages, never sent to a backend. + pub const BACKEND_EXITED: &str = "plugin.backend.exited"; + + // Cross-plugin relay (`lithe/host/functions/*`). Each + // refusal has its own code and number, so a caller can branch on either. + + /// The caller's manifest does not declare a call to this function. + pub const NOT_DECLARED: &str = "plugin.function.notDeclared"; + /// The plugin that should provide the function does not export it, or + /// has not registered it with its running backend. + pub const NOT_EXPORTED: &str = "plugin.function.notExported"; + /// The providing plugin is not installed or not enabled, or was disabled + /// or removed while the call was running. + pub const PROVIDER_DISABLED: &str = "plugin.function.providerDisabled"; + /// The caller declared another contract version than the provider exports. + pub const VERSION_MISMATCH: &str = "plugin.function.versionMismatch"; + /// The provider is already on the call chain, so the call would come + /// back to a plugin that is waiting for it (including a plugin calling + /// itself). + pub const CALL_CYCLE: &str = "plugin.function.callCycle"; + /// The call chain is longer than the depth limit. + pub const DEPTH_EXCEEDED: &str = "plugin.function.depthExceeded"; + /// The calling plugin already has the most relayed calls it may have + /// running at once. + pub const TOO_MANY_CALLS: &str = "plugin.function.tooManyCalls"; + /// The relayed params or result exceed the relay's byte limit. + pub const PAYLOAD_TOO_LARGE: &str = "plugin.function.payloadTooLarge"; + /// The provider's backend could not serve the call: it did not start in + /// time, exited, or withdrew the function while the call ran. + pub const PROVIDER_UNAVAILABLE: &str = "plugin.function.providerUnavailable"; +} + +#[cfg(test)] +mod tests { + //! Golden cases from `shared/plugins/contract/v1/fixtures/backend/function-protocol.json`. + //! Timing behavior (cancellation, deadlines, late replies, revocation of a + //! running call) is covered against a scripted backend in the engine tests. + + use super::*; + + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../shared/plugins/contract/v1/fixtures/backend/function-protocol.json" + )); + + fn fixture() -> Value { + serde_json::from_str(FIXTURE).expect("the function-protocol fixture is JSON") + } + + fn cases(section: &str) -> Vec { + fixture()[section] + .as_array() + .unwrap_or_else(|| panic!("fixture section {section} is missing")) + .clone() + } + + fn id_set(value: &Value) -> BTreeSet { + value + .as_array() + .expect("an ID list") + .iter() + .map(|id| id.as_str().expect("a string ID").to_string()) + .collect() + } + + fn name(case: &Value) -> &str { + case["name"].as_str().expect("every case is named") + } + + #[test] + fn the_offer_matches_the_fixture() { + let fixture = fixture(); + let (key, offer) = client_experimental_capabilities(); + assert_eq!(fixture["protocol"], key); + assert_eq!(fixture["offer"], offer); + } + + #[test] + fn negotiation_follows_the_fixture() { + for case in cases("negotiation") { + let negotiated = negotiate(&case["capabilities"]); + let expected = &case["expected"]; + match expected["functions"].as_array() { + Some(_) => assert_eq!( + negotiated, + Some(( + expected["version"].as_u64().unwrap_or(0), + id_set(&expected["functions"]) + )), + "{}", + name(&case) + ), + None => assert_eq!(negotiated, None, "{}", name(&case)), + } + } + } + + #[test] + fn registration_follows_the_fixture() { + for case in cases("registration") { + let current = id_set(&case["registered"]); + let params = case.get("params"); + let expected = &case["expected"]; + let outcome = match case["method"].as_str() { + Some(REGISTER_METHOD) => { + apply_registration(¤t, params).map(|registry| (registry, Vec::new())) + } + Some(UNREGISTER_METHOD) => apply_unregistration(¤t, params), + other => panic!("{}: unknown method {other:?}", name(&case)), + }; + if expected["status"] == "accepted" { + let (registry, removed) = + outcome.unwrap_or_else(|reason| panic!("{}: {reason}", name(&case))); + assert_eq!(registry, id_set(&expected["functions"]), "{}", name(&case)); + let expected_removed: Vec = + id_set(&expected["removed"]).into_iter().collect(); + assert_eq!(removed, expected_removed, "{}", name(&case)); + } else { + assert_eq!(expected["code"], codes::INVALID_REGISTRATION); + assert!(outcome.is_err(), "{} should be refused", name(&case)); + } + } + } + + #[test] + fn invocation_planning_follows_the_fixture() { + for case in cases("invocations") { + let session = &case["session"]; + let functions = id_set(&session["functions"]); + let reference: FunctionReference = + serde_json::from_value(case["request"]["function"].clone()) + .unwrap_or_else(|error| panic!("{}: {error}", name(&case))); + let planned = plan_invocation( + &FunctionSession { + plugin_id: session["pluginID"].as_str(), + negotiated: session["negotiated"].as_bool().unwrap_or(false), + functions: &functions, + }, + &reference, + "operation-1", + &case["request"]["params"], + ); + let expected = &case["expected"]; + if expected["status"] == "sent" { + let params = planned.unwrap_or_else(|error| panic!("{}: {error:?}", name(&case))); + assert_eq!(expected["method"], INVOKE_METHOD, "{}", name(&case)); + assert_eq!(params, expected["params"], "{}", name(&case)); + } else { + let error = planned.expect_err(name(&case)); + assert_eq!( + error.details.as_deref(), + expected["code"].as_str(), + "{}", + name(&case) + ); + } + } + } + + #[test] + fn outcomes_follow_the_fixture() { + for case in cases("outcomes") { + let expected = &case["expected"]; + match invocation_outcome(&case["response"]) { + Ok(result) => assert_eq!(result, expected["result"], "{}", name(&case)), + Err(failure) => { + let error = &expected["error"]; + assert_eq!(failure.code, error["code"], "{}", name(&case)); + assert_eq!(failure.message, error["message"], "{}", name(&case)); + assert_eq!( + failure.plugin_error_code.as_deref(), + error["pluginErrorCode"].as_str(), + "{}", + name(&case) + ); + } + } + } + } + + #[test] + fn progress_follows_the_fixture() { + for case in cases("progress") { + let parsed = parse_progress(case.get("params")); + let expected = &case["expected"]; + if expected.is_null() { + assert_eq!(parsed, None, "{}", name(&case)); + } else { + assert_eq!( + parsed, + Some(FunctionProgress { + operation_id: expected["operationID"].as_str().unwrap().to_string(), + message: expected["message"].as_str().map(ToString::to_string), + percentage: expected["percentage"].as_u64(), + }), + "{}", + name(&case) + ); + } + } + } + + #[test] + fn backend_requests_follow_the_fixture() { + for case in cases("backendRequests") { + assert_eq!( + refused_backend_request(case["method"].as_str().unwrap()), + case["expected"]["code"].as_str(), + "{}", + name(&case) + ); + } + } + + #[test] + fn host_requests_follow_the_fixture() { + for case in cases("hostRequests") { + let granted: BTreeSet = id_set(&case["hostServices"]); + let outcome = host_request( + case["method"].as_str().unwrap(), + case.get("params"), + Some(0), + &granted, + ); + let expected = &case["expected"]; + if expected["relay"].as_bool() == Some(true) { + assert_eq!(outcome, Ok(HostRoute::Relay), "{}", name(&case)); + continue; + } + if let Some(service) = expected["forward"].as_str() { + assert_eq!( + outcome, + Ok(HostRoute::Forward( + HOST_METHODS + .iter() + .map(|entry| entry.service) + .find(|known| *known == service) + .expect("a known service") + )), + "{}", + name(&case) + ); + continue; + } + match expected.get("answer") { + Some(answer) => { + assert_eq!( + outcome, + Ok(HostRoute::Answer(answer.clone())), + "{}", + name(&case) + ) + } + None => { + let refusal = outcome.expect_err(name(&case)); + assert_eq!(refusal.code, expected["code"], "{}", name(&case)); + assert_eq!(refusal.rpc_code, expected["rpcCode"], "{}", name(&case)); + assert_eq!( + refusal.details.as_ref(), + expected.get("details"), + "{}", + name(&case) + ); + } + } + } + } + + #[test] + fn invocation_contexts_follow_the_fixture() { + for case in cases("invocationContexts") { + let mut params = case["params"].clone(); + let outcome = attach_context(&mut params, case.get("context")); + let expected = &case["expected"]; + match expected["code"].as_str() { + Some(code) => assert_eq!( + outcome.expect_err(name(&case)).details.as_deref(), + Some(code), + "{}", + name(&case) + ), + None => { + outcome.unwrap_or_else(|error| panic!("{}: {error:?}", name(&case))); + assert_eq!(params, expected["params"], "{}", name(&case)); + } + } + } + } + + #[test] + fn invocation_callers_follow_the_fixture() { + for case in cases("invocationCallers") { + let mut params = case["params"].clone(); + attach_caller(&mut params, case["caller"].as_str()); + assert_eq!(params, case["expected"]["params"], "{}", name(&case)); + } + } + + #[test] + fn relay_errors_follow_the_fixture() { + for case in cases("relayErrors") { + let code = case["code"].as_str().unwrap(); + assert_eq!(host_rpc_code(code), case["rpcCode"], "{}", name(&case)); + } + } + + /// Every failure a relayed call can end with has its own number, so a + /// caller can branch on either the string or the number. + #[test] + fn relay_refusals_have_distinct_numbers() { + let relay_codes = [ + codes::FAILED, + codes::INVALID_RESULT, + codes::NOT_DECLARED, + codes::NOT_EXPORTED, + codes::PROVIDER_DISABLED, + codes::VERSION_MISMATCH, + codes::CALL_CYCLE, + codes::DEPTH_EXCEEDED, + codes::TOO_MANY_CALLS, + codes::PAYLOAD_TOO_LARGE, + codes::PROVIDER_UNAVAILABLE, + ]; + let numbers: BTreeSet = relay_codes.iter().map(|code| host_rpc_code(code)).collect(); + assert_eq!(numbers.len(), relay_codes.len()); + assert!(!numbers.contains(&-32603)); + } + + /// The relay's discovery and invocation requests are not the recursive + /// call: a backend cannot send `lithe/function/invoke`. + #[test] + fn relay_methods_do_not_weaken_the_recursive_call_refusal() { + assert_eq!( + refused_backend_request(INVOKE_METHOD), + Some(codes::RECURSIVE_CALL) + ); + assert_eq!( + refused_backend_request("lithe/build/model"), + Some(codes::RECURSIVE_CALL) + ); + } + + #[test] + fn host_failures_follow_the_fixture() { + for case in cases("hostFailures") { + let (code, rpc_code) = host_failure(case["code"].as_str()); + assert_eq!(code, case["expected"]["code"], "{}", name(&case)); + assert_eq!(rpc_code, case["expected"]["rpcCode"], "{}", name(&case)); + } + } + + #[test] + fn host_notifications_follow_the_fixture() { + for case in cases("hostNotifications") { + let granted: BTreeSet = case["hostServices"] + .as_array() + .unwrap() + .iter() + .map(|service| service.as_str().unwrap().to_string()) + .collect(); + let outcome = host_notification( + case["method"].as_str().unwrap(), + &case["params"], + Some(0), + &granted, + ); + let expected = &case["expected"]; + match expected["code"].as_str() { + Some(code) => assert_eq!( + outcome.expect_err(name(&case)).details.as_deref(), + Some(code), + "{}", + name(&case) + ), + None => assert_eq!( + outcome.unwrap_or_else(|error| panic!("{}: {error:?}", name(&case))), + expected["delivered"].as_bool().unwrap(), + "{}", + name(&case) + ), + } + } + } + + /// Details pass only as a bounded object, so a host cannot hand a + /// backend an unbounded or untyped payload through a failure. + #[test] + fn host_failure_details_are_a_bounded_object() { + let details = json!({ "currentVersion": 7 }); + assert_eq!(bounded_host_details(Some(&details)), Some(details)); + assert_eq!(bounded_host_details(Some(&json!("text"))), None); + let oversized = json!({ "text": "x".repeat(MAX_HOST_DETAILS_BYTES) }); + assert_eq!(bounded_host_details(Some(&oversized)), None); + assert_eq!(bounded_host_details(None), None); + } + + /// An oversized notification is refused before it reaches a backend. + #[test] + fn host_notifications_are_bounded() { + let params = json!({ + "subscriptionID": "s1", + "sequence": 1, + "event": { "text": "x".repeat(MAX_HOST_NOTIFICATION_BYTES) } + }); + let refusal = host_notification(SUBSCRIPTIONS_NOTIFY, ¶ms, Some(0), &BTreeSet::new()) + .expect_err("the notification is too large"); + assert_eq!(refusal.details.as_deref(), Some(codes::HOST_INVALID_PARAMS)); + } + + /// A backend that negotiated no protocol version is offered no host + /// service and receives no host notification, whatever it was granted. + #[test] + fn an_un_negotiated_backend_gets_no_host_services() { + let granted = BTreeSet::from([HOST_SERVICE_WORKSPACE.to_string()]); + let refusal = host_request(GET_CAPABILITIES, None, None, &granted) + .expect_err("no negotiated version"); + assert_eq!(refusal.code, codes::HOST_UNAVAILABLE); + assert_eq!(refusal.rpc_code, -32601); + assert!(host_request(GET_CAPABILITIES, None, Some(0), &granted).is_ok()); + let params = json!({ "subscriptionID": "s1", "sequence": 1, "event": {} }); + assert!( + !host_notification(SUBSCRIPTIONS_NOTIFY, ¶ms, None, &granted) + .expect("a well-formed notification is skipped, not refused") + ); + assert!( + host_notification(SUBSCRIPTIONS_NOTIFY, ¶ms, Some(0), &granted) + .expect("a negotiated backend receives it") + ); + } + + /// Capability discovery lists only what the session may call. + #[test] + fn capabilities_list_only_granted_services() { + let granted = BTreeSet::from([HOST_SERVICE_WORKSPACE.to_string()]); + let answer = capabilities_answer(0, &granted); + let services = answer["services"].as_object().expect("services"); + assert!(services.contains_key(HOST_SERVICE_WORKSPACE)); + assert!(services.contains_key(HOST_SERVICE_HOST)); + assert!(services.contains_key(HOST_SERVICE_SUBSCRIPTIONS)); + assert!(!services.contains_key(HOST_SERVICE_CREDENTIALS)); + } + + /// Every grantable service has at least one method, so a grant never + /// admits nothing. + #[test] + fn grantable_services_have_methods() { + for service in GRANTABLE_HOST_SERVICES { + assert!( + HOST_METHODS.iter().any(|entry| entry.service == *service), + "{service}" + ); + } + } + + /// The secret bound is inclusive and measured in UTF-8 bytes. + #[test] + fn credential_secrets_are_bounded() { + let granted = BTreeSet::from([HOST_SERVICE_CREDENTIALS.to_string()]); + let set = |secret: String| { + host_request( + CREDENTIALS_SET, + Some(&json!({ "key": "k", "secret": secret })), + Some(0), + &granted, + ) + }; + assert!(set("x".repeat(MAX_CREDENTIAL_SECRET_BYTES)).is_ok()); + assert_eq!( + set("x".repeat(MAX_CREDENTIAL_SECRET_BYTES + 1)) + .unwrap_err() + .code, + codes::HOST_INVALID_PARAMS + ); + } + + /// Snapshot reads are separately granted and may name only bounded opaque references. + #[test] + fn keymap_snapshot_host_requests_require_grants_and_bounded_references() { + let method = "lithe/host/keymapImport/readChunk"; + let granted = BTreeSet::from([method.to_string()]); + let valid = json!({ "snapshotID": "abc-123", "providerID": "editor", "fileID": "file-1", "offset": 0, "maximumBytes": 131072 }); + assert!(matches!( + host_request(method, Some(&valid), Some(0), &granted), + Ok(HostRoute::Forward("keymapImport")) + )); + assert!(host_request(method, Some(&valid), Some(0), &BTreeSet::new()).is_err()); + for invalid in [ + json!({ "snapshotID": "../private", "providerID": "editor", "fileID": "file-1", "offset": 0, "maximumBytes": 1 }), + json!({ "snapshotID": "abc", "providerID": "editor", "fileID": "file-1", "offset": 0, "maximumBytes": 131073 }), + json!({ "snapshotID": "abc", "providerID": "editor", "fileID": "file-1", "offset": -1, "maximumBytes": 1 }), + json!({ "snapshotID": "abc", "providerID": "editor", "fileID": "file-1", "offset": 0, "maximumBytes": 1, "path": "/private" }), + ] { + assert!(host_request(method, Some(&invalid), Some(0), &granted).is_err()); + } + let list = "lithe/host/keymapImport/listInputs"; + let list_grant = BTreeSet::from([list.to_string()]); + assert!(host_request( + list, + Some(&json!({ "snapshotID": "abc", "providerID": "editor" })), + Some(0), + &list_grant + ) + .is_ok()); + assert!(host_request( + list, + Some(&json!({ "snapshotID": "abc", "providerID": "editor", "path": "secret" })), + Some(0), + &list_grant + ) + .is_err()); + } + + /// A message over the bound is cut at a character boundary, never inside + /// a multi-byte character. + #[test] + fn long_failure_messages_are_truncated_on_character_boundaries() { + let message = format!("{}é", "a".repeat(MAX_MESSAGE_BYTES - 1)); + let failure = invocation_outcome(&json!({ "error": { "code": 1, "message": message } })) + .expect_err("an error response fails"); + assert_eq!(failure.message, "a".repeat(MAX_MESSAGE_BYTES - 1)); + } + + #[test] + fn oversized_results_are_invalid() { + let result = "x".repeat(MAX_RESULT_BYTES); + let failure = invocation_outcome(&json!({ "result": result })) + .expect_err("a result over the bound is refused"); + assert_eq!(failure.code, codes::INVALID_RESULT); + } + + /// A status message is cut at the progress bound before it reaches the + /// host, at a character boundary; other methods pass untouched. + #[test] + fn forwarded_status_messages_are_bounded() { + let message = format!("{}é", "a".repeat(MAX_MESSAGE_BYTES - 1)); + let params = json!({ "serverID": "metals", "state": "preparing", "message": message }); + let forwarded = forwarded_host_params(LANGUAGE_SERVER_SET_STATUS, Some(¶ms)); + assert_eq!(forwarded["message"], "a".repeat(MAX_MESSAGE_BYTES - 1)); + assert_eq!(forwarded["state"], "preparing"); + let request = json!({ "method": "x", "message": "m".repeat(MAX_MESSAGE_BYTES + 1) }); + assert_eq!( + forwarded_host_params(LANGUAGE_SERVER_REQUEST, Some(&request)), + request + ); + assert_eq!( + forwarded_host_params(LANGUAGE_SERVER_REQUEST, None), + Value::Null + ); + } + + /// A content scheme is a lowercase RFC 3986 scheme the host does not own. + #[test] + fn content_schemes_exclude_host_owned_ones() { + for accepted in ["metals-library", "rust-std", "a", "x1+y.z"] { + assert!(is_content_scheme(accepted), "{accepted}"); + } + for refused in [ + "", "File", "1abc", "-abc", "a b", "a_b", "file", "https", "data", "lithe", "untitled", + ] { + assert!(!is_content_scheme(refused), "{refused}"); + } + assert!(is_content_scheme(&"a".repeat(64))); + assert!(!is_content_scheme(&"a".repeat(65))); + } + + /// Only a session granted the language server methods lists them, and a + /// session without the grant still lists nothing of the service. + #[test] + fn capabilities_list_language_server_methods_only_when_granted() { + let services = |granted: &[&str]| { + let granted = granted.iter().map(|method| method.to_string()).collect(); + capabilities_answer(0, &granted)["services"].clone() + }; + assert!(services(&[]).get(HOST_SERVICE_LANGUAGE_SERVER).is_none()); + let granted = services(&[LANGUAGE_SERVER_REQUEST, LANGUAGE_SERVER_SET_STATUS]); + let entry = &granted[HOST_SERVICE_LANGUAGE_SERVER]; + assert_eq!( + entry["methods"], + json!([LANGUAGE_SERVER_REQUEST, LANGUAGE_SERVER_SET_STATUS]) + ); + assert_eq!( + entry["notifications"], + json!([ + LANGUAGE_SERVER_DID_CHANGE_STATE, + LANGUAGE_SERVER_DID_RECEIVE_NOTIFICATION + ]) + ); + } + + #[test] + fn the_registry_is_bounded() { + let current: BTreeSet = (0..MAX_FUNCTIONS) + .map(|index| format!("f{index}")) + .collect(); + let error = + apply_registration(¤t, Some(&json!({ "functions": [{ "id": "extra" }] }))) + .expect_err("the session limit holds"); + assert!(error.contains("Too many")); + } +} diff --git a/rust/crates/plugin-server-policy/Cargo.toml b/rust/crates/plugin-server-policy/Cargo.toml new file mode 100644 index 00000000..597c872b --- /dev/null +++ b/rust/crates/plugin-server-policy/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "lithe-plugin-server-policy" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Session policy of a plugin-owned language server: admission, gates, joined commands and operation ownership" + +[dependencies] +lithe-document-uri = { path = "../document-uri" } +lithe-plugin-language-servers = { path = "../plugin-language-servers" } +lithe-plugin-rpc = { path = "../plugin-rpc" } +lithe-protocol = { path = "../protocol" } +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0", features = ["preserve_order"] } diff --git a/rust/crates/core/src/lsp/interface/plugin_server.rs b/rust/crates/plugin-server-policy/src/lib.rs similarity index 94% rename from rust/crates/core/src/lsp/interface/plugin_server.rs rename to rust/crates/plugin-server-policy/src/lib.rs index 866dd197..fb020bf1 100644 --- a/rust/crates/core/src/lsp/interface/plugin_server.rs +++ b/rust/crates/plugin-server-policy/src/lib.rs @@ -14,12 +14,12 @@ //! the operation's `requestCompleted` event. Cancelling, timing out, and //! failing the session therefore only pick the operations to complete. -use super::plugin_function::{codes, MAX_HOST_NOTIFICATION_BYTES}; -use crate::plugins::language_server_sessions::{ +use lithe_plugin_language_servers::{ is_protocol_name, matches_pattern, Gate, RemoteSupport, DID_CHANGE_CONFIGURATION, EXECUTE_COMMAND, }; -use crate::protocol::{CoreError, ErrorCode}; +use lithe_plugin_rpc::{codes, MAX_HOST_NOTIFICATION_BYTES}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::Deserialize; use serde_json::{json, Value}; use std::collections::{BTreeMap, BTreeSet, VecDeque}; @@ -73,7 +73,7 @@ pub struct SessionPolicy { impl SessionPolicy { /// Checks the bounds of a policy that a plugin-owned session received. - pub(super) fn validate(&self, plugin_id: Option<&str>) -> Result<(), CoreError> { + pub fn validate(&self, plugin_id: Option<&str>) -> Result<(), CoreError> { let invalid = |field: &str| { CoreError::new( ErrorCode::InvalidRequest, @@ -118,24 +118,31 @@ impl SessionPolicy { /// A request that waits for its gates to open. Core allocates the JSON-RPC /// request only when the gates open, so cancelling it writes nothing. #[derive(Debug, Clone)] -pub(super) struct HeldRequest { +pub struct HeldRequest { + /// Host operation the request belongs to. pub operation_id: String, + /// Method to send once the gates open. pub method: String, + /// Request parameters. pub params: Value, + /// When the request arrived. pub created_at: Instant, + /// When the request times out if its gates stay closed. pub deadline: Instant, } /// An operation that must be completed, with the method its event names. #[derive(Debug, Clone, PartialEq, Eq)] -pub(super) struct Waiter { +pub struct Waiter { + /// Host operation to complete. pub operation_id: String, + /// Method the completion event names. pub method: String, } /// What the engine does with an admitted request. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum Admission { +pub enum Admission { /// Allocate the request and write it to the server now. Send, /// The request waits in the policy until a gate opens. @@ -146,17 +153,22 @@ pub(super) enum Admission { } /// One request being admitted. -pub(super) struct Arrival<'a> { +pub struct Arrival<'a> { + /// Host operation the request belongs to. pub operation_id: &'a str, + /// Requested method. pub method: &'a str, + /// Request parameters. pub params: &'a Value, + /// Arrival time. pub now: Instant, + /// When the request times out. pub deadline: Instant, } /// What a server notification changed in the policy. #[derive(Debug, Default)] -pub(super) struct Observation { +pub struct Observation { /// The `pluginServerNotification` result to publish for the backend. pub forward: Option, /// Requests whose gates are now open, in arrival order. @@ -181,7 +193,7 @@ struct RunningCommand { /// Mutable part of the policy: gates opened so far, requests waiting for /// them, and serialized commands in flight. #[derive(Debug)] -pub(super) struct PluginServerState { +pub struct PluginServerState { policy: SessionPolicy, open_gates: BTreeSet, held: VecDeque, @@ -190,7 +202,9 @@ pub(super) struct PluginServerState { } impl PluginServerState { - pub(super) fn new(policy: SessionPolicy) -> Self { + /// Fresh state for a session started under `policy`: no gate open, + /// nothing held or running. + pub fn new(policy: SessionPolicy) -> Self { Self { policy, open_gates: BTreeSet::new(), @@ -203,7 +217,7 @@ impl PluginServerState { /// Refuses a call that names another plugin or another server than the /// one this session runs. The host resolves the server by ID, so a /// mismatch means it resolved a session of a different contribution. - pub(super) fn check_owner( + pub fn check_owner( &self, session_plugin_id: Option<&str>, plugin_id: &str, @@ -216,7 +230,7 @@ impl PluginServerState { } /// Decides what happens to a request the backend sent to the server. - pub(super) fn admit_request(&mut self, arrival: Arrival<'_>) -> Result { + pub fn admit_request(&mut self, arrival: Arrival<'_>) -> Result { if !self.policy.request_methods.contains(arrival.method) { return Err(not_declared("The language-server method is not declared.")); } @@ -271,7 +285,7 @@ impl PluginServerState { /// Decides whether a notification may be sent. Gates never hold a /// notification: it carries no answer a backend waits for, and the /// configuration notification may be exactly what opens a gate. - pub(super) fn admit_notification(&self, method: &str, params: &Value) -> Result<(), CoreError> { + pub fn admit_notification(&self, method: &str, params: &Value) -> Result<(), CoreError> { // A command is only run as a request, where it is checked against // the declared commands. let declared = method == DID_CHANGE_CONFIGURATION @@ -287,7 +301,7 @@ impl PluginServerState { /// Applies a notification the server sent: it may open gates and may /// be one the backend asked to receive. - pub(super) fn observe(&mut self, method: &str, params: &Value) -> Observation { + pub fn observe(&mut self, method: &str, params: &Value) -> Observation { for gate in &self.policy.gates { if gate.method == method && matches_pattern(params, &gate.pattern) { self.open_gates.insert(gate.id.clone()); @@ -308,7 +322,7 @@ impl PluginServerState { /// Whether the operation has no request on the wire: it waits behind a /// gate or joined another request. Cancelling it only completes it. - pub(super) fn is_unsent(&self, operation_id: &str) -> bool { + pub fn is_unsent(&self, operation_id: &str) -> bool { if self .held .iter() @@ -324,13 +338,13 @@ impl PluginServerState { } /// Whether the operation came through `admit_request` and is not done. - pub(super) fn owns(&self, operation_id: &str) -> bool { + pub fn owns(&self, operation_id: &str) -> bool { self.owned.contains_key(operation_id) } /// Requests whose deadline passed while they waited for a gate. Their /// ownership ends when the engine finishes them. - pub(super) fn expired_held(&self, now: Instant) -> Vec { + pub fn expired_held(&self, now: Instant) -> Vec { self.held .iter() .filter(|held| now >= held.deadline) @@ -339,14 +353,14 @@ impl PluginServerState { } /// Every request still waiting for a gate, for a session that ended. - pub(super) fn all_held(&self) -> Vec { + pub fn all_held(&self) -> Vec { self.held.iter().map(HeldRequest::waiter).collect() } /// Ends the policy's ownership of an operation the engine completes and /// returns the requests that joined it, which complete the same way. A /// joiner that finishes on its own (cancelled) just leaves the list. - pub(super) fn finish(&mut self, operation_id: &str) -> Vec { + pub fn finish(&mut self, operation_id: &str) -> Vec { self.held.retain(|held| held.operation_id != operation_id); let Some(owned) = self.owned.remove(operation_id) else { return Vec::new(); @@ -380,7 +394,7 @@ impl PluginServerState { } /// The method a finished operation was admitted with. - pub(super) fn method_of(&self, operation_id: &str) -> Option<&str> { + pub fn method_of(&self, operation_id: &str) -> Option<&str> { self.owned .get(operation_id) .map(|owned| owned.method.as_str()) @@ -455,7 +469,7 @@ impl HeldRequest { /// The `plugin.host.*` code a backend sees for a failure the engine reports /// with its own runtime code. Anything that ends a request without an /// answer from the server means the server is not usable now. -pub(super) fn host_error_code(runtime_code: &str) -> &str { +pub fn host_error_code(runtime_code: &str) -> &str { match runtime_code { "requestTimeout" => codes::HOST_TIMEOUT, "requestCancelled" => codes::HOST_CANCELLED, @@ -467,7 +481,7 @@ pub(super) fn host_error_code(runtime_code: &str) -> &str { /// Marks locations that are not files as read-only and gives them a path to /// show. The host opens such a location through the plugin's content /// provider, so it can never write the document back. -pub(super) fn normalize_navigation_result(mut result: Option) -> Option { +pub fn normalize_navigation_result(mut result: Option) -> Option { let locations = result .as_mut() .and_then(|result| result.get_mut("locations")) @@ -503,7 +517,7 @@ fn display_path(uri: &str) -> Option { None => remainder.trim_start_matches('/'), }; let path = path.split(['?', '#']).next().unwrap_or(path); - let decoded = super::client::percent_decode(path); + let decoded = lithe_document_uri::percent_decode(path); let components: Vec<&str> = decoded .split('/') .filter(|component| !component.is_empty()) @@ -517,7 +531,7 @@ fn byte_size(value: &Value) -> usize { /// The refusal for a call that reached a session of another contribution, /// or a session that is no plugin-owned language server at all. -pub(super) fn foreign_contribution() -> CoreError { +pub fn foreign_contribution() -> CoreError { refusal( ErrorCode::PermissionDenied, "The language server belongs to another contribution.", @@ -526,7 +540,7 @@ pub(super) fn foreign_contribution() -> CoreError { } /// The refusal for a call to a server that is not running yet or any more. -pub(super) fn unavailable() -> CoreError { +pub fn unavailable() -> CoreError { refusal( ErrorCode::InvalidRequest, "The language server is not ready.", diff --git a/rust/crates/plugin-web-content/Cargo.toml b/rust/crates/plugin-web-content/Cargo.toml new file mode 100644 index 00000000..667d523e --- /dev/null +++ b/rust/crates/plugin-web-content/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "lithe-plugin-web-content" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Plugin pages in host tool windows: what a page is served, the bridge requests it may make, and how replies and events are framed" + +[dependencies] +lithe-plugin-manifest = { path = "../plugin-manifest" } +lithe-plugin-model = { path = "../plugin-model" } +lithe-protocol = { path = "../protocol" } +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0", features = ["preserve_order"] } diff --git a/rust/crates/core/src/plugins/web_content.rs b/rust/crates/plugin-web-content/src/lib.rs similarity index 99% rename from rust/crates/core/src/plugins/web_content.rs rename to rust/crates/plugin-web-content/src/lib.rs index 0a8d9f1c..e23493c6 100644 --- a/rust/crates/core/src/plugins/web_content.rs +++ b/rust/crates/plugin-web-content/src/lib.rs @@ -11,8 +11,9 @@ //! Note: the decision is recorded in Lithe-IDEA's //! .agents/notes/implemented/architecture/2026-10-18-plugin-web-content-in-host-windows.md -use super::declarative::{decode_payload, validate_manifest_value}; -use crate::protocol::{CoreError, ErrorCode}; +use lithe_plugin_manifest::validate_manifest_value; +use lithe_plugin_model::decode_payload; +use lithe_protocol::{CoreError, ErrorCode}; use serde::Deserialize; use serde_json::{json, Map, Value}; use std::collections::hash_map::RandomState; @@ -980,5 +981,4 @@ pub fn event_command(payload: Value) -> Result { } #[cfg(test)] -#[path = "web_content_tests.rs"] mod tests; diff --git a/rust/crates/core/src/plugins/web_content_tests.rs b/rust/crates/plugin-web-content/src/tests.rs similarity index 99% rename from rust/crates/core/src/plugins/web_content_tests.rs rename to rust/crates/plugin-web-content/src/tests.rs index 0026dcf0..c2e80df1 100644 --- a/rust/crates/core/src/plugins/web_content_tests.rs +++ b/rust/crates/plugin-web-content/src/tests.rs @@ -4,7 +4,7 @@ use super::*; fn manifest() -> Value { serde_json::from_str(include_str!( - "../../../../../shared/plugins/contract/v1/fixtures/ui/web-panel.plugin.json" + "../../../../shared/plugins/contract/v1/fixtures/ui/web-panel.plugin.json" )) .expect("fixture") } diff --git a/rust/crates/project-generators/Cargo.toml b/rust/crates/project-generators/Cargo.toml new file mode 100644 index 00000000..9947599f --- /dev/null +++ b/rust/crates/project-generators/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "lithe-project-generators" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Project generators declared by Lithe plugins: declarations, the New Project form, generation plans and archive extraction" + +[dependencies] +lithe-plugin-model = { path = "../plugin-model" } +lithe-protocol = { path = "../protocol" } +regex = "1.11" +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0", features = ["preserve_order"] } +url = "2.5" +zip = { version = "2.4", default-features = false, features = ["deflate"] } + +[dev-dependencies] +rand = "0.8" diff --git a/rust/crates/core/src/project_generators/archive.rs b/rust/crates/project-generators/src/archive.rs similarity index 95% rename from rust/crates/core/src/project_generators/archive.rs rename to rust/crates/project-generators/src/archive.rs index 6180bdfa..371c0441 100644 --- a/rust/crates/core/src/project_generators/archive.rs +++ b/rust/crates/project-generators/src/archive.rs @@ -5,8 +5,8 @@ //! existing files are never replaced, and the bytes actually written are //! bounded regardless of what the headers claim. -use crate::plugins::declarative::{PluginRejection, Validation}; -use crate::protocol::CoreError; +use lithe_plugin_model::{PluginRejection, Validation}; +use lithe_protocol::CoreError; use std::collections::HashSet; use std::fs::{self, File, OpenOptions}; use std::io::{self, Read, Write}; @@ -51,7 +51,7 @@ pub(crate) fn extract(archive: &Path, destination: &Path) -> Result Result<(), CoreError> { - crate::protocol::cancellation::check() + lithe_protocol::cancellation::check() } fn extract_with_checkpoint( @@ -419,13 +419,13 @@ mod tests { let destination = directory.path().join("project"); fs::create_dir(&destination).unwrap(); let id = format!("archive-chunks-{}", rand::random::()); - let _scope = crate::protocol::cancellation::Scope::begin(Some(id.clone()), Some(3000)); + let _scope = lithe_protocol::cancellation::Scope::begin(Some(id.clone()), Some(3000)); let result = extract_with_checkpoint(&path, &destination, MAX_EXTRACTED_BYTES, &mut || { // Observe one completed write, then cancel before another chunk. if fs::metadata(destination.join("large.txt")) .is_ok_and(|metadata| metadata.len() >= COPY_CHUNK_BYTES as u64) { - assert!(crate::protocol::cancellation::cancel(&id)); + assert!(lithe_protocol::cancellation::cancel(&id)); } checkpoint() }); @@ -449,7 +449,7 @@ mod tests { { // An already reached deadline controls the event order without // sleeping or relying on the speed of filesystem extraction. - crate::protocol::cancellation::with_cleanup_deadline( + lithe_protocol::cancellation::with_cleanup_deadline( std::time::Duration::ZERO, checkpoint, ) @@ -460,7 +460,7 @@ mod tests { let Err(ExtractionError::Interrupted(error)) = result else { panic!("chunk timeout must remain an operation interruption"); }; - assert!(matches!(error.code, crate::protocol::ErrorCode::TimedOut)); + assert!(matches!(error.code, lithe_protocol::ErrorCode::TimedOut)); assert_eq!( fs::metadata(destination.join("large.txt")).unwrap().len(), COPY_CHUNK_BYTES as u64 @@ -542,16 +542,14 @@ mod tests { let payload = serde_json::json!({"archivePath": path, "destination": destination}); let id = format!("archive-command-{}", rand::random::()); { - let _scope = crate::protocol::cancellation::Scope::begin(Some(id.clone()), None); - assert!(crate::protocol::cancellation::cancel(&id)); - let error = crate::project_generators::project_extract_archive_command(payload.clone()) - .unwrap_err(); - assert!(matches!(error.code, crate::protocol::ErrorCode::Cancelled)); + let _scope = lithe_protocol::cancellation::Scope::begin(Some(id.clone()), None); + assert!(lithe_protocol::cancellation::cancel(&id)); + let error = crate::project_extract_archive_command(payload.clone()).unwrap_err(); + assert!(matches!(error.code, lithe_protocol::ErrorCode::Cancelled)); } - crate::protocol::cancellation::with_cleanup_deadline(std::time::Duration::ZERO, || { - let error = - crate::project_generators::project_extract_archive_command(payload).unwrap_err(); - assert!(matches!(error.code, crate::protocol::ErrorCode::TimedOut)); + lithe_protocol::cancellation::with_cleanup_deadline(std::time::Duration::ZERO, || { + let error = crate::project_extract_archive_command(payload).unwrap_err(); + assert!(matches!(error.code, lithe_protocol::ErrorCode::TimedOut)); }); assert_eq!(fs::read_dir(destination).unwrap().count(), 0); } diff --git a/rust/crates/core/src/project_generators/commands.rs b/rust/crates/project-generators/src/commands.rs similarity index 82% rename from rust/crates/core/src/project_generators/commands.rs rename to rust/crates/project-generators/src/commands.rs index 1e1ae0f7..494c8fb1 100644 --- a/rust/crates/core/src/project_generators/commands.rs +++ b/rust/crates/project-generators/src/commands.rs @@ -9,12 +9,10 @@ use super::archive; use super::form::{self, FormInput}; use super::manifest::{GeneratorSection, ProjectGeneratorDeclaration, FIELD_TOOL}; use super::plan; -use crate::plugins::capabilities::HostCapabilities; -use crate::plugins::declarative::{ - decode_payload, outcome, validate_manifest_value, DeclarativePluginManifest, PluginRejection, - Validation, -}; -use crate::protocol::CoreError; +use super::GeneratorManifest; +use lithe_plugin_model::capabilities::{self, HostCapabilities}; +use lithe_plugin_model::{decode_payload, outcome, PluginRejection, Validation}; +use lithe_protocol::CoreError; use serde::Deserialize; use serde_json::{json, Value}; use std::path::PathBuf; @@ -71,8 +69,11 @@ struct ExtractPayload { } /// `project.generatorCatalog`: every generator of the given plugins, with -/// the plugins that provide each requirement. -pub(crate) fn project_generator_catalog_command(payload: Value) -> Result { +/// the plugins that provide each requirement. Manifests the plugin host's +/// validation (`M::validate`) rejects are reported in `issues`. +pub fn project_generator_catalog_command( + payload: Value, +) -> Result { let payload: CatalogPayload = decode_payload(payload, "project.generatorCatalog")?; let mut manifests = Vec::new(); let mut issues = Vec::new(); @@ -82,9 +83,9 @@ pub(crate) fn project_generator_catalog_command(payload: Value) -> Result Result bool| -> Vec { + let providers = |matches: &dyn Fn(&M) -> bool| -> Vec { manifests .iter() .filter(|(manifest, _)| matches(manifest)) @@ -120,19 +121,12 @@ pub(crate) fn project_generator_catalog_command(payload: Value) -> Result ( "languageID", id, - providers(&|candidate| { - candidate.language_ids().any(|language| language == id) - }), + providers(&|candidate| candidate.declares_language(id)), ), (_, Some(id)) => ( "buildSystemID", id, - providers(&|candidate| { - candidate - .build_systems() - .iter() - .any(|system| &system.id == id) - }), + providers(&|candidate| candidate.declares_build_system(id)), ), _ => unreachable!("validated requirement names one ID"), }; @@ -167,13 +161,16 @@ pub(crate) fn project_generator_catalog_command(payload: Value) -> Result = dyn Fn(&dyn Fn(&M) -> bool) -> Vec + 'a; + /// The plugins that declare the tool each tool field selects, so a field with /// no installation can tell the user which plugin to enable or install. Core /// only names declarations; whether a definition is trusted, and whether the /// tool is installed, is the host's registry and discovery. -fn tool_providers( +fn tool_providers( generator: &ProjectGeneratorDeclaration, - providers: &dyn Fn(&dyn Fn(&DeclarativePluginManifest) -> bool) -> Vec, + providers: &ProviderSearch<'_, M>, ) -> Vec { generator .fields @@ -191,9 +188,11 @@ fn tool_providers( } /// `project.generatorForm`: field values, visibility, and problems. -pub(crate) fn project_generator_form_command(payload: Value) -> Result { +pub fn project_generator_form_command( + payload: Value, +) -> Result { let payload: FormPayload = decode_payload(payload, "project.generatorForm")?; - Ok(outcome(with_generator( + Ok(outcome(with_generator::( payload.manifest, &payload.generator_id, |generator| Ok(("form", form::evaluate(generator, &payload.form).to_json())), @@ -201,9 +200,11 @@ pub(crate) fn project_generator_form_command(payload: Value) -> Result Result { +pub fn project_generation_plan_command( + payload: Value, +) -> Result { let payload: PlanPayload = decode_payload(payload, "project.generationPlan")?; - Ok(outcome(with_generator( + Ok(outcome(with_generator::( payload.manifest, &payload.generator_id, |generator| { @@ -213,7 +214,7 @@ pub(crate) fn project_generation_plan_command(payload: Value) -> Result Result { +pub fn project_check_name_command(payload: Value) -> Result { let payload: CheckNamePayload = decode_payload(payload, "project.checkName")?; Ok(outcome(Ok(( "name", @@ -222,7 +223,7 @@ pub(crate) fn project_check_name_command(payload: Value) -> Result Result { +pub fn project_extract_archive_command(payload: Value) -> Result { let payload: ExtractPayload = decode_payload(payload, "project.extractArchive")?; let result = match archive::extract(&payload.archive_path, &payload.destination) { Ok(files) => Ok(files), @@ -236,12 +237,12 @@ pub(crate) fn project_extract_archive_command(payload: Value) -> Result( manifest: Value, generator_id: &str, run: impl FnOnce(&ProjectGeneratorDeclaration) -> Validation<(&'static str, Value)>, ) -> Validation<(&'static str, Value)> { - let manifest = validate_manifest_value(manifest)?; + let manifest = M::validate(manifest)?; let generator = manifest .project_generators() .iter() diff --git a/rust/crates/core/src/project_generators/form.rs b/rust/crates/project-generators/src/form.rs similarity index 99% rename from rust/crates/core/src/project_generators/form.rs rename to rust/crates/project-generators/src/form.rs index 8c7e941d..2bc1c8e4 100644 --- a/rust/crates/core/src/project_generators/form.rs +++ b/rust/crates/project-generators/src/form.rs @@ -10,7 +10,7 @@ use super::manifest::{ FIELD_MULTI_SELECT, FIELD_TEXT, FIELD_TOOL, }; use super::template::{FieldValue, Values}; -use crate::plugins::declarative::version_components; +use lithe_plugin_model::version_components; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::{BTreeMap, BTreeSet}; diff --git a/rust/crates/core/src/project_generators/mod.rs b/rust/crates/project-generators/src/lib.rs similarity index 56% rename from rust/crates/core/src/project_generators/mod.rs rename to rust/crates/project-generators/src/lib.rs index 968b80f6..adb79334 100644 --- a/rust/crates/core/src/project_generators/mod.rs +++ b/rust/crates/project-generators/src/lib.rs @@ -31,5 +31,28 @@ mod manifest; mod plan; mod template; -pub(crate) use commands::*; -pub(crate) use manifest::{programs, validate_declarations, ProjectGeneratorDeclaration}; +pub use commands::{ + project_check_name_command, project_extract_archive_command, project_generation_plan_command, + project_generator_catalog_command, project_generator_form_command, +}; +pub use manifest::{programs, validate_declarations, ProjectGeneratorDeclaration}; + +use lithe_plugin_model::capabilities::Requires; +use lithe_plugin_model::PluginManifest; + +/// What the `project.*` commands read from one plugin manifest that the +/// plugin host validated; the host's manifest type implements it. +pub trait GeneratorManifest: PluginManifest { + /// The name shown to the user. + fn display_name(&self) -> &str; + /// Capability versions the manifest requires. + fn requires(&self) -> &Requires; + /// The validated `projectGenerators`, in declaration order. + fn project_generators(&self) -> &[ProjectGeneratorDeclaration]; + /// Whether the plugin declares the language `language_id`. + fn declares_language(&self, language_id: &str) -> bool; + /// Whether the plugin declares the build system `build_system_id`. + fn declares_build_system(&self, build_system_id: &str) -> bool; + /// Whether the plugin declares a definition of the tool `tool` names. + fn declares_tool(&self, tool: &str) -> bool; +} diff --git a/rust/crates/core/src/project_generators/manifest.rs b/rust/crates/project-generators/src/manifest.rs similarity index 98% rename from rust/crates/core/src/project_generators/manifest.rs rename to rust/crates/project-generators/src/manifest.rs index e8830c61..fd73a5fd 100644 --- a/rust/crates/core/src/project_generators/manifest.rs +++ b/rust/crates/project-generators/src/manifest.rs @@ -1,9 +1,9 @@ //! The `projectGenerators` section of a manifest and its validation. use super::template::{self, Source, Variable}; -use crate::plugins::declarative::{ - invalid, is_bare_command, is_safe_relative_path, is_setting_identifier, TemplatePart, UIIcon, - Validation, +use lithe_plugin_model::settings::is_setting_identifier; +use lithe_plugin_model::{ + invalid, is_bare_command, is_safe_relative_path, TemplatePart, UIIcon, Validation, }; use regex::Regex; use serde::{Deserialize, Serialize}; @@ -39,7 +39,7 @@ const MAX_TIMEOUT_SECONDS: u32 = 1800; /// One way to start a project, listed in the New Project window. #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct ProjectGeneratorDeclaration { +pub struct ProjectGeneratorDeclaration { /// Lowercase letters, digits, and hyphens; unique within the plugin. pub(crate) id: String, pub(crate) display_name: String, @@ -353,7 +353,7 @@ impl GeneratorStep { /// is the approved value, so an update that adds a command or changes what /// one runs is a new program the user must approve. File and archive steps /// start no process and are covered by the `project.generate` permission. -pub(crate) fn programs(generator: &ProjectGeneratorDeclaration) -> Vec { +pub fn programs(generator: &ProjectGeneratorDeclaration) -> Vec { generator .steps .iter() @@ -416,7 +416,7 @@ impl GeneratorField { /// Validates every generator and sorts fields-free collections that have no /// meaningful order. Field and step order are kept: fields are drawn and /// defaults evaluated in order, steps run in order. -pub(crate) fn validate_declarations(generators: &[ProjectGeneratorDeclaration]) -> Validation<()> { +pub fn validate_declarations(generators: &[ProjectGeneratorDeclaration]) -> Validation<()> { if generators.len() > MAX_GENERATORS { return Err(invalid( "projectGenerators", @@ -670,7 +670,7 @@ impl GeneratorField { if !self .tool .as_deref() - .is_some_and(crate::toolchain::definitions::tool_id) + .is_some_and(lithe_plugin_model::is_tool_id) { return Err(invalid( format!("{field}.tool"), @@ -678,7 +678,7 @@ impl GeneratorField { )); } if let Some(version) = &self.minimum_version { - crate::plugins::declarative::validate_minimum_version( + lithe_plugin_model::validate_minimum_version( version, &format!("{field}.minimumVersion"), )?; diff --git a/rust/crates/core/src/project_generators/plan.rs b/rust/crates/project-generators/src/plan.rs similarity index 97% rename from rust/crates/core/src/project_generators/plan.rs rename to rust/crates/project-generators/src/plan.rs index f3891f50..d6c4df0c 100644 --- a/rust/crates/core/src/project_generators/plan.rs +++ b/rust/crates/project-generators/src/plan.rs @@ -3,7 +3,7 @@ use super::form::{self, FormInput, FormState}; use super::manifest::{variable_is_known, GeneratorStep, ProjectGeneratorDeclaration, FIELD_TOOL}; use super::template::{self, FieldValue, Values, Variable}; -use crate::plugins::declarative::{is_safe_relative_path, PluginRejection, Validation}; +use lithe_plugin_model::{is_safe_relative_path, PluginRejection, Validation}; use serde_json::{json, Value}; use std::fs; use std::path::{Path, PathBuf}; @@ -296,10 +296,8 @@ fn template_files( let parts = template::parse(&relative_text) .filter(|parts| { parts.iter().all(|part| match part { - crate::plugins::declarative::TemplatePart::Variable(variable) => { - known(variable) - } - crate::plugins::declarative::TemplatePart::Text(_) => true, + lithe_plugin_model::TemplatePart::Variable(variable) => known(variable), + lithe_plugin_model::TemplatePart::Text(_) => true, }) }) .ok_or_else(|| template_error(format!("{relative_text} names an unknown variable.")))?; diff --git a/rust/crates/core/src/project_generators/template.rs b/rust/crates/project-generators/src/template.rs similarity index 99% rename from rust/crates/core/src/project_generators/template.rs rename to rust/crates/project-generators/src/template.rs index ca98af17..3d9502be 100644 --- a/rust/crates/core/src/project_generators/template.rs +++ b/rust/crates/project-generators/src/template.rs @@ -6,7 +6,7 @@ //! must reach the project unchanged. In files, only `{{` followed by //! `project.` or `field.` is a variable; any other `{{` is copied as text. -use crate::plugins::declarative::{parse_template_with, TemplatePart}; +use lithe_plugin_model::{parse_template_with, TemplatePart}; use std::collections::BTreeMap; /// What a variable reads. diff --git a/rust/crates/project/Cargo.toml b/rust/crates/project/Cargo.toml new file mode 100644 index 00000000..c0393e0c --- /dev/null +++ b/rust/crates/project/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "lithe-project" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Project services for Lithe: workspace files, indexing policy, search, local history and document rendering" + +[features] +# Lets lithe-core's tests trace which directories a workspace scan enumerates. +test-support = [] + +[dependencies] +ammonia = "4.1.4" +base64 = "0.22" +comrak = { version = "0.54.0", default-features = false, features = ["shortcodes"] } +encoding_rs = "0.8" +lithe-core-rules = { path = "../core-rules" } +lithe-diagnostics = { path = "../diagnostics" } +lithe-git = { path = "../git" } +lithe-protocol = { path = "../protocol" } +lithe-request-context = { path = "../request-context" } +lithe-workspace = { path = "../workspace" } +regex = "1.11" +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0", features = ["preserve_order"] } +sha2 = "0.10" +url = "2.5" diff --git a/rust/crates/project/src/contracts.rs b/rust/crates/project/src/contracts.rs new file mode 100644 index 00000000..d8399a55 --- /dev/null +++ b/rust/crates/project/src/contracts.rs @@ -0,0 +1,204 @@ +//! Serializable response models of the project services: workspace snapshots, +//! directory listings, search results, file reads and writes, and local history. + +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// One file or directory in a deterministic workspace tree. +pub struct WorkspaceNode { + /// Normalized workspace-relative path; empty for the root. + pub path: String, + /// Last path component, or the root folder's name. + pub name: String, + /// Whether this node is a directory. + pub is_directory: bool, + /// Omitted for unloaded directories/files; an empty array is a known directory leaf. + #[serde(skip_serializing_if = "Option::is_none")] + pub children: Option>, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Complete visible workspace tree and its flattened file paths. +pub struct WorkspaceSnapshotResponse { + /// Optional directories that could not be read; hosts must not infer deletions from this inventory. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub incomplete_directories: Vec, + /// Root of the visible tree; it has no children when the request skipped the tree. + pub root: WorkspaceNode, + /// Every visible workspace-relative file path. + pub files: Vec, + /// Linked worktrees checked out inside their own repository. They are left + /// out of `root` and `files`; hosts use them to ignore watcher events. + pub nested_worktrees: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Visible children of the directories requested by `workspace.listDirectories`. +pub struct DirectoryListingResponse { + /// One entry per requested directory that still exists and is visible, + /// in request order. + pub directories: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// One directory's children, each without its own children. +pub struct DirectoryListing { + /// Normalized workspace-relative path; empty for the root. + pub path: String, + /// Directories first, then case-insensitive names, as in snapshots. + pub children: Vec, + /// Nested linked worktrees directly inside this directory, which + /// `children` leaves out. + pub nested_worktrees: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// File, content, or symbol match with optional source location. +pub struct SearchMatch { + /// Result category: file path, file content, or symbol. + pub kind: String, + /// Workspace-relative path of the matched file. + pub path: String, + /// One-based line of a content match; absent for file and symbol matches. + pub line: Option, + /// Text shown for the match: the trimmed line, the path, or the symbol name. + pub preview: String, + /// Symbol name of a symbol match. + #[serde(skip_serializing_if = "Option::is_none")] + pub symbol_name: Option, + /// Matching fragments in the displayed name, measured in UTF-16 code units. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub name_match_ranges: Vec, + /// Original content line, present only when requested by the host. + #[serde(skip_serializing_if = "Option::is_none")] + pub source_line: Option, + /// Content match ranges in UTF-16 units of `sourceLine`. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub content_match_ranges: Vec, + /// Name relevance computed before category truncation; absent for content search. + #[serde(skip_serializing_if = "Option::is_none")] + pub matching_degree: Option, + /// Git ignore status for a returned name; absent when Git cannot answer. + #[serde(skip_serializing_if = "Option::is_none")] + pub is_ignored: Option, +} + +/// One half-open matched fragment in a displayed file or symbol name. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchNameRange { + /// First matched UTF-16 code unit. + pub start: usize, + /// Matched length in UTF-16 code units. + pub length: usize, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Bounded, deterministically ordered search matches. +pub struct SearchResponse { + /// Matches in result order, already capped per category. + pub matches: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Preview of replacements occurring on one source line. +pub struct ReplacementMatch { + /// One-based line holding the replaced occurrences. + pub line: usize, + /// The line before replacement. + pub before: String, + /// The line after every occurrence on it is replaced. + pub after: String, + /// Number of occurrences replaced on this line. + pub occurrence_count: usize, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// All preview matches and resulting text for one file. +pub struct ReplacementFile { + /// Workspace-relative path of the file. + pub path: String, + /// Changed lines in line order. + pub matches: Vec, + /// Whole file text after replacement. + pub replacement_text: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Non-mutating replacement preview grouped by workspace-relative path. +pub struct ReplacementPreviewResponse { + /// Files with at least one replacement, sorted by path. + pub files: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Decoded contents read from one workspace-relative path. +pub struct FileReadResponse { + /// Normalized workspace-relative path that was read. + pub path: String, + /// Decoded text without the byte order mark. + pub text: String, + /// Stable id of the encoding used to decode `text`. + pub encoding: String, + /// A leading byte order mark was present and is not part of `text`. + pub has_bom: bool, + /// Invalid bytes were replaced with U+FFFD (explicit `encoding` only). + pub lossy: bool, + /// SHA-256 (lowercase hex) of the exact bytes read. + pub identity: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Path and byte count produced by a successful file write. +pub struct FileWriteResponse { + /// Normalized workspace-relative path that was written. + pub path: String, + /// Encoded length including any byte order mark. + pub bytes_written: usize, + /// Stable id of the encoding the text was written with. + pub encoding: String, + /// A byte order mark was written. + pub has_bom: bool, + /// SHA-256 (lowercase hex) of the exact bytes written. + pub identity: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Metadata for one retained local-history snapshot. +pub struct HistoryEntryResponse { + /// Snapshot identity, unique within the file's history. + pub id: String, + /// Capture time in seconds since the Unix epoch. + pub timestamp: i64, + /// Workspace-relative path of the file the snapshot belongs to. + pub relative_path: String, + /// Why the snapshot was taken, such as `projectBaseline`; see [`crate::is_valid_reason`]. + pub reason: String, + /// Storage-relative path of the snapshot content, read with `history.content`. + pub content_path: String, + /// Size of the snapshot content in bytes. + pub byte_count: usize, + /// User label assigned through `history.rename`. + #[serde(skip_serializing_if = "Option::is_none")] + pub label: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Local-history entries in deterministic newest-first order. +pub struct HistoryEntriesResponse { + /// Snapshot metadata, newest first. + pub entries: Vec, +} diff --git a/rust/crates/core/src/project/directory_actions.rs b/rust/crates/project/src/directory_actions.rs similarity index 98% rename from rust/crates/core/src/project/directory_actions.rs rename to rust/crates/project/src/directory_actions.rs index 60951c37..6b7c56e6 100644 --- a/rust/crates/core/src/project/directory_actions.rs +++ b/rust/crates/project/src/directory_actions.rs @@ -17,7 +17,7 @@ //! The sequence contract is `rust/fixtures/workspace/directory-actions-v1.json`; the //! request and response shapes are pinned by `directory-rules-v1.json`. -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::BTreeMap; @@ -90,7 +90,7 @@ struct RelocateRequest { /// exclusion that a manually excluded ancestor still enforces; the state is then the /// input. Excluding remembers the replaced role and canceling restores it; changing a role /// keeps include exceptions; excluding deletes only the directory's own exception. -pub(crate) fn reduce_command(payload: Value) -> Result { +pub fn reduce_command(payload: Value) -> Result { let request: ReduceRequest = parse(payload)?; validate_path(&request.path)?; validate_rules(&request.rules)?; @@ -154,7 +154,7 @@ pub(crate) fn reduce_command(payload: Value) -> Result { /// With a `destination` the key is rewritten under it, in `target` for a move across /// workspace roots and in `rules` otherwise; without one the keys are dropped. `target` is /// absent from the response unless the request had one. -pub(crate) fn relocate_command(payload: Value) -> Result { +pub fn relocate_command(payload: Value) -> Result { let request: RelocateRequest = parse(payload)?; validate_path(&request.source)?; if let Some(destination) = &request.destination { diff --git a/rust/crates/core/src/project/document_lifecycle.rs b/rust/crates/project/src/document_lifecycle.rs similarity index 95% rename from rust/crates/core/src/project/document_lifecycle.rs rename to rust/crates/project/src/document_lifecycle.rs index 61fc961c..24c215d0 100644 --- a/rust/crates/core/src/project/document_lifecycle.rs +++ b/rust/crates/project/src/document_lifecycle.rs @@ -1,6 +1,6 @@ //! Deterministic document persistence states shared by every platform editor. -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] @@ -88,22 +88,35 @@ impl DocumentLifecycleState { pub enum DocumentLifecycleEvent { /// The platform editor applied local text and advanced its revision. Edited { + /// Editor revision after the edit. revision: u64, /// Whether the new editor text equals the last persisted snapshot. matches_saved_content: bool, }, /// A caller is ready to persist the current immutable snapshot. - SaveStarted { operation_id: String }, + SaveStarted { + /// Identity of the save that now owns persistence. + operation_id: String, + }, /// The write owned by the matching operation completed successfully. - SaveSucceeded { operation_id: String }, + SaveSucceeded { + /// Identity of the save that completed. + operation_id: String, + }, /// The write owned by the matching operation failed. - SaveFailed { operation_id: String }, + SaveFailed { + /// Identity of the save that failed. + operation_id: String, + }, /// A watcher observed a disk change not attributed to the current save. ExternalChanged, /// The platform observed a missing file or rejected a guarded write. DiskConflict, /// A requested disk reload completed and installed this revision. - ReloadSucceeded { revision: u64 }, + ReloadSucceeded { + /// Editor revision holding the reloaded text. + revision: u64, + }, /// The user chose the live editor text after a conflict. KeepEditor, /// The user requested that disk replace the live editor text. @@ -132,7 +145,9 @@ pub enum DocumentLifecycleAction { #[serde(rename_all = "camelCase")] /// Request for one deterministic document lifecycle transition. pub struct DocumentLifecycleRequest { + /// Current persistence state of the document. pub state: DocumentLifecycleState, + /// Event to apply to `state`. pub event: DocumentLifecycleEvent, } @@ -140,7 +155,9 @@ pub struct DocumentLifecycleRequest { #[serde(rename_all = "camelCase")] /// Next state and platform effect for one lifecycle event. pub struct DocumentLifecycleDecision { + /// State after the event. pub state: DocumentLifecycleState, + /// Work the platform must do next. pub action: DocumentLifecycleAction, } diff --git a/rust/crates/core/src/project/document_save_batch.rs b/rust/crates/project/src/document_save_batch.rs similarity index 96% rename from rust/crates/core/src/project/document_save_batch.rs rename to rust/crates/project/src/document_save_batch.rs index c60a2cdf..218efc6a 100644 --- a/rust/crates/core/src/project/document_save_batch.rs +++ b/rust/crates/project/src/document_save_batch.rs @@ -9,12 +9,12 @@ use std::collections::{HashMap, HashSet}; -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use serde_json::Value; /// Maximum number of open documents accepted by one batch request. -pub(crate) const MAX_SAVE_BATCH_DOCUMENTS: usize = 10_000; +pub const MAX_SAVE_BATCH_DOCUMENTS: usize = 10_000; /// Persistence facts a platform reports for one open document. #[derive(Debug, Deserialize)] @@ -104,7 +104,7 @@ struct ReduceResponse { /// A writable document takes part when it is dirty or when its editor view may /// hold unsynchronized input. Returns `invalidRequest` for malformed payloads, /// empty or duplicate identities, or more than [`MAX_SAVE_BATCH_DOCUMENTS`]. -pub(crate) fn plan_command(payload: Value) -> Result { +pub fn plan_command(payload: Value) -> Result { let request: PlanRequest = decode(payload)?; validate_documents(&request.documents)?; let members = request @@ -123,7 +123,7 @@ pub(crate) fn plan_command(payload: Value) -> Result { /// /// Returns `invalidRequest` when results do not name each member exactly once, /// in addition to the document validation of [`plan_command`]. -pub(crate) fn reduce_command(payload: Value) -> Result { +pub fn reduce_command(payload: Value) -> Result { let request: ReduceRequest = decode(payload)?; validate_documents(&request.documents)?; if request.members.len() > MAX_SAVE_BATCH_DOCUMENTS { diff --git a/rust/crates/core/src/project/encoding.rs b/rust/crates/project/src/encoding.rs similarity index 99% rename from rust/crates/core/src/project/encoding.rs rename to rust/crates/project/src/encoding.rs index 7db417af..467ba58d 100644 --- a/rust/crates/core/src/project/encoding.rs +++ b/rust/crates/project/src/encoding.rs @@ -11,7 +11,7 @@ use encoding_rs::{ }; use sha2::{Digest, Sha256}; -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; /// UTF-8 byte order mark. const UTF8_BOM: [u8; 3] = [0xEF, 0xBB, 0xBF]; diff --git a/rust/crates/project/src/file_name_order.rs b/rust/crates/project/src/file_name_order.rs new file mode 100644 index 00000000..25878dd2 --- /dev/null +++ b/rust/crates/project/src/file_name_order.rs @@ -0,0 +1,78 @@ +//! `workspace.orderEntries`: orders natively enumerated siblings with the shared +//! file name order in `lithe_core_rules::file_names`. + +use lithe_core_rules::file_names::FileNameKey; +use lithe_protocol::{CoreError, ErrorCode}; +use serde::Deserialize; +use serde_json::{json, Value}; + +/// Upper bound for one `workspace.orderEntries` request, matching the plugin directory scan +/// budget; hosts send one directory at a time. +const MAX_ORDER_ENTRIES: usize = 100_000; +/// Longest accepted sibling name, in UTF-8 bytes; longer names are rejected, never truncated. +const MAX_ORDER_NAME_BYTES: usize = 4_096; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// Siblings of one natively enumerated directory, in enumeration order. +struct OrderEntriesRequest { + entries: Vec, + /// Ranks directories before files, the Project view default; `false` orders by name only. + #[serde(default = "directories_first_default")] + directories_first: bool, +} + +fn directories_first_default() -> bool { + true +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// One sibling name and its kind; the host keeps the full entry and only uses the index. +struct OrderEntry { + name: String, + is_directory: bool, +} + +/// Orders natively enumerated siblings for `workspace.orderEntries`. +/// +/// Returns `{ order }`, the request indices in display order. The order is total and +/// deterministic: names compare with IDEA's `FileNameComparator`, and identical names +/// keep their request order. Oversized requests fail with `invalid_request`. +pub fn order_entries_command(payload: Value) -> Result { + let request: OrderEntriesRequest = serde_json::from_value(payload).map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid entry order request") + .with_details(error.to_string()) + })?; + if request.entries.len() > MAX_ORDER_ENTRIES { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Too many entries to order", + )); + } + if request + .entries + .iter() + .any(|entry| entry.name.len() > MAX_ORDER_NAME_BYTES) + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Entry name is too long to order", + )); + } + let directories_first = request.directories_first; + let mut keyed = request + .entries + .into_iter() + .enumerate() + .map(|(index, entry)| { + ( + directories_first && !entry.is_directory, + FileNameKey::new(&entry.name), + index, + ) + }) + .collect::>(); + keyed.sort(); + Ok(json!({ "order": keyed.into_iter().map(|(_, _, index)| index).collect::>() })) +} diff --git a/rust/crates/core/src/project/file_operations.rs b/rust/crates/project/src/file_operations.rs similarity index 96% rename from rust/crates/core/src/project/file_operations.rs rename to rust/crates/project/src/file_operations.rs index 36bdd2b2..42cb3fd7 100644 --- a/rust/crates/core/src/project/file_operations.rs +++ b/rust/crates/project/src/file_operations.rs @@ -12,7 +12,7 @@ //! reports that the name now exists, add that name to `existing` and plan again. Core //! never touches the file system. -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::Deserialize; use serde_json::{json, Value}; use std::collections::HashSet; @@ -61,7 +61,7 @@ struct PlanPasteRequest { /// /// Returns `{ name }`. The name always carries the copy suffix, because a duplicate lives /// next to its source. -pub(crate) fn plan_duplicate_command(payload: Value) -> Result { +pub fn plan_duplicate_command(payload: Value) -> Result { let request: PlanDuplicateRequest = serde_json::from_value(payload).map_err(invalid)?; let item = CopyItem { name: request.name, @@ -81,7 +81,7 @@ pub(crate) fn plan_duplicate_command(payload: Value) -> Result /// Returns `{ names }`, one per item in request order. An item keeps its own name when /// that name is free; otherwise it receives the copy suffix. Items planned earlier in the /// same paste count as taken, so two sources with the same name never collide. -pub(crate) fn plan_paste_command(payload: Value) -> Result { +pub fn plan_paste_command(payload: Value) -> Result { let request: PlanPasteRequest = serde_json::from_value(payload).map_err(invalid)?; if request.items.len() > MAX_PASTE_ITEMS { return Err(CoreError::new( diff --git a/rust/crates/project/src/files.rs b/rust/crates/project/src/files.rs new file mode 100644 index 00000000..46862380 --- /dev/null +++ b/rust/crates/project/src/files.rs @@ -0,0 +1,145 @@ +//! Workspace traversal, file operations, search, and replacement previews. + +use super::encoding::{self, DocumentEncoding}; +use super::search_index::{self, UpdateOutcome, WorkspaceSearchIndex}; +use super::search_paging::SearchPage; +use crate::contracts::{ + DirectoryListing, DirectoryListingResponse, FileReadResponse, FileWriteResponse, + ReplacementPreviewResponse, SearchMatch, SearchResponse, WorkspaceNode, + WorkspaceSnapshotResponse, +}; +use lithe_core_rules::file_names::FileNameKey; +use lithe_protocol::{invalid_relative_path, CoreError, ErrorCode}; +use lithe_workspace::fs; +use regex::{Regex, RegexBuilder}; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +/// Default workspace exclusions, also used by Git discovery for generated directories. +/// The list lives in `lithe-core-rules`, the only definition every host shares. +pub(crate) use lithe_core_rules::visibility::BUILT_IN_HIDDEN_DIRECTORIES; +use lithe_core_rules::visibility::{ + self as shared_visibility, disabled_built_ins, glob_matches, is_disabled_directory, +}; + +mod events; +mod matcher; +mod paths; +mod replace; +mod requests; +mod search; +mod search_everywhere; +mod snapshot; +mod text_io; +mod visibility; + +pub use events::*; +pub use matcher::*; +pub(crate) use paths::*; +pub use replace::*; +pub use requests::*; +pub use search::*; +pub use search_everywhere::*; +pub use snapshot::*; +pub use text_io::*; +pub use visibility::*; + +const MAX_FILE_SIZE: u64 = 2 * 1024 * 1024; + +const MAX_OPEN_FILE_SIZE: u64 = 32 * 1024 * 1024; + +#[cfg(test)] +mod event_filter_tests { + use super::*; + + #[test] + fn path_filters_match_the_displayed_relative_root_before_limits() { + let filter = + SearchPathFilters::new(&["src/**".into()], &["/test/".into()], "module").unwrap(); + assert!(filter.allows("src/main/a.ts")); + assert!(!filter.allows("src/test/a.ts")); + assert!(!filter.allows("other/a.ts")); + let rooted = SearchPathFilters::new(&["module/src".into()], &[], "module").unwrap(); + assert!(rooted.allows("src/a.ts")); + } + + #[test] + fn literal_replacement_uses_original_offsets_after_unicode_case_folding() { + let matcher = Matcher::new("x", false, false, false).unwrap(); + assert_eq!( + matcher.replace_with_options("İx", "y", false), + ("İy".into(), 1) + ); + assert_eq!( + matcher.replace_with_options("Kxİx", "y", false), + ("Kyİy".into(), 2) + ); + let partial = Matcher::new("i", false, false, false).unwrap(); + assert_eq!( + partial.replace_with_options("İ", "z", false), + ("z".into(), 1) + ); + let combining = Matcher::new("\u{307}", false, false, false).unwrap(); + assert_eq!( + combining.replace_with_options("İ", "z", false), + ("z".into(), 1) + ); + } + + #[test] + fn search_snapshots_merge_at_100ms_and_keep_the_best_bounded_candidates() { + use std::sync::{Arc, Mutex}; + use std::time::{Duration, Instant}; + let events = Arc::new(Mutex::new(Vec::::new())); + let received = events.clone(); + lithe_request_context::events::with_sink( + Arc::new(move |raw| { + received + .lock() + .unwrap() + .push(serde_json::from_str(raw).unwrap()); + }), + || { + let make_match = |name: &str, degree| SearchMatch { + kind: "file".into(), + path: name.into(), + line: None, + preview: name.into(), + symbol_name: None, + name_match_ranges: vec![], + source_line: None, + content_match_ranges: Vec::new(), + matching_degree: Some(degree), + is_ignored: None, + }; + let mut files = vec![make_match("First", 1)]; + let (mut types, mut symbols, mut last) = (vec![], vec![], None); + let now = Instant::now(); + let mut flush = |files: &mut Vec, milliseconds, force| { + emit_search_results( + [(files, 1), (&mut types, 1), (&mut symbols, 1)], + 1, + &mut last, + now + Duration::from_millis(milliseconds), + force, + ); + }; + flush(&mut files, 0, false); + files.push(make_match("Better", 10)); + flush(&mut files, 99, false); + assert_eq!(events.lock().unwrap().len(), 1); + flush(&mut files, 100, false); + files.push(make_match("Best", 20)); + flush(&mut files, 101, true); + }, + ); + let events = events.lock().unwrap(); + assert_eq!(events.len(), 3); + for (event, path) in events.iter().zip(["First", "Better", "Best"]) { + assert_eq!(event["type"], "searchResults"); + assert_eq!(event["results"]["matches"].as_array().unwrap().len(), 1); + assert_eq!(event["results"]["matches"][0]["path"], path); + } + } +} diff --git a/rust/crates/project/src/files/events.rs b/rust/crates/project/src/files/events.rs new file mode 100644 index 00000000..97ba600e --- /dev/null +++ b/rust/crates/project/src/files/events.rs @@ -0,0 +1,54 @@ +//! Filtering workspace change events down to the ones a snapshot cares about. + +use super::*; + +/// Whether an ordinary workspace event belongs to visible project inputs. +/// Callers first relativize to the most specific explicitly watched root; native +/// event-loss flags always bypass this filter and request recovery. +pub fn is_workspace_event_relevant(relative: &str) -> bool { + is_workspace_event_relevant_with(relative, &[]) +} + +/// Like [`is_workspace_event_relevant`], but a built-in folder the user turned +/// off with a `!` hidden-path entry is visible, so its events are kept. +fn is_workspace_event_relevant_with(relative: &str, hidden_file_patterns: &[String]) -> bool { + let disabled = disabled_built_ins(hidden_file_patterns); + !relative.split(['/', '\\']).any(|component| { + BUILT_IN_HIDDEN_DIRECTORIES.iter().any(|name| { + component.eq_ignore_ascii_case(name) && !is_disabled_directory(name, &disabled) + }) + }) +} + +/// Changed paths for `workspace.filterChanges`: a bare path list, or the +/// paths with the hidden-path entries whose `!` lines turn built-in folders off. +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub enum WorkspaceChangeFilterRequest { + /// Changed workspace-relative paths, filtered with the built-in rules only. + Paths(Vec), + /// Changed paths plus the hidden-path entries that adjust the built-in folders. + #[serde(rename_all = "camelCase")] + WithRules { + /// Changed workspace-relative paths. + paths: Vec, + /// Hidden-path entries; only their `!` lines matter here. + #[serde(default)] + hidden_file_patterns: Vec, + }, +} + +/// Keeps the changed paths outside the effective built-in hidden folders. +pub fn filter_workspace_changes(request: WorkspaceChangeFilterRequest) -> Vec { + let (paths, patterns) = match request { + WorkspaceChangeFilterRequest::Paths(paths) => (paths, Vec::new()), + WorkspaceChangeFilterRequest::WithRules { + paths, + hidden_file_patterns, + } => (paths, hidden_file_patterns), + }; + paths + .into_iter() + .filter(|path| is_workspace_event_relevant_with(path, &patterns)) + .collect() +} diff --git a/rust/crates/project/src/files/matcher.rs b/rust/crates/project/src/files/matcher.rs new file mode 100644 index 00000000..90f5a7a1 --- /dev/null +++ b/rust/crates/project/src/files/matcher.rs @@ -0,0 +1,332 @@ +//! Search path filters and the text matcher. + +use super::*; + +/// Include/exclude fragments are compiled once and applied before page caps. +pub(crate) struct SearchPathFilters { + includes: Vec, + excludes: Vec, + prefix: String, +} + +impl SearchPathFilters { + pub(crate) fn new( + includes: &[String], + excludes: &[String], + prefix: &str, + ) -> Result { + fn compile(patterns: &[String]) -> Result, CoreError> { + patterns + .iter() + .filter(|pattern| !pattern.trim().is_empty()) + .map(|pattern| { + let mut source = String::new(); + let mut chars = pattern.trim().chars().peekable(); + while let Some(character) = chars.next() { + match character { + '*' if chars.peek() == Some(&'*') => { + chars.next(); + source.push_str(".*"); + } + '*' => source.push_str("[^/]*"), + '?' => source.push_str("[^/]"), + other => source.push_str(®ex::escape(&other.to_string())), + } + } + RegexBuilder::new(&source) + .case_insensitive(true) + .build() + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid search path filter") + .with_details(error.to_string()) + }) + }) + .collect() + } + Ok(Self { + includes: compile(includes)?, + excludes: compile(excludes)?, + prefix: prefix.trim_end_matches('/').to_string(), + }) + } + + pub(crate) fn allows(&self, path: &str) -> bool { + if self.includes.is_empty() && self.excludes.is_empty() { + return true; + } + let path = if self.prefix.is_empty() { + path.to_string() + } else { + format!("{}/{}", self.prefix, path) + }; + (self.includes.is_empty() || self.includes.iter().any(|filter| filter.is_match(&path))) + && !self.excludes.iter().any(|filter| filter.is_match(&path)) + } +} + +/// Splits a mask list such as `*.md, *.txt`; an empty list disables filtering. +pub(crate) fn parse_file_mask(mask: &str) -> Vec { + mask.split(',') + .map(|part| part.trim()) + .filter(|part| !part.is_empty()) + .map(|part| part.to_string()) + .collect() +} + +/// Matches masks against the file name only and accepts any matching pattern. +pub(crate) fn file_mask_allows(masks: &[String], path: &str) -> bool { + if masks.is_empty() { + return true; + } + let name = path.rsplit('/').next().unwrap_or(path); + masks.iter().any(|mask| glob_matches(mask, name)) +} + +/// Literal or regular-expression search semantics compiled for repeated matches. +pub(crate) struct Matcher { + plain_query: String, + regex: Option, + case_sensitive: bool, + whole_words: bool, +} + +impl Matcher { + /// Projects original byte ranges into the editor's UTF-16 coordinate space. + pub(crate) fn content_ranges(&self, text: &str) -> Vec { + let ranges = if let Some(regex) = &self.regex { + regex + .find_iter(text) + .map(|item| (item.start(), item.end())) + .collect::>() + } else { + self.literal_match_ranges(text) + }; + let (mut offset, mut units) = (0, 0); + ranges + .into_iter() + .map(|(start, end)| { + units += text[offset..start].encode_utf16().count(); + let first = units; + units += text[start..end].encode_utf16().count(); + offset = end; + crate::contracts::SearchNameRange { + start: first, + length: units - first, + } + }) + .collect() + } + + pub(crate) fn new( + query: &str, + case_sensitive: bool, + whole_words: bool, + regular_expression: bool, + ) -> Result { + if regular_expression { + let pattern = query.to_string(); + let regex = RegexBuilder::new(&pattern) + .case_insensitive(!case_sensitive) + .build() + .map_err(|error| { + CoreError::new(ErrorCode::ParseFailed, "Invalid search expression") + .with_details(error.to_string()) + })?; + Ok(Self { + plain_query: query.to_string(), + regex: Some(regex), + case_sensitive, + whole_words: false, + }) + } else { + Ok(Self { + plain_query: query.to_string(), + regex: None, + case_sensitive, + whole_words, + }) + } + } + + pub(crate) fn matches(&self, text: &str) -> bool { + if let Some(regex) = &self.regex { + return regex.is_match(text); + } + let (haystack, needle) = if self.case_sensitive { + (text.to_string(), self.plain_query.clone()) + } else { + (text.to_lowercase(), self.plain_query.to_lowercase()) + }; + if !self.whole_words { + return haystack.contains(&needle); + } + haystack.match_indices(&needle).any(|(start, _)| { + let end = start + needle.len(); + let before = haystack[..start].chars().next_back(); + let after = haystack[end..].chars().next(); + !before.is_some_and(is_word_character) && !after.is_some_and(is_word_character) + }) + } + + /// Finds literal matches in the original line, preserving UTF-8 boundaries. + fn literal_match_ranges(&self, text: &str) -> Vec<(usize, usize)> { + let haystack = if self.case_sensitive { + text.to_string() + } else { + text.to_lowercase() + }; + let needle = if self.case_sensitive { + self.plain_query.clone() + } else { + self.plain_query.to_lowercase() + }; + if needle.is_empty() { + return Vec::new(); + } + let ranges = haystack + .match_indices(&needle) + .filter_map(|(start, matched)| { + let end = start + matched.len(); + if self.whole_words { + let before = haystack[..start].chars().next_back(); + let after = haystack[end..].chars().next(); + if before.is_some_and(is_word_character) || after.is_some_and(is_word_character) + { + return None; + } + } + Some((start, end)) + }) + .collect::>(); + // Lowercase expansions can change byte offsets relative to the original. + if self.case_sensitive { + ranges + } else { + original_ranges_for_lowercase_matches(text, &ranges) + } + } + + /// Preserve Case applies only to literal replacements; regex keeps captures. + pub(crate) fn replace_with_options( + &self, + text: &str, + replacement: &str, + preserve_case: bool, + ) -> (String, usize) { + if let Some(regex) = &self.regex { + let count = regex.find_iter(text).count(); + return (regex.replace_all(text, replacement).into_owned(), count); + } + let ranges = self.literal_match_ranges(text); + let mut result = String::with_capacity(text.len()); + let mut cursor = 0; + for (start, end) in &ranges { + result.push_str(&text[cursor..*start]); + if preserve_case { + result.push_str(&apply_case_pattern(&text[*start..*end], replacement)); + } else { + result.push_str(replacement); + } + cursor = *end; + } + result.push_str(&text[cursor..]); + (result, ranges.len()) + } + + /// Finds the first replacement touching or following a UTF-16 column. + pub(crate) fn replace_first_from( + &self, + text: &str, + replacement: &str, + column: usize, + ) -> Option { + let mut utf16 = 0usize; + let mut column_byte = text.len(); + for (index, character) in text.char_indices() { + if utf16 + character.len_utf16() > column { + column_byte = index; + break; + } + utf16 += character.len_utf16(); + } + let follows = |start: usize, end: usize| { + start >= column_byte || (column_byte >= start && column_byte < end) + }; + let (start, end, value) = if let Some(regex) = &self.regex { + let captures = regex.captures_iter(text).find(|capture| { + capture + .get(0) + .is_some_and(|matched| follows(matched.start(), matched.end())) + })?; + let matched = captures.get(0)?; + let mut value = String::new(); + captures.expand(replacement, &mut value); + (matched.start(), matched.end(), value) + } else { + let (start, end) = self + .literal_match_ranges(text) + .into_iter() + .find(|(start, end)| follows(*start, *end))?; + (start, end, replacement.to_string()) + }; + Some(format!("{}{}{}", &text[..start], value, &text[end..])) + } +} + +/// Maps ordered lowercase-match byte ranges to complete original characters. +fn original_ranges_for_lowercase_matches( + text: &str, + ranges: &[(usize, usize)], +) -> Vec<(usize, usize)> { + let mut chars = text.char_indices(); + let (mut source_start, mut source_end, mut folded_end) = (0, 0, 0); + let mut result: Vec<(usize, usize)> = Vec::with_capacity(ranges.len()); + for &(start, end) in ranges { + while folded_end <= start { + let Some((index, character)) = chars.next() else { + break; + }; + source_start = index; + source_end = index + character.len_utf8(); + folded_end += character.to_lowercase().map(char::len_utf8).sum::(); + } + let original_start = source_start; + while folded_end < end { + let Some((index, character)) = chars.next() else { + break; + }; + source_end = index + character.len_utf8(); + folded_end += character.to_lowercase().map(char::len_utf8).sum::(); + } + if let Some(previous) = result + .last_mut() + .filter(|previous| original_start < previous.1) + { + previous.1 = previous.1.max(source_end); + } else { + result.push((original_start, source_end)); + } + } + result +} + +pub(crate) fn read_searchable_text(path: &Path) -> Option { + let metadata = fs::metadata(path).ok()?; + if !metadata.is_file() || metadata.len() > MAX_FILE_SIZE { + return None; + } + let text = fs::read_to_string(path).ok()?; + is_plain_text(&text).then_some(text) +} + +/// Whether `text` has no NUL or other control characters besides tab, line feed, vertical tab, form feed and carriage return. +pub fn is_plain_text(text: &str) -> bool { + text.chars().all(|character| { + let value = character as u32; + value != 0 && !(value < 0x09 || (value > 0x0D && value < 0x20) || value == 0x7F) + }) +} + +fn is_word_character(character: char) -> bool { + character.is_ascii_alphanumeric() || character == '_' || character == '$' +} diff --git a/rust/crates/project/src/files/paths.rs b/rust/crates/project/src/files/paths.rs new file mode 100644 index 00000000..e717fd9a --- /dev/null +++ b/rust/crates/project/src/files/paths.rs @@ -0,0 +1,97 @@ +//! Resolving and checking workspace-relative paths. + +use super::*; + +pub(crate) fn existing_root(value: &str) -> Result { + let path = PathBuf::from(value); + let metadata = fs::metadata(&path) + .map_err(|_| CoreError::new(ErrorCode::WorkspaceNotFound, "Workspace does not exist"))?; + if !metadata.is_dir() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Workspace root must be a directory", + )); + } + fs::canonicalize(path).map_err(CoreError::from) +} + +pub(crate) fn safe_relative_path(root: &Path, value: &str) -> Result { + let relative = Path::new(value); + if relative.is_absolute() || invalid_relative_path(value) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Path must be relative to the workspace", + )); + } + let path = root.join(relative); + let canonical = fs::canonicalize(path).map_err(CoreError::from)?; + if !canonical.starts_with(root) { + return Err(CoreError::new( + ErrorCode::PermissionDenied, + "Path is outside the workspace", + )); + } + Ok(canonical) +} + +pub(crate) fn writable_relative_path(root: &Path, value: &str) -> Result { + let relative = Path::new(value); + if relative.is_absolute() || invalid_relative_path(value) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Path must be relative to the workspace", + )); + } + let path = root.join(relative); + if let Some(parent) = path.parent() { + let mut existing_parent = parent; + while !fs::try_exists(existing_parent)? { + existing_parent = existing_parent.parent().ok_or_else(|| { + CoreError::new(ErrorCode::PermissionDenied, "Path is outside the workspace") + })?; + } + let canonical_parent = fs::canonicalize(existing_parent).map_err(CoreError::from)?; + if !canonical_parent.starts_with(root) { + return Err(CoreError::new( + ErrorCode::PermissionDenied, + "Path is outside the workspace", + )); + } + } + if let Ok(metadata) = fs::symlink_metadata(&path) { + if metadata.file_type().is_symlink() { + return Err(CoreError::new( + ErrorCode::PermissionDenied, + "Path is outside the workspace", + )); + } + let canonical = fs::canonicalize(&path).map_err(CoreError::from)?; + if !canonical.starts_with(root) { + return Err(CoreError::new( + ErrorCode::PermissionDenied, + "Path is outside the workspace", + )); + } + } + Ok(path) +} + +pub(crate) fn relative_path(path: &Path, root: &Path) -> String { + path.strip_prefix(root) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/") + .trim_matches('/') + .to_string() +} + +pub(crate) fn safe_relative_path_string(value: &str) -> Result { + let path = Path::new(value); + if path.is_absolute() || invalid_relative_path(value) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Path must be relative to the workspace", + )); + } + Ok(value.replace('\\', "/").trim_matches('/').to_string()) +} diff --git a/rust/crates/project/src/files/replace.rs b/rust/crates/project/src/files/replace.rs new file mode 100644 index 00000000..e453dab1 --- /dev/null +++ b/rust/crates/project/src/files/replace.rs @@ -0,0 +1,183 @@ +//! Replacing text and previewing replacements. + +use super::*; + +/// Computes replacement text without touching storage or editor state. +pub fn replace_text(request: ReplacementTextRequest) -> Result { + let query = request.query.trim(); + if query.is_empty() { + return Ok(serde_json::json!({"text": request.text, "count": 0})); + } + let matcher = Matcher::new( + query, + request.case_sensitive, + request.whole_words, + request.regular_expression, + )?; + let mut count = 0usize; + let mut lines = Vec::new(); + for (index, line) in request.text.split('\n').enumerate() { + lithe_protocol::cancellation::check()?; + if let Some(position) = &request.from { + if count == 0 && index + 1 >= position.line.max(1) { + let column = if index + 1 == position.line.max(1) { + position.column.saturating_sub(1) + } else { + 0 + }; + if let Some(replaced) = + matcher.replace_first_from(line, &request.replacement, column) + { + lines.push(replaced); + count = 1; + continue; + } + } + lines.push(line.to_string()); + } else { + let (replaced, occurrences) = + matcher.replace_with_options(line, &request.replacement, false); + count += occurrences; + lines.push(replaced); + } + } + Ok(serde_json::json!({"text": lines.join("\n"), "count": count})) +} + +/// Computes per-file replacements without writing any workspace content. +pub fn replace_preview( + request: ReplacementPreviewRequest, +) -> Result { + let root = existing_root(&request.root)?; + let indexing_policy = crate::indexing::context(&root); + let query = request.query.trim().to_string(); + if query.is_empty() { + return Ok(ReplacementPreviewResponse { files: Vec::new() }); + } + let rules = VisibilityRules::new( + request.hidden_directory_names.clone(), + request.hidden_file_patterns.clone(), + ) + .with_workspace_roots(&root, &request.workspace_roots); + let shared_index = search_index::get_or_build(&root, &rules)?; + let index = shared_index + .read() + .map_err(|_| CoreError::new(ErrorCode::Unknown, "Search index lock was poisoned"))?; + let matcher = Matcher::new( + &query, + request.case_sensitive, + request.whole_words, + request.regular_expression, + )?; + let candidate_ids = index.candidate_ids(&query, request.regular_expression); + let candidate_set = candidate_ids.iter().copied().collect::>(); + let paths = if request.paths.is_empty() { + candidate_ids + .into_iter() + .filter_map(|id| index.file(id).map(|file| file.path.clone())) + .collect::>() + } else { + let mut paths = Vec::new(); + for value in &request.paths { + let relative = safe_relative_path_string(value)?; + let include = request.text_overrides.contains_key(&relative) + || index + .id_for_path(&relative) + .map(|id| candidate_set.contains(&id)) + .unwrap_or(true); + if include { + paths.push(relative); + } + } + paths + }; + drop(index); + let masks = parse_file_mask(&request.file_mask); + let mut files = Vec::new(); + for path in paths { + lithe_protocol::cancellation::check()?; + let relative = safe_relative_path_string(&path)?; + if !file_mask_allows(&masks, &relative) { + continue; + } + let file = root.join(&relative); + if rules.is_delegated(&file) || crate::indexing::is_excluded(&root, &file, false)? { + continue; + } + let text = request + .text_overrides + .get(&relative) + .cloned() + .or_else(|| read_searchable_text(&file)); + let Some(text) = text else { continue }; + let mut matches = Vec::new(); + let mut replaced_lines = Vec::new(); + for (index, line) in text.split('\n').enumerate() { + lithe_protocol::cancellation::check()?; + let (after, occurrence_count) = + matcher.replace_with_options(line, &request.replacement, request.preserve_case); + replaced_lines.push(after.clone()); + if occurrence_count > 0 { + matches.push(crate::contracts::ReplacementMatch { + line: index + 1, + before: line.to_string(), + after, + occurrence_count, + }); + } + } + if !matches.is_empty() { + files.push(crate::contracts::ReplacementFile { + path: relative, + matches, + replacement_text: replaced_lines.join("\n"), + }); + } + } + files.sort_by(|left, right| left.path.cmp(&right.path)); + if let Some(policy) = &indexing_policy { + policy.check()?; + } + Ok(ReplacementPreviewResponse { files }) +} + +/// Matches IDEA's Preserve Case behavior for literal replacement text. +/// +/// All-uppercase matches uppercase the replacement, initial-uppercase matches +/// capitalize it, and camelCase or mixed-case matches leave it unchanged. +pub(crate) fn apply_case_pattern(matched: &str, replacement: &str) -> String { + let letters = matched.chars().filter(|value| value.is_alphabetic()); + let mut has_lower = false; + let mut has_upper = false; + for letter in letters { + if letter.is_lowercase() { + has_lower = true; + } else if letter.is_uppercase() { + has_upper = true; + } + } + // With no letters there is no case shape to preserve. + if !has_lower && !has_upper { + return replacement.to_string(); + } + // Require multiple letters so a single `F` is not mistaken for SCREAMING_CASE. + let letter_count = matched + .chars() + .filter(|value| value.is_alphabetic()) + .count(); + if has_upper && !has_lower && letter_count > 1 { + return replacement.to_uppercase(); + } + let first_is_upper = matched + .chars() + .find(|value| value.is_alphabetic()) + .is_some_and(char::is_uppercase); + if first_is_upper { + let mut characters = replacement.chars(); + return match characters.next() { + Some(first) => first.to_uppercase().collect::() + characters.as_str(), + None => String::new(), + }; + } + replacement.to_string() +} diff --git a/rust/crates/project/src/files/requests.rs b/rust/crates/project/src/files/requests.rs new file mode 100644 index 00000000..7b3419c0 --- /dev/null +++ b/rust/crates/project/src/files/requests.rs @@ -0,0 +1,303 @@ +//! Request and response payloads of the workspace file commands. + +use super::*; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Workspace root and visibility overrides used to build a file-tree snapshot. +pub struct WorkspaceSnapshotRequest { + /// Absolute workspace root that bounds every path in the request. + pub root: String, + /// Explicit roots of this workspace; nested roots own their inventories independently. + #[serde(default)] + pub workspace_roots: Vec, + /// Extra directory names hidden from the tree, on top of the built-in ones. + #[serde(default)] + pub hidden_directory_names: Vec, + /// Hidden-path globs; `!` lines turn built-in hidden folders back on. + #[serde(default)] + pub hidden_file_patterns: Vec, + /// When false, the response keeps only the file list and returns `root` + /// without children; hosts that load the tree per directory skip encoding + /// and decoding a node for every path. + #[serde(default = "default_include_tree")] + pub include_tree: bool, +} + +fn default_include_tree() -> bool { + true +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Workspace-relative directories whose visible children a host is loading. +pub struct DirectoryListingRequest { + /// Absolute workspace root that bounds every path in the request. + pub root: String, + /// Workspace-relative directories; an empty string is the root itself. + #[serde(default)] + pub paths: Vec, + /// Extra directory names hidden from the tree, on top of the built-in ones. + #[serde(default)] + pub hidden_directory_names: Vec, + /// Hidden-path globs; `!` lines turn built-in hidden folders back on. + #[serde(default)] + pub hidden_file_patterns: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +/// Bounded file, content, and symbol search options. +pub struct SearchRequest { + /// Workspace-relative scopes, applied before result caps without changing policy ownership. + #[serde(default)] + pub scope_paths: Vec, + /// Absolute workspace root that bounds every path in the request. + pub root: String, + /// Request-local ownership boundaries; omitted roots preserve single-root behavior. + #[serde(default)] + pub workspace_roots: Vec, + /// Text to search for: a literal, or a pattern when `regular_expression` is set. + pub query: String, + /// Match letter case exactly. + #[serde(default)] + pub case_sensitive: bool, + /// Match only at word boundaries. + #[serde(default)] + pub whole_words: bool, + /// Treat `query` as a regular expression. + #[serde(default)] + pub regular_expression: bool, + /// Overall result cap, clamped to 1..=10000. + #[serde(default = "default_max_results")] + pub max_results: usize, + /// Cap on file-name matches; absent uses the overall cap. + #[serde(default)] + pub max_file_results: Option, + /// Cap on content matches; absent uses the overall cap. + #[serde(default)] + pub max_content_results: Option, + /// Cap on symbol matches; absent uses the overall cap. + #[serde(default)] + pub max_symbol_results: Option, + /// Search Everywhere may discover generated file names without indexing their contents. + #[serde(default)] + pub include_generated_file_names: bool, + /// Extra directory names hidden from the tree, on top of the built-in ones. + #[serde(default)] + pub hidden_directory_names: Vec, + /// Hidden-path globs; `!` lines turn built-in hidden folders back on. + #[serde(default)] + pub hidden_file_patterns: Vec, + /// Comma-separated file masks such as `*.md, *.txt`; empty means no filter. + #[serde(default)] + pub file_mask: String, + /// Case-insensitive glob fragments that a candidate path must match. + #[serde(default)] + pub include_path_patterns: Vec, + /// Case-insensitive glob fragments that exclude matching candidate paths. + #[serde(default)] + pub exclude_path_patterns: Vec, + /// Open and recent files that break ties between equal file-name matches. Paths are + /// relative to `root` with `/` separators; without it ordering is unchanged. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context: Option, + /// Optional root prefix used solely for include/exclude matching. + #[serde(default)] + pub path_filter_prefix: String, + /// Include untrimmed lines and UTF-16 ranges for content matches. + #[serde(default)] + pub include_content_ranges: bool, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Workspace root and visibility rules used to warm or invalidate an index. +pub struct SearchIndexRequest { + /// Absolute workspace root that bounds every path in the request. + pub root: String, + /// Explicit workspace roots used when warming this index variant. + #[serde(default)] + pub workspace_roots: Vec, + /// Extra directory names hidden from the tree, on top of the built-in ones. + #[serde(default)] + pub hidden_directory_names: Vec, + /// Hidden-path globs; `!` lines turn built-in hidden folders back on. + #[serde(default)] + pub hidden_file_patterns: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Changed workspace-relative paths to apply to a cached search index. +pub struct SearchIndexUpdateRequest { + /// Absolute workspace root that bounds every path in the request. + pub root: String, + /// Explicit workspace roots used when rebuilding after an update. + #[serde(default)] + pub workspace_roots: Vec, + /// Workspace-relative paths that changed on disk. + #[serde(default)] + pub paths: Vec, + /// Extra directory names hidden from the tree, on top of the built-in ones. + #[serde(default)] + pub hidden_directory_names: Vec, + /// Hidden-path globs; `!` lines turn built-in hidden folders back on. + #[serde(default)] + pub hidden_file_patterns: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Search-index size and whether an incremental request required a rebuild. +pub struct SearchIndexStatusResponse { + /// Files held in the index. + pub file_count: usize, + /// Always zero: Core no longer indexes language symbols. The field stays + /// because the macOS bridge still decodes it as required. + pub symbol_count: usize, + /// Trigram postings held in the index. + pub posting_count: usize, + /// Whether the update had to discard the index for a full rebuild. + pub rebuilt: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Search and replacement options used to produce a non-mutating preview. +pub struct ReplacementPreviewRequest { + /// Absolute workspace root that bounds every path in the request. + pub root: String, + /// Nested roots are previewed through their own root and policy. + #[serde(default)] + pub workspace_roots: Vec, + /// Text to replace: a literal, or a pattern when `regular_expression` is set. + pub query: String, + /// Replacement text; regex replacements may reference capture groups. + pub replacement: String, + /// Match letter case exactly. + #[serde(default)] + pub case_sensitive: bool, + /// Match only at word boundaries. + #[serde(default)] + pub whole_words: bool, + /// Treat `query` as a regular expression. + #[serde(default)] + pub regular_expression: bool, + /// Preserve all-uppercase or initial-uppercase shape from literal matches. + #[serde(default)] + pub preserve_case: bool, + /// Comma-separated file masks such as `*.md, *.txt`; empty means no filter. + #[serde(default)] + pub file_mask: String, + /// Workspace-relative files to preview; empty previews every indexed candidate. + #[serde(default)] + pub paths: Vec, + /// Unsaved editor text by workspace-relative path, used instead of the file on disk. + #[serde(default)] + pub text_overrides: HashMap, + /// Extra directory names hidden from the tree, on top of the built-in ones. + #[serde(default)] + pub hidden_directory_names: Vec, + /// Hidden-path globs; `!` lines turn built-in hidden folders back on. + #[serde(default)] + pub hidden_file_patterns: Vec, +} + +/// Non-mutating replacement against an already captured text snapshot. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReplacementTextRequest { + /// Original content; the host retains its identity and controls any write. + pub text: String, + /// Search expression interpreted identically to workspace search. + pub query: String, + /// Replacement text, including capture expansion for regex searches. + pub replacement: String, + /// Whether letter case must match exactly. + #[serde(default)] + pub case_sensitive: bool, + /// Require the shared word-boundary rules for literal searches. + #[serde(default)] + pub whole_words: bool, + /// Treat the query as a regular expression. + #[serde(default)] + pub regular_expression: bool, + /// When present, replace one match touching or following this location. + pub from: Option, +} + +/// A one-based source line and UTF-16 column. +#[derive(Debug, Deserialize)] +pub struct ReplacementTextPosition { + /// One-based line index. + pub line: usize, + /// One-based UTF-16 column. + pub column: usize, +} + +/// Provider-supplied snapshots for content search without filesystem access. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchTextBatchRequest { + /// User query, interpreted with the same rules as workspace search. + pub query: String, + /// Whether case must match exactly. + #[serde(default)] + pub case_sensitive: bool, + /// Restrict literal queries to shared word boundaries. + #[serde(default)] + pub whole_words: bool, + /// Interpret the query as a regular expression. + #[serde(default)] + pub regular_expression: bool, + /// Adjacent lines supplied for each displayed result. + #[serde(default)] + pub context_lines: usize, + /// Bounded host-read batch; Core does not resolve these opaque paths. + pub files: Vec, +} + +/// One provider-owned path and its captured content. +#[derive(Debug, Deserialize)] +pub struct SearchTextSnapshot { + /// Opaque identity returned without modification. + pub path: String, + /// Text read by the provider. + pub text: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Request to read one validated workspace-relative text file. +pub struct FileReadRequest { + /// Absolute workspace root that bounds every path in the request. + pub root: String, + /// Workspace-relative path of the file to read. + pub path: String, + /// Stable encoding id or alias to decode with; absent means detect. + #[serde(default)] + pub encoding: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Request to replace one validated workspace-relative text file. +pub struct FileWriteRequest { + /// Absolute workspace root that bounds every path in the request. + pub root: String, + /// Workspace-relative path of the file to replace. + pub path: String, + /// Complete new file text. + pub text: String, + /// Stable encoding id or alias to encode with; absent means UTF-8. + #[serde(default)] + pub encoding: Option, + /// Overrides the encoding's own byte-order-mark choice (Unicode encodings only). + #[serde(default)] + pub bom: Option, +} + +fn default_max_results() -> usize { + 200 +} diff --git a/rust/crates/project/src/files/search.rs b/rust/crates/project/src/files/search.rs new file mode 100644 index 00000000..6b52bb4f --- /dev/null +++ b/rust/crates/project/src/files/search.rs @@ -0,0 +1,284 @@ +//! Text search, with and without the search index. + +use super::*; + +/// Applies workspace matching to provider snapshots in one bounded request. +pub fn search_text_batch(request: SearchTextBatchRequest) -> Result { + if request.files.len() > 32 + || request + .files + .iter() + .any(|file| file.text.len() > MAX_FILE_SIZE as usize) + || request + .files + .iter() + .map(|file| file.text.len()) + .sum::() + > 16 * 1024 * 1024 + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Text search batch is too large", + )); + } + let query = request.query.trim(); + if query.is_empty() { + return Ok(serde_json::json!({"files": []})); + } + let matcher = Matcher::new( + query, + request.case_sensitive, + request.whole_words, + request.regular_expression, + )?; + let context = request.context_lines.min(20); + let mut files = Vec::new(); + for file in request.files { + lithe_protocol::cancellation::check()?; + if file.text.contains('\0') { + continue; + } + let lines: Vec<_> = file.text.split('\n').collect(); + let mut matches = Vec::new(); + for (index, line) in lines.iter().enumerate() { + if !matcher.matches(line) { + continue; + } + let ranges = matcher.content_ranges(line); + matches.push( + serde_json::json!({"line": index + 1, "lineText": line, "ranges": ranges, + "contextBefore": &lines[index.saturating_sub(context)..index], + "contextAfter": &lines[index + 1..(index + 1 + context).min(lines.len())]}), + ); + } + if !matches.is_empty() { + files.push(serde_json::json!({"path": file.path, "matches": matches})); + } + } + Ok(serde_json::json!({"files": files})) +} + +/// Searches visible file paths and current file contents using the shared index. +pub fn search(request: SearchRequest) -> Result { + let root = existing_root(&request.root)?; + let indexing_policy = crate::indexing::context(&root); + let query = request.query.trim().to_string(); + if query.is_empty() { + return Ok(SearchResponse { + matches: Vec::new(), + }); + } + + let rules = VisibilityRules::new( + request.hidden_directory_names.clone(), + request.hidden_file_patterns.clone(), + ) + .with_workspace_roots(&root, &request.workspace_roots); + let index = search_index::get_or_build(&root, &rules)?; + let index = index + .read() + .map_err(|_| CoreError::new(ErrorCode::Unknown, "Search index lock was poisoned"))?; + let result = search_with_index(&root, &request, &query, &index, None)?; + if let Some(policy) = &indexing_policy { + policy.check()?; + } + Ok(result) +} + +pub(crate) fn scope_allows(scopes: &[String], path: &str) -> bool { + scopes.is_empty() + || scopes.iter().any(|scope| { + #[cfg(windows)] + let (scope, path) = (scope.to_lowercase(), path.to_lowercase()); + scope.is_empty() + || scope == "." + || path == scope.as_str() + || path + .strip_prefix(scope.as_str()) + .is_some_and(|suffix| suffix.starts_with('/')) + }) +} + +pub(crate) fn search_with_index( + root: &Path, + request: &SearchRequest, + query: &str, + index: &WorkspaceSearchIndex, + page: Option<&SearchPage>, +) -> Result { + let matcher = Matcher::new( + query, + request.case_sensitive, + request.whole_words, + request.regular_expression, + )?; + let masks = parse_file_mask(&request.file_mask); + let path_filters = SearchPathFilters::new( + &request.include_path_patterns, + &request.exclude_path_patterns, + &request.path_filter_prefix, + )?; + let limit = request.max_results.clamp(1, 10_000); + let file_limit = request.max_file_results.unwrap_or(limit).min(limit); + let content_limit = request.max_content_results.unwrap_or(limit).min(limit); + let mut matches = Vec::new(); + let mut file_matches = 0; + + for id in index.all_file_ids() { + lithe_protocol::cancellation::check()?; + if matches.len() >= limit || file_matches >= file_limit { + break; + } + let Some(indexed_file) = index.file(id) else { + continue; + }; + let path = &indexed_file.path; + if !scope_allows(&request.scope_paths, path) + || !file_mask_allows(&masks, path) + || !path_filters.allows(path) + { + continue; + } + if matcher.matches(path) { + matches.push(SearchMatch { + kind: "file".to_string(), + path: path.clone(), + line: None, + preview: path.clone(), + symbol_name: None, + name_match_ranges: Vec::new(), + source_line: None, + content_match_ranges: Vec::new(), + matching_degree: None, + is_ignored: None, + }); + file_matches += 1; + } + } + + let mut content_matches = 0; + let mut candidates = index.candidate_ids(query, request.regular_expression); + if page.is_some() { + candidates.sort_by_key(|id| index.file(*id).map(|file| file.path.as_str())); + } + for id in candidates { + lithe_protocol::cancellation::check()?; + if matches.len() >= limit || content_matches >= content_limit { + break; + } + let Some(indexed_file) = index.file(id) else { + continue; + }; + let path = &indexed_file.path; + if page.is_some_and(|page| !page.accepts_path(path)) + || !scope_allows(&request.scope_paths, path) + || !file_mask_allows(&masks, path) + || !path_filters.allows(path) + { + continue; + } + let file = root.join(path); + let Some(text) = read_searchable_text(&file) else { + continue; + }; + for (index, line) in text.split('\n').enumerate() { + lithe_protocol::cancellation::check()?; + if matcher.matches(line) { + let result = SearchMatch { + kind: "content".to_string(), + path: path.clone(), + line: Some(index + 1), + preview: line.trim().to_string(), + symbol_name: None, + name_match_ranges: Vec::new(), + source_line: request.include_content_ranges.then(|| line.to_string()), + content_match_ranges: if request.include_content_ranges { + matcher.content_ranges(line) + } else { + Vec::new() + }, + matching_degree: None, + is_ignored: None, + }; + if page.is_some_and(|page| !page.accepts(&result)) { + continue; + } + matches.push(result); + content_matches += 1; + if matches.len() >= limit || content_matches >= content_limit { + break; + } + } + } + } + + Ok(SearchResponse { matches }) +} + +/// Builds or reuses the search index and reports its current size. +pub fn warm_search_index( + request: SearchIndexRequest, +) -> Result { + let root = existing_root(&request.root)?; + let rules = VisibilityRules::new(request.hidden_directory_names, request.hidden_file_patterns) + .with_workspace_roots(&root, &request.workspace_roots); + let index = search_index::get_or_build(&root, &rules)?; + let index = index + .read() + .map_err(|_| CoreError::new(ErrorCode::Unknown, "Search index lock was poisoned"))?; + let stats = index.stats(); + Ok(SearchIndexStatusResponse { + file_count: stats.file_count, + symbol_count: 0, + posting_count: stats.posting_count, + rebuilt: false, + }) +} + +/// Applies changed paths to a cached index, rebuilding when its rules changed. +/// `update_providers` reconciles the root's running providers once Git +/// decisions are rebuilt, as for [`crate::indexing::configure`]. +pub fn update_search_index( + request: SearchIndexUpdateRequest, + mut update_providers: impl FnMut(&Path) -> Result<(), CoreError>, +) -> Result { + let root = existing_root(&request.root)?; + let rules = VisibilityRules::new(request.hidden_directory_names, request.hidden_file_patterns) + .with_workspace_roots(&root, &request.workspace_roots); + if request.paths.iter().any(|path| { + path.ends_with(".gitignore") + || path.contains(".git/") + || path.ends_with(".git") + || root.join(path).is_dir() + }) { + crate::indexing::refresh(&root)?; + } + let outcome = search_index::update_paths(&root, &rules, &request.paths); + let rebuilt = matches!(outcome, UpdateOutcome::RequiresRebuild); + if rebuilt { + search_index::invalidate_root(&root); + } + let index = search_index::get_or_build(&root, &rules)?; + // Refresh starts with no Git decisions. Publish only after traversal has + // resolved them, otherwise providers briefly (or permanently) lose exclusions. + update_providers(&root)?; + let index = index + .read() + .map_err(|_| CoreError::new(ErrorCode::Unknown, "Search index lock was poisoned"))?; + let stats = index.stats(); + Ok(SearchIndexStatusResponse { + file_count: stats.file_count, + symbol_count: 0, + posting_count: stats.posting_count, + rebuilt: rebuilt || matches!(outcome, UpdateOutcome::NotIndexed), + }) +} + +/// Drops the cached index for one validated workspace root. +pub fn invalidate_search_index(request: SearchIndexRequest) -> Result<(), CoreError> { + let requested_root = PathBuf::from(&request.root); + let root = search_index::canonicalize_with_missing_components(&requested_root) + .unwrap_or(requested_root); + search_index::invalidate_root(&root); + Ok(()) +} diff --git a/rust/crates/project/src/files/search_everywhere.rs b/rust/crates/project/src/files/search_everywhere.rs new file mode 100644 index 00000000..b7187f92 --- /dev/null +++ b/rust/crates/project/src/files/search_everywhere.rs @@ -0,0 +1,289 @@ +//! Search Everywhere: ranked name and text matches across the workspace. + +use super::*; + +/// Combines file and content results for Search Everywhere. +/// +/// The `type` and `symbol` buckets stay in the paging contract but Core no +/// longer fills them: symbol knowledge belongs to language servers and +/// plugins, so a source-specific regex here would favour one language. +pub fn search_everywhere(request: SearchRequest) -> Result { + search_everywhere_with_page(request, None) +} + +/// Searches one bounded page when a continuation state is supplied. +pub(crate) fn search_everywhere_with_page( + request: SearchRequest, + mut page: Option<&mut SearchPage>, +) -> Result { + let root = existing_root(&request.root)?; + let indexing_policy = crate::indexing::context(&root); + let query = request.query.trim().to_string(); + if query.is_empty() { + return Ok(SearchResponse { + matches: Vec::new(), + }); + } + let rules = VisibilityRules::new( + request.hidden_directory_names.clone(), + request.hidden_file_patterns.clone(), + ) + .with_workspace_roots(&root, &request.workspace_roots); + let matcher = Matcher::new( + &query, + request.case_sensitive, + request.whole_words, + request.regular_expression, + )?; + let masks = parse_file_mask(&request.file_mask); + let path_filters = SearchPathFilters::new( + &request.include_path_patterns, + &request.exclude_path_patterns, + &request.path_filter_prefix, + )?; + let limit = request.max_results.clamp(1, 10_000); + let file_limit = if page.as_ref().is_some_and(|page| page.exhausted("file")) { + 0 + } else { + request.max_file_results.unwrap_or(limit).min(limit) + }; + let symbol_limit = if page + .as_ref() + .is_some_and(|page| page.exhausted("type") && page.exhausted("symbol")) + { + 0 + } else { + request.max_symbol_results.unwrap_or(limit).min(limit) + }; + let content_limit = if page.as_ref().is_some_and(|page| page.exhausted("content")) { + 0 + } else { + request.max_content_results.unwrap_or(limit).min(limit) + }; + let context_index = request + .context + .as_ref() + .map(crate::name_ranking::ContextIndex::new); + let mut files = Vec::new(); + let mut types = Vec::new(); + let mut symbols = Vec::new(); + let mut last_flush = None; + if file_limit > 0 { + let name_rules = VisibilityRules { + // Generated output is a file-name source, not a content/symbol index. + hidden_directories: rules + .hidden_directories + .iter() + .filter(|name| { + !request.include_generated_file_names + || !BUILT_IN_HIDDEN_DIRECTORIES.contains(&name.as_str()) + || [".git", ".svn", ".worktree", ".worktrees"].contains(&name.as_str()) + }) + .cloned() + .collect(), + hidden_file_patterns: rules.hidden_file_patterns.clone(), + delegated_roots: rules.delegated_roots.clone(), + }; + let names = search_index::get_names(&root, &name_rules)?; + let names = names + .read() + .map_err(|_| CoreError::new(ErrorCode::Unknown, "Search index lock was poisoned"))?; + for id in names.all_file_ids() { + lithe_protocol::cancellation::check()?; + let Some(file) = names.file(id) else { continue }; + if !scope_allows(&request.scope_paths, &file.path) + || !file_mask_allows(&masks, &file.path) + || !path_filters.allows(&file.path) + { + continue; + } + let name = file.path.rsplit('/').next().unwrap_or(&file.path); + let mut result = SearchMatch { + kind: "file".to_string(), + path: file.path.clone(), + line: None, + preview: file.path.clone(), + symbol_name: None, + name_match_ranges: Vec::new(), + source_line: None, + content_match_ranges: Vec::new(), + matching_degree: None, + is_ignored: None, + }; + if match_everywhere_name(&request, &matcher, name, &mut result) { + result.matching_degree.get_or_insert(0); + } else if match_everywhere_name(&request, &matcher, &file.path, &mut result) { + // Directory-only matches remain candidates (including Agent mentions), below every basename hit. + result.matching_degree = Some(-1); + result.name_match_ranges.clear(); + } else { + continue; + } + if let Some(index) = &context_index { + result.matching_degree = result + .matching_degree + .map(|degree| index.contextual_degree(degree, &file.path)); + } + if let Some(page) = page.as_ref() { + page.push(&mut files, result, file_limit); + } else { + files.push(result); + } + emit_search_results( + [ + (&mut files, file_limit), + (&mut types, symbol_limit), + (&mut symbols, symbol_limit), + ], + limit, + &mut last_flush, + std::time::Instant::now(), + false, + ); + } + } + emit_search_results( + [ + (&mut files, file_limit), + (&mut types, symbol_limit), + (&mut symbols, symbol_limit), + ], + limit, + &mut last_flush, + std::time::Instant::now(), + true, + ); + // Publish names before content parsing starts. + // Zero source caps are execution boundaries, not just post-scan truncation. + let shared_index = if content_limit > 0 { + Some(search_index::get_for_text(&root, &rules)?) + } else { + None + }; + let index = shared_index + .as_ref() + .map(|index| { + index + .read() + .map_err(|_| CoreError::new(ErrorCode::Unknown, "Search index lock was poisoned")) + }) + .transpose()?; + if let Some(page) = page.as_mut() { + page.finish("file", &mut files, file_limit); + page.finish("type", &mut types, symbol_limit); + page.finish("symbol", &mut symbols, symbol_limit); + } else { + for (bucket, cap) in [ + (&mut files, file_limit), + (&mut types, symbol_limit), + (&mut symbols, symbol_limit), + ] { + rank_search_bucket(bucket, cap); + } + } + let mut content_request = request.clone(); + content_request.max_file_results = Some(0); + if page.is_some() { + content_request.max_results = content_limit + 1; + content_request.max_content_results = Some(content_limit + 1); + } + let mut content = if content_limit > 0 { + search_with_index( + &root, + &content_request, + &query, + index + .as_ref() + .expect("content source requires content index"), + page.as_deref(), + )? + .matches + } else { + Vec::new() + }; + if let Some(page) = page.as_mut() { + page.finish("content", &mut content, content_limit); + } + let mut matches = files; + matches.extend(types); + matches.extend(symbols); + matches.extend(content); + matches.truncate(limit); + drop(index); + // Ask Git once for the bounded result set, not once per row or indexed file. + let paths: Vec = matches.iter().map(|item| item.path.clone()).collect(); + if let Some(ignored) = lithe_git::ignored_paths(&root, &paths)? { + for item in &mut matches { + item.is_ignored = Some(ignored.contains(&item.path)); + } + } + if let Some(policy) = &indexing_policy { + policy.check()?; + } + Ok(SearchResponse { matches }) +} + +/// Stable total ordering shared by bounded collection and cursor continuation. +pub(crate) fn compare_search_matches(a: &SearchMatch, b: &SearchMatch) -> std::cmp::Ordering { + b.matching_degree + .cmp(&a.matching_degree) + .then_with(|| a.path.cmp(&b.path)) + .then_with(|| a.line.cmp(&b.line)) + .then_with(|| a.symbol_name.cmp(&b.symbol_name)) +} + +fn rank_search_bucket(bucket: &mut Vec, cap: usize) { + bucket.sort_by(compare_search_matches); + bucket.truncate(cap); +} + +// Replaceable snapshots let slow hosts keep only the newest batch. Ranking +// before each cap preserves the final top matches even when better hits arrive late. +pub(crate) fn emit_search_results( + buckets: [(&mut Vec, usize); 3], + limit: usize, + last_flush: &mut Option, + now: std::time::Instant, + force: bool, +) { + if !lithe_request_context::events::has_sink() + || buckets.iter().all(|(bucket, _)| bucket.is_empty()) + || (!force + && last_flush.is_some_and(|last| { + now.duration_since(last) < std::time::Duration::from_millis(100) + })) + { + return; + } + let mut matches = Vec::new(); + for (bucket, cap) in buckets { + rank_search_bucket(bucket, cap + 1); + matches.extend( + bucket + .iter() + .take(cap.min(limit.saturating_sub(matches.len()))) + .cloned(), + ); + } + *last_flush = Some(now); + lithe_request_context::events::emit(serde_json::json!({ + "type": "searchResults", "results": SearchResponse { matches } + })); +} + +fn match_everywhere_name( + request: &SearchRequest, + literal: &Matcher, + name: &str, + result: &mut SearchMatch, +) -> bool { + if request.regular_expression || request.whole_words || request.case_sensitive { + return literal.matches(name); + } + let Some(hit) = crate::name_matching::match_name(request.query.trim(), name) else { + return false; + }; + result.name_match_ranges = hit.ranges; + result.matching_degree = Some(hit.degree); + true +} diff --git a/rust/crates/project/src/files/snapshot.rs b/rust/crates/project/src/files/snapshot.rs new file mode 100644 index 00000000..d2acee96 --- /dev/null +++ b/rust/crates/project/src/files/snapshot.rs @@ -0,0 +1,269 @@ +//! Workspace snapshots and directory listings. + +use super::*; + +/// Scans visible workspace entries and invalidates any stale search index. +pub fn snapshot(request: WorkspaceSnapshotRequest) -> Result { + let root = existing_root(&request.root)?; + let indexing_policy = crate::indexing::context(&root); + search_index::invalidate_root(&root); + let rules = VisibilityRules::new(request.hidden_directory_names, request.hidden_file_patterns) + .with_workspace_roots(&root, &request.workspace_roots); + let mut files = Vec::new(); + let mut nested_worktrees = Vec::new(); + let mut incomplete_directories = Vec::new(); + let node = if request.include_tree { + scan_node( + &root, + &root, + &rules, + &mut files, + &mut nested_worktrees, + &mut incomplete_directories, + )? + } else { + collect_snapshot_files( + &root, + &root, + &rules, + &mut files, + &mut nested_worktrees, + &mut incomplete_directories, + )?; + WorkspaceNode { + path: String::new(), + name: node_name(&root), + is_directory: true, + children: None, + } + }; + if let Some(policy) = indexing_policy { + policy.check()?; + } + Ok(WorkspaceSnapshotResponse { + root: node, + files, + nested_worktrees, + incomplete_directories, + }) +} + +/// Lists the visible children of each requested directory, one level deep. +/// +/// The project tree loads directories as they are expanded, so a workspace +/// with many files no longer builds a node for each of them up front. Paths +/// that no longer exist, are hidden, or lie inside a nested linked worktree +/// are left out of the response instead of failing the whole request, because +/// a directory can disappear between the host's expand and this call. +pub fn list_directories( + request: DirectoryListingRequest, +) -> Result { + let root = existing_root(&request.root)?; + let rules = VisibilityRules::new(request.hidden_directory_names, request.hidden_file_patterns); + let mut directories = Vec::new(); + for relative in request.paths { + lithe_protocol::cancellation::check()?; + let Some(directory) = listable_directory(&root, &relative, &rules) else { + continue; + }; + let listing = match visible_children(&directory, &root, &rules, ChildOrder::Tree) { + Ok(listing) => listing, + Err(error) + if matches!( + error.code, + ErrorCode::PermissionDenied | ErrorCode::WorkspaceNotFound + ) => + { + continue + } + Err(error) => return Err(error), + }; + let children = listing + .children + .into_iter() + .map(|(path, is_directory)| WorkspaceNode { + path: relative_path(&path, &root), + name: node_name(&path), + is_directory, + children: if is_directory + && !crate::indexing::is_excluded(&root, &path, true).unwrap_or(true) + && directory_is_visibly_empty(&path, &root, &rules) + { + Some(Vec::new()) + } else { + None + }, + }) + .collect(); + directories.push(DirectoryListing { + path: relative_path(&directory, &root), + children, + nested_worktrees: listing + .nested_worktrees + .iter() + .map(|worktree| relative_path(worktree, &root)) + .collect(), + }); + } + Ok(DirectoryListingResponse { directories }) +} + +/// Resolves a requested directory without following symlinks, so a listing +/// never reaches outside the workspace or into a directory scans skip. +fn listable_directory(root: &Path, relative: &str, rules: &VisibilityRules) -> Option { + if relative.is_empty() { + return Some(root.to_path_buf()); + } + if Path::new(relative).is_absolute() || invalid_relative_path(relative) { + return None; + } + let mut current = root.to_path_buf(); + for component in relative.split('/') { + current.push(component); + let metadata = fs::symlink_metadata(¤t).ok()?; + if !metadata.is_dir() || rules.is_hidden(&relative_path(¤t, root), true) { + return None; + } + } + let nested = + is_inside_nested_linked_worktree(root, relative) || is_nested_linked_worktree(¤t); + (!nested).then_some(current) +} + +fn scan_node( + path: &Path, + root: &Path, + rules: &VisibilityRules, + files: &mut Vec, + nested_worktrees: &mut Vec, + incomplete_directories: &mut Vec, +) -> Result { + lithe_protocol::cancellation::check()?; + let metadata = fs::symlink_metadata(path)?; + let relative = relative_path(path, root); + let name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("Workspace") + .to_string(); + if rules.is_delegated(path) || crate::indexing::is_excluded(root, path, metadata.is_dir())? { + return Ok(WorkspaceNode { + path: relative, + name, + is_directory: metadata.is_dir(), + children: None, + }); + } + if !metadata.is_dir() { + files.push(relative.clone()); + return Ok(WorkspaceNode { + path: relative, + name, + is_directory: false, + children: None, + }); + } + + let listing = visible_children(path, root, rules, ChildOrder::Traversal)?; + nested_worktrees.extend( + listing + .nested_worktrees + .iter() + .map(|worktree| relative_path(worktree, root)), + ); + let mut visible_children = Vec::new(); + for (child_path, _) in listing.children { + lithe_protocol::cancellation::check()?; + match scan_node( + &child_path, + root, + rules, + files, + nested_worktrees, + incomplete_directories, + ) { + Ok(child) => visible_children.push(child), + Err(error) + if matches!( + error.code, + ErrorCode::PermissionDenied | ErrorCode::WorkspaceNotFound + ) => + { + incomplete_directories.push(relative_path(&child_path, root)); + lithe_diagnostics::core_events::directory_skipped( + "app.workspace.directory_skipped", + &relative_path(&child_path, root), + &error.code, + ); + } + Err(error) => return Err(error), + } + } + Ok(WorkspaceNode { + path: relative, + name, + is_directory: true, + children: Some(visible_children), + }) +} + +/// Collects the snapshot file list without building tree nodes. +fn collect_snapshot_files( + path: &Path, + root: &Path, + rules: &VisibilityRules, + files: &mut Vec, + nested_worktrees: &mut Vec, + incomplete_directories: &mut Vec, +) -> Result<(), CoreError> { + lithe_protocol::cancellation::check()?; + if rules.is_delegated(path) || crate::indexing::is_excluded(root, path, true)? { + return Ok(()); + } + let listing = visible_children(path, root, rules, ChildOrder::Traversal)?; + nested_worktrees.extend( + listing + .nested_worktrees + .iter() + .map(|worktree| relative_path(worktree, root)), + ); + for (child_path, is_directory) in listing.children { + if is_directory { + // A directory that vanished mid-scan is skipped, as in `scan_node`. + if let Err(error) = collect_snapshot_files( + &child_path, + root, + rules, + files, + nested_worktrees, + incomplete_directories, + ) { + if !matches!( + error.code, + ErrorCode::PermissionDenied | ErrorCode::WorkspaceNotFound + ) { + return Err(error); + } + incomplete_directories.push(relative_path(&child_path, root)); + lithe_diagnostics::core_events::directory_skipped( + "app.workspace.directory_skipped", + &relative_path(&child_path, root), + &error.code, + ); + } + } else { + if crate::indexing::is_excluded(root, &child_path, false)? { + continue; + } + files.push(relative_path(&child_path, root)); + } + } + Ok(()) +} + +fn node_name(path: &Path) -> String { + path.file_name() + .and_then(|value| value.to_str()) + .unwrap_or("Workspace") + .to_string() +} diff --git a/rust/crates/project/src/files/text_io.rs b/rust/crates/project/src/files/text_io.rs new file mode 100644 index 00000000..7011ef5c --- /dev/null +++ b/rust/crates/project/src/files/text_io.rs @@ -0,0 +1,198 @@ +//! Reading, decoding, encoding and writing workspace files. + +use super::*; + +/// Reads one bounded, workspace-contained text file. +/// +/// Without `encoding` the codec is detected from the byte order mark and +/// the bytes; with one, the bytes are decoded leniently through it. +pub fn read_file(request: FileReadRequest) -> Result { + let root = existing_root(&request.root)?; + let path = safe_relative_path(&root, &request.path)?; + let requested = request + .encoding + .as_deref() + .map(DocumentEncoding::parse) + .transpose()?; + let metadata = fs::metadata(&path)?; + if metadata.len() > MAX_OPEN_FILE_SIZE { + return Err(too_large()); + } + let bytes = fs::read(&path)?; + let decoded = decode_text(&bytes, requested)?; + Ok(FileReadResponse { + path: relative_path(&path, &root), + text: decoded.text, + encoding: decoded.encoding, + has_bom: decoded.has_bom, + lossy: decoded.lossy, + identity: decoded.identity, + }) +} + +/// Bytes a host already holds, as base64, to decode as `file.read` would. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TextDecodeRequest { + /// Standard base64 of the exact bytes. + pub bytes: String, + /// Stable encoding id or alias to decode with; absent means detect. + #[serde(default)] + pub encoding: Option, +} + +/// `file.read`'s answer without a path. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TextDecodeResponse { + /// Decoded text without the byte order mark. + pub text: String, + /// Stable id of the encoding used to decode `text`. + pub encoding: String, + /// A leading byte order mark was present and is not part of `text`. + pub has_bom: bool, + /// Invalid bytes were replaced with U+FFFD (explicit `encoding` only). + pub lossy: bool, + /// SHA-256 (lowercase hex) of the exact bytes decoded. + pub identity: String, +} + +/// Text to encode as `file.write` would, without a file. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TextEncodeRequest { + /// Text to encode. + pub text: String, + /// Stable encoding id or alias to encode with; absent means UTF-8. + #[serde(default)] + pub encoding: Option, + /// Overrides the encoding's own byte-order-mark choice (Unicode encodings only). + #[serde(default)] + pub bom: Option, +} + +/// `file.write`'s answer with the encoded bytes in place of a path. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TextEncodeResponse { + /// Standard base64 of the exact encoded bytes, including any byte order mark. + pub bytes: String, + /// Stable id of the encoding the text was encoded with. + pub encoding: String, + /// A byte order mark was written. + pub has_bom: bool, + /// SHA-256 (lowercase hex) of the encoded bytes. + pub identity: String, +} + +/// `text.decode`: the decoding of `file.read` for bytes the host read itself, +/// so a host that guards its own file access needs no temporary file. +pub fn decode_text_bytes(request: TextDecodeRequest) -> Result { + use base64::Engine as _; + // Base64 is 4 characters per 3 bytes: anything longer is over the limit + // before it is decoded into memory. + if request.bytes.len() as u64 > MAX_OPEN_FILE_SIZE.div_ceil(3) * 4 { + return Err(too_large()); + } + let bytes = base64::engine::general_purpose::STANDARD + .decode(request.bytes.as_bytes()) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid text decode request") + .with_details(error.to_string()) + })?; + if bytes.len() as u64 > MAX_OPEN_FILE_SIZE { + return Err(too_large()); + } + let requested = request + .encoding + .as_deref() + .map(DocumentEncoding::parse) + .transpose()?; + decode_text(&bytes, requested) +} + +/// `text.encode`: the encoding of `file.write`, returning the bytes instead of +/// writing them. An unmappable character fails the same way. +pub fn encode_text(request: TextEncodeRequest) -> Result { + use base64::Engine as _; + let selected = request + .encoding + .as_deref() + .map(DocumentEncoding::parse) + .transpose()? + .unwrap_or(DocumentEncoding::Utf8); + let bytes = encoding::encode(&request.text, selected, request.bom)?; + Ok(TextEncodeResponse { + encoding: selected.id().to_string(), + has_bom: writes_bom(selected, request.bom), + identity: encoding::bytes_identity(&bytes), + bytes: base64::engine::general_purpose::STANDARD.encode(&bytes), + }) +} + +/// Decodes document bytes and refuses anything that is not plain text. +fn decode_text( + bytes: &[u8], + requested: Option, +) -> Result { + let decoded = encoding::decode(bytes, requested)?; + if !is_plain_text(&decoded.text) { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "File is not plain text", + )); + } + Ok(TextDecodeResponse { + text: decoded.text, + encoding: decoded.encoding.id().to_string(), + has_bom: decoded.had_bom, + lossy: decoded.lossy, + identity: encoding::bytes_identity(bytes), + }) +} + +fn too_large() -> CoreError { + CoreError::new(ErrorCode::ParseFailed, "File is too large to open") + .with_details("The maximum supported file size is 32 MB") +} + +/// Whether encoding with `selected` and the `bom` override writes a byte order mark. +fn writes_bom(selected: DocumentEncoding, bom: Option) -> bool { + bom.unwrap_or(matches!( + selected, + DocumentEncoding::Utf8Bom | DocumentEncoding::Utf16LeBom | DocumentEncoding::Utf16BeBom + )) +} + +/// Replaces one workspace-contained file and refreshes its cached search entry. +/// +/// Without `encoding`/`bom` the text is written as plain UTF-8, as before. +pub fn write_file(request: FileWriteRequest) -> Result { + let root = existing_root(&request.root)?; + let path = writable_relative_path(&root, &request.path)?; + let selected = request + .encoding + .as_deref() + .map(DocumentEncoding::parse) + .transpose()? + .unwrap_or(DocumentEncoding::Utf8); + // Encode before touching the file system so an unmappable character leaves + // the existing file and directory tree untouched. + let bytes = encoding::encode(&request.text, selected, request.bom)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&path, &bytes).map_err(|error| { + CoreError::new(ErrorCode::PermissionDenied, "Could not write file") + .with_details(error.to_string()) + })?; + search_index::update_cached_file(&root, &path); + let has_bom = writes_bom(selected, request.bom); + Ok(FileWriteResponse { + path: relative_path(&path, &root), + bytes_written: bytes.len(), + encoding: selected.id().to_string(), + has_bom, + identity: encoding::bytes_identity(&bytes), + }) +} diff --git a/rust/crates/project/src/files/visibility.rs b/rust/crates/project/src/files/visibility.rs new file mode 100644 index 00000000..aeb452db --- /dev/null +++ b/rust/crates/project/src/files/visibility.rs @@ -0,0 +1,254 @@ +//! Which files and directories a snapshot shows. + +use super::*; + +/// Effective hidden-path rules plus the nested roots this request delegates. +/// +/// Matching is `lithe-core-rules`' [`shared_visibility::VisibilityRules`], the same code the +/// hosts reach through `workspace.classifyVisibility`; only the delegated roots, which depend on +/// the file system, are added here. +pub struct VisibilityRules { + pub(crate) hidden_directories: Vec, + pub(crate) hidden_file_patterns: Vec, + /// Canonical nested roots delegated by this request, never by the global policy registry. + pub(crate) delegated_roots: Vec, +} + +impl VisibilityRules { + /// Adds the built-in rules except those turned off by a `!` entry. + pub fn new(hidden_directories: Vec, hidden_files: Vec) -> Self { + let rules = shared_visibility::VisibilityRules::new(hidden_directories, hidden_files); + Self { + hidden_directories: rules.hidden_directories, + hidden_file_patterns: rules.hidden_file_patterns, + delegated_roots: Vec::new(), + } + } + + /// Binds scan ownership to the caller's workspace without changing directory browsing. + pub(crate) fn with_workspace_roots(mut self, root: &Path, roots: &[String]) -> Self { + for value in roots { + // Disconnected siblings must not fail an otherwise valid scoped request. + // Missing descendants still retain their boundary until the host removes the root. + let candidate = search_index::canonicalize_with_missing_components(Path::new(value)) + .unwrap_or_else(|| PathBuf::from(value)); + if candidate != root && candidate.starts_with(root) { + self.delegated_roots.push(candidate); + } + } + self.delegated_roots.sort(); + self.delegated_roots.dedup(); + self + } + + pub(crate) fn is_delegated(&self, path: &Path) -> bool { + self.delegated_roots + .iter() + .any(|root| path.starts_with(root)) + } + + /// Whether the workspace-relative `path` is hidden by these rules. + pub fn is_hidden(&self, path: &str, is_directory: bool) -> bool { + shared_visibility::is_path_hidden( + &self.hidden_directories, + &self.hidden_file_patterns, + path, + Some(is_directory), + ) + } +} + +/// How `visible_children` orders the siblings it returns. Directories always come first. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ChildOrder { + /// The rows the project tree displays: IDEA's natural file-name order, where digit + /// runs compare by value (see `lithe_core_rules::file_names`). + Tree, + /// The sequence behind flat file lists and the search index: lowercased names in + /// byte order. Consumers that keep only the first N entries of that sequence, such + /// as Windows Quick Open (the first 100 matches of the snapshot list), depend on it, + /// so a display-order change must not reorder it. + Traversal, +} + +/// Visible children of one directory in the requested order, plus the nested linked +/// worktrees that were left out of it. +pub(crate) struct VisibleDirectory { + /// Child paths and whether each is a directory, sorted by the requested `ChildOrder`. + pub(crate) children: Vec<(PathBuf, bool)>, + pub(crate) nested_worktrees: Vec, +} + +#[cfg(any(test, feature = "test-support"))] +thread_local! { + /// Scoped enumeration trace for pruning regressions; absent in production builds. + pub static DIRECTORY_VISITS: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; +} + +/// Lists the children that workspace scans descend into. Snapshot and search +/// share this so the tree, the file list, and the index always agree. +pub(crate) fn visible_children( + path: &Path, + root: &Path, + rules: &VisibilityRules, + order: ChildOrder, +) -> Result { + #[cfg(any(test, feature = "test-support"))] + DIRECTORY_VISITS.with_borrow_mut(|visits| { + if let Some(visits) = visits { + visits.push(path.to_path_buf()); + } + }); + let mut nested_worktrees = Vec::new(); + let mut children = fs::read_dir(path) + .map_err(CoreError::from)? + .filter_map(Result::ok) + .filter_map(|entry| visible_child(entry, root, rules, &mut nested_worktrees)) + .collect::>(); + match order { + // Siblings follow IDEA's Project view: directories first, then natural file-name + // order, so `v0.1.4.md` precedes `v0.1.12.md`. + ChildOrder::Tree => children.sort_by_cached_key(|(path, is_directory)| { + ( + std::cmp::Reverse(*is_directory), + FileNameKey::new( + path.file_name() + .and_then(|value| value.to_str()) + .unwrap_or_default(), + ), + ) + }), + ChildOrder::Traversal => children.sort_by(|left, right| { + right.1.cmp(&left.1).then_with(|| { + left.0 + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_lowercase() + .cmp( + &right + .0 + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_lowercase(), + ) + }) + }), + } + nested_worktrees.sort(); + Ok(VisibleDirectory { + children, + nested_worktrees, + }) +} + +/// Checks only until the first visible entry; unreadable directories remain expandable. +pub(crate) fn directory_is_visibly_empty( + path: &Path, + root: &Path, + rules: &VisibilityRules, +) -> bool { + let Ok(mut entries) = fs::read_dir(path) else { + return false; + }; + !entries.any(|entry| match entry { + Ok(entry) => visible_child(entry, root, rules, &mut Vec::new()).is_some(), + Err(_) => true, + }) +} + +/// Applies the same visibility boundary to full listings and leaf probes. +fn visible_child( + entry: fs::DirEntry, + root: &Path, + rules: &VisibilityRules, + nested_worktrees: &mut Vec, +) -> Option<(PathBuf, bool)> { + let child_path = entry.path(); + // Both native and remote readDir already supplied the non-following type; + // re-statting every file would cost one network round trip per entry. + let file_type = entry.file_type().ok()?; + if file_type.is_symlink() + || rules.is_hidden(&relative_path(&child_path, root), file_type.is_dir()) + { + return None; + } + if file_type.is_dir() && is_nested_linked_worktree(&child_path) { + nested_worktrees.push(child_path); + return None; + } + Some((child_path, file_type.is_dir())) +} + +/// A `.git` pointer file is a single `gitdir:` line; anything larger is not one. +const MAX_GIT_POINTER_FILE_SIZE: u64 = 4 * 1024; + +/// Returns whether `directory` is a linked worktree checked out inside the main +/// worktree of the repository that owns it, such as `.claude/worktrees/`. +/// +/// Such a checkout repeats the project's own files, and agent tools can create +/// several of them, so scanning them multiplies the tree, index, and watcher +/// load. Git records a linked worktree as `/.git/worktrees/`; +/// submodules (`.git/modules/...`), bare repositories, and worktrees placed +/// outside their main checkout do not match and stay visible. +pub(crate) fn is_nested_linked_worktree(directory: &Path) -> bool { + let marker = directory.join(".git"); + let Ok(metadata) = fs::symlink_metadata(&marker) else { + return false; + }; + if !metadata.is_file() || metadata.len() > MAX_GIT_POINTER_FILE_SIZE { + return false; + } + let Ok(text) = fs::read_to_string(&marker) else { + return false; + }; + let Some(git_directory) = text + .lines() + .next() + .and_then(|line| line.strip_prefix("gitdir:")) + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return false; + }; + let git_directory = directory.join(git_directory); + let Some(worktrees) = git_directory.parent() else { + return false; + }; + if worktrees.file_name().and_then(|name| name.to_str()) != Some("worktrees") { + return false; + } + let Some(common_directory) = worktrees.parent() else { + return false; + }; + if common_directory.file_name().and_then(|name| name.to_str()) != Some(".git") { + return false; + } + let Some(main_worktree) = common_directory.parent() else { + return false; + }; + let (Ok(main_worktree), Ok(directory)) = + (fs::canonicalize(main_worktree), fs::canonicalize(directory)) + else { + return false; + }; + directory != main_worktree && directory.starts_with(&main_worktree) +} + +/// Returns whether a workspace-relative path lies below a nested linked +/// worktree. Used for watcher-reported paths that never went through a scan. +pub(crate) fn is_inside_nested_linked_worktree(root: &Path, relative: &str) -> bool { + let components = relative + .split('/') + .filter(|value| !value.is_empty()) + .collect::>(); + let mut directory = root.to_path_buf(); + for component in components.iter().take(components.len().saturating_sub(1)) { + directory.push(component); + if is_nested_linked_worktree(&directory) { + return true; + } + } + false +} diff --git a/rust/crates/core/src/project/history.rs b/rust/crates/project/src/history.rs similarity index 94% rename from rust/crates/core/src/project/history.rs rename to rust/crates/project/src/history.rs index 9e23777c..db23f720 100644 --- a/rust/crates/core/src/project/history.rs +++ b/rust/crates/project/src/history.rs @@ -1,8 +1,8 @@ //! Versioned local-history snapshots with bounded retention and storage validation. -use crate::project::files::VisibilityRules; -use crate::protocol::{invalid_relative_path, CoreError, ErrorCode}; -use crate::protocol::{HistoryEntriesResponse, HistoryEntryResponse}; +use crate::contracts::{HistoryEntriesResponse, HistoryEntryResponse}; +use crate::files::VisibilityRules; +use lithe_protocol::{invalid_relative_path, CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeSet, HashMap}; use std::fs; @@ -68,18 +68,27 @@ const BUILT_IN_IGNORED_DIRECTORY_NAMES: &[&str] = &[ #[serde(rename_all = "camelCase")] /// Snapshot contents, reason, visibility, and retention policy for one record. pub struct HistoryRecordRequest { + /// Absolute workspace root that bounds every path in the request. pub workspace_root: String, + /// Directory holding this project's local-history snapshots and metadata. pub storage_root: String, + /// Workspace-relative path of the file to snapshot. pub path: String, + /// Why the snapshot is taken; must pass [`is_valid_reason`]. pub reason: String, + /// Text to record; absent reads the file from disk. #[serde(default)] pub content: Option, + /// Whether recording also drops expired snapshots and enforces the storage cap; defaults to true. #[serde(default = "default_prune_expired")] pub prune_expired: bool, + /// Extra directory names hidden from the tree, on top of the built-in ones. #[serde(default)] pub hidden_directory_names: Vec, + /// Hidden-path globs; `!` lines turn built-in hidden folders back on. #[serde(default)] pub hidden_file_patterns: Vec, + /// Rules for files whose changes are not recorded. #[serde(default)] pub ignore_rules: HistoryIgnoreRules, /// Storage cap in bytes; `None` keeps the 100 MB default. @@ -98,8 +107,10 @@ pub struct HistoryRecordRequest { #[derive(Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] pub struct HistoryIgnoreRules { + /// Globs in hidden-path syntax; `!` lines turn built-in folders back on. #[serde(default)] pub path_patterns: Vec, + /// Case-insensitive regexes matched against the workspace-relative path. #[serde(default)] pub path_regexes: Vec, } @@ -135,8 +146,10 @@ fn built_in_ignore_pattern(name: &str) -> String { #[serde(rename_all = "camelCase")] /// Rules and an optional sample path for the settings preview. pub struct HistoryIgnorePreviewRequest { + /// Workspace-relative sample path to test; absent only validates the rules. #[serde(default)] pub path: Option, + /// Rules being edited in settings. #[serde(default)] pub ignore_rules: HistoryIgnoreRules, /// Hidden-path entries, so the preview honours built-ins the user turned off. @@ -151,7 +164,9 @@ pub struct HistoryIgnorePreviewRequest { #[serde(rename_all = "camelCase")] /// One regex rule the regex engine rejected, by its position in the list. pub struct HistoryIgnoreRegexError { + /// Position of the rejected regex in `pathRegexes`. pub index: usize, + /// The regex engine's error message. pub message: String, } @@ -163,9 +178,12 @@ pub enum HistoryIgnoreMatch { Hidden, /// The wildcard rule as written, built-in or the user's own. Pattern { + /// The wildcard rule. pattern: String, }, + /// A regex rule, by its position in `pathRegexes`. Regex { + /// Position of the matching regex in `pathRegexes`. index: usize, }, } @@ -174,9 +192,11 @@ pub enum HistoryIgnoreMatch { #[serde(rename_all = "camelCase")] /// Validation and sample-path result for the local history settings. pub struct HistoryIgnorePreviewResponse { + /// Regexes that failed to compile. pub regex_errors: Vec, /// The built-in wildcard rules, for settings to show and edit against. pub built_in_ignore_patterns: Vec, + /// Rule that keeps the sample path out of history; absent when it would be recorded. pub matched: Option, /// Whether Git ignores the path, for comparison only: local history does not /// follow Git ignore rules. `None` without a path, a workspace or a repository. @@ -187,6 +207,7 @@ pub struct HistoryIgnorePreviewResponse { #[serde(rename_all = "camelCase")] /// One project's history storage, for usage reporting or clearing. pub struct HistoryStorageRequest { + /// Directory holding this project's local-history snapshots and metadata. pub storage_root: String, /// Cap reported by `usage`; `None` keeps the 100 MB default. #[serde(default)] @@ -197,7 +218,9 @@ pub struct HistoryStorageRequest { #[serde(rename_all = "camelCase")] /// Allocated bytes of one project's history and the cap that eviction keeps. pub struct HistoryUsageResponse { + /// Bytes the snapshots and metadata occupy on disk. pub used_bytes: u64, + /// Storage cap in bytes that eviction keeps the history under. pub limit_bytes: u64, } @@ -205,12 +228,17 @@ pub struct HistoryUsageResponse { #[serde(rename_all = "camelCase")] /// Optional file filter and visibility policy for listing snapshot metadata. pub struct HistoryEntriesRequest { + /// Absolute workspace root that bounds every path in the request. pub workspace_root: String, + /// Directory holding this project's local-history snapshots and metadata. pub storage_root: String, + /// Workspace-relative file to list; absent lists every visible file. #[serde(default)] pub path: Option, + /// Extra directory names hidden from the tree, on top of the built-in ones. #[serde(default)] pub hidden_directory_names: Vec, + /// Hidden-path globs; `!` lines turn built-in hidden folders back on. #[serde(default)] pub hidden_file_patterns: Vec, } @@ -219,7 +247,9 @@ pub struct HistoryEntriesRequest { #[serde(rename_all = "camelCase")] /// Validated storage-relative path of snapshot content to read. pub struct HistoryContentRequest { + /// Directory holding this project's local-history snapshots and metadata. pub storage_root: String, + /// Storage-relative content path from a history entry. pub content_path: String, } @@ -227,8 +257,11 @@ pub struct HistoryContentRequest { #[serde(rename_all = "camelCase")] /// Request to move all history metadata after a workspace path relocation. pub struct HistoryRelocateRequest { + /// Directory holding this project's local-history snapshots and metadata. pub storage_root: String, + /// Workspace-relative path before the move. pub source_path: String, + /// Workspace-relative path after the move. pub destination_path: String, } @@ -236,9 +269,13 @@ pub struct HistoryRelocateRequest { #[serde(rename_all = "camelCase")] /// Request to assign or clear the user label on one snapshot. pub struct HistoryRenameRequest { + /// Directory holding this project's local-history snapshots and metadata. pub storage_root: String, + /// Workspace-relative path of the file the snapshot belongs to. pub path: String, + /// Identity of the snapshot to label. pub id: String, + /// New label; absent or blank clears it. #[serde(default)] pub label: Option, } @@ -247,8 +284,11 @@ pub struct HistoryRenameRequest { #[serde(rename_all = "camelCase")] /// Request to delete one snapshot and its metadata entry. pub struct HistoryDeleteRequest { + /// Directory holding this project's local-history snapshots and metadata. pub storage_root: String, + /// Workspace-relative path of the file the snapshot belongs to. pub path: String, + /// Identity of the snapshot to delete. pub id: String, } @@ -315,7 +355,7 @@ pub fn record(request: HistoryRecordRequest) -> Result Result Result Result { +pub fn plan_event_command(payload: serde_json::Value) -> Result { #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct Request { @@ -543,7 +581,7 @@ fn relocate_file_history(storage: &Path, source: &str, destination: &str) -> Res serde_json::to_vec(&relocated).expect("history metadata should encode"), )?; } - let mut cleanup = crate::diagnostics::core_events::StorageCleanup::default(); + let mut cleanup = lithe_diagnostics::core_events::StorageCleanup::default(); cleanup.observe(fs::remove_dir_all(source_directory)); cleanup.finish("history-relocation"); Ok(()) @@ -605,7 +643,7 @@ pub fn delete(request: HistoryDeleteRequest) -> Result<(), CoreError> { .map(|mut entries| entries.next().is_none()) .unwrap_or(false) { - let mut cleanup = crate::diagnostics::core_events::StorageCleanup::default(); + let mut cleanup = lithe_diagnostics::core_events::StorageCleanup::default(); cleanup.empty_directory(&directory); cleanup.finish("snapshot-directory"); } @@ -641,10 +679,8 @@ pub fn ignore_preview( ); // A missing workspace only loses the Git line, not the rule preview. let git_ignored = match request.workspace_root.as_deref().map(existing_root) { - Some(Ok(root)) => { - crate::git::ignored_paths(&root, std::slice::from_ref(&relative))? - .map(|ignored| ignored.contains(&relative)) - } + Some(Ok(root)) => lithe_git::ignored_paths(&root, std::slice::from_ref(&relative))? + .map(|ignored| ignored.contains(&relative)), _ => None, }; (matched, git_ignored) @@ -884,7 +920,7 @@ fn default_prune_expired() -> bool { } fn trim_entries(directory: &Path, storage: &Path) { - let mut cleanup = crate::diagnostics::core_events::StorageCleanup::default(); + let mut cleanup = lithe_diagnostics::core_events::StorageCleanup::default(); let mut removed = false; for entry in read_entries(directory, storage) .into_iter() @@ -906,7 +942,7 @@ fn trim_entries(directory: &Path, storage: &Path) { /// touching snapshots of real edits. fn prune_expired(storage: &Path, rules: &HistoryIgnoreRules) { let cutoff = unix_seconds() - RETENTION_SECONDS; - let mut cleanup = crate::diagnostics::core_events::StorageCleanup::default(); + let mut cleanup = lithe_diagnostics::core_events::StorageCleanup::default(); let mut removed = false; for entry in all_entries(storage) { let excluded_baseline = entry.reason == "projectBaseline" @@ -943,7 +979,7 @@ fn enforce_storage_limit(storage: &Path, limit: u64) -> Result<(), CoreError> { entry.relative_path.clone(), ) }); - let mut cleanup = crate::diagnostics::core_events::StorageCleanup::default(); + let mut cleanup = lithe_diagnostics::core_events::StorageCleanup::default(); for entry in entries { if usage <= limit { break; @@ -995,7 +1031,7 @@ fn all_entries(storage: &Path) -> Vec { fn remove_entry( storage: &Path, entry: &StoredEntry, - cleanup: &mut crate::diagnostics::core_events::StorageCleanup, + cleanup: &mut lithe_diagnostics::core_events::StorageCleanup, ) { cleanup.observe(fs::remove_file(storage.join(&entry.content_path))); cleanup.observe(fs::remove_file(metadata_path(storage, entry))); @@ -1166,7 +1202,8 @@ fn monotonic_nonce() -> u128 { .unwrap_or_default() } -pub(crate) fn is_valid_reason(value: &str) -> bool { +/// Whether `value` is a snapshot reason Core records. +pub fn is_valid_reason(value: &str) -> bool { matches!( value, "projectBaseline" @@ -1213,7 +1250,7 @@ fn parse_iso_timestamp(value: &str) -> i64 { #[cfg(test)] mod tests { use super::*; - use crate::tests::support::temporary_root; + use crate::test_support::temporary_root; fn record_request( workspace: &Path, diff --git a/rust/crates/core/src/project/indexing.rs b/rust/crates/project/src/indexing.rs similarity index 92% rename from rust/crates/core/src/project/indexing.rs rename to rust/crates/project/src/indexing.rs index b21dca31..3f36a456 100644 --- a/rust/crates/core/src/project/indexing.rs +++ b/rust/crates/project/src/indexing.rs @@ -1,7 +1,7 @@ //! Workspace-owned indexing exclusions, independent of tree visibility and Git writes. use super::files::{existing_root, relative_path}; -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, HashMap}; use std::path::{Path, PathBuf}; @@ -12,8 +12,11 @@ use std::sync::{Arc, Mutex, OnceLock}; #[serde(rename_all = "camelCase")] /// User-owned policy. Missing policies preserve legacy Core callers' behavior. pub struct IndexingPolicy { + /// Workspace-relative directories the user excluded. pub manual_excluded_directories: Vec, + /// Workspace-relative directories kept indexed even when Git ignores them. pub included_directories: Vec, + /// Whether directories Git ignores are excluded too. pub git_ignore_enabled: bool, } @@ -21,14 +24,18 @@ pub struct IndexingPolicy { #[serde(rename_all = "camelCase")] /// Installs a policy before workspace consumers start; refresh discards Git decisions. pub struct ConfigureIndexingRequest { + /// Absolute workspace root that bounds every path in the request. pub root: String, /// Runtime identity. Omitted by legacy callers, which share one guarded lease. #[serde(default)] pub owner_id: Option, + /// Policy to install for `root`. #[serde(flatten)] pub policy: IndexingPolicy, + /// Discard cached Git decisions even when the policy is unchanged. #[serde(default)] pub refresh: bool, + /// Workspace-relative directories whose exclusion reason the response reports. #[serde(default)] pub paths: Vec, } @@ -37,14 +44,18 @@ pub struct ConfigureIndexingRequest { #[serde(rename_all = "camelCase")] /// Effective directory reasons and Git availability, never a claim about third-party scans. pub struct IndexingStatus { + /// Lease token the caller passes back to release or refresh. pub revision: u64, + /// Excluded workspace-relative directories mapped to `manual` or `git`. pub excluded_directories: BTreeMap, + /// Whether Git could not answer, so Git-ignored directories stayed indexed. pub git_ignore_failed: bool, } /// Immutable policy generation with lazily cached Git directory decisions. -pub(crate) struct IndexingContext { - pub(crate) revision: u64, +pub struct IndexingContext { + /// Policy generation; each installation advances it. + pub revision: u64, /// Watcher scan refreshes retain the installation being committed or rolled back. installation_revision: u64, /// Legacy release token; watcher refreshes preserve host ownership. @@ -100,13 +111,12 @@ fn validate_paths(paths: &mut Vec) -> Result<(), CoreError> { } /// Registers a project policy atomically. In-flight readers cannot publish a retired generation. -pub fn configure(request: ConfigureIndexingRequest) -> Result { - configure_with_provider_update(request, crate::lsp::indexing_changed) -} - -fn configure_with_provider_update( +/// +/// `update_providers` reconciles the running providers (language servers) of a +/// root with the new policy; the caller that owns them passes it in. +pub fn configure( mut request: ConfigureIndexingRequest, - mut update_provider: impl FnMut(&Path) -> Result<(), CoreError>, + mut update_providers: impl FnMut(&Path) -> Result<(), CoreError>, ) -> Result { if request .owner_id @@ -232,7 +242,7 @@ fn configure_with_provider_update( } } } - update_provider(&root)?; + update_providers(&root)?; { // Publication and policy replacement share the registry lock. A // scan cannot pass its generation check and then publish an old set. @@ -277,7 +287,7 @@ fn configure_with_provider_update( true, ) { // Failed installation owns recovery; WARN keeps the original failure. - crate::diagnostics::core_events::recovery_failed( + lithe_diagnostics::core_events::recovery_failed( "app.indexing.rollback_failed", "lease", serde_json::to_value(&error.code) @@ -291,8 +301,8 @@ fn configure_with_provider_update( super::search_index::invalidate_root(&root); // Restore providers that received only part of the failed update. // Preserve the original error if shutdown/cancellation also blocks recovery. - if let Err(error) = update_provider(&root) { - crate::diagnostics::core_events::recovery_failed( + if let Err(error) = update_providers(&root) { + lithe_diagnostics::core_events::recovery_failed( "app.indexing.rollback_failed", "provider", serde_json::to_value(&error.code) @@ -357,7 +367,9 @@ fn rollback_failed_policy(root: &Path, failed: &Arc) -> Result< #[serde(rename_all = "camelCase")] /// Revision-guarded release; a late old window must not retire a newly opened project. pub struct ReleaseIndexingRequest { + /// Absolute workspace root that bounds every path in the request. pub root: String, + /// Lease token from the configure response. pub revision: u64, /// Releases only this runtime's lease, never another runtime using the same root. #[serde(default)] @@ -368,7 +380,9 @@ pub struct ReleaseIndexingRequest { #[serde(rename_all = "camelCase")] /// Refreshes external Git facts without replacing the caller's published lease. pub struct RefreshIndexingRequest { + /// Absolute workspace root that bounds every path in the request. pub root: String, + /// Runtime that owns the lease. pub owner_id: String, /// Existing release token, not the internal scan generation. pub revision: u64, @@ -376,7 +390,11 @@ pub struct RefreshIndexingRequest { /// Invalidates cached decisions for a live owner and reconciles existing providers. /// No content index is built, and a late request cannot acquire or revive a lease. -pub fn refresh_indexing(request: RefreshIndexingRequest) -> Result { +/// `update_providers` reconciles the root's running providers, as for [`configure`]. +pub fn refresh_indexing( + request: RefreshIndexingRequest, + mut update_providers: impl FnMut(&Path) -> Result<(), CoreError>, +) -> Result { let root = existing_root(&request.root)?; let owner = Some(request.owner_id); let check_lease = |context: &IndexingContext| -> Result<(), CoreError> { @@ -410,7 +428,7 @@ pub fn refresh_indexing(request: RefreshIndexingRequest) -> Result Result Result { release_lease(request, false) } @@ -504,13 +523,14 @@ fn release_lease( Ok(true) } -pub(crate) fn context(root: &Path) -> Option> { +/// Installed policy for `root`, if any. +pub fn context(root: &Path) -> Option> { policies().lock().ok()?.get(root).cloned() } /// Provider requests without a live owner retain their standalone behavior. /// Retired records still cancel late inventory readers through `context`. -pub(crate) fn active_provider_context(root: &Path) -> Option> { +pub fn active_provider_context(root: &Path) -> Option> { context(root).filter(|context| !context.retired.load(Ordering::Acquire)) } @@ -552,9 +572,7 @@ fn refreshed_context(old: &IndexingContext) -> Arc { /// Resolve native Git decisions before a live provider receives a new generation. /// This reuses the inventory walker (no file text reads) and prunes excluded trees. -pub(crate) fn prepare_provider_exclusions( - root: &Path, -) -> Result>, CoreError> { +pub fn prepare_provider_exclusions(root: &Path) -> Result>, CoreError> { let Some(context) = active_provider_context(root) else { return Ok(None); }; @@ -577,16 +595,19 @@ pub(crate) fn prepare_provider_exclusions( fn contains(directory: &str, path: &str) -> bool { #[cfg(windows)] let (directory, path) = (directory.to_lowercase(), path.to_lowercase()); + #[cfg(windows)] + let (directory, path) = (directory.as_str(), path.as_str()); directory == "." || path == directory || path - .strip_prefix(&directory[..]) + .strip_prefix(directory) .is_some_and(|suffix| suffix.starts_with('/')) } impl IndexingContext { - pub(crate) fn check(&self) -> Result<(), CoreError> { - crate::protocol::cancellation::check()?; + /// Fails with `cancelled` when the request was cancelled or this policy was replaced. + pub fn check(&self) -> Result<(), CoreError> { + lithe_protocol::cancellation::check()?; if self.retired.load(Ordering::Acquire) { return Err(CoreError::new( ErrorCode::Cancelled, @@ -674,7 +695,7 @@ impl IndexingContext { .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())) .filter_map(|entry| entry.file_name().into_string().ok()) .collect::>(); - let ignored = crate::git::ignored_paths(parent, &candidates)?; + let ignored = lithe_git::ignored_paths(parent, &candidates)?; let mut decisions = HashMap::new(); if let Some(ignored) = ignored { // The index is authoritative for tracked descendants; check-ignore on a directory @@ -684,7 +705,7 @@ impl IndexingContext { // A nested repository owns its ignore rules and tracked content. false } else if ignored.contains(&name) { - let response = crate::git::execute_git_readonly( + let response = lithe_git::execute_git_readonly( &parent.to_string_lossy(), &[ "--literal-pathspecs".into(), @@ -736,7 +757,7 @@ pub(crate) fn is_excluded(root: &Path, path: &Path, is_directory: bool) -> Resul } /// Resolved roots for provider adapters. Git decisions are populated by the project scan. -pub(crate) fn excluded_roots(root: &Path) -> Vec { +pub fn excluded_roots(root: &Path) -> Vec { let Some(context) = active_provider_context(root) else { return Vec::new(); }; @@ -762,7 +783,7 @@ pub(crate) fn excluded_roots(root: &Path) -> Vec { } /// File URIs are normalized through the same workspace root as index consumers. -pub(crate) fn uri_is_excluded(root_uri: &str, uri: &str) -> Result { +pub fn uri_is_excluded(root_uri: &str, uri: &str) -> Result { let root = url::Url::parse(root_uri) .ok() .and_then(|value| value.to_file_path().ok()); @@ -806,6 +827,7 @@ mod tests { "includedDirectories":[],"gitIgnoreEnabled":true })) .unwrap(), + |_| Ok(()), ) .unwrap() .revision @@ -817,11 +839,14 @@ mod tests { .contains(&"ignored/deep/Excluded.txt".into())); let stale = context(&workspace.0).unwrap(); fs::write(workspace.0.join(".gitignore"), "ignored/\n").unwrap(); - let refreshed = refresh_indexing(RefreshIndexingRequest { - root: workspace.0.to_string_lossy().into_owned(), - owner_id: "first".into(), - revision: first, - }) + let refreshed = refresh_indexing( + RefreshIndexingRequest { + root: workspace.0.to_string_lossy().into_owned(), + owner_id: "first".into(), + revision: first, + }, + |_| Ok(()), + ) .unwrap(); assert_eq!(refreshed.revision, first); assert!(stale.check().is_err()); @@ -829,11 +854,14 @@ mod tests { .files() .contains(&"ignored/deep/Excluded.txt".into())); fs::write(workspace.0.join(".gitignore"), "").unwrap(); - refresh_indexing(RefreshIndexingRequest { - root: workspace.0.to_string_lossy().into_owned(), - owner_id: "second".into(), - revision: second, - }) + refresh_indexing( + RefreshIndexingRequest { + root: workspace.0.to_string_lossy().into_owned(), + owner_id: "second".into(), + revision: second, + }, + |_| Ok(()), + ) .unwrap(); assert!(workspace .files() @@ -848,11 +876,14 @@ mod tests { } let revision = install("reopened"); let current = context(&workspace.0).unwrap(); - let error = refresh_indexing(RefreshIndexingRequest { - root: workspace.0.to_string_lossy().into_owned(), - owner_id: "first".into(), - revision: first, - }) + let error = refresh_indexing( + RefreshIndexingRequest { + root: workspace.0.to_string_lossy().into_owned(), + owner_id: "first".into(), + revision: first, + }, + |_| Ok(()), + ) .err() .expect("closed owner must fail"); assert!(matches!(error.code, ErrorCode::Cancelled)); @@ -868,7 +899,7 @@ mod tests { #[test] fn directory_action_fixture_preserves_real_git_search_eligibility() { let fixture: serde_json::Value = serde_json::from_str(include_str!( - "../../../../fixtures/workspace/directory-actions-v1.json" + "../../../fixtures/workspace/directory-actions-v1.json" )) .unwrap(); let workspace = Workspace::new(); @@ -963,7 +994,7 @@ mod tests { struct Workspace(PathBuf); impl Workspace { fn new() -> Self { - let root = crate::tests::support::temporary_root("indexing-policy"); + let root = crate::test_support::temporary_root("indexing-policy"); fs::create_dir_all(root.join("ignored/deep")).unwrap(); fs::create_dir_all(root.join("src")).unwrap(); fs::write(root.join("ignored/deep/Excluded.txt"), "Excluded needle").unwrap(); @@ -976,6 +1007,7 @@ mod tests { "manualExcludedDirectories": manual, "includedDirectories": included, "gitIgnoreEnabled": git, "refresh": true})) .unwrap(), + |_| Ok(()), ) .unwrap() } @@ -987,7 +1019,7 @@ mod tests { .files } fn git(&self, args: &[&str]) { - let response = crate::git::execute_git_readonly( + let response = lithe_git::execute_git_readonly( &self.0.to_string_lossy(), &args.iter().map(|s| s.to_string()).collect::>(), None, @@ -1073,6 +1105,7 @@ mod tests { "root":parent.0,"workspaceRoots":roots,"paths":["ignored/Visible.txt"] })) .unwrap(), + |_| Ok(()), ) .unwrap(); assert_eq!( @@ -1106,7 +1139,9 @@ mod tests { }) .unwrap(); assert_eq!( - search(&parent.0, &[parent.0.clone()]).matches.len(), + search(&parent.0, std::slice::from_ref(&parent.0)) + .matches + .len(), 3, "removing an added root restores parent scanning even while its policy is retired" ); @@ -1155,7 +1190,7 @@ mod tests { parent.configure(&[], &[], false); nested.configure(&["deep"], &[], false); let fixture: serde_json::Value = serde_json::from_str(include_str!( - "../../../../fixtures/workspace/indexing-policy-v1.json" + "../../../fixtures/workspace/indexing-policy-v1.json" )) .unwrap(); for case in fixture["ownershipCases"].as_array().unwrap() { @@ -1284,6 +1319,7 @@ mod tests { "root": workspace.0, "paths": [".gitignore"] })) .unwrap(), + |_| Ok(()), ) .unwrap(); let after = context(&workspace.0).unwrap(); @@ -1346,7 +1382,7 @@ mod tests { configure(serde_json::from_value(json!({ "root": workspace.0, "ownerId": owner, "manualExcludedDirectories": if excluded { vec!["ignored"] } else { vec![] }, "includedDirectories": [], "gitIgnoreEnabled": false, "refresh": refresh - })).unwrap()).unwrap().revision + })).unwrap(), |_| Ok(())).unwrap().revision }; let close = |owner: &str, revision| { release(ReleaseIndexingRequest { @@ -1414,11 +1450,11 @@ mod tests { "includedDirectories":[],"gitIgnoreEnabled":false })).unwrap() }; - let original = configure(request("owner-a", false)).unwrap(); - let other = configure(request("owner-b", false)).unwrap(); + let original = configure(request("owner-a", false), |_| Ok(())).unwrap(); + let other = configure(request("owner-b", false), |_| Ok(())).unwrap(); let old_context = context(&workspace.0).unwrap(); let mut calls = 0; - let result = configure_with_provider_update(request("owner-a", true), |_| { + let result = configure(request("owner-a", true), |_| { calls += 1; if calls == 1 { if watcher_refresh { @@ -1463,10 +1499,10 @@ mod tests { "includedDirectories":[],"gitIgnoreEnabled":false })).unwrap() }; - configure(request(false)).unwrap(); + configure(request(false), |_| Ok(())).unwrap(); let mut received = 0; - let result = configure_with_provider_update(request(true), |_| { - received = configure(request(true))?.revision; + let result = configure(request(true), |_| { + received = configure(request(true), |_| Ok(()))?.revision; Err(CoreError::new( ErrorCode::Cancelled, "caller cancelled after concurrent publication", @@ -1491,10 +1527,10 @@ mod tests { "includedDirectories":[],"gitIgnoreEnabled":false,"refresh":refresh })).unwrap() }; - let original = configure(request("owner-a", false, false)).unwrap(); + let original = configure(request("owner-a", false, false), |_| Ok(())).unwrap(); let mut other = 0; - let cancelled = configure_with_provider_update(request("owner-a", false, true), |_| { - other = configure(request("owner-b", true, false))?.revision; + let cancelled = configure(request("owner-a", false, true), |_| { + other = configure(request("owner-b", true, false), |_| Ok(()))?.revision; Ok(()) }); assert!(matches!( @@ -1535,9 +1571,9 @@ mod tests { }; let mut received = 0; let mut other = 0; - let cancelled = configure_with_provider_update(request("owner-a", false), |_| { - received = configure(request("owner-a", false))?.revision; - other = configure(request("owner-b", true))?.revision; + let cancelled = configure(request("owner-a", false), |_| { + received = configure(request("owner-a", false), |_| Ok(()))?.revision; + other = configure(request("owner-b", true), |_| Ok(()))?.revision; Ok(()) }); assert!(cancelled.is_err()); @@ -1564,8 +1600,8 @@ mod tests { "root":workspace.0,"ownerId":"owner-a","manualExcludedDirectories":[],"includedDirectories":[],"gitIgnoreEnabled":false,"refresh":refresh })).unwrap() }; - let original = configure(request(false)).unwrap(); - let cancelled = configure_with_provider_update(request(true), |_| { + let original = configure(request(false), |_| Ok(())).unwrap(); + let cancelled = configure(request(true), |_| { assert!(release(ReleaseIndexingRequest { root: workspace.0.to_string_lossy().into_owned(), owner_id: Some("owner-a".into()), @@ -1628,7 +1664,7 @@ mod tests { workspace.configure(&["ignored"], &[], true); assert_eq!(workspace.files(), ["src/Included.txt"]); let status = configure(serde_json::from_value(json!({"root":workspace.0, - "manualExcludedDirectories":["ignored"],"includedDirectories":[],"gitIgnoreEnabled":true})).unwrap()).unwrap(); + "manualExcludedDirectories":["ignored"],"includedDirectories":[],"gitIgnoreEnabled":true})).unwrap(), |_| Ok(())).unwrap(); assert!(status.git_ignore_failed); assert_eq!(status.excluded_directories["ignored"], "manual"); } diff --git a/rust/crates/project/src/lib.rs b/rust/crates/project/src/lib.rs new file mode 100644 index 00000000..370ccb13 --- /dev/null +++ b/rust/crates/project/src/lib.rs @@ -0,0 +1,55 @@ +//! Project services: workspace files and visibility, indexing policy, search, local history, +//! document persistence decisions and Markdown rendering. +//! +//! Language-server sessions sit above this crate. Calls that change indexing or the search index +//! take an `update_providers` callback, which lithe-core uses to tell running sessions which +//! directories are now excluded. + +pub mod contracts; +pub mod directory_actions; +mod document_lifecycle; +pub mod document_save_batch; +mod encoding; +pub mod file_name_order; +pub mod file_operations; +pub mod files; +mod history; +pub mod indexing; +mod markdown; +mod name_matching; +pub mod name_ranking; +pub mod recent_projects; +mod search_index; +mod search_paging; + +pub use document_lifecycle::*; +pub use files::*; +pub use history::*; +pub use markdown::*; +pub use search_paging::{search_page, SearchPageRequest}; + +#[cfg(test)] +mod test_support { + use std::path::PathBuf; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::OnceLock; + use std::time::{SystemTime, UNIX_EPOCH}; + + /// A fresh path under the system temporary directory that no other fixture shares. + pub(crate) fn temporary_root(label: &str) -> PathBuf { + static NONCE: OnceLock = OnceLock::new(); + static SEQUENCE: AtomicU64 = AtomicU64::new(0); + // A clock tick is not a unique ID: parallel fixtures must never share cleanup ownership. + let nonce = NONCE.get_or_init(|| { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be valid") + .as_nanos() + }); + let sequence = SEQUENCE.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "lithe-project-{label}-{}-{nonce}-{sequence}", + std::process::id() + )) + } +} diff --git a/rust/crates/core/src/project/markdown.rs b/rust/crates/project/src/markdown.rs similarity index 97% rename from rust/crates/core/src/project/markdown.rs rename to rust/crates/project/src/markdown.rs index ba85d5b8..2621a3a8 100644 --- a/rust/crates/core/src/project/markdown.rs +++ b/rust/crates/project/src/markdown.rs @@ -9,6 +9,7 @@ use std::collections::HashSet; #[serde(rename_all = "camelCase")] /// Markdown source to render with the shared dialect. pub struct MarkdownRenderRequest { + /// Markdown text to render. pub source: String, } @@ -16,6 +17,7 @@ pub struct MarkdownRenderRequest { #[serde(rename_all = "camelCase")] /// Sanitized HTML safe for embedding in host previews. pub struct MarkdownRenderResponse { + /// Sanitized HTML rendering of the source. pub html: String, } @@ -174,7 +176,7 @@ mod tests { #[test] fn preserves_rich_content_for_offline_preview() { let fixture: serde_json::Value = serde_json::from_str(include_str!( - "../../../../fixtures/editor/markdown-rich-content-v1.json" + "../../../fixtures/editor/markdown-rich-content-v1.json" )) .expect("rich Markdown fixture should decode"); let output = html(fixture["source"].as_str().expect("fixture source")); @@ -194,7 +196,7 @@ mod tests { #[test] fn preserves_html_alignment_and_inline_badges_without_allowing_styles() { let fixture: serde_json::Value = serde_json::from_str(include_str!( - "../../../../fixtures/editor/markdown-html-v1.json" + "../../../fixtures/editor/markdown-html-v1.json" )) .expect("Markdown HTML fixture should decode"); let output = html(fixture["source"].as_str().expect("fixture source")); diff --git a/rust/crates/core/src/project/name_matching.rs b/rust/crates/project/src/name_matching.rs similarity index 98% rename from rust/crates/core/src/project/name_matching.rs rename to rust/crates/project/src/name_matching.rs index 887e5a65..84493343 100644 --- a/rust/crates/core/src/project/name_matching.rs +++ b/rust/crates/project/src/name_matching.rs @@ -1,6 +1,6 @@ //! Word-fragment matching for Search Everywhere names, ranking and UTF-16 highlighting. -use crate::protocol::SearchNameRange; +use crate::contracts::SearchNameRange; /// One match owns both its ranking and the fragments painted by native clients. pub(crate) struct NameMatch { @@ -192,7 +192,7 @@ mod tests { #[test] fn unicode_contiguous_matches_map_back_to_original_utf16() { let fixture: serde_json::Value = serde_json::from_str(include_str!( - "../../../../fixtures/search/unicode-contiguous.json" + "../../../fixtures/search/unicode-contiguous.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { diff --git a/rust/crates/core/src/project/name_ranking.rs b/rust/crates/project/src/name_ranking.rs similarity index 97% rename from rust/crates/core/src/project/name_ranking.rs rename to rust/crates/project/src/name_ranking.rs index b70c6d7f..6fbff166 100644 --- a/rust/crates/core/src/project/name_ranking.rs +++ b/rust/crates/project/src/name_ranking.rs @@ -5,9 +5,10 @@ //! (open and recently visited files); the score, the UTF-16 highlight ranges and the tie-break //! order all come from the same matcher that backs `workspace.searchEverywhere`. -use super::file_name_order::FileNameKey; use super::name_matching::match_name; -use crate::protocol::{CoreError, ErrorCode, SearchNameRange}; +use crate::contracts::SearchNameRange; +use lithe_core_rules::file_names::FileNameKey; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; @@ -47,7 +48,7 @@ struct RankCandidate { /// compares them with the workspace-relative, `/`-separated path of each file under its root. #[derive(Deserialize, Serialize, Default, Clone, Debug, PartialEq, Eq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct RankContext { +pub struct RankContext { /// Paths of open editors, including the active one. #[serde(default)] pub(crate) open_paths: Vec, @@ -124,7 +125,7 @@ struct RankedName { /// recency of recent files, then IDEA's file-name order, then request order. A candidate /// matches through its name, or through its path at a reduced degree. Oversized requests fail /// with `invalid_request` instead of being truncated. -pub(crate) fn rank_names_command(payload: Value) -> Result { +pub fn rank_names_command(payload: Value) -> Result { let request: RankNamesRequest = serde_json::from_value(payload).map_err(|error| { CoreError::new(ErrorCode::InvalidRequest, "Invalid name ranking request") .with_details(error.to_string()) diff --git a/rust/crates/core/src/project/recent_projects.rs b/rust/crates/project/src/recent_projects.rs similarity index 89% rename from rust/crates/core/src/project/recent_projects.rs rename to rust/crates/project/src/recent_projects.rs index c105f25a..bbe1da14 100644 --- a/rust/crates/core/src/project/recent_projects.rs +++ b/rust/crates/project/src/recent_projects.rs @@ -5,7 +5,7 @@ //! survive, in what order, and how pins interact with the retention capacity, //! so every platform keeps the same set after the same sequence of actions. -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashSet; @@ -27,6 +27,7 @@ const MAX_PATH_BYTES: usize = 4096; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct RecentProjectEntry { + /// Host-normalized root path, compared byte for byte. pub path: String, /// Missing timestamps from older stores sort as the oldest entries. #[serde(default)] @@ -42,19 +43,29 @@ pub struct RecentProjectEntry { pub enum RecentProjectsAction { /// The project was opened at `openedAt`; it moves first and keeps its pin. Record { + /// Root path of the opened project. path: String, + /// Open time in milliseconds since the Unix epoch. #[serde(rename = "openedAt")] opened_at: i64, }, /// Projects removed by the user or found unavailable by the host. - Remove { paths: Vec }, + Remove { + /// Root paths to drop. + paths: Vec, + }, /// Flips the pin of an existing project; unknown paths are ignored. - TogglePinned { path: String }, + TogglePinned { + /// Root path whose pin flips. + path: String, + }, /// Adds projects discovered elsewhere (for example another IDE) that are /// not already listed. The first import is stamped `openedAt` and each /// later one a millisecond older, preserving the source order. Import { + /// Discovered root paths in source order. paths: Vec, + /// Time stamped on the first import, in milliseconds since the Unix epoch. #[serde(rename = "openedAt")] opened_at: i64, }, @@ -66,7 +77,9 @@ pub enum RecentProjectsAction { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct RecentProjectsRequest { + /// Stored list before the action. pub entries: Vec, + /// Action to apply. pub action: RecentProjectsAction, /// Unpinned capacity; defaults to [`DEFAULT_RECENT_PROJECT_CAPACITY`]. #[serde(default)] @@ -78,13 +91,16 @@ pub struct RecentProjectsRequest { #[derive(Debug, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct RecentProjectsResponse { + /// Retained list: pinned first, then most recently opened. pub entries: Vec, + /// Paths this reduction added. pub added: Vec, + /// Paths this reduction dropped. pub removed: Vec, } /// JSON entry point for `workspace.recentProjects.reduce`. -pub(crate) fn reduce_command(payload: Value) -> Result { +pub fn reduce_command(payload: Value) -> Result { let request: RecentProjectsRequest = serde_json::from_value(payload).map_err(|error| { CoreError::new(ErrorCode::InvalidRequest, "Invalid recent projects request") .with_details(error.to_string()) @@ -194,9 +210,9 @@ fn deduplicate(entries: Vec) -> Vec { fn order_and_limit(entries: Vec, capacity: usize) -> Vec { let (mut pinned, mut unpinned): (Vec<_>, Vec<_>) = entries.into_iter().partition(|entry| entry.pinned); - // `sort_by` is stable, so equal timestamps keep the caller's order. - pinned.sort_by(|left, right| right.last_opened_at.cmp(&left.last_opened_at)); - unpinned.sort_by(|left, right| right.last_opened_at.cmp(&left.last_opened_at)); + // `sort_by_key` is stable, so equal timestamps keep the caller's order. + pinned.sort_by_key(|entry| std::cmp::Reverse(entry.last_opened_at)); + unpinned.sort_by_key(|entry| std::cmp::Reverse(entry.last_opened_at)); unpinned.truncate(capacity); // Pins are exempt from the capacity but still bounded by the input limit. pinned.truncate(MAX_ENTRIES); diff --git a/rust/crates/core/src/project/search_index.rs b/rust/crates/project/src/search_index.rs similarity index 96% rename from rust/crates/core/src/project/search_index.rs rename to rust/crates/project/src/search_index.rs index 17d9c1b4..44bb8c96 100644 --- a/rust/crates/core/src/project/search_index.rs +++ b/rust/crates/project/src/search_index.rs @@ -1,11 +1,11 @@ //! Incremental workspace search indexing with exact final-content matching. -use crate::project::files::{ +use crate::files::{ is_inside_nested_linked_worktree, read_searchable_text, relative_path, visible_children, ChildOrder, VisibilityRules, }; -use crate::protocol::{CoreError, ErrorCode}; -use crate::remote::{workspace, workspace_io as fs}; +use lithe_protocol::{CoreError, ErrorCode}; +use lithe_workspace::{self as workspace, fs}; use std::collections::{HashMap, HashSet}; use std::ffi::OsString; use std::path::{Path, PathBuf}; @@ -291,7 +291,7 @@ impl WorkspaceSearchIndex { names_only, }; for path in paths { - crate::protocol::cancellation::check()?; + lithe_protocol::cancellation::check()?; if let Some(policy) = &policy { policy.check()?; } @@ -477,7 +477,7 @@ fn collect_files( files: &mut Vec, policy: Option<&super::indexing::IndexingContext>, ) -> Result<(), CoreError> { - crate::protocol::cancellation::check()?; + lithe_protocol::cancellation::check()?; if rules.is_delegated(path) { return Ok(()); } @@ -497,7 +497,7 @@ fn collect_files( { // A transient/inaccessible descendant must not hide unrelated workspace results. // Log only a workspace-relative path; root errors and cancellation still propagate. - crate::diagnostics::core_events::directory_skipped( + lithe_diagnostics::core_events::directory_skipped( "app.search.directory_skipped", &relative_path(path, root), &error.code, @@ -565,7 +565,7 @@ mod tests { #[test] fn names_and_cold_text_do_not_wait_for_content_build_and_invalidation_discards_its_slot() { - let root = crate::tests::support::temporary_root("independent-search-sources"); + let root = crate::test_support::temporary_root("independent-search-sources"); fs::create_dir_all(&root).unwrap(); struct Cleanup(PathBuf); impl Drop for Cleanup { @@ -620,7 +620,7 @@ mod tests { #[test] fn source_caps_build_only_required_cold_indexes_and_skip_generated_text_enumeration() { - let root = crate::tests::support::temporary_root("search-source-caps"); + let root = crate::test_support::temporary_root("search-source-caps"); fs::create_dir_all(root.join("src")).unwrap(); let root = root.canonicalize().unwrap(); struct Cleanup(PathBuf); @@ -710,7 +710,7 @@ mod tests { #[test] fn missing_descendants_are_skipped_but_root_and_cancellation_errors_propagate() { - let root = crate::tests::support::temporary_root("search-missing-descendant"); + let root = crate::test_support::temporary_root("search-missing-descendant"); let rules = VisibilityRules { hidden_directories: vec![], delegated_roots: Vec::new(), @@ -726,8 +726,8 @@ mod tests { ErrorCode::WorkspaceNotFound )); let operation_id = root.to_string_lossy().into_owned(); - let _scope = crate::protocol::cancellation::Scope::begin(Some(operation_id.clone()), None); - assert!(crate::protocol::cancellation::cancel(&operation_id)); + let _scope = lithe_protocol::cancellation::Scope::begin(Some(operation_id.clone()), None); + assert!(lithe_protocol::cancellation::cancel(&operation_id)); assert!(matches!( collect_files(&root, &root.join("vanished"), &rules, &mut paths, None) .unwrap_err() @@ -740,7 +740,7 @@ mod tests { #[test] fn inaccessible_generated_subtree_preserves_source_and_text_results() { use std::os::unix::fs::PermissionsExt; - let root = crate::tests::support::temporary_root("search-unreadable-generated"); + let root = crate::test_support::temporary_root("search-unreadable-generated"); let blocked = root.join(".build/blocked"); fs::create_dir_all(&blocked).unwrap(); fs::create_dir_all(root.join("src")).unwrap(); @@ -802,7 +802,7 @@ mod tests { #[test] fn directory_exclusions_prune_enumeration_and_incremental_updates() { - let root = crate::tests::support::temporary_root("search-directory-pruning"); + let root = crate::test_support::temporary_root("search-directory-pruning"); fs::create_dir_all(&root).unwrap(); let root = root.canonicalize().unwrap(); struct Cleanup(PathBuf); @@ -826,10 +826,9 @@ mod tests { fs::write(file, "Name before").unwrap(); } let fixture: serde_json::Value = - serde_json::from_str(include_str!("../../../../fixtures/search/exclusions.json")) - .unwrap(); - // The patterns come from the shared fixture, so Rust and Swift always - // judge the same list. + serde_json::from_str(include_str!("../../../fixtures/search/exclusions.json")).unwrap(); + // The patterns come from the shared fixture, so every host judges the + // same list. let rules = VisibilityRules { hidden_directories: vec![], delegated_roots: Vec::new(), @@ -885,7 +884,7 @@ mod tests { #[test] fn changing_exclusions_releases_old_indexes_and_bounds_resident_variants() { - let root = crate::tests::support::temporary_root("search-cache-exclusions"); + let root = crate::test_support::temporary_root("search-cache-exclusions"); fs::create_dir_all(&root).unwrap(); let root = root.canonicalize().unwrap(); struct Cleanup(PathBuf); diff --git a/rust/crates/core/src/project/search_paging.rs b/rust/crates/project/src/search_paging.rs similarity index 95% rename from rust/crates/core/src/project/search_paging.rs rename to rust/crates/project/src/search_paging.rs index 75814449..ee55a628 100644 --- a/rust/crates/core/src/project/search_paging.rs +++ b/rust/crates/project/src/search_paging.rs @@ -3,7 +3,8 @@ use super::files::{ compare_search_matches, search_everywhere, search_everywhere_with_page, SearchRequest, }; -use crate::protocol::{CoreError, ErrorCode, SearchMatch, SearchResponse}; +use crate::contracts::{SearchMatch, SearchResponse}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::cmp::Ordering; @@ -11,7 +12,7 @@ use std::cmp::Ordering; #[derive(Deserialize)] #[serde(rename_all = "camelCase")] /// Optional paging fields leave the existing synchronous request contract unchanged. -pub(crate) struct SearchPageRequest { +pub struct SearchPageRequest { #[serde(flatten)] search: SearchRequest, page_size: Option, @@ -117,7 +118,7 @@ impl SearchPage { } /// Executes legacy requests unchanged, or bounded pages with a query-bound continuation. -pub(crate) fn search_page(request: SearchPageRequest) -> Result { +pub fn search_page(request: SearchPageRequest) -> Result { let Some(size) = request.page_size else { return search_everywhere(request.search) .map(|response| serde_json::to_value(response).unwrap()); @@ -128,8 +129,7 @@ pub(crate) fn search_page(request: SearchPageRequest) -> Result, + /// Replace an existing file without an `expected` check. pub overwrite: bool, + /// Create missing parent directories first. pub create_parents: bool, } /// What to run in [`RemoteSession::spawn`]. #[derive(Clone, Debug, Default)] pub struct SpawnSpec { + /// Program and arguments; a program without `/` is resolved through `PATH`. pub argv: Vec, + /// Absolute host working directory; the server's own when absent. pub cwd: Option, + /// Base environment the `env` variables are applied to. pub environment: EnvironmentSource, + /// Variables added to, or replacing, the base environment. pub env: BTreeMap, + /// Keep standard input open for [`ProcessHandle::write_stdin`]. pub pipe_stdin: bool, } @@ -64,48 +71,70 @@ pub struct SpawnSpec { pub struct TerminalSpec { /// `None` starts the user's login shell. pub argv: Option>, + /// Absolute host working directory; when absent, the user's home, or `/` + /// without one. pub cwd: Option, + /// Variables added to the server environment. pub env: BTreeMap, + /// Initial width in character cells. pub columns: u16, + /// Initial height in character cells. pub rows: u16, } /// A finished process's collected output. #[derive(Debug, Default)] pub struct ProcessOutput { + /// Everything the process wrote to standard output. pub stdout: Vec, + /// Everything the process wrote to standard error. pub stderr: Vec, + /// Exit report; [`ProcessHandle::wait`] only returns once it arrived. pub exit: Option, } +/// An event of one spawned process. #[derive(Debug)] pub enum ProcessEvent { + /// Decoded bytes from one output stream. Output(OutputStream, Vec), + /// The process exited; no output follows. Exit(ProcessExitEvent), } +/// An event of one terminal. #[derive(Debug)] pub enum TerminalEvent { + /// Decoded bytes the terminal's program wrote. Output(Vec), + /// The terminal's program exited; no output follows. Exit(TerminalExitEvent), } +/// An event of one watch. #[derive(Debug)] pub enum WatchEvent { + /// Coalesced changes below the watched root. Changed(WatchChangedEvent), + /// The watch degraded or ended. Error(WatchErrorEvent), } +/// An event of one tunnel. #[derive(Debug)] pub enum TunnelEvent { + /// Decoded bytes the target sent. Data(Vec), + /// The tunnel ended; no data follows. Closed(TunnelClosedEvent), } /// Every entry of a finished walk. #[derive(Debug)] pub struct WalkOutcome { + /// Entries in walk order. pub entries: Vec, + /// The walk's final report. pub done: WalkDoneEvent, } @@ -141,20 +170,24 @@ impl RemoteSession { } } + /// Sends `server.ping` and returns the round-trip time. pub fn ping(&self) -> Result { let started = Instant::now(); self.call_unit(methods::SERVER_PING, &json!({}))?; Ok(started.elapsed()) } + /// Lists the processes, terminals and watches the session still runs. pub fn state(&self) -> Result { self.call(methods::SERVER_STATE, &json!({})) } + /// Metadata of `path` itself, without following a final link. pub fn stat(&self, path: &str) -> Result { self.call(methods::FS_STAT, &PathParams { path: path.into() }) } + /// Lists one directory, sorted by name byte order. pub fn read_dir(&self, path: &str) -> Result { self.call(methods::FS_READ_DIR, &PathParams { path: path.into() }) } @@ -192,6 +225,7 @@ impl RemoteSession { } } + /// Replaces the whole file atomically and returns its metadata after the write. pub fn write_file( &self, path: &str, @@ -210,6 +244,7 @@ impl RemoteSession { ) } + /// Creates a directory; with `recursive`, also its missing parents. pub fn create_dir(&self, path: &str, recursive: bool) -> Result<(), ClientError> { self.call_unit( methods::FS_CREATE_DIR, @@ -221,6 +256,7 @@ impl RemoteSession { ) } + /// Removes a path; a non-empty directory needs `recursive`. pub fn remove(&self, path: &str, recursive: bool) -> Result<(), ClientError> { self.call_unit( methods::FS_REMOVE, @@ -231,6 +267,7 @@ impl RemoteSession { ) } + /// Renames or moves `from` to `to`. pub fn rename(&self, from: &str, to: &str, overwrite: bool) -> Result<(), ClientError> { self.call_unit( methods::FS_RENAME, @@ -262,11 +299,13 @@ impl RemoteSession { ) } + /// Resolves every link of `path`. pub fn real_path(&self, path: &str) -> Result { self.call::<_, RealPathResult>(methods::FS_REAL_PATH, &PathParams { path: path.into() }) .map(|result| result.path) } + /// Sets the permission bits of `path`. pub fn set_permissions(&self, path: &str, mode: u32) -> Result<(), ClientError> { self.call_unit( methods::FS_SET_PERMISSIONS, @@ -277,6 +316,7 @@ impl RemoteSession { ) } + /// Reports whether the volume holding `path` is case-sensitive. pub fn volume_info(&self, path: &str) -> Result { self.call(methods::FS_VOLUME_INFO, &PathParams { path: path.into() }) } @@ -333,6 +373,7 @@ impl RemoteSession { result } + /// Spawns a process, subscribing to its events before it starts. pub fn spawn(&self, spec: SpawnSpec) -> Result, ClientError> { let process_id = self.allocate_id(); let events = self.subscribe("process", process_id); @@ -361,6 +402,7 @@ impl RemoteSession { } } + /// Opens a terminal, subscribing to its events before it starts. pub fn open_terminal(&self, spec: TerminalSpec) -> Result, ClientError> { let terminal_id = self.allocate_id(); let events = self.subscribe("terminal", terminal_id); @@ -400,6 +442,8 @@ impl RemoteSession { } } + /// Watches `root` recursively, subscribing to its events before the watch + /// starts. pub fn watch( &self, root: &str, @@ -429,6 +473,8 @@ impl RemoteSession { } } + /// Returns the user's login environment; `refresh` re-runs the login shell + /// instead of using the session's cached result. pub fn resolve_environment( &self, refresh: bool, @@ -440,11 +486,13 @@ impl RemoteSession { ) } + /// Lists the TCP ports listening on the host. pub fn listening_ports(&self) -> Result, ClientError> { self.call::<_, ListeningPortsResult>(methods::PORTS_LISTENING, &json!({})) .map(|result| result.ports) } + /// Connects a tunnel to `target`, subscribing to its events before it opens. pub fn open_tunnel(&self, target: TunnelTarget) -> Result, ClientError> { let tunnel_id = self.allocate_id(); let events = self.subscribe("tunnel", tunnel_id); @@ -465,14 +513,19 @@ impl RemoteSession { } } +/// A spawned process. Dropping it only ends the subscription; the process +/// keeps running until it exits or is killed. pub struct ProcessHandle<'a> { session: &'a RemoteSession, + /// Client-chosen process id. pub id: u64, + /// Operating-system pid, for diagnostics only. pub pid: u32, events: Receiver, } impl ProcessHandle<'_> { + /// Writes to standard input, retrying while the process is not reading. pub fn write_stdin(&self, data: &[u8]) -> Result<(), ClientError> { self.session.call_input( methods::PROCESS_WRITE_STDIN, @@ -480,6 +533,7 @@ impl ProcessHandle<'_> { ) } + /// Closes standard input, so the process reads end-of-file. pub fn close_stdin(&self) -> Result<(), ClientError> { self.session.call_unit( methods::PROCESS_CLOSE_STDIN, @@ -489,6 +543,8 @@ impl ProcessHandle<'_> { ) } + /// Sends `SIGTERM`, or `SIGKILL` with `force`, to every process of the + /// process's session. pub fn kill(&self, force: bool) -> Result<(), ClientError> { self.session.call_unit( methods::PROCESS_KILL, @@ -547,14 +603,19 @@ impl Drop for ProcessHandle<'_> { } } +/// An open terminal. Dropping it only ends the subscription; the terminal +/// keeps running until it exits or is closed. pub struct TerminalHandle<'a> { session: &'a RemoteSession, + /// Client-chosen terminal id. pub id: u64, + /// Operating-system pid of the terminal's program, for diagnostics only. pub pid: u32, events: Receiver, } impl TerminalHandle<'_> { + /// Writes input, retrying while the terminal is not read. pub fn write(&self, data: &[u8]) -> Result<(), ClientError> { self.session.call_input( methods::TERMINAL_WRITE, @@ -565,6 +626,7 @@ impl TerminalHandle<'_> { ) } + /// Changes the terminal's size in character cells. pub fn resize(&self, columns: u16, rows: u16) -> Result<(), ClientError> { self.session.call_unit( methods::TERMINAL_RESIZE, @@ -586,6 +648,7 @@ impl TerminalHandle<'_> { ) } + /// Next event, or `None` once the subscription ended. pub fn next_event(&self, timeout: Duration) -> Result, ClientError> { let Some(event) = receive_optional(&self.events, timeout)? else { return Ok(None); @@ -603,8 +666,11 @@ impl Drop for TerminalHandle<'_> { } } +/// An active watch. Dropping it only ends the subscription; the watch keeps +/// running until it is stopped. pub struct WatchHandle<'a> { session: &'a RemoteSession, + /// Client-chosen watch id. pub id: u64, /// Directories registered by a per-directory backend. pub watched_directories: Option, @@ -612,6 +678,7 @@ pub struct WatchHandle<'a> { } impl WatchHandle<'_> { + /// Next event, or `None` once the subscription ended. pub fn next_event(&self, timeout: Duration) -> Result, ClientError> { let Some(event) = receive_optional(&self.events, timeout)? else { return Ok(None); @@ -622,6 +689,7 @@ impl WatchHandle<'_> { })) } + /// Stops the watch. pub fn stop(&self) -> Result<(), ClientError> { self.session .call_unit(methods::WATCH_STOP, &WatchParams { watch_id: self.id }) @@ -634,21 +702,27 @@ impl Drop for WatchHandle<'_> { } } +/// An open tunnel. Dropping it only ends the subscription; the tunnel stays +/// open until it is closed or the connection ends. pub struct TunnelHandle<'a> { session: &'a RemoteSession, + /// Client-chosen tunnel id. pub id: u64, events: Receiver, } impl<'a> TunnelHandle<'a> { + /// Sends bytes, retrying while the target is not reading. pub fn write(&self, data: &[u8]) -> Result<(), ClientError> { self.writer().write(data) } + /// Ends the outgoing direction; see [`TunnelWriter::shutdown_write`]. pub fn shutdown_write(&self) -> Result<(), ClientError> { self.writer().shutdown_write() } + /// Aborts both directions; see [`TunnelWriter::close`]. pub fn close(&self) -> Result<(), ClientError> { self.writer().close() } @@ -662,6 +736,7 @@ impl<'a> TunnelHandle<'a> { } } + /// Next event, or `None` once the subscription ended. pub fn next_event(&self, timeout: Duration) -> Result, ClientError> { let Some(event) = receive_optional(&self.events, timeout)? else { return Ok(None); @@ -675,6 +750,7 @@ impl<'a> TunnelHandle<'a> { } } +/// The write side of a tunnel, see [`TunnelHandle::writer`]. #[derive(Clone, Copy)] pub struct TunnelWriter<'a> { session: &'a RemoteSession, @@ -682,6 +758,7 @@ pub struct TunnelWriter<'a> { } impl TunnelWriter<'_> { + /// Sends bytes, retrying while the target is not reading. pub fn write(&self, data: &[u8]) -> Result<(), ClientError> { self.session.call_input( methods::TUNNEL_WRITE, diff --git a/rust/crates/remote/src/client/bootstrap.rs b/rust/crates/remote/src/client/bootstrap.rs index 4f2015fd..f9770b0f 100644 --- a/rust/crates/remote/src/client/bootstrap.rs +++ b/rust/crates/remote/src/client/bootstrap.rs @@ -61,6 +61,7 @@ const INSTALL_SCRIPT: &str = "umask 077 && mkdir -p \"$1\" && cat > \"$1/.upload /// Runs the binary passed as `$0` with `--identity`. const IDENTITY_SCRIPT: &str = "exec \"$0\" --identity"; +/// File name of the server executable inside its install directory. pub const SERVER_EXECUTABLE: &str = "lithe-remote-server"; /// Hex digits of the SHA-256 used in the install directory name. Sixteen @@ -71,9 +72,13 @@ pub(crate) const INSTALL_HASH_PREFIX: usize = 16; /// Server build target selected from the remote's `uname` output. #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub enum ServerTarget { + /// Linux on x86_64. LinuxX86_64, + /// Linux on AArch64. LinuxAarch64, + /// macOS on Intel. MacosX86_64, + /// macOS on Apple silicon. MacosAarch64, } @@ -108,9 +113,13 @@ impl ServerTarget { /// A server binary available on the local machine. #[derive(Clone, Debug)] pub struct ServerArtifact { + /// Platform the binary runs on. pub target: ServerTarget, + /// Location of the binary on the local machine. pub path: PathBuf, + /// Server version, the first part of the install directory name. pub version: String, + /// Lowercase hex SHA-256 of the binary; the installed server must report it. pub sha256: String, } @@ -149,7 +158,9 @@ pub enum InstallLocation { /// Result of probing the end of a chain. #[derive(Clone, Debug, Eq, PartialEq)] pub struct HostProbe { + /// Platform at the end of the chain. pub target: ServerTarget, + /// `$HOME` when it is an absolute path. pub home: Option, } @@ -158,6 +169,7 @@ pub struct HostProbe { pub struct InstalledServer { /// Absolute path of the executable on the remote. pub path: String, + /// Platform the installed binary runs on. pub target: ServerTarget, /// Whether this call uploaded it. pub uploaded: bool, @@ -177,30 +189,40 @@ impl InstalledServer { } } +/// Why probing or installing the server failed. #[derive(Debug)] pub enum BootstrapError { + /// The transport chain could not be turned into a command line. Transport(TransportError), /// A local command could not run or exceeded its deadline. Command(io::Error), /// The chain did not reach a POSIX shell. `stderr` usually explains why, /// for example an SSH authentication failure. ProbeFailed { + /// Standard error of the probe command. stderr: String, }, + /// The remote's `uname` names a platform with no server build. UnsupportedPlatform { + /// `uname -s` of the remote. system: String, + /// `uname -m` of the remote. machine: String, }, /// This client ships no server for the remote's platform. NoArtifact(ServerTarget), /// The remote reported no home directory and no explicit location was set. NoInstallLocation, + /// The upload or the rename into place failed. InstallFailed { + /// Standard error of the install command. stderr: String, }, /// The installed binary did not report the uploaded artifact's identity. VerificationFailed { + /// SHA-256 of the uploaded artifact. expected: String, + /// SHA-256 the binary reported, or `None` when it reported none. found: Option, }, } @@ -247,7 +269,9 @@ impl From for BootstrapError { /// Probes the end of a chain and installs the server there. pub struct Bootstrapper { + /// Chain that reaches the remote. pub chain: TransportChain, + /// Directory the server is installed under. pub location: InstallLocation, /// Deadline for each probe or identity command. pub command_timeout: Duration, @@ -256,6 +280,8 @@ pub struct Bootstrapper { } impl Bootstrapper { + /// Targets `$HOME/.lithe-server`, with a 30 s deadline per command and 300 s + /// for the upload. pub fn new(chain: TransportChain) -> Self { Self { chain, @@ -265,6 +291,7 @@ impl Bootstrapper { } } + /// Reports the platform and home directory at the end of the chain. pub fn probe(&self) -> Result { let argv = probe_command(); let argv: Vec<&str> = argv.iter().map(String::as_str).collect(); @@ -385,6 +412,7 @@ pub struct ProbeReport { pub machine: String, /// `$HOME` when it is an absolute path. pub home: Option, + /// Host facts for display. pub details: HostDetails, } @@ -398,11 +426,15 @@ pub struct HostDetails { pub operating_system: Option, /// `uname -r`. pub kernel: Option, + /// Online processors, from `getconf _NPROCESSORS_ONLN`. pub cpu_count: Option, + /// Physical memory in bytes. pub memory_bytes: Option, /// Size of the file system holding the home directory. pub disk_total_bytes: Option, + /// Free bytes on that file system available to the user. pub disk_available_bytes: Option, + /// Whole seconds since boot. pub uptime_seconds: Option, } diff --git a/rust/crates/remote/src/client/command.rs b/rust/crates/remote/src/client/command.rs index 6beaee28..e5c34c91 100644 --- a/rust/crates/remote/src/client/command.rs +++ b/rust/crates/remote/src/client/command.rs @@ -20,15 +20,19 @@ const EXIT_POLL_INTERVAL: Duration = Duration::from_millis(20); /// keep them open indefinitely. const OUTPUT_DRAIN_GRACE: Duration = Duration::from_millis(500); +/// Result of a finished one-shot command. #[derive(Debug)] pub struct CommandOutput { /// Exit code; `None` when the command was terminated by a signal. pub status: Option, + /// Standard output, at most the first 1 MiB. pub stdout: Vec, + /// Standard error, at most the first 1 MiB. pub stderr: Vec, } impl CommandOutput { + /// Whether the command exited with status 0. pub fn success(&self) -> bool { self.status == Some(0) } diff --git a/rust/crates/remote/src/client/forward.rs b/rust/crates/remote/src/client/forward.rs index 9691cedc..f372041a 100644 --- a/rust/crates/remote/src/client/forward.rs +++ b/rust/crates/remote/src/client/forward.rs @@ -24,6 +24,8 @@ const ACCEPT_FAILURE_BACKOFF: Duration = Duration::from_millis(200); /// local side has gone away. Data ends the wait immediately. const IDLE_CHECK: Duration = Duration::from_secs(1); +/// A running local port forward. Dropping it stops accepting connections; +/// connections already accepted run until either side closes. pub struct PortForward { local_address: SocketAddr, stopping: Arc, @@ -92,6 +94,7 @@ impl PortForward { }) } + /// Address the forward listens on, with the port the system chose for port 0. pub fn local_address(&self) -> SocketAddr { self.local_address } diff --git a/rust/crates/remote/src/client/launch.rs b/rust/crates/remote/src/client/launch.rs index 30aea776..22f868e8 100644 --- a/rust/crates/remote/src/client/launch.rs +++ b/rust/crates/remote/src/client/launch.rs @@ -62,11 +62,13 @@ pub fn launch_command(root: Option<&str>, server_arguments: &[&str]) -> Vec), /// The shell answered something other than this script's protocol. Protocol(String), + /// The connection's stream failed. Io(io::Error), } diff --git a/rust/crates/remote/src/client/session.rs b/rust/crates/remote/src/client/session.rs index 76b69a97..bece7528 100644 --- a/rust/crates/remote/src/client/session.rs +++ b/rust/crates/remote/src/client/session.rs @@ -59,7 +59,9 @@ const EXIT_POLL_INTERVAL: Duration = Duration::from_millis(20); /// Server-initiated notification. #[derive(Clone, Debug, PartialEq)] pub struct Event { + /// Event name, `.`. pub event: String, + /// Event parameters, including the `Id` of the resource. pub params: Value, /// Position in the session's event stream. pub seq: Option, @@ -81,16 +83,19 @@ impl Event { } } +/// Failure of a session request or of establishing the session. #[derive(Debug)] pub enum ClientError { /// The server answered with an error. Remote(RemoteError), + /// The transport chain could not build a command line. Transport(TransportError), /// The transport command could not be started. Spawn(io::Error), /// The stream ended or broke. `diagnostics` holds the transport's last /// standard-error output, which names SSH or container failures. Disconnected { + /// Last standard-error output of the transport, up to 8 KiB. diagnostics: String, }, /// No response arrived in time. A late response is discarded. @@ -119,6 +124,38 @@ impl fmt::Display for ClientError { impl std::error::Error for ClientError {} +impl ClientError { + /// Stable transport or protocol category for logs. It never copies a peer + /// message, address or diagnostics text. + pub fn failure_category(&self) -> String { + match self { + Self::Timeout => "timeout".into(), + Self::Disconnected { .. } => "closed".into(), + Self::Transport(_) => "transport".into(), + Self::Spawn(_) => "spawn".into(), + Self::Protocol(_) => "protocol".into(), + Self::Remote(error) => format!( + "remote_{}", + serde_json::to_value(error.code) + .expect("protocol code encodes") + .as_str() + .expect("protocol code is a token") + .to_ascii_lowercase() + ), + } + } + + /// True when a cleanup request found nothing to clean. The server prunes + /// exited processes, stopped watches and closed terminals, so + /// `UnknownResource` is the expected missing case, not a cleanup failure. + pub fn is_resource_already_gone(&self) -> bool { + matches!( + self, + Self::Remote(remote) if remote.code == crate::protocol::ErrorCode::UnknownResource + ) + } +} + type Reply = Result; #[derive(Default)] @@ -133,11 +170,13 @@ type Routes = Mutex>>; /// Options for establishing a session. #[derive(Clone, Debug)] pub struct ConnectOptions { + /// Client identification sent in `server.hello` for server diagnostics. pub client_name: String, /// Bounds transport start-up, the preamble and `server.hello` together. pub handshake_timeout: Duration, /// How long a persistent session keeps running without a client. pub idle_timeout: Option, + /// When event delivery starts. pub resume: ResumeMode, } @@ -146,7 +185,10 @@ pub struct ConnectOptions { pub enum ResumeMode { /// Right after `server.hello`, with every kept event after the given /// sequence number (`None`: every kept event). Suits new sessions. - Immediately { after_seq: Option }, + Immediately { + /// Last sequence number the client already received. + after_seq: Option, + }, /// Only when the caller calls [`RemoteSession::resume`]. A reconnecting /// client first reads `server.state` and subscribes to the resources it /// reattaches to, so their missed events reach those subscriptions. @@ -266,6 +308,8 @@ impl Outgoing { } } +/// A connected remote server: typed requests, resource subscriptions and the +/// general event channel over one transport stream. pub struct RemoteSession { outgoing: Arc, pending: Arc>, @@ -499,6 +543,8 @@ impl RemoteSession { receiver } + /// Ends the subscription of one resource; its later events go to the general + /// event channel. pub fn unsubscribe(&self, family: &str, id: u64) { lock(&self.routes).remove(&(family.to_owned(), id)); } @@ -795,6 +841,36 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn failure_categories_never_contain_peer_details() { + use crate::protocol::ErrorCode; + for (error, expected) in [ + (ClientError::Timeout, "timeout"), + ( + ClientError::Disconnected { + diagnostics: "/private/address".into(), + }, + "closed", + ), + (ClientError::Protocol("private payload".into()), "protocol"), + ( + ClientError::Remote(RemoteError::new( + ErrorCode::PermissionDenied, + "private scratch", + )), + "remote_permission_denied", + ), + ] { + assert_eq!(error.failure_category(), expected); + assert!(!error.is_resource_already_gone()); + } + assert!(ClientError::Remote(RemoteError::new( + ErrorCode::UnknownResource, + "process 7 is not active" + )) + .is_resource_already_gone()); + } + fn waiter(outgoing: Arc, payload: Vec) -> (PendingRequest, SyncSender) { let (sender, reply) = mpsc::sync_channel(1); let pending = Arc::new(Mutex::new(Pending::default())); diff --git a/rust/crates/remote/src/client/transport.rs b/rust/crates/remote/src/client/transport.rs index ac08ee10..ca73f0ac 100644 --- a/rust/crates/remote/src/client/transport.rs +++ b/rust/crates/remote/src/client/transport.rs @@ -26,12 +26,16 @@ const SSH_KEEPALIVE_MISSES: u32 = 3; /// Ordered hops from the local machine to the workspace host. #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct TransportChain { + /// Hops in order, outermost first; empty for the local machine. pub hops: Vec, } +/// One hop of a transport chain. #[derive(Clone, Debug, Eq, PartialEq)] pub enum Hop { + /// An SSH connection to a host. Ssh(SshHop), + /// An `exec` into a running container. Container(ContainerHop), } @@ -44,12 +48,14 @@ pub struct SshHop { pub program: String, /// `host`, `user@host` or a `Host` alias from the SSH configuration. pub destination: String, + /// Port passed as `-p`; the SSH configuration decides when absent. pub port: Option, /// Extra `-o key=value` options, in order. pub options: Vec<(String, String)>, } impl SshHop { + /// A hop to `destination` with the default `ssh` executable and no options. pub fn new(destination: impl Into) -> Self { Self { program: "ssh".into(), @@ -60,9 +66,12 @@ impl SshHop { } } +/// Container engine whose CLI runs the `exec`. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ContainerEngine { + /// Docker, or a Docker-compatible CLI. Docker, + /// Podman. Podman, } @@ -79,6 +88,7 @@ impl ContainerEngine { /// and Colima use the Docker engine with their own `program`. #[derive(Clone, Debug, Eq, PartialEq)] pub struct ContainerHop { + /// Engine whose command-line syntax the hop uses. pub engine: ContainerEngine, /// Engine CLI executable. Defaults to the engine name. pub program: String, @@ -86,11 +96,15 @@ pub struct ContainerHop { pub container: String, /// `user`, `uid` or `user:group`; the image default when absent. pub user: Option, + /// Working directory passed as `-w`; the image default when absent. pub working_directory: Option, + /// Variables passed as `-e`; names must be valid environment variable names. pub environment: BTreeMap, } impl ContainerHop { + /// A hop into `container` with the engine's default executable and the image + /// defaults for user, directory and environment. pub fn new(engine: ContainerEngine, container: impl Into) -> Self { Self { engine, @@ -103,14 +117,21 @@ impl ContainerHop { } } +/// Why a transport chain could not build a command line. #[derive(Clone, Debug, Eq, PartialEq)] pub enum TransportError { /// An argument that must cross an SSH hop contains a character that /// cannot be passed safely to every supported login shell. - UnsafeArgument { argument: String }, + UnsafeArgument { + /// The rejected argument. + argument: String, + }, /// A hop field is malformed, for example a destination starting with `-` /// that SSH would parse as an option. - InvalidHop { reason: String }, + InvalidHop { + /// What is wrong, for people. + reason: String, + }, } impl fmt::Display for TransportError { @@ -128,10 +149,12 @@ impl fmt::Display for TransportError { impl std::error::Error for TransportError {} impl TransportChain { + /// The chain without hops, which runs programs on the local machine. pub fn local() -> Self { Self::default() } + /// A chain through `hops`, outermost first. pub fn new(hops: Vec) -> Self { Self { hops } } diff --git a/rust/crates/remote/src/protocol/frame.rs b/rust/crates/remote/src/protocol/frame.rs index 4257be9b..d5c6cf1c 100644 --- a/rust/crates/remote/src/protocol/frame.rs +++ b/rust/crates/remote/src/protocol/frame.rs @@ -33,7 +33,10 @@ pub enum FrameError { /// More than the allowed number of bytes arrived before the preamble. /// The captured bytes usually explain the failure, for example /// `lithe-remote-server: not found`. - PreambleNotFound { noise: Vec }, + PreambleNotFound { + /// Every byte that arrived before the reader gave up. + noise: Vec, + }, } impl fmt::Display for FrameError { diff --git a/rust/crates/remote/src/protocol/message.rs b/rust/crates/remote/src/protocol/message.rs index f959c6c2..9543c1ff 100644 --- a/rust/crates/remote/src/protocol/message.rs +++ b/rust/crates/remote/src/protocol/message.rs @@ -13,23 +13,31 @@ use std::fmt; pub enum Message { /// Client-to-server call. `id` is unique per connection. Request { + /// Client-chosen id that the response repeats. id: u64, + /// Method name, such as `fs.stat`. method: String, + /// Method parameters; `null` for methods without any. #[serde(default)] params: Value, }, /// Server reply to the request with the same `id`. Exactly one of /// `result` and `error` is present. Response { + /// Id of the request this response answers. id: u64, + /// Method result on success. #[serde(default, skip_serializing_if = "Option::is_none")] result: Option, + /// Failure on error. #[serde(default, skip_serializing_if = "Option::is_none")] error: Option, }, /// Server-initiated notification. Event { + /// Event name, `.`, such as `process.exit`. event: String, + /// Event parameters, including the `Id` of the resource. #[serde(default)] params: Value, /// Position in the session's event stream, increasing by one per @@ -75,10 +83,15 @@ pub enum ErrorCode { UnknownMethod, /// A path argument was not an absolute remote path. InvalidPath, + /// The path does not exist. NotFound, + /// The host denied access to the path or operation. PermissionDenied, + /// The destination exists and the request did not allow replacing it. AlreadyExists, + /// A directory operation reached a path that is not a directory. NotADirectory, + /// A file operation reached a directory. IsADirectory, /// A directory removal without `recursive` found entries. DirectoryNotEmpty, @@ -111,7 +124,9 @@ pub enum ErrorCode { #[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct RemoteError { + /// Stable failure category that clients branch on. pub code: ErrorCode, + /// Explanation for people; it may change between releases. pub message: String, /// Host-specific diagnostics, such as the OS error number. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -119,6 +134,7 @@ pub struct RemoteError { } impl RemoteError { + /// Builds an error without details. pub fn new(code: ErrorCode, message: impl Into) -> Self { Self { code, @@ -127,6 +143,7 @@ impl RemoteError { } } + /// Attaches host-specific diagnostics. pub fn with_details(mut self, details: Value) -> Self { self.details = Some(details); self diff --git a/rust/crates/remote/src/protocol/methods.rs b/rust/crates/remote/src/protocol/methods.rs index c323d5c7..b109f468 100644 --- a/rust/crates/remote/src/protocol/methods.rs +++ b/rust/crates/remote/src/protocol/methods.rs @@ -15,40 +15,67 @@ use base64::Engine; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; +/// Opens the connection: negotiates the protocol version and returns the host +/// and session facts. It must be the first request of every connection. pub const SERVER_HELLO: &str = "server.hello"; +/// Ends the session and everything it started; the next connection with the +/// same key starts a fresh session. pub const SERVER_SHUTDOWN: &str = "server.shutdown"; +/// Round-trip check that returns an empty object. pub const SERVER_PING: &str = "server.ping"; +/// Lists the running processes, terminals and watches as a [`ServerState`]. pub const SERVER_STATE: &str = "server.state"; +/// Starts event delivery to this connection, see [`ResumeParams`]. pub const SERVER_RESUME: &str = "server.resume"; +/// Acknowledges received events, see [`AckParams`]. pub const SERVER_ACK: &str = "server.ack"; +/// Returns the [`FileMetadata`] of a path without following a final link. pub const FS_STAT: &str = "fs.stat"; +/// Lists one directory as a [`ReadDirResult`]. pub const FS_READ_DIR: &str = "fs.readDir"; +/// Reads a byte range of a file, see [`ReadFileParams`]. pub const FS_READ_FILE: &str = "fs.readFile"; +/// Replaces a whole file atomically, see [`WriteFileParams`]. pub const FS_WRITE_FILE: &str = "fs.writeFile"; +/// Creates a directory, see [`CreateDirParams`]. pub const FS_CREATE_DIR: &str = "fs.createDir"; +/// Removes a path; links are removed, never followed. pub const FS_REMOVE: &str = "fs.remove"; +/// Renames or moves a path, see [`RenameParams`]. pub const FS_RENAME: &str = "fs.rename"; +/// Copies a file or tree, see [`CopyParams`]. pub const FS_COPY: &str = "fs.copy"; /// Moves an entry to the host account's recoverable system trash. pub const FS_TRASH: &str = "fs.trash"; +/// Resolves every link of a path and returns it as a [`RealPathResult`]. pub const FS_REAL_PATH: &str = "fs.realPath"; +/// Sets the permission bits of a path, see [`SetPermissionsParams`]. pub const FS_SET_PERMISSIONS: &str = "fs.setPermissions"; +/// Reports properties of the volume holding a path as a [`VolumeInfo`]. pub const FS_VOLUME_INFO: &str = "fs.volumeInfo"; +/// Creates a private disposable directory, see [`TempDirectoryParams`]. pub const FS_TEMP_DIRECTORY: &str = "fs.tempDirectory"; /// Connection-owned staging for large files, with verified no-replace publication. pub const FS_UPLOAD_START: &str = "fs.uploadStart"; +/// Appends one chunk to a staged upload and returns the next expected offset. pub const FS_UPLOAD_CHUNK: &str = "fs.uploadChunk"; +/// Verifies a staged upload's size and SHA-256 and publishes it without +/// replacing an existing target. pub const FS_UPLOAD_COMMIT: &str = "fs.uploadCommit"; +/// Discards a staged upload and its staging file. pub const FS_UPLOAD_ABORT: &str = "fs.uploadAbort"; /// Private runtime snapshots owned by the session, not by a live connection. pub const FS_CREATE_RUN_DIRECTORY: &str = "fs.createRunDirectory"; +/// Removes a run directory this session allocated; releasing it again +/// succeeds without effect. pub const FS_RELEASE_RUN_DIRECTORY: &str = "fs.releaseRunDirectory"; /// Caller-generated opaque identity, known before allocation can be cancelled. #[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct RunDirectoryParams { + /// 1–64 ASCII letters, digits, `_` or `-`, unique within the session. pub directory_id: String, } @@ -61,9 +88,13 @@ pub const MAX_UPLOAD_BYTES: u64 = 2 * 1024 * 1024 * 1024; #[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct UploadStartParams { + /// Absolute host path the file is published at; it must not exist yet. pub path: String, + /// Exact byte length of the whole file, at most [`MAX_UPLOAD_BYTES`]. pub size: u64, + /// Lower- or upper-case hex SHA-256 of the whole file, 64 characters. pub sha256: String, + /// Permission bits of the published file, `0o000`–`0o777`. pub mode: u32, } @@ -71,8 +102,12 @@ pub struct UploadStartParams { #[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct UploadChunkParams { + /// Id the server returned from `fs.uploadStart`. pub upload_id: u64, + /// Byte offset of this chunk; it must equal the offset the previous chunk + /// acknowledged. pub offset: u64, + /// Base64 bytes, 1 to [`MAX_UPLOAD_CHUNK_BYTES`] once decoded. pub data: String, } @@ -80,42 +115,75 @@ pub struct UploadChunkParams { #[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct UploadParams { + /// Id the server returned from `fs.uploadStart`. pub upload_id: u64, } +/// Starts streaming the entries below a root, see [`WalkStartParams`]. pub const WALK_START: &str = "walk.start"; +/// Stops a walk; its `walk.done` event reports `cancelled`. pub const WALK_CANCEL: &str = "walk.cancel"; +/// Starts watching a directory tree, see [`WatchStartParams`]. pub const WATCH_START: &str = "watch.start"; +/// Stops a watch. pub const WATCH_STOP: &str = "watch.stop"; +/// Starts a process with piped output, see [`SpawnParams`]. pub const PROCESS_SPAWN: &str = "process.spawn"; +/// Writes Base64 bytes to a process's standard input; fails with `BUSY` when +/// the process stops reading. pub const PROCESS_WRITE_STDIN: &str = "process.writeStdin"; +/// Closes a process's standard input, so it reads end-of-file. pub const PROCESS_CLOSE_STDIN: &str = "process.closeStdin"; +/// Signals every process of a process's session, see [`KillParams`]. pub const PROCESS_KILL: &str = "process.kill"; +/// Opens a pseudo-terminal running a program, see [`TerminalOpenParams`]. pub const TERMINAL_OPEN: &str = "terminal.open"; +/// Writes Base64 input to a terminal; fails with `BUSY` when it is not read. pub const TERMINAL_WRITE: &str = "terminal.write"; +/// Changes a terminal's size in character cells. pub const TERMINAL_RESIZE: &str = "terminal.resize"; +/// Sends `SIGHUP` to the terminal's session and `SIGKILL` after 2 seconds. pub const TERMINAL_CLOSE: &str = "terminal.close"; +/// Returns the user's login environment, see [`EnvironmentResolveParams`]. pub const ENVIRONMENT_RESOLVE: &str = "environment.resolve"; +/// Connects a tunnel to a remote TCP or Unix socket target; fails with +/// `CONNECT_FAILED` when the target refuses. pub const TUNNEL_OPEN: &str = "tunnel.open"; +/// Sends Base64 bytes through a tunnel; fails with `BUSY` when the peer stops +/// reading. pub const TUNNEL_WRITE: &str = "tunnel.write"; +/// Sends queued data, then shuts down the tunnel's write side; replies keep +/// arriving. pub const TUNNEL_SHUTDOWN_WRITE: &str = "tunnel.shutdownWrite"; +/// Aborts both directions of a tunnel and discards queued data. pub const TUNNEL_CLOSE: &str = "tunnel.close"; +/// Lists the TCP ports listening on the host as a [`ListeningPortsResult`]. pub const PORTS_LISTENING: &str = "ports.listening"; +/// Output of a spawned process, carried by [`ProcessOutputEvent`]. pub const EVENT_PROCESS_OUTPUT: &str = "process.output"; +/// End of a spawned process, carried by [`ProcessExitEvent`]. pub const EVENT_PROCESS_EXIT: &str = "process.exit"; +/// Output of a terminal, carried by [`TerminalOutputEvent`]. pub const EVENT_TERMINAL_OUTPUT: &str = "terminal.output"; +/// End of a terminal, carried by [`TerminalExitEvent`]. pub const EVENT_TERMINAL_EXIT: &str = "terminal.exit"; +/// Changes below a watched root, carried by [`WatchChangedEvent`]. pub const EVENT_WATCH_CHANGED: &str = "watch.changed"; +/// A degraded or ended watch, carried by [`WatchErrorEvent`]. pub const EVENT_WATCH_ERROR: &str = "watch.error"; +/// A batch of walked entries, carried by [`WalkEntriesEvent`]. pub const EVENT_WALK_ENTRIES: &str = "walk.entries"; +/// End of a walk, carried by [`WalkDoneEvent`]. pub const EVENT_WALK_DONE: &str = "walk.done"; +/// Bytes received through a tunnel, carried by [`TunnelDataEvent`]. pub const EVENT_TUNNEL_DATA: &str = "tunnel.data"; +/// End of a tunnel, carried by [`TunnelClosedEvent`]. pub const EVENT_TUNNEL_CLOSED: &str = "tunnel.closed"; /// Largest byte count one `fs.readFile` or `fs.writeFile` call may move. @@ -137,9 +205,12 @@ pub fn decode_bytes(data: &str) -> Result, base64::DecodeError> { // Server and session // --------------------------------------------------------------------------- +/// Parameters of `server.hello`. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct HelloParams { + /// Protocol version the client speaks; a different server version fails + /// with `PROTOCOL_MISMATCH`. pub protocol_version: u32, /// Free-form client identification for server diagnostics. pub client_name: String, @@ -150,15 +221,21 @@ pub struct HelloParams { pub idle_timeout_seconds: Option, } +/// Result of `server.hello`. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct HelloResult { + /// Protocol version the server speaks, equal to the client's. pub protocol_version: u32, + /// Version of the `lithe-remote-server` executable. pub server_version: String, + /// Facts about the host the client branches on. pub host: HostCapabilities, + /// State of the session this connection attached to. pub session: SessionInfo, } +/// The session a connection attached to. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct SessionInfo { @@ -187,6 +264,7 @@ pub struct ResumeParams { pub after_seq: Option, } +/// Result of `server.resume`. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct ResumeResult { @@ -201,6 +279,7 @@ pub struct ResumeResult { #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct AckParams { + /// Sequence number of the latest event the client received. pub seq: u64, } @@ -210,12 +289,16 @@ pub struct AckParams { #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct HostCapabilities { + /// Operating system of the host, for display and diagnostics. pub os: OperatingSystem, /// Normalized CPU architecture of the running server: `x86_64` or /// `aarch64`. pub arch: String, + /// Host name as the host reports it. pub host_name: String, + /// Account the server runs as; `None` when the host cannot name it. pub user_name: Option, + /// Home directory of that account; `None` when it is not set. pub home_directory: Option, /// Login shell of the remote user, used for terminals and for resolving /// the user's environment. @@ -223,22 +306,29 @@ pub struct HostCapabilities { /// Separator used by host paths. Always `/` for current hosts; carried so /// that clients do not hard-code it. pub path_separator: String, + /// How spawned processes are kept from outliving the server. pub process: ProcessCapabilities, + /// How file changes are watched. pub watch: WatchCapabilities, /// Optional method groups this server implements, sorted. pub features: Vec, } +/// Operating system of a remote host. #[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub enum OperatingSystem { + /// Linux, including containers and WSL. Linux, + /// macOS. Macos, } +/// Process facts of a remote host. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct ProcessCapabilities { + /// What stops spawned processes from outliving the server. pub orphan_protection: OrphanProtection, } @@ -255,9 +345,11 @@ pub enum OrphanProtection { ServerShutdown, } +/// File watching facts of a remote host. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct WatchCapabilities { + /// Kernel facility behind `watch.*`. pub backend: WatchBackend, /// Upper bound of watched directories imposed by the host, when the /// backend watches directories individually (inotify). Large trees beyond @@ -265,13 +357,18 @@ pub struct WatchCapabilities { pub directory_limit: Option, } +/// Kernel facility a host uses to watch files. #[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub enum WatchBackend { + /// Linux inotify, one watch per directory. Inotify, + /// macOS FSEvents, one stream per watched tree. Fsevents, } +/// Optional method group a server implements, listed in +/// [`HostCapabilities::features`]. Clients check it before calling the group. #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub enum Feature { @@ -297,38 +394,55 @@ pub enum Feature { Persistence, } +/// Result of `server.state`: the resources a reconnecting client can +/// reattach to. #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct ServerState { + /// Processes the session still owns. pub processes: Vec, + /// Open terminals. pub terminals: Vec, + /// Active watches. pub watches: Vec, } +/// A process the session still owns. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct ProcessState { + /// Client-chosen id from `process.spawn`. pub process_id: u64, + /// Operating-system pid of the session leader, for diagnostics only. pub pid: u32, + /// Program and arguments the process was spawned with. pub argv: Vec, /// The process itself exited, but members of its session still run; /// `process.kill` still reaches them. pub leader_exited: bool, } +/// An open terminal. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct TerminalState { + /// Client-chosen id from `terminal.open`. pub terminal_id: u64, + /// Operating-system pid of the terminal's program, for diagnostics only. pub pid: u32, + /// Current width in character cells. pub columns: u16, + /// Current height in character cells. pub rows: u16, } +/// An active watch. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct WatchState { + /// Client-chosen id from `watch.start`. pub watch_id: u64, + /// Absolute host path of the watched directory. pub root: String, } @@ -336,17 +450,23 @@ pub struct WatchState { // Filesystem // --------------------------------------------------------------------------- +/// Parameters of the `fs.*` methods that take only a path. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct PathParams { + /// Absolute host path. pub path: String, } +/// Kind of a filesystem entry, never following links. #[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub enum FileKind { + /// A regular file. File, + /// A directory. Directory, + /// A symbolic link, whatever it points to. Symlink, /// Sockets, devices and FIFOs. Other, @@ -356,12 +476,16 @@ pub enum FileKind { #[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct FileMetadata { + /// Kind of the entry itself. pub kind: FileKind, + /// Size in bytes as the host reports it; for a link, the length of its + /// target text. pub size: u64, /// Modification time in milliseconds since the Unix epoch. Milliseconds /// stay exact in JavaScript numbers, which a future TypeScript client /// relies on. pub modified_ms: Option, + /// No write permission bit is set. pub readonly: bool, /// Permission bits (`0o755` style), for executable markers and /// `fs.setPermissions`. @@ -377,13 +501,17 @@ pub struct FileMetadata { pub symlink_target_mode: Option, } +/// One entry of a `fs.readDir` listing. #[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct DirectoryEntry { + /// File name within the listed directory. pub name: String, + /// Metadata of the entry itself. pub metadata: FileMetadata, } +/// Result of `fs.readDir`. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct ReadDirResult { @@ -395,16 +523,20 @@ pub struct ReadDirResult { pub skipped_non_utf8: u32, } +/// Parameters of `fs.readFile`. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct ReadFileParams { + /// Absolute host path of the file. pub path: String, + /// Byte offset the read starts at; `0` by default. #[serde(default)] pub offset: u64, /// Requested byte count; at most [`MAX_TRANSFER_BYTES`]. pub length: u64, } +/// Result of `fs.readFile`. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct ReadFileResult { @@ -412,6 +544,8 @@ pub struct ReadFileResult { pub data: String, /// Whether the read reached the end of the file. pub eof: bool, + /// Metadata of the file at the time of the read, for a later + /// [`FileVersion`] check. pub metadata: FileMetadata, } @@ -420,13 +554,18 @@ pub struct ReadFileResult { #[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct FileVersion { + /// Size in bytes the client last saw. pub size: u64, + /// Modification time in milliseconds the client last saw. pub modified_ms: Option, } +/// Parameters of `fs.writeFile`. The file is replaced atomically through a +/// temporary sibling, and writes go through symbolic links. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct WriteFileParams { + /// Absolute host path of the file. pub path: String, /// Base64 content that replaces the whole file. pub data: String, @@ -435,16 +574,21 @@ pub struct WriteFileParams { /// `overwrite` is set. #[serde(default)] pub expected: Option, + /// Replace an existing file without an `expected` check. #[serde(default)] pub overwrite: bool, + /// Create missing parent directories first. #[serde(default)] pub create_parents: bool, } +/// Parameters of `fs.createDir`. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct CreateDirParams { + /// Absolute host path of the new directory. pub path: String, + /// Create missing parents too, and succeed when the directory exists. #[serde(default)] pub recursive: bool, /// POSIX permissions applied during creation, before any content is written. @@ -460,19 +604,26 @@ pub struct TempDirectoryParams { pub prefix: String, } +/// Parameters of `fs.remove`. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct RemoveParams { + /// Absolute host path to remove. pub path: String, + /// Remove a non-empty directory with its contents. #[serde(default)] pub recursive: bool, } +/// Parameters of `fs.rename`. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct RenameParams { + /// Absolute host path of the existing entry. pub from: String, + /// Absolute host path it moves to. pub to: String, + /// Replace an existing destination. #[serde(default)] pub overwrite: bool, } @@ -483,14 +634,21 @@ pub struct RenameParams { #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct CopyParams { + /// Absolute host path of the source. pub from: String, + /// Absolute host path of the copy. pub to: String, + /// Copy a directory with its contents. #[serde(default)] pub recursive: bool, + /// Replace an existing destination. Without it the copy is published by + /// an atomic no-replace rename, and a concurrently created destination + /// fails with `ALREADY_EXISTS`. #[serde(default)] pub overwrite: bool, } +/// Result of `fs.copy`. #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct CopyResult { @@ -499,20 +657,25 @@ pub struct CopyResult { pub skipped_special_files: u64, } +/// Parameters of `fs.setPermissions`. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct SetPermissionsParams { + /// Absolute host path. pub path: String, /// Permission bits, for example `0o755`. pub mode: u32, } +/// Result of `fs.realPath`. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct RealPathResult { + /// Absolute host path with every link resolved. pub path: String, } +/// Result of `fs.volumeInfo`. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct VolumeInfo { @@ -532,7 +695,9 @@ pub struct VolumeInfo { #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct WalkStartParams { + /// Client-chosen id that the walk's events carry. pub walk_id: u64, + /// Absolute host path of the directory to walk. pub root: String, /// Entry names that are reported but not descended into, such as /// `node_modules` or `target`. @@ -543,24 +708,31 @@ pub struct WalkStartParams { pub max_entries: Option, } +/// Parameters of `walk.cancel`. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct WalkParams { + /// Id the walk was started with. pub walk_id: u64, } +/// One walked entry. #[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct WalkEntry { /// Path relative to the walk root with `/` separators. pub path: String, + /// Kind of the entry itself. pub kind: FileKind, } +/// Parameters of the `walk.entries` event. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct WalkEntriesEvent { + /// Id the walk was started with. pub walk_id: u64, + /// Next entries in walk order. pub entries: Vec, } @@ -568,10 +740,15 @@ pub struct WalkEntriesEvent { #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct WalkDoneEvent { + /// Id the walk was started with. pub walk_id: u64, + /// Entries reported in `walk.entries` events. pub total: u64, + /// The walk stopped at `maxEntries`. pub truncated: bool, + /// The walk stopped because of `walk.cancel`. pub cancelled: bool, + /// Entries skipped because their names are not valid UTF-8. pub skipped_non_utf8: u64, /// Directories that could not be read, usually for lack of permission. pub unreadable_directories: u64, @@ -581,9 +758,11 @@ pub struct WalkDoneEvent { // Watch // --------------------------------------------------------------------------- +/// Parameters of `watch.start`. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct WatchStartParams { + /// Client-chosen id that the watch's events carry. pub watch_id: u64, /// Directory watched recursively. pub root: String, @@ -593,6 +772,7 @@ pub struct WatchStartParams { pub exclude_names: Vec, } +/// Result of `watch.start`. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct WatchStartResult { @@ -601,25 +781,33 @@ pub struct WatchStartResult { pub watched_directories: Option, } +/// Parameters of `watch.stop`. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct WatchParams { + /// Id the watch was started with. pub watch_id: u64, } +/// How a watched path changed. #[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub enum ChangeKind { + /// The path appeared. Created, + /// The content or metadata of the path changed. Modified, + /// The path disappeared. Deleted, } +/// One change below a watched root. #[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct FileChange { /// Absolute host path. pub path: String, + /// How the path changed. pub kind: ChangeKind, } @@ -628,19 +816,27 @@ pub struct FileChange { #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct WatchChangedEvent { + /// Id the watch was started with. pub watch_id: u64, + /// Changes since the previous event, one per path. pub changes: Vec, + /// Changes were lost; the client must rescan the watched tree. pub overflow: bool, } +/// Parameters of the `watch.error` event. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct WatchErrorEvent { + /// Id the watch was started with. pub watch_id: u64, + /// What went wrong. pub code: WatchErrorCode, + /// Explanation for people. pub message: String, } +/// Why a watch reported `watch.error`. #[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum WatchErrorCode { @@ -667,15 +863,20 @@ pub enum EnvironmentSource { Login, } +/// Parameters of `process.spawn`. The process leads a new session, so +/// `process.kill` reaches everything it starts. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct SpawnParams { + /// Client-chosen id that the process's events carry. pub process_id: u64, /// Program and arguments. The program is resolved through the base /// environment's `PATH` when it contains no `/`. pub argv: Vec, + /// Absolute host working directory; the server's own when absent. #[serde(default)] pub cwd: Option, + /// Base environment the `env` variables are applied to. #[serde(default)] pub environment: EnvironmentSource, /// Variables added to, or replacing, the base environment. @@ -687,6 +888,7 @@ pub struct SpawnParams { pub pipe_stdin: bool, } +/// Result of `process.spawn`. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct SpawnResult { @@ -694,22 +896,29 @@ pub struct SpawnResult { pub pid: u32, } +/// Parameters of `process.writeStdin`. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct WriteStdinParams { + /// Id the process was spawned with. pub process_id: u64, + /// Base64 bytes for standard input. pub data: String, } +/// Parameters of the `process.*` methods that take only the process id. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct ProcessParams { + /// Id the process was spawned with. pub process_id: u64, } +/// Parameters of `process.kill`. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct KillParams { + /// Id the process was spawned with. pub process_id: u64, /// `SIGKILL` instead of `SIGTERM`. Either signal goes to every process /// of the session the process leads, including job-control groups it @@ -718,18 +927,25 @@ pub struct KillParams { pub force: bool, } +/// Output stream of a spawned process. #[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub enum OutputStream { + /// Standard output. Stdout, + /// Standard error. Stderr, } +/// Parameters of the `process.output` event. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct ProcessOutputEvent { + /// Id the process was spawned with. pub process_id: u64, + /// Stream the bytes were read from. pub stream: OutputStream, + /// Base64 bytes in the order the process wrote them to this stream. pub data: String, } @@ -737,7 +953,9 @@ pub struct ProcessOutputEvent { #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct ProcessExitEvent { + /// Id the process was spawned with. pub process_id: u64, + /// Exit status when the process exited normally. pub exit_code: Option, /// Terminating signal number when the process did not exit normally. pub signal: Option, @@ -747,55 +965,75 @@ pub struct ProcessExitEvent { // Terminals // --------------------------------------------------------------------------- +/// Parameters of `terminal.open`. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct TerminalOpenParams { + /// Client-chosen id that the terminal's events carry. pub terminal_id: u64, /// Program and arguments. Defaults to the user's login shell started as /// a login shell, which loads the same profile files as an SSH login. #[serde(default)] pub argv: Option>, + /// Absolute host working directory; when absent, the user's home, or `/` + /// without one. #[serde(default)] pub cwd: Option, /// Variables added to the server environment. `TERM` defaults to /// `xterm-256color`. #[serde(default)] pub env: BTreeMap, + /// Initial width in character cells. pub columns: u16, + /// Initial height in character cells. pub rows: u16, } +/// Result of `terminal.open`. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct TerminalOpenResult { + /// Operating-system pid of the terminal's program, for diagnostics only. pub pid: u32, } +/// Parameters of `terminal.write`. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct TerminalWriteParams { + /// Id the terminal was opened with. pub terminal_id: u64, + /// Base64 input bytes, as typed or pasted. pub data: String, } +/// Parameters of `terminal.resize`. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct TerminalResizeParams { + /// Id the terminal was opened with. pub terminal_id: u64, + /// New width in character cells. pub columns: u16, + /// New height in character cells. pub rows: u16, } +/// Parameters of `terminal.close`. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct TerminalParams { + /// Id the terminal was opened with. pub terminal_id: u64, } +/// Parameters of the `terminal.output` event. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct TerminalOutputEvent { + /// Id the terminal was opened with. pub terminal_id: u64, + /// Base64 bytes the terminal's program wrote. pub data: String, } @@ -803,8 +1041,11 @@ pub struct TerminalOutputEvent { #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct TerminalExitEvent { + /// Id the terminal was opened with. pub terminal_id: u64, + /// Exit status when the program exited normally. pub exit_code: Option, + /// Terminating signal number when the program did not exit normally. pub signal: Option, } @@ -812,6 +1053,7 @@ pub struct TerminalExitEvent { // Environment // --------------------------------------------------------------------------- +/// Parameters of `environment.resolve`. The result is cached for the session. #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct EnvironmentResolveParams { @@ -821,16 +1063,20 @@ pub struct EnvironmentResolveParams { pub refresh: bool, } +/// Result of `environment.resolve`. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct EnvironmentResolveResult { + /// Every variable of the resolved environment. pub environment: BTreeMap, + /// Where the environment came from. pub source: ResolvedEnvironmentSource, /// Why a better source was not used, for example a profile that timed out. #[serde(default, skip_serializing_if = "Option::is_none")] pub diagnostics: Option, } +/// Where a resolved environment came from, best first. #[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub enum ResolvedEnvironmentSource { @@ -851,40 +1097,55 @@ pub enum ResolvedEnvironmentSource { #[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum TunnelTarget { + /// A TCP endpoint as seen from the host. Tcp { + /// Host name or address, usually a loopback address. host: String, + /// TCP port. port: u16, }, /// A Unix domain socket, such as a Docker or database socket. Unix { + /// Absolute host path of the socket. path: String, }, } +/// Parameters of `tunnel.open`. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct TunnelOpenParams { + /// Client-chosen id that the tunnel's events carry. pub tunnel_id: u64, + /// Endpoint the host connects to. pub target: TunnelTarget, } +/// Parameters of `tunnel.write`. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct TunnelWriteParams { + /// Id the tunnel was opened with. pub tunnel_id: u64, + /// Base64 bytes for the target. pub data: String, } +/// Parameters of `tunnel.shutdownWrite` and `tunnel.close`. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct TunnelParams { + /// Id the tunnel was opened with. pub tunnel_id: u64, } +/// Parameters of the `tunnel.data` event. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct TunnelDataEvent { + /// Id the tunnel was opened with. pub tunnel_id: u64, + /// Base64 bytes the target sent. pub data: String, } @@ -892,20 +1153,25 @@ pub struct TunnelDataEvent { #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct TunnelClosedEvent { + /// Id the tunnel was opened with. pub tunnel_id: u64, + /// Why the tunnel failed; absent when both sides closed normally. #[serde(default, skip_serializing_if = "Option::is_none")] pub error: Option, } +/// A TCP port listening on the host. #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct ListeningPort { /// Bound address as reported by the host, such as `127.0.0.1`, `::` or /// `0.0.0.0`. pub address: String, + /// TCP port. pub port: u16, } +/// Result of `ports.listening`. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct ListeningPortsResult { @@ -918,7 +1184,9 @@ pub struct ListeningPortsResult { #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct ServerIdentity { + /// Version of the executable. pub server_version: String, + /// Protocol version the executable speaks. pub protocol_version: u32, /// Lower-case hex SHA-256 of the executable file itself. pub sha256: String, diff --git a/rust/crates/request-context/Cargo.toml b/rust/crates/request-context/Cargo.toml new file mode 100644 index 00000000..9ee58b38 --- /dev/null +++ b/rust/crates/request-context/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "lithe-request-context" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "State scoped to one Core request: its provenance and trace correlation, and the ordered, redacted event channel to the host" + +[dependencies] +lithe-diagnostics = { path = "../diagnostics" } +regex = "1.11" +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0", features = ["preserve_order"] } diff --git a/rust/crates/request-context/src/events.rs b/rust/crates/request-context/src/events.rs new file mode 100644 index 00000000..3d5968d5 --- /dev/null +++ b/rust/crates/request-context/src/events.rs @@ -0,0 +1,299 @@ +//! The ordered, request-scoped event channel to the host, and the redaction +//! every event passes through. +//! +//! Events contain sanitized diagnostics, never command input or environment. +//! A request with an event consumer (see [`with_sink`]) receives +//! `requestStarted` before its work and `requestFinished` after it; domains +//! [`emit`] structured events in between, and process tracers allocate one +//! invocation per spawned child (see [`next_invocation`]). +//! +//! Hosts keep a console journal of these events. The journal's record states +//! and retention budget are part of this contract: `requestStarted` carries +//! [`JOURNAL_RETENTION`], each invocation's `finished` carries the record's +//! final state, and `requestFinished` names the records that never confirmed +//! an exit and whether a failure needs a record of its own. Hosts store, lock, +//! and throttle; they do not derive states or redact again. + +use serde::Serialize; +use std::cell::RefCell; +use std::collections::BTreeSet; +use std::sync::{Arc, OnceLock}; + +/// Console journal retention shared by every host, in host string length units. +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JournalRetention { + /// Records kept across all requests; the oldest are dropped first. + pub max_records: usize, + /// Output kept per record; earlier lines are dropped first. + pub max_record_output_characters: usize, + /// Output lines kept per record, including empty lines. + pub max_record_output_lines: usize, + /// Output kept across all records before the oldest record is dropped. + pub max_total_output_characters: usize, +} + +/// One budget for both products, chosen for the lower resident memory. +pub const JOURNAL_RETENTION: JournalRetention = JournalRetention { + max_records: 200, + max_record_output_characters: 32_768, + max_record_output_lines: 2_000, + max_total_output_characters: 1_048_576, +}; + +/// A serialized event consumer, invoked synchronously during its owning request. +pub type EventSink = Arc; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +/// One ordered request boundary event. +struct Event<'a> { + operation_id: &'a str, + #[serde(flatten)] + kind: Kind, +} + +#[derive(Serialize)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +enum Kind { + RequestStarted { + retention: JournalRetention, + }, + RequestFinished { + error: Option, + /// Records of this request still running; hosts mark them `unconfirmed`. + unconfirmed_invocations: Vec, + /// No process of this request failed or is still running: none started, or + /// each one succeeded, such as the queries before a refusal. The failure is shown + /// as its own record instead of on an executed command. + failure_record: bool, + }, +} + +/// Per-request identity and sequence shared by nested invocations. +struct Context { + sink: EventSink, + operation_id: String, + next_invocation: u64, + /// Transfer summaries remain attached to the transfer despite subsequent inspection queries. + last_transfer: Option, + /// Processes of the current request that finished without succeeding. + failed_invocations: usize, + /// Started processes of the current request without a `finished` event. + open_invocations: BTreeSet, +} +thread_local! { static CURRENT: RefCell> = const { RefCell::new(None) }; } + +/// Restores the caller's observer after a nested request or callback returns. +struct ObserverScope(Option); +impl ObserverScope { + fn begin(context: Option) -> Self { + Self(CURRENT.with(|current| current.replace(context))) + } +} +impl Drop for ObserverScope { + fn drop(&mut self) { + CURRENT.with(|current| current.replace(self.0.take())); + } +} + +/// Whether the current request has an incremental event consumer. +pub fn has_sink() -> bool { + CURRENT.with(|current| current.borrow().is_some()) +} + +/// Installs a consumer only for the duration of this synchronous request. +pub fn with_sink(sink: EventSink, operation: impl FnOnce() -> T) -> T { + let _scope = ObserverScope::begin(Some(Context { + sink, + operation_id: String::new(), + next_invocation: 0, + last_transfer: None, + failed_invocations: 0, + open_invocations: Default::default(), + })); + operation() +} + +/// Hands one serialized event to `sink` outside the observed request's scope. +pub fn deliver(sink: &EventSink, event: &str) { + // A host may synchronously call git.authRespond from this callback. That + // request must not inherit or overwrite the observed operation's identity. + let _scope = ObserverScope::begin(None); + sink(event); +} + +/// Called after cancellation registration so immediate cancellation cannot race startup. +pub fn request_started(operation_id: Option<&str>) { + let context = CURRENT.with(|current| { + let mut current = current.borrow_mut(); + let context = current.as_mut()?; + context.operation_id = operation_id.unwrap_or_default().to_string(); + context.failed_invocations = 0; + context.open_invocations.clear(); + Some((context.sink.clone(), context.operation_id.clone())) + }); + if let Some((sink, operation_id)) = context { + send( + &sink, + &operation_id, + Kind::RequestStarted { + retention: JOURNAL_RETENTION, + }, + ); + } +} + +/// A final event shares the same ordered channel, including preflight failures. +pub fn request_finished(response: &str) { + let context = CURRENT.with(|current| { + current.borrow_mut().as_mut().map(|context| { + ( + context.sink.clone(), + context.operation_id.clone(), + context.failed_invocations, + std::mem::take(&mut context.open_invocations), + ) + }) + }); + let Some((sink, operation_id, failed, open)) = context else { + return; + }; + let value: serde_json::Value = serde_json::from_str(response).unwrap_or_default(); + let raw_error = value + .get("error") + .filter(|value| !value.is_null()) + .or_else(|| { + value + .pointer("/data/operationError") + .filter(|value| !value.is_null()) + }); + let error = raw_error.map(|error| serde_json::json!({ + "code": error.get("code"), + "message": redact(error.get("message").and_then(|value| value.as_str()).unwrap_or("Git operation failed")), + "details": error.get("details").and_then(|value| value.as_str()).map(redact), + })); + let error = error.or_else(|| value.pointer("/data/exitCode").and_then(|value| value.as_i64()).filter(|code| *code != 0).map(|code| serde_json::json!({ "code": "process_failed", "message": format!("Git exited with code {code}") }))); + // A request cancelled without a failed command was superseded; it has nothing + // to report. Any other failure that no command reported needs its own record. + let failure_record = failed == 0 + && open.is_empty() + && error + .as_ref() + .is_some_and(|error| error["code"] != "cancelled"); + send( + &sink, + &operation_id, + Kind::RequestFinished { + error, + unconfirmed_invocations: open.into_iter().collect(), + failure_record, + }, + ); +} + +fn send(sink: &EventSink, operation_id: &str, kind: Kind) { + if let Ok(value) = serde_json::to_value(Event { operation_id, kind }) { + deliver(sink, &value.to_string()); + } +} + +/// The channel one traced process reports on: the request's consumer and +/// operation, and the invocation number unique within that request. +pub struct InvocationChannel { + /// Consumer of the owning request. + pub sink: EventSink, + /// Operation identity of the owning request. + pub operation_id: String, + /// Invocation number, unique within the owning request. + pub id: u64, +} + +/// Allocates the next invocation of the current request; `None` when the +/// request has no event consumer. +pub fn next_invocation() -> Option { + CURRENT.with(|current| { + let mut current = current.borrow_mut(); + let context = current.as_mut()?; + context.next_invocation += 1; + Some(InvocationChannel { + sink: context.sink.clone(), + operation_id: context.operation_id.clone(), + id: context.next_invocation, + }) + }) +} + +/// Records that invocation `id` started. A `transfer` (fetch, push, pull, +/// clone) also becomes the invocation later `remoteResult` events attach to. +pub fn invocation_started(id: u64, transfer: bool) { + CURRENT.with(|current| { + if let Some(context) = current.borrow_mut().as_mut() { + if transfer { + context.last_transfer = Some(id); + } + context.open_invocations.insert(id); + } + }); +} + +/// Records that invocation `id` finished, and whether it failed. +pub fn invocation_finished(id: u64, failed: bool) { + CURRENT.with(|current| { + if let Some(context) = current.borrow_mut().as_mut() { + context.open_invocations.remove(&id); + context.failed_invocations += usize::from(failed); + } + }); +} + +/// Sends structured native diagnostics on the same ordered request channel. +pub fn emit(mut value: serde_json::Value) { + let context = CURRENT.with(|current| { + current.borrow().as_ref().map(|context| { + ( + context.sink.clone(), + context.operation_id.clone(), + context.last_transfer, + ) + }) + }); + if let Some((sink, operation_id, transfer)) = context { + if value["type"] == "remoteResult" { + value["invocationId"] = transfer.into(); + } + // Structured results can carry native error text; it is redacted here + // like every other event so hosts never redact a second time. + if let Some(error) = value + .get_mut("error") + .and_then(|error| error.as_object_mut()) + { + for key in ["message", "details"] { + if let Some(text) = error.get(key).and_then(|text| text.as_str()) { + let redacted = redact(text); + error.insert(key.into(), redacted.into()); + } + } + } + value["operationId"] = operation_id.into(); + deliver(&sink, &value.to_string()); + } +} + +/// Redacts complete records, including credentials split across native chunks. +pub fn redact(text: &str) -> String { + static URL_AUTH: OnceLock = OnceLock::new(); + let pattern = URL_AUTH.get_or_init(|| { + regex::Regex::new(r"(?i)(https?|ssh)://[^\s/@]+(?::[^\s/@]*)?@") + .expect("valid URL redaction") + }); + let text = pattern.replace_all(text, "$1://redacted@").into_owned(); + lithe_diagnostics::redaction::redact_text(lithe_diagnostics::redaction::RedactTextRequest { + text, + }) + .redacted +} diff --git a/rust/crates/request-context/src/lib.rs b/rust/crates/request-context/src/lib.rs new file mode 100644 index 00000000..ae3d2dcb --- /dev/null +++ b/rust/crates/request-context/src/lib.rs @@ -0,0 +1,15 @@ +//! State scoped to one Core request and shared by every domain that runs inside +//! it: where the request came from ([`Source`]), the operation it belongs to +//! ([`trace`]), and the ordered event channel that streams diagnostics to the +//! host while the request runs ([`events`]). +//! +//! All state is thread-local and restored when the scope that set it ends, so +//! nested requests and callbacks never see or overwrite the outer request's +//! state. The dispatcher in `lithe-core` opens these scopes; domains only read +//! them and emit events. + +pub mod events; +mod source; +pub mod trace; + +pub use source::{source_token, Source}; diff --git a/rust/crates/request-context/src/source.rs b/rust/crates/request-context/src/source.rs new file mode 100644 index 00000000..c4a0c52c --- /dev/null +++ b/rust/crates/request-context/src/source.rs @@ -0,0 +1,25 @@ +//! Provenance of a request: whether a user started it or a background refresh did. + +use serde::{Deserialize, Serialize}; + +/// Execution provenance is retained for diagnostics; display does not merge commands. +#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum Source { + /// A user deliberately invoked the operation. + User, + /// An automatic refresh or watcher invoked the query. + Background, + /// Provenance was not supplied; preserve each execution independently. + #[default] + Unknown, +} + +/// The token logs record for `source`. +pub fn source_token(source: Source) -> &'static str { + match source { + Source::User => "user", + Source::Background => "background", + Source::Unknown => "unknown", + } +} diff --git a/rust/crates/request-context/src/trace.rs b/rust/crates/request-context/src/trace.rs new file mode 100644 index 00000000..b27f1b70 --- /dev/null +++ b/rust/crates/request-context/src/trace.rs @@ -0,0 +1,29 @@ +//! Trace correlation of the running request, inherited by the process traces +//! started inside it. + +use crate::Source; +use std::cell::RefCell; + +/// Correlation inherited by Git process traces started inside the request. +#[derive(Debug, Clone, Default)] +pub struct Context { + /// Operation shared by the user action, its requests and their Git processes. + pub op: Option, + /// Provenance resolved once from the request envelope. + pub source: Source, +} + +thread_local! { + static CURRENT: RefCell = RefCell::new(Context::default()); +} + +/// Snapshot for traces that finish on another thread, such as streamed history. +pub fn current() -> Context { + CURRENT.with(|current| current.borrow().clone()) +} + +/// Makes `context` the running request's correlation and returns the enclosing +/// one, which the caller restores with another `replace` when its request ends. +pub fn replace(context: Context) -> Context { + CURRENT.with(|current| current.replace(context)) +} diff --git a/rust/crates/text/Cargo.toml b/rust/crates/text/Cargo.toml new file mode 100644 index 00000000..ed9ee050 --- /dev/null +++ b/rust/crates/text/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "lithe-text" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "UTF-16 positions, text edit planning and the in-process completions, hover, navigation and snippets that work without a language server" + +[dependencies] +lithe-protocol = { path = "../protocol" } +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0", features = ["preserve_order"] } diff --git a/rust/crates/core/src/lsp/lightweight/edits.rs b/rust/crates/text/src/edits.rs similarity index 92% rename from rust/crates/core/src/lsp/lightweight/edits.rs rename to rust/crates/text/src/edits.rs index 5329c5f2..40d0efdb 100644 --- a/rust/crates/core/src/lsp/lightweight/edits.rs +++ b/rust/crates/text/src/edits.rs @@ -1,16 +1,18 @@ //! UTF-16-aware text edit validation and application. -use crate::lsp::interface::{ +use crate::position::{ LspDocumentContentChange, LspPosition, LspPositionResponse, LspRange, LspRangeResponse, }; -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] /// Source text and LSP edits to validate and apply as one operation. pub struct ApplyTextEditsRequest { + /// Document text the edits apply to. pub text: String, + /// Edits in any order; they must not overlap. #[serde(default)] pub edits: Vec, } @@ -19,7 +21,9 @@ pub struct ApplyTextEditsRequest { #[serde(rename_all = "camelCase")] /// Replacement expressed in LSP UTF-16 coordinates. pub struct LspTextEdit { + /// Range the edit replaces. pub range: LspRange, + /// Replacement text. pub new_text: String, } @@ -27,6 +31,7 @@ pub struct LspTextEdit { #[serde(rename_all = "camelCase")] /// Text-only response shared by edit and snippet commands. pub struct TextResponse { + /// Resulting text. pub text: String, } @@ -68,7 +73,7 @@ pub fn apply_text_edits(request: ApplyTextEditsRequest) -> Result, @@ -119,7 +132,7 @@ pub(crate) struct EditRejection { /// Line table of a text in byte offsets. `\r\n`, `\n` and a lone `\r` each /// terminate one line, as the LSP specification and Monaco define them. -pub(crate) struct TextLineIndex<'a> { +pub struct TextLineIndex<'a> { text: &'a str, line_starts: Vec, /// Byte offset just before each line's terminator (or the text end). @@ -129,7 +142,8 @@ pub(crate) struct TextLineIndex<'a> { } impl<'a> TextLineIndex<'a> { - pub(crate) fn new(text: &'a str) -> Self { + /// Indexes the lines of `text`. + pub fn new(text: &'a str) -> Self { let bytes = text.as_bytes(); let mut line_starts = vec![0]; let mut content_ends = Vec::new(); @@ -163,7 +177,7 @@ impl<'a> TextLineIndex<'a> { /// Wire name of the document's line ending: `lf`, `crlf`, `cr` or `mixed`. /// A text without terminators reports `lf`. - pub(crate) fn line_ending(&self) -> &'static str { + pub fn line_ending(&self) -> &'static str { match self.separators.as_slice() { [] | ["\n"] => "lf", ["\r\n"] => "crlf", @@ -174,12 +188,12 @@ impl<'a> TextLineIndex<'a> { /// Separator written for inserted line breaks: the first one in the text, /// LF when there is none. - pub(crate) fn insertion_separator(&self) -> &'static str { + pub fn insertion_separator(&self) -> &'static str { self.separators.first().copied().unwrap_or("\n") } /// Resolves a zero-based line and UTF-16 column to a byte offset. - pub(crate) fn resolve( + pub fn resolve( &self, (line, column): (i64, i64), policy: PositionPolicy, @@ -219,7 +233,7 @@ impl<'a> TextLineIndex<'a> { /// Zero-length insertions at the same position keep the caller's order, and an /// insertion at a replaced range's boundary is adjacent rather than /// overlapping, independent of where it appears in the batch. -pub(crate) fn plan_replacements( +pub fn plan_replacements( index: &TextLineIndex<'_>, edits: Vec, policy: PositionPolicy, @@ -257,7 +271,7 @@ pub(crate) fn plan_replacements( } /// Rewrites every `\r\n`, `\r` and `\n` in `text` as `separator`. -pub(crate) fn normalize_line_breaks(text: &str, separator: &str) -> String { +pub fn normalize_line_breaks(text: &str, separator: &str) -> String { if !text.contains(['\r', '\n']) { return text.to_string(); } @@ -279,7 +293,7 @@ pub(crate) fn normalize_line_breaks(text: &str, separator: &str) -> String { } /// Applies ranged content changes sequentially in the given order. -pub(crate) fn apply_content_changes_sequential( +pub fn apply_content_changes_sequential( text: &str, changes: &[LspDocumentContentChange], ) -> Result { diff --git a/rust/crates/text/src/lib.rs b/rust/crates/text/src/lib.rs new file mode 100644 index 00000000..caa66a44 --- /dev/null +++ b/rust/crates/text/src/lib.rs @@ -0,0 +1,20 @@ +//! Text primitives shared by the editor and LSP layers: zero-based positions +//! and ranges in UTF-16 code units, edit validation and planning, line +//! indexing, and the in-process completions, hover, navigation and snippets +//! that stay available without a language server. +//! +//! The `lsp.*` and `editor.*` JSON commands that use them are dispatched by +//! `lithe-core`. + +mod edits; +mod position; +mod snippets; +mod symbols; + +pub use edits::*; +pub use position::*; +pub use snippets::*; +pub use symbols::*; + +#[cfg(test)] +mod tests; diff --git a/rust/crates/text/src/position.rs b/rust/crates/text/src/position.rs new file mode 100644 index 00000000..426ed364 --- /dev/null +++ b/rust/crates/text/src/position.rs @@ -0,0 +1,67 @@ +//! Zero-based LSP positions and ranges in UTF-16 code units, as hosts send +//! them and as Core returns them. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Half-open document range expressed in zero-based LSP coordinates. +pub struct LspRange { + /// First position inside the range. + pub start: LspPosition, + /// Position just after the range. + pub end: LspPosition, +} + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Zero-based LSP line and UTF-16 code-unit column. +pub struct LspPosition { + /// Zero-based line. + pub line: i64, + /// Zero-based column in UTF-16 code units. + pub utf16_column: i64, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Normalized text replacement returned to host applications. +pub struct LspTextEditResponse { + /// Range the replacement covers. + pub range: LspRangeResponse, + /// Text that replaces the range. + pub new_text: String, +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +/// Serializable form of an LSP document range. +pub struct LspRangeResponse { + /// First position inside the range. + pub start: LspPositionResponse, + /// Position just after the range. + pub end: LspPositionResponse, +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +/// Serializable form of a zero-based LSP position. +pub struct LspPositionResponse { + /// Zero-based line. + pub line: i64, + /// Zero-based column in UTF-16 code units. + pub utf16_column: i64, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +/// One LSP `textDocument/didChange` content change. +pub struct LspDocumentContentChange { + /// Inclusive start / exclusive end range; omitted for a full-document replacement. + #[serde(default)] + pub range: Option, + /// Text that replaces the range, or the whole document when the range is + /// omitted. + #[serde(default)] + pub text: String, +} diff --git a/rust/crates/core/src/lsp/lightweight/snippets.rs b/rust/crates/text/src/snippets.rs similarity index 90% rename from rust/crates/core/src/lsp/lightweight/snippets.rs rename to rust/crates/text/src/snippets.rs index 4da97965..4ef5462b 100644 --- a/rust/crates/core/src/lsp/lightweight/snippets.rs +++ b/rust/crates/text/src/snippets.rs @@ -1,12 +1,13 @@ //! Conversion of LSP snippets into insertion-ready plain text. -use super::edits::TextResponse; +use crate::edits::TextResponse; use serde::Deserialize; #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] /// LSP snippet whose placeholders should be reduced to insertion text. pub struct PlainSnippetRequest { + /// Snippet in LSP snippet syntax. pub value: String, } @@ -17,7 +18,9 @@ pub fn plain_snippet(request: PlainSnippetRequest) -> TextResponse { } } -pub(crate) fn snippet_plain_text(value: &str) -> String { +/// Removes snippet control syntax from `value`, keeping each placeholder's +/// default text. +pub fn snippet_plain_text(value: &str) -> String { let mut output = String::new(); let mut chars = value.chars().peekable(); while let Some(character) = chars.next() { diff --git a/rust/crates/core/src/lsp/lightweight/symbols.rs b/rust/crates/text/src/symbols.rs similarity index 90% rename from rust/crates/core/src/lsp/lightweight/symbols.rs rename to rust/crates/text/src/symbols.rs index ccd9736d..890622b2 100644 --- a/rust/crates/core/src/lsp/lightweight/symbols.rs +++ b/rust/crates/text/src/symbols.rs @@ -1,10 +1,8 @@ //! In-process document symbols, references, renames, and semantic-token helpers. -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 crate::edits::{range_for_offsets, utf16_position_to_byte_offset}; +use crate::position::{LspPosition, LspPositionResponse, LspRangeResponse, LspTextEditResponse}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -12,8 +10,11 @@ use std::collections::BTreeMap; #[serde(rename_all = "camelCase")] /// Document and cursor used by lightweight completion or hover. pub struct BuiltinRequest { + /// Path of the document. pub file_path: String, + /// Current document text. pub text: String, + /// Cursor position. pub position: LspPosition, } @@ -21,9 +22,13 @@ pub struct BuiltinRequest { #[serde(rename_all = "camelCase")] /// Document, cursor, and LSP method used by lightweight navigation. pub struct BuiltinNavigationRequest { + /// Path of the document. pub file_path: String, + /// Current document text. pub text: String, + /// Cursor position. pub position: LspPosition, + /// LSP navigation method, such as `textDocument/definition`. pub method: String, } @@ -31,6 +36,7 @@ pub struct BuiltinNavigationRequest { #[serde(rename_all = "camelCase")] /// Deterministically ordered completion candidates from the current document. pub struct BuiltinCompletionResponse { + /// Candidates in display order. pub items: Vec, } @@ -38,11 +44,15 @@ pub struct BuiltinCompletionResponse { #[serde(rename_all = "camelCase")] /// Completion candidate expressed in the normalized Core response shape. pub struct BuiltinCompletionItem { + /// Text shown in the completion list. pub label: String, + /// Text inserted when the candidate is accepted. pub insert_text: String, /// Numeric LSP `CompletionItemKind`, when the fallback can infer one. pub kind: Option, + /// Extra description shown next to the label. pub detail: Option, + /// Replacement of the typed prefix. pub text_edit: LspTextEditResponse, } @@ -50,6 +60,7 @@ pub struct BuiltinCompletionItem { #[serde(rename_all = "camelCase")] /// Optional hover result for the identifier at the cursor. pub struct BuiltinHoverResponse { + /// Hover for the identifier at the cursor, if any. pub hover: Option, } @@ -57,8 +68,11 @@ pub struct BuiltinHoverResponse { #[serde(rename_all = "camelCase")] /// Lightweight hover contents and the identifier range they describe. pub struct BuiltinHover { + /// Hover text. pub contents: String, + /// Whether `contents` is Markdown rather than plain text. pub is_markdown: bool, + /// Range of the identifier the hover describes. pub range: LspRangeResponse, } @@ -66,6 +80,7 @@ pub struct BuiltinHover { #[serde(rename_all = "camelCase")] /// Locations found by the lightweight navigation fallback. pub struct BuiltinNavigationResponse { + /// Targets found, in document order. pub locations: Vec, } @@ -73,9 +88,13 @@ pub struct BuiltinNavigationResponse { #[serde(rename_all = "camelCase")] /// One normalized navigation target. pub struct BuiltinLocation { + /// Path of the document that contains the target. pub file_path: String, + /// Range of the target. pub range: LspRangeResponse, + /// Whether the host should open the target read-only. pub is_read_only: bool, + /// Path to show instead of `file_path`, when one applies. pub display_path: Option, } diff --git a/rust/crates/text/src/tests.rs b/rust/crates/text/src/tests.rs new file mode 100644 index 00000000..50d03e9c --- /dev/null +++ b/rust/crates/text/src/tests.rs @@ -0,0 +1,204 @@ +use super::*; +use serde_json::Value; + +#[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 text_edits_follow_shared_windows_fixture() { + let fixture: Value = + serde_json::from_str(include_str!("../../../fixtures/lsp/text-edits-v1.json")).unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let edits: Vec = case["edits"].as_array().unwrap().iter().map(|edit| serde_json::json!({ + "range": { "start": { "line": edit["range"]["start"]["line"], "utf16Column": edit["range"]["start"]["character"] }, + "end": { "line": edit["range"]["end"]["line"], "utf16Column": edit["range"]["end"]["character"] } }, + "newText": edit["newText"] + })).collect(); + let result = apply_text_edits( + serde_json::from_value(serde_json::json!({ "text": case["text"], "edits": edits })) + .unwrap(), + ); + if let Some(expected) = case.get("expectedError") { + assert_eq!( + result.unwrap_err().details.as_deref(), + expected.as_str(), + "{}", + case["name"] + ); + } else { + assert_eq!( + result.unwrap().text, + case["expectedText"].as_str().unwrap(), + "{}", + case["name"] + ); + } + } +} diff --git a/rust/crates/toolchain/Cargo.toml b/rust/crates/toolchain/Cargo.toml new file mode 100644 index 00000000..3866b049 --- /dev/null +++ b/rust/crates/toolchain/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "lithe-toolchain" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Plugin-owned toolchain policy for Lithe: tool definitions, directory pins, consumer preferences, resolution and version checks" + +[dependencies] +ignore = "=0.4.33" +lithe-plugin-model = { path = "../plugin-model" } +lithe-protocol = { path = "../protocol" } +regex = "1.11" +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0", features = ["preserve_order"] } diff --git a/rust/crates/core/src/toolchain/definitions.rs b/rust/crates/toolchain/src/definitions.rs similarity index 93% rename from rust/crates/core/src/toolchain/definitions.rs rename to rust/crates/toolchain/src/definitions.rs index cc685961..72071af1 100644 --- a/rust/crates/core/src/toolchain/definitions.rs +++ b/rust/crates/toolchain/src/definitions.rs @@ -1,37 +1,13 @@ //! Validates plugin-owned tool policies and constructs the enabled-plugin registry. //! Filesystem evidence and native execution remain host-owned responsibilities. -use crate::plugins::declarative::{invalid, validate_manifest_value, UIIcon, Validation}; -use crate::plugins::executables::{ordered_indices, ExecutableCandidate}; +use crate::ManifestValidator; +use lithe_plugin_model::executables::{ordered_indices, ExecutableCandidate}; +use lithe_plugin_model::{invalid, is_tool_id, UIIcon, Validation}; use regex::Regex; use serde_json::{json, Map, Value}; use std::collections::{BTreeMap, BTreeSet}; -/// Validates a short tool ID or a plugin-qualified tool ID, without granting ownership. -pub(crate) fn tool_id(value: &str) -> bool { - let local = |s: &str| { - s.len() <= 64 - && s.as_bytes().first().is_some_and(u8::is_ascii_lowercase) - && s.bytes() - .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') - }; - value.len() <= 192 - && match value.split_once(':') { - Some((plugin, id)) => { - !id.contains(':') - && !plugin.is_empty() - && plugin.split(['.', '-']).all(|part| { - !part.is_empty() - && part - .bytes() - .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) - }) - && local(id) - } - None => local(value), - } -} - /// Checks declared export names; native process-environment filtering still applies. pub(crate) fn safe_environment_name(value: &str) -> bool { !value.is_empty() @@ -233,7 +209,7 @@ fn candidates(definition: &Value) -> Validation> { object(value, &allowed, required, "executables")?; for key in ["id", "insertBefore", "insertAfter"] { if let Some(id) = value.get(key) { - if !text(id, 64, key).is_ok_and(|id| tool_id(id) && !id.contains(':')) { + if !text(id, 64, key).is_ok_and(|id| is_tool_id(id) && !id.contains(':')) { return Err(invalid( "executables", "Candidate IDs and anchors must be bounded local identifiers.", @@ -336,16 +312,15 @@ fn candidates(definition: &Value) -> Validation> { return Err(invalid("executables", "A shell environment candidate must match the home variable and executable layout.")); } } - "command" => { + "command" if !definition["commands"] .as_array() - .is_some_and(|commands| commands.contains(&value["command"])) - { - return Err(invalid( - "executables.command", - "A command must belong to its tool definition.", - )); - } + .is_some_and(|commands| commands.contains(&value["command"])) => + { + return Err(invalid( + "executables.command", + "A command must belong to its tool definition.", + )); } _ => (), } @@ -448,7 +423,7 @@ fn definition(plugin_id: &str, value: &Value) -> Validation<()> { "toolDefinitions", )?; let id = text(&value["id"], 192, "toolDefinitions.id")?; - if !tool_id(id) + if !is_tool_id(id) || id .split_once(':') .is_some_and(|(owner, _)| owner != plugin_id) @@ -653,14 +628,20 @@ fn provider(value: &Value) -> Validation<()> { "toolchainProviders", )?; let id = text(&value["id"], 64, "toolchainProviders.id")?; - if !tool_id(id) || id.contains(':') { + if !is_tool_id(id) || id.contains(':') { return Err(invalid( "toolchainProviders.id", "A provider ID is local to its plugin.", )); } text(&value["displayName"], 64, "toolchainProviders.displayName")?; - strings(&value["tools"], 1, 32, "toolchainProviders.tools", tool_id)?; + strings( + &value["tools"], + 1, + 32, + "toolchainProviders.tools", + is_tool_id, + )?; let mut patterns = BTreeSet::new(); let watches = match value.get("watchFiles") { Some(watches) => list(watches, 1, 32, "watchFiles")?, @@ -688,7 +669,7 @@ fn provider(value: &Value) -> Validation<()> { } /// Checks declaration shape and deterministic policy without asserting native trust. -pub(crate) fn validate_declarations( +pub fn validate_declarations( plugin_id: &str, definitions: &[Value], providers: &[Value], @@ -728,7 +709,10 @@ pub(crate) fn validate_declarations( /// owner. Those definitions are left out and reported in `conflicts`; every other definition /// still registers. Duplicate plugin IDs make the enabled set ambiguous, so they reject the /// whole batch. -pub(crate) fn registry(input: &Value) -> Validation { +/// +/// `validate_manifest` revalidates each complete manifest. The plugin host supplies it: +/// only the host assembles the sections every domain contributes to a manifest. +pub fn registry(input: &Value, validate_manifest: ManifestValidator) -> Validation { object(input, &["plugins"], &["plugins"], "registry")?; let plugins = list(&input["plugins"], 0, 128, "plugins")?; let mut definitions = Vec::new(); @@ -737,9 +721,7 @@ pub(crate) fn registry(input: &Value) -> Validation { let mut plugin_ids = BTreeSet::new(); for entry in plugins { object(entry, &["manifest"], &["manifest"], "plugins")?; - let manifest = validate_manifest_value(entry["manifest"].clone())?; - let manifest = serde_json::to_value(manifest) - .map_err(|_| invalid("manifest", "The validated manifest cannot be encoded."))?; + let manifest = validate_manifest(entry["manifest"].clone())?; let plugin_id = text(&manifest["id"], 128, "id")?; if !plugin_ids.insert(plugin_id.to_owned()) { return Err(invalid( diff --git a/rust/crates/core/src/toolchain/fixed.rs b/rust/crates/toolchain/src/fixed.rs similarity index 92% rename from rust/crates/core/src/toolchain/fixed.rs rename to rust/crates/toolchain/src/fixed.rs index 9b9051d0..9820892c 100644 --- a/rust/crates/core/src/toolchain/fixed.rs +++ b/rust/crates/toolchain/src/fixed.rs @@ -1,7 +1,7 @@ //! Inert consumer-owned choices; native hosts must attest paths and version probes. use super::pins; -use crate::plugins::declarative::{PluginRejection, Validation}; +use lithe_plugin_model::{PluginRejection, Validation}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -24,7 +24,7 @@ pub enum FixedToolchainSelection { impl FixedToolchainSelection { /// Validates a saved path lexically, without looking up its definition or files. - pub(crate) fn parse(value: &Value) -> Validation { + pub fn parse(value: &Value) -> Validation { let invalid = || { PluginRejection::at( "plugin.toolchain.invalidParams", diff --git a/rust/crates/core/src/toolchain/mod.rs b/rust/crates/toolchain/src/lib.rs similarity index 55% rename from rust/crates/core/src/toolchain/mod.rs rename to rust/crates/toolchain/src/lib.rs index 16282da9..a49f86b5 100644 --- a/rust/crates/core/src/toolchain/mod.rs +++ b/rust/crates/toolchain/src/lib.rs @@ -2,22 +2,28 @@ //! //! No manager configuration, native scanning or process lifecycle belongs here. -pub(crate) mod definitions; -pub(crate) mod fixed; -pub(crate) mod paths; +pub mod definitions; +pub mod fixed; +pub mod paths; mod pin_sources; -pub(crate) mod pins; -pub(crate) mod preferences; -pub(crate) mod query; +pub mod pins; +pub mod preferences; +pub mod query; mod resolution; -use crate::plugins::declarative::{PluginRejection, Validation}; -use crate::protocol::CoreError; +use lithe_plugin_model::{PluginRejection, Validation}; +use lithe_protocol::CoreError; use serde_json::{json, Value}; +/// Revalidates one complete plugin manifest and returns its normalized JSON form. +/// +/// The plugin host owns the manifest schema; it supplies this function so toolchain +/// policy reads only manifests that passed the same validation as installation. +pub type ManifestValidator = fn(Value) -> Validation; + fn command( input: Value, - operation: fn(&Value) -> Validation, + operation: impl FnOnce(&Value) -> Validation, key: &str, ) -> Result { let result = if input.to_string().len() > 4 * 1024 * 1024 { @@ -35,14 +41,30 @@ fn command( } /// Registers the definitions of enabled plugins; a tool ID declared by several plugins -/// belongs to none of them and is reported in `conflicts` instead. -pub fn registry_command(input: Value) -> Result { - command(input, definitions::registry, "registry") +/// belongs to none of them and is reported in `conflicts` instead. `validate_manifest` +/// revalidates each manifest; see [`definitions::registry`]. +pub fn registry_command( + input: Value, + validate_manifest: ManifestValidator, +) -> Result { + command( + input, + |input| definitions::registry(input, validate_manifest), + "registry", + ) } /// Validates one provider reply against its request and host filesystem facts. -pub fn decode_reply_command(input: Value) -> Result { - command(input, query::decode, "snapshot") +/// `validate_manifest` revalidates the registry it carries. +pub fn decode_reply_command( + input: Value, + validate_manifest: ManifestValidator, +) -> Result { + command( + input, + |input| query::decode(input, validate_manifest), + "snapshot", + ) } /// Builds the error reply for a query that timed out or found no backend. @@ -86,26 +108,61 @@ pub fn edit_consumer_preferences_command(input: Value) -> Result Result { - command(input, resolution::resolve, "resolution") +pub fn resolve_command( + input: Value, + validate_manifest: ManifestValidator, +) -> Result { + command( + input, + |input| resolution::resolve(input, validate_manifest), + "resolution", + ) } /// Extracts and compares a version using only its enabled plugin definition. -pub fn check_version_command(input: Value) -> Result { - command(input, resolution::check_version, "version") +pub fn check_version_command( + input: Value, + validate_manifest: ManifestValidator, +) -> Result { + command( + input, + |input| resolution::check_version(input, validate_manifest), + "version", + ) } /// Expands a tool definition's launch template for a selected executable. -pub fn instantiate_launch_command(input: Value) -> Result { - command(input, resolution::instantiate_launch, "launch") +pub fn instantiate_launch_command( + input: Value, + validate_manifest: ManifestValidator, +) -> Result { + command( + input, + |input| resolution::instantiate_launch(input, validate_manifest), + "launch", + ) } /// Produces bounded scan declarations, not a native scan or tool-name table. -pub fn discovery_plan_command(input: Value) -> Result { - command(input, resolution::discovery_plan, "plan") +pub fn discovery_plan_command( + input: Value, + validate_manifest: ManifestValidator, +) -> Result { + command( + input, + |input| resolution::discovery_plan(input, validate_manifest), + "plan", + ) } /// Lists checked installations, versions and the automatic choice for a settings page. -pub fn candidates_command(input: Value) -> Result { - command(input, resolution::candidates, "candidates") +pub fn candidates_command( + input: Value, + validate_manifest: ManifestValidator, +) -> Result { + command( + input, + |input| resolution::candidates(input, validate_manifest), + "candidates", + ) } diff --git a/rust/crates/core/src/toolchain/paths.rs b/rust/crates/toolchain/src/paths.rs similarity index 86% rename from rust/crates/core/src/toolchain/paths.rs rename to rust/crates/toolchain/src/paths.rs index d7c8df6b..b7968489 100644 --- a/rust/crates/core/src/toolchain/paths.rs +++ b/rust/crates/toolchain/src/paths.rs @@ -2,7 +2,7 @@ /// Normalizes a bounded absolute path using the target platform's separators. /// Native adapters must supply canonical filesystem facts separately. -pub(crate) fn normalize(path: &str, platform: &str, case_sensitive: bool) -> Option { +pub fn normalize(path: &str, platform: &str, case_sensitive: bool) -> Option { if path.is_empty() || path.len() > 4096 || path.chars().any(char::is_control) { return None; } @@ -68,7 +68,7 @@ pub(crate) fn normalize(path: &str, platform: &str, case_sensitive: bool) -> Opt } /// Compares complete native path components with target-platform case rules. -pub(crate) fn within(path: &str, root: &str, platform: &str, case_sensitive: bool) -> bool { +pub fn within(path: &str, root: &str, platform: &str, case_sensitive: bool) -> bool { let Some(path) = normalize(path, platform, case_sensitive) else { return false; }; @@ -78,14 +78,8 @@ pub(crate) fn within(path: &str, root: &str, platform: &str, case_sensitive: boo path == root || path.starts_with(&format!("{}/", root.trim_end_matches('/'))) } -/// Uses the default target-platform case rules for native containment. -#[cfg(test)] -pub(crate) fn path_within(path: &str, root: &str, platform: &str) -> bool { - within(path, root, platform, platform != "windows") -} - /// Joins a package-independent relative executable layout beneath a native home. -pub(crate) fn join(home: &str, relative: &str, platform: &str) -> Option { +pub fn join(home: &str, relative: &str, platform: &str) -> Option { if relative.is_empty() || relative.contains(['\\', ':']) || relative.chars().any(char::is_control) diff --git a/rust/crates/core/src/toolchain/pin_sources.rs b/rust/crates/toolchain/src/pin_sources.rs similarity index 87% rename from rust/crates/core/src/toolchain/pin_sources.rs rename to rust/crates/toolchain/src/pin_sources.rs index a02263f1..7389bba3 100644 --- a/rust/crates/core/src/toolchain/pin_sources.rs +++ b/rust/crates/toolchain/src/pin_sources.rs @@ -4,9 +4,9 @@ //! publication. Reading or editing these documents never grants execution authority. use super::pins; -use crate::plugins::declarative::{PluginRejection, Validation}; -use crate::protocol::json_document::parse_unique_json; use ignore::gitignore::GitignoreBuilder; +use lithe_plugin_model::{PluginRejection, Validation}; +use lithe_protocol::json_document::parse_unique_json; use serde::Deserialize; use serde_json::{json, Value}; @@ -190,29 +190,6 @@ fn prepare_ignore(original: Option<&str>) -> Validation<(String, bool)> { mod tests { use super::*; - #[test] - fn source_edits_match_shared_fixture() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../../fixtures/toolchain-pin-storage.json" - )) - .unwrap(); - for case in fixture["cases"].as_array().unwrap() { - let response: Value = serde_json::from_str(&crate::execute_json( - &json!({"command": case["command"], "payload": case["input"]}).to_string(), - )) - .unwrap(); - if let Some(code) = case.get("error") { - assert_eq!(response["data"]["status"], "rejected", "{case}: {response}"); - assert_eq!( - &response["data"]["error"]["code"], code, - "{case}: {response}" - ); - } else { - assert_eq!(response["data"], case["expected"], "{case}: {response}"); - } - } - } - #[test] fn ignore_growth_cannot_publish_a_document_the_next_edit_would_refuse() { let full = "#\n".repeat(MAX_IGNORE_LINES); diff --git a/rust/crates/core/src/toolchain/pins.rs b/rust/crates/toolchain/src/pins.rs similarity index 89% rename from rust/crates/core/src/toolchain/pins.rs rename to rust/crates/toolchain/src/pins.rs index 46383d61..8cdf5283 100644 --- a/rust/crates/core/src/toolchain/pins.rs +++ b/rust/crates/toolchain/src/pins.rs @@ -1,6 +1,8 @@ //! Validates inert machine-local pins and resolves directory inheritance without I/O. -use crate::plugins::declarative::{PluginRejection, Validation}; +use lithe_plugin_model::{ + is_local_tool_id, is_tool_id, is_tool_namespace, PluginRejection, Validation, +}; use serde_json::{json, Map, Value}; const MAX_DIRECTORIES: usize = 1024; @@ -11,7 +13,7 @@ const MAX_LEGACY_PROPERTIES: usize = 16; /// Resolves a stored pin without granting execution authority or modifying the document. /// Callers must independently require an active definition and validate native files. -pub(crate) fn resolve(input: &Value) -> Validation { +pub fn resolve(input: &Value) -> Validation { let request = object(input, "input", &["document", "directory", "tool"])?; let document = required(request, "document", "input")?; validate(document)?; @@ -27,7 +29,7 @@ pub(crate) fn resolve(input: &Value) -> Validation { )); } let tool = text(required(request, "tool", "input")?, "tool", 192)?; - if !valid_tool_id(tool) { + if !is_tool_id(tool) { return Err(invalid("tool", "Expected a valid tool identifier.")); } let pins = document.get("pins").and_then(Value::as_object); @@ -80,7 +82,7 @@ pub(crate) fn edit(input: &Value) -> Validation { &format!("{field}.tool"), 192, )?; - if !valid_tool_id(tool) || !seen.insert(tool) { + if !is_tool_id(tool) || !seen.insert(tool) { return Err(invalid( format!("{field}.tool"), "Expected a unique valid tool identifier.", @@ -127,7 +129,7 @@ pub(crate) fn edit(input: &Value) -> Validation { /// Checks the complete local document, retaining legacy entries and dormant tool IDs. /// This validation is lexical only; paths are never probed, canonicalized, or launched. -pub(crate) fn validate(document: &Value) -> Validation<()> { +pub fn validate(document: &Value) -> Validation<()> { let root = object(document, "document", &["version", "toolchains", "pins"])?; if required(root, "version", "document")?.as_u64() != Some(1) { return Err(invalid( @@ -173,7 +175,7 @@ pub(crate) fn validate(document: &Value) -> Validation<()> { count(tools.len(), MAX_TOOLS_PER_DIRECTORY, &field)?; for (tool, pin) in tools { let field = format!("{field}.{tool}"); - if !valid_tool_id(tool) { + if !is_tool_id(tool) { return Err(invalid(&field, "Expected a valid tool identifier.")); } validate_pin(pin, &field)?; @@ -208,8 +210,8 @@ fn validate_pin(value: &Value, field: &str) -> Validation<()> { )?; let valid = provider.split_once(':').is_some_and(|(plugin, id)| { plugin.starts_with(|character: char| character.is_ascii_lowercase()) - && valid_namespace(plugin) - && valid_local_id(id) + && is_tool_namespace(plugin) + && is_local_tool_id(id) }); if !valid { return Err(invalid( @@ -232,36 +234,6 @@ pub(super) fn valid_directory(value: &str) -> bool { .all(|part| !part.is_empty() && part != "." && part != "..")) } -/// Checks both reserved short IDs and plugin-qualified IDs, not registry membership. -pub(super) fn valid_tool_id(value: &str) -> bool { - value.len() <= 192 - && match value.split_once(':') { - Some((namespace, id)) => valid_namespace(namespace) && valid_local_id(id), - None => valid_local_id(value), - } -} - -/// Checks the lowercase dotted/dashed namespace grammar used by tool IDs. -fn valid_namespace(value: &str) -> bool { - !value.is_empty() - && value.split(['.', '-']).all(|part| { - !part.is_empty() - && part - .bytes() - .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) - }) -} - -/// Checks the bounded local portion of a tool or provider ID. -pub(super) fn valid_local_id(value: &str) -> bool { - !value.is_empty() - && value.len() <= 64 - && value.as_bytes()[0].is_ascii_lowercase() - && value - .bytes() - .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') -} - /// Recognizes POSIX, drive-rooted Windows, and UNC paths on either build platform. pub(crate) fn valid_native_path(value: &str) -> bool { if value.chars().any(char::is_control) { diff --git a/rust/crates/core/src/toolchain/preferences.rs b/rust/crates/toolchain/src/preferences.rs similarity index 95% rename from rust/crates/core/src/toolchain/preferences.rs rename to rust/crates/toolchain/src/preferences.rs index 914695e4..ae5e9728 100644 --- a/rust/crates/core/src/toolchain/preferences.rs +++ b/rust/crates/toolchain/src/preferences.rs @@ -5,7 +5,7 @@ //! and revalidate the owning document after collecting native evidence. use super::{fixed::FixedToolchainSelection, pins}; -use crate::plugins::declarative::{PluginRejection, Validation}; +use lithe_plugin_model::{PluginRejection, Validation}; use serde_json::{json, Map, Value}; const MAX_DOCUMENT_BYTES: usize = 2 * 1024 * 1024; @@ -52,7 +52,7 @@ fn resolve_choice(input: &Value) -> Validation { .get("tool") .and_then(Value::as_str) .ok_or_else(|| invalid("tool"))?; - if !pins::valid_tool_id(tool) { + if !lithe_plugin_model::is_tool_id(tool) { return Err(invalid("tool")); } let consumer = request @@ -237,7 +237,7 @@ fn automatic(inheritance: Vec) -> Value { /// Validates dormant IDs as inert preferences without requiring installation /// or package trust. Their presence never registers a tool or grants authority. -pub(crate) fn validate(document: &Value) -> Validation<()> { +pub fn validate(document: &Value) -> Validation<()> { if document.to_string().len() > MAX_DOCUMENT_BYTES { return Err(limit("document")); } @@ -282,7 +282,7 @@ fn scope(value: &Value, field: &str, workspace: bool) -> Validation<()> { fn tools_map(value: &Value, field: &str, workspace: bool, consumer: bool) -> Validation<()> { for (tool, binding) in map(value, field, MAX_TOOLS)? { - if !pins::valid_tool_id(tool) { + if !lithe_plugin_model::is_tool_id(tool) { return Err(invalid(field)); } if binding.get("inherit").is_some() { @@ -303,7 +303,7 @@ fn tools_map(value: &Value, field: &str, workspace: bool, consumer: bool) -> Val fn valid_consumer(value: &str) -> bool { value.len() <= 256 && value.split_once('/').is_some_and(|(plugin, local)| { - crate::plugins::declarative::is_plugin_id(plugin) && pins::valid_local_id(local) + lithe_plugin_model::is_plugin_id(plugin) && lithe_plugin_model::is_local_tool_id(local) }) } @@ -346,31 +346,18 @@ mod tests { #[test] fn shared_preferences_fixture_resolves_without_native_or_domain_facts() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../fixtures/toolchains/consumer-preferences.json" + "../../../fixtures/toolchains/consumer-preferences.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { let input = json!({"document":fixture["document"],"tool":"dev.example.runtime:sdk", "directory":case["directory"],"consumer":case["consumer"]}); - let result = crate::toolchain::resolve_consumer_preferences_command(input).unwrap(); + let result = crate::resolve_consumer_preferences_command(input).unwrap(); assert_eq!(result["status"], "ok", "{result}"); assert_eq!(result["preferences"]["fixed"], case["expected"]); } } - #[test] - fn consumer_preferences_json_dispatcher_preserves_the_value_boundary() { - let payload = request(document()); - let response: Value = serde_json::from_str(&crate::execute_json(&json!({ - "id":"consumer-preferences","command":"toolchains.resolveConsumerPreferences","payload":payload - }).to_string())).unwrap(); - assert_eq!(response["ok"], true, "{response}"); - assert_eq!( - response["data"]["preferences"]["fixed"], - json!({"home":"/fixture/sdk-one"}) - ); - } - #[test] fn explicit_preference_edit_preserves_siblings_and_reset_exposes_live_inheritance() { let original = document(); diff --git a/rust/crates/core/src/toolchain/query.rs b/rust/crates/toolchain/src/query.rs similarity index 98% rename from rust/crates/core/src/toolchain/query.rs rename to rust/crates/toolchain/src/query.rs index 532e96d9..69514654 100644 --- a/rust/crates/core/src/toolchain/query.rs +++ b/rust/crates/toolchain/src/query.rs @@ -1,7 +1,8 @@ //! Validates provider query replies against host-owned identity and filesystem facts. use super::paths; -use crate::plugins::declarative::{PluginRejection, Validation}; +use crate::ManifestValidator; +use lithe_plugin_model::{PluginRejection, Validation}; use serde::Deserialize; use serde_json::{json, Value}; use std::collections::{BTreeMap, BTreeSet}; @@ -219,7 +220,7 @@ fn object_keys( } /// Maps each query error to one stable JSON-RPC number without transport work. -pub(crate) fn rpc_code(code: &str) -> Option { +pub fn rpc_code(code: &str) -> Option { Some(match code { "plugin.toolchain.methodUnavailable" => -32601, INVALID => -32602, @@ -553,7 +554,7 @@ fn checked_installation( /// Validates an external installation against trusted, authority-local native facts. /// Returns canonical home/executable paths without touching the native filesystem. /// Project-code candidates require their separate consumer trust policy. -pub(crate) fn validate_installation( +pub fn validate_installation( home: &str, definition: &Value, filesystem: &[Value], @@ -579,7 +580,7 @@ pub(crate) fn validate_installation( /// Decodes one provider reply using only the revalidated registry and native facts. /// No filesystem, process, environment or JSON-RPC transport is accessed here. -pub(crate) fn decode(input: &Value) -> Validation { +pub fn decode(input: &Value, validate_manifest: ManifestValidator) -> Validation { object_keys( input, &[ @@ -604,7 +605,7 @@ pub(crate) fn decode(input: &Value) -> Validation { ], INVALID, )?; - let registry = super::definitions::registry(&input["registry"])?; + let registry = super::definitions::registry(&input["registry"], validate_manifest)?; let request_value = &input["request"]; let request: Request = serde_json::from_value(request_value.clone()) .map_err(|_| reject(INVALID, "request", "Malformed toolchain query request."))?; @@ -620,7 +621,7 @@ pub(crate) fn decode(input: &Value) -> Validation { || params .tools .iter() - .any(|tool| !super::definitions::tool_id(tool)) + .any(|tool| !lithe_plugin_model::is_tool_id(tool)) || params.tools.iter().collect::>().len() != params.tools.len() || !params.tools.windows(2).all(|pair| pair[0] < pair[1]) || !request_value["params"].as_object().is_some_and(|object| { @@ -851,7 +852,7 @@ pub(crate) fn decode(input: &Value) -> Validation { let mut installations = BTreeMap::new(); let mut references = BTreeMap::new(); for installation in &reply.installations { - if !super::definitions::tool_id(&installation.tool) + if !lithe_plugin_model::is_tool_id(&installation.tool) || !bounded(&installation.version, 128) || !bounded(&installation.home, 4096) { @@ -897,7 +898,7 @@ pub(crate) fn decode(input: &Value) -> Validation { let mut statement_facts = BTreeMap::new(); let mut diagnostics = BTreeMap::new(); for statement in &reply.statements { - if !super::definitions::tool_id(&statement.tool) + if !lithe_plugin_model::is_tool_id(&statement.tool) || !bounded(&statement.source, 4096) || !bounded(&statement.request, 512) || statement diff --git a/rust/crates/toolchain/src/resolution.rs b/rust/crates/toolchain/src/resolution.rs new file mode 100644 index 00000000..65b21d9f --- /dev/null +++ b/rust/crates/toolchain/src/resolution.rs @@ -0,0 +1,631 @@ +//! Resolves captured toolchain candidates without native I/O or manager semantics. + +use super::{definitions, fixed::FixedToolchainSelection, paths, pins, query}; +use crate::ManifestValidator; +use lithe_plugin_model::{PluginRejection, Validation}; +use regex::RegexBuilder; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet}; + +mod automatic; +mod discovery; +mod installations; +mod launch; +mod probe; +mod project; +mod snapshot; + +pub(crate) use automatic::*; +pub(crate) use discovery::*; +pub(crate) use installations::*; +pub(crate) use launch::*; +pub(crate) use probe::*; +pub(crate) use project::*; +pub(crate) use snapshot::*; + +/// Resolves definitions, provider replies, pins and trusted native observations once. +pub(crate) fn resolve(input: &Value, validate_manifest: ManifestValidator) -> Validation { + fields( + input, + &[ + "registry", + "tool", + "directory", + "operationID", + "local", + "platform", + "filesystemCaseSensitive", + "workspaceRoot", + "workspaceRoots", + "filesystem", + "minimumVersion", + "probes", + "queries", + "systemInstallations", + "globalDefault", + "environment", + "commandHomes", + "projectCodeAllowed", + "providerChoices", + "generation", + "context", + "fixed", + ], + )?; + let registry = definitions::registry(&input["registry"], validate_manifest)?; + let tool = input["tool"] + .as_str() + .filter(|tool| lithe_plugin_model::is_tool_id(tool)) + .ok_or_else(|| reject("tool"))?; + let directory = input["directory"] + .as_str() + .ok_or_else(|| reject("directory"))?; + input["operationID"] + .as_str() + .filter(|value| bounded(value, 128)) + .ok_or_else(|| reject("operationID"))?; + let document = input + .get("local") + .cloned() + .unwrap_or(json!({"version":1,"toolchains":{}})); + let pin = pins::resolve(&json!({"document":document,"directory":directory,"tool":tool}))?; + let fixed = input + .get("fixed") + .map(FixedToolchainSelection::parse) + .transpose()?; + let Some(definition) = enabled(®istry, tool) else { + return Ok(output( + input, + json!({"kind":"skip","reason":reason("definitionUnavailable",tool)}), + vec![], + vec![], + vec![], + )); + }; + let generation = input["generation"] + .as_u64() + .filter(|value| *value > 0) + .ok_or_else(|| reject("generation"))?; + let (snapshot, root) = prepare(input, definition)?; + let platform = snapshot.platform; + let case_sensitive = snapshot.case_sensitive; + if input + .get("projectCodeAllowed") + .is_some_and(|value| !value.is_boolean()) + { + return Err(reject("projectCodeAllowed")); + } + string_maps(input, &["environment", "commandHomes", "providerChoices"])?; + if let Some(choices) = input.get("providerChoices") { + fields(choices, &["project", "global", "automatic"])?; + } + if let Some(fixed) = fixed { + return Ok(match snapshot.fixed_selection(&fixed) { + Ok(selection) => output( + input, + json!({"kind":"resolved","selection":selection}), + vec![json!({"candidateID":"consumerFixed","layer":"pinned","state":"resolved"})], + vec![], + vec![], + ), + Err(code) => output( + input, + json!({"kind":"terminalFailure","reason":reason(code,tool)}), + vec![ + json!({"candidateID":"consumerFixed","layer":"pinned","state":"terminalFailure","reason":reason(code,tool)}), + ], + vec![], + vec![], + ), + }); + } + // A failed optional provider must never revoke a usable explicit fixed value. + if let Some(home) = pin["pin"]["home"].as_str() { + let result = snapshot.selection( + home, + pin["pin"]["provider"].as_str().unwrap_or(""), + "pinned", + ); + return Ok(match result { + Ok(selection) => output( + input, + json!({"kind":"resolved","selection":selection}), + vec![json!({"candidateID":"pinned","layer":"pinned","state":"resolved"})], + vec![], + vec![], + ), + Err(code) => output( + input, + json!({"kind":"terminalFailure","reason":reason(code,tool)}), + vec![ + json!({"candidateID":"pinned","layer":"pinned","state":"terminalFailure","reason":reason(code,tool)}), + ], + vec![], + vec![], + ), + }); + } + let mut installations = Vec::new(); + let mut statements = Vec::new(); + let mut unmet = Vec::new(); + let mut query_providers = BTreeSet::new(); + for value in array(input, "queries", 256)? { + if value["registry"] != input["registry"] { + return Err(reject("queries.registry")); + } + if value["platform"] != platform { + return Err(reject("queries.platform")); + } + if value["request"]["params"]["workspace"]["root"] + .as_str() + .and_then(|path| snapshot.key(path).ok()) + != Some(snapshot.key(&root)?) + { + return Err(reject("queries.workspace")); + } + let requested_directory = value["request"]["params"]["workspace"]["directory"] + .as_str() + .unwrap_or(""); + if requested_directory != directory { + return Err(reject("queries.directory")); + } + if value["filesystem"] != input["filesystem"] { + return Err(reject("queries.filesystem")); + } + let query_case = value + .get("filesystemCaseSensitive") + .and_then(Value::as_bool) + .unwrap_or(platform != "windows"); + if query_case != case_sensitive + || value.get("workspaceRoots") != input.get("workspaceRoots") + { + return Err(reject("queries.filesystemPolicy")); + } + if value["currentGeneration"].as_u64() != Some(generation) { + return Err(reject("queries.generation")); + } + if value["request"]["params"]["context"] != input["context"] { + return Err(reject("queries.context")); + } + if !query_providers.insert(( + value["pluginID"].to_string(), + value["request"]["params"]["providerID"].to_string(), + )) { + return Err(reject("queries.providerID")); + } + let decoded = match query::decode(value, validate_manifest) { + Ok(decoded) => decoded, + Err(error) + if matches!( + error.code, + "plugin.toolchain.timeout" | "plugin.toolchain.providerUnavailable" + ) => + { + // Only a provider asked about this tool leaves it without an answer; `cause` + // keeps a missed deadline apart from a backend that could not answer at all. + if value["request"]["params"]["tools"] + .as_array() + .is_some_and(|tools| tools.iter().any(|asked| asked == tool)) + { + unmet.push(json!({"code":"providerUnavailable","tool":tool, + "provider":format!("{}:{}",value["pluginID"].as_str().unwrap_or(""),value["request"]["params"]["providerID"].as_str().unwrap_or("")), + "cause":error.code})); + } + continue; + } + Err(error) => return Err(error), + }; + installations.extend( + decoded["installations"] + .as_array() + .into_iter() + .flatten() + .filter(|value| value["tool"] == tool) + .cloned(), + ); + statements.extend( + decoded["statements"] + .as_array() + .into_iter() + .flatten() + .filter(|value| value["tool"] == tool) + .cloned(), + ); + unmet.extend( + decoded["diagnostics"] + .as_array() + .into_iter() + .flatten() + .filter(|value| value["tool"] == tool && value["code"] != "requestedNotInstalled") + .cloned(), + ); + } + installations.extend(system_installations(input, tool)?); + let candidates: Vec<_> = definition["executables"] + .as_array() + .into_iter() + .flatten() + .filter(|value| applicable(value, platform)) + .collect(); + let mut trace_rows = Vec::new(); + let mut conflicts = Vec::new(); + let mut outcome = None; + let mut project_done = false; + if let Some(default) = input.get("globalDefault").filter(|value| !value.is_null()) { + fields(default, &["home"])?; + default["home"] + .as_str() + .filter(|value| bounded(value, 4096)) + .ok_or_else(|| reject("globalDefault.home"))?; + } + // Explicit application defaults obey the same terminal policy without a plugin slot. + let default_preflight = input + .get("globalDefault") + .filter(|value| !value.is_null()) + .is_some() + && !candidates + .iter() + .any(|value| value["kind"] == "setting" && value["layer"] == "global"); + let mut default_checked = !default_preflight; + for candidate in &candidates { + let kind = candidate["kind"].as_str().unwrap_or(""); + let id = candidate["id"].as_str().unwrap_or(kind); + let layer = match kind { + "setting" if candidate["layer"] == "project" => "pinned", + "projectFile" => "project", + "toolchain" if candidate["scope"] == "project" => "project", + "toolchain" if candidate["scope"] == "installed" => "automatic", + _ => "global", + }; + if !default_checked && matches!(layer, "global" | "automatic") && outcome.is_none() { + default_checked = true; + let home = input["globalDefault"]["home"] + .as_str() + .ok_or_else(|| reject("globalDefault"))?; + let result = snapshot.selection(home, "host:default", "global"); + outcome = Some(match result { + Ok(selection) => json!({"kind":"resolved","selection":selection}), + Err(code) => json!({"kind":"terminalFailure","reason":reason(code,tool)}), + }); + } + if outcome.is_some() { + trace(&mut trace_rows, id, layer, "shadowed", None); + continue; + } + let mut selected = None; + let mut failure = None; + let mut terminal = false; + match (kind, layer) { + ("setting", "pinned") => { + if let Some(home) = pin["pin"]["home"].as_str() { + match snapshot.selection( + home, + pin["pin"]["provider"].as_str().unwrap_or(""), + layer, + ) { + Ok(value) => selected = Some(value), + Err(code) => { + failure = Some(reason(code, tool)); + terminal = true; + } + } + } + } + ("setting", _) => { + if let Some(value) = input.get("globalDefault").filter(|value| !value.is_null()) { + let home = value["home"] + .as_str() + .ok_or_else(|| reject("globalDefault"))?; + match snapshot.selection(home, "host:default", layer) { + Ok(value) => selected = Some(value), + Err(code) => { + failure = Some(reason(code, tool)); + terminal = true; + } + } + } + } + (_, "project") if !project_done => { + project_done = true; + let mut choices = Vec::new(); + for source in &candidates { + if source["kind"] != "projectFile" { + continue; + } + for ancestor in ancestors(directory) { + let anchor = if ancestor == "." { + root.clone() + } else { + paths::join(&root, ancestor, platform) + .ok_or_else(|| reject("directory"))? + }; + let native = + paths::join(&anchor, source["path"].as_str().unwrap_or(""), platform) + .ok_or_else(|| reject("projectFile"))?; + if snapshot.fact(&native, "file").is_err() { + continue; + } + let markers = + source["requires"] + .as_array() + .into_iter() + .flatten() + .all(|marker| { + marker + .as_str() + .and_then(|marker| paths::join(&anchor, marker, platform)) + .is_some_and(|path| snapshot.fact(&path, "file").is_ok()) + }); + if !markers { + continue; + } + let mut value = (*source).clone(); + value["nativePath"] = json!(native); + if let Some(home) = source["home"].as_str() { + value["nativeHome"] = json!(if home == "." { + anchor.clone() + } else { + paths::join(&anchor, home, platform) + .ok_or_else(|| reject("projectFile.home"))? + }); + } + choices.push(ProjectChoice { + anchor, + category: if source["projectKind"] == "environment" { + 1 + } else { + 0 + }, + native: true, + value, + candidate: true, + }); + } + } + if candidates + .iter() + .any(|value| value["kind"] == "toolchain" && value["scope"] == "project") + { + for statement in statements + .iter() + .filter(|value| value["scope"] == "project") + { + let anchor = statement["sourceDirectory"] + .as_str() + .ok_or_else(|| reject("statements.sourceDirectory"))?; + choices.push(ProjectChoice { + anchor: anchor.to_owned(), + category: if statement["kind"] == "environment" { + 1 + } else { + 2 + }, + native: statement["reader"] == "native", + value: statement.clone(), + candidate: false, + }); + } + } + choices.sort_by_key(|value| { + ( + std::cmp::Reverse(value.anchor.split('/').count()), + value.category, + ) + }); + // The nearest declaration masks inherited declarations even when unmet. + if let Some(nearest) = choices.first().map(|value| value.anchor.split('/').count()) + { + choices.retain(|value| value.anchor.split('/').count() == nearest); + } + while let Some(first) = choices.first() { + let depth = first.anchor.split('/').count(); + let category = first.category; + let count = choices + .iter() + .take_while(|value| { + value.anchor.split('/').count() == depth && value.category == category + }) + .count(); + let mut group: Vec<_> = choices.drain(..count).collect(); + let native_sources: BTreeSet<_> = group + .iter() + .filter(|value| value.native && !value.candidate) + .map(|value| value.value["source"].clone().to_string()) + .collect(); + group.retain(|value| { + value.native || !native_sources.contains(&value.value["source"].to_string()) + }); + let homes: BTreeSet<_> = group + .iter() + .filter_map(|value| value.value["installation"].as_str().map(str::to_owned)) + .collect(); + let requests: BTreeSet<_> = group + .iter() + .filter(|value| !value.candidate) + .map(|value| { + ( + value.value["request"].to_string(), + value.value["installation"].to_string(), + ) + }) + .collect(); + let choice = input["providerChoices"]["project"].as_str(); + if let Some(chosen) = choice { + group.retain(|value| value.candidate || value.value["provider"] == chosen); + } + let retained: BTreeSet<_> = group + .iter() + .filter(|value| !value.candidate) + .map(|value| { + ( + value.value["request"].to_string(), + value.value["installation"].to_string(), + ) + }) + .collect(); + if (choice.is_none() && (homes.len() > 1 || requests.len() > 1)) + || retained.len() > 1 + { + let providers: BTreeSet<_> = group + .iter() + .filter_map(|value| value.value["provider"].as_str()) + .collect(); + conflicts.push(json!({"scope":"project","providers":providers,"action":"chooseProvider"})); + failure = Some(reason("providerConflict", tool)); + break; + } + for choice in &group { + match project_selection(&snapshot, choice) { + Ok(value) => { + selected = Some(value); + break; + } + Err(code) => { + let mut why = reason(code, tool); + for key in ["provider", "source", "request"] { + if let Some(value) = choice.value.get(key) { + why[key] = value.clone(); + } + } + if code == "requestedNotInstalled" && !unmet.contains(&why) { + unmet.push(why.clone()); + } + failure = Some(why); + } + } + } + if selected.is_some() { + break; + } + } + } + (_, "project") => continue, + ("toolchain", "global") => { + let globals: Vec<_> = statements + .iter() + .filter(|value| value["scope"] == "global") + .collect(); + let chosen = input["providerChoices"]["global"].as_str(); + let globals: Vec<_> = globals + .into_iter() + .filter(|value| chosen.is_none_or(|chosen| value["provider"] == chosen)) + .collect(); + let homes: BTreeSet<_> = globals + .iter() + .filter_map(|value| value["installation"].as_str()) + .collect(); + let requests: BTreeSet<_> = globals + .iter() + .map(|value| { + ( + value["request"].to_string(), + value["installation"].to_string(), + ) + }) + .collect(); + if homes.len() > 1 || requests.len() > 1 { + let providers: BTreeSet<_> = globals + .iter() + .filter_map(|value| value["provider"].as_str()) + .collect(); + conflicts.push( + json!({"scope":"global","providers":providers,"action":"chooseProvider"}), + ); + failure = Some(reason("providerConflict", tool)); + } else if let Some(value) = globals.first() { + if let Some(home) = value["installation"].as_str() { + match snapshot.selection( + home, + value["provider"].as_str().unwrap_or(""), + layer, + ) { + Ok(value) => selected = Some(value), + Err(code) => failure = Some(reason(code, tool)), + } + } else { + let why = json!({"code":"requestedNotInstalled","tool":tool,"provider":value["provider"],"source":value["source"],"request":value["request"]}); + unmet.push(why.clone()); + failure = Some(why); + } + } + } + ("toolchain", "automatic") => { + let set = automatic_set(&snapshot, &installations)?; + failure = set.failure.map(|code| reason(code, tool)); + let (choice, conflict) = automatic_choice( + &snapshot, + set.eligible, + input["providerChoices"]["automatic"].as_str(), + )?; + selected = choice; + if let Some(conflict) = conflict { + conflicts.push(conflict); + failure = Some(reason("ambiguousInstallation", tool)); + } + } + ("environment", _) => { + if let Some(home) = candidate["variable"] + .as_str() + .and_then(|name| input["environment"][name].as_str()) + .filter(|value| !value.is_empty()) + { + match snapshot.layout_selection( + home, + candidate["path"].as_str(), + "host:shell", + layer, + ) { + Ok(value) => selected = Some(value), + Err(code) => failure = Some(reason(code, tool)), + } + } + } + ("command", _) => { + if let Some(home) = candidate["command"] + .as_str() + .and_then(|name| input["commandHomes"][name].as_str()) + { + match snapshot.selection(home, "host:shell", layer) { + Ok(value) => selected = Some(value), + Err(code) => failure = Some(reason(code, tool)), + } + } + } + _ => return Err(reject("executables.kind")), + } + if let Some(selection) = selected { + trace(&mut trace_rows, id, layer, "resolved", None); + outcome = Some(json!({"kind":"resolved","selection":selection})); + } else if let Some(why) = failure { + trace( + &mut trace_rows, + id, + layer, + if terminal { "terminalFailure" } else { "skip" }, + Some(why.clone()), + ); + if terminal { + outcome = Some(json!({"kind":"terminalFailure","reason":why})); + } + } else { + trace(&mut trace_rows, id, layer, "empty", None); + } + } + if outcome.is_none() && !default_checked { + let home = input["globalDefault"]["home"] + .as_str() + .ok_or_else(|| reject("globalDefault"))?; + outcome = Some(match snapshot.selection(home, "host:default", "global") { + Ok(selection) => json!({"kind":"resolved","selection":selection}), + Err(code) => json!({"kind":"terminalFailure","reason":reason(code,tool)}), + }); + } + Ok(output( + input, + outcome.unwrap_or_else(|| json!({"kind":"skip","reason":reason("notFound",tool)})), + trace_rows, + unmet, + conflicts, + )) +} diff --git a/rust/crates/toolchain/src/resolution/automatic.rs b/rust/crates/toolchain/src/resolution/automatic.rs new file mode 100644 index 00000000..28fe9b71 --- /dev/null +++ b/rust/crates/toolchain/src/resolution/automatic.rs @@ -0,0 +1,229 @@ +//! Automatic selection among candidates. + +use super::*; + +/// The automatic layer's candidate set, keyed by canonical home. +pub(crate) struct Automatic { + pub(crate) eligible: BTreeMap, + /// Reported installations that failed a check, with the executable to probe when + /// the layout itself was valid. + skipped: BTreeMap, + pub(crate) failure: Option<&'static str>, +} + +pub(crate) fn automatic_set( + snapshot: &Snapshot<'_>, + installations: &[Value], +) -> Validation { + let mut set = Automatic { + eligible: BTreeMap::new(), + skipped: BTreeMap::new(), + failure: None, + }; + for installation in installations { + let Some(home) = installation["home"].as_str() else { + continue; + }; + let provider = installation["provider"].as_str().unwrap_or(""); + match snapshot.selection(home, provider, "automatic") { + Ok(value) => { + let key = snapshot.key(value["home"].as_str().unwrap_or(""))?; + if set + .eligible + .get(&key) + .is_none_or(|kept: &Value| attribution(&value) < attribution(kept)) + { + set.eligible.insert(key, value); + } + } + Err(code) => { + set.failure = Some(code); + let checked = snapshot.checked(home, None).ok(); + let canonical = checked + .as_ref() + .and_then(|value| value["home"].as_str()) + .unwrap_or(home); + let mut row = json!({"home":canonical,"provider":provider,"reason":code}); + if let Some(executable) = checked.as_ref().map(|value| &value["executable"]) { + row["executable"] = executable.clone(); + } + let key = snapshot + .key(canonical) + .unwrap_or_else(|_| canonical.to_owned()); + if set + .skipped + .get(&key) + .is_none_or(|kept: &Value| attribution(&row) < attribution(kept)) + { + set.skipped.insert(key, row); + } + } + } + } + set.skipped.retain(|key, _| !set.eligible.contains_key(key)); + Ok(set) +} + +/// Picks the highest version; equal highest versions in different homes need `chosen`, +/// otherwise they become an `automatic` conflict naming every route. +pub(crate) fn automatic_choice( + snapshot: &Snapshot<'_>, + eligible: BTreeMap, + chosen: Option<&str>, +) -> Validation<(Option, Option)> { + let newest = eligible + .values() + .filter_map(|value| components(value["version"].as_str()?)) + .max(); + let mut best: Vec<_> = eligible + .into_values() + .filter(|value| value["version"].as_str().and_then(components) == newest) + .collect(); + if best.len() <= 1 { + return Ok((best.pop(), None)); + } + if let Some(chosen) = chosen { + let identity = snapshot.key(chosen)?; + let selected = best + .iter() + .find(|value| { + value["home"] + .as_str() + .and_then(|home| snapshot.key(home).ok()) + .as_ref() + == Some(&identity) + }) + .cloned(); + if selected.is_some() { + return Ok((selected, None)); + } + } + let providers: BTreeSet<_> = best + .iter() + .filter_map(|value| value["provider"].as_str()) + .collect(); + let installations: Vec<_> = best.iter().map(|value| value["home"].clone()).collect(); + Ok(( + None, + Some( + json!({"scope":"automatic","providers":providers,"installations":installations, + "action":"chooseInstallation"}), + ), + )) +} + +/// Lists what a settings page shows for one tool's global and automatic layers without +/// resolving a directory: login-shell homes and the discovered installations with their +/// checked executables, versions or skip reasons, and Core's automatic choice. Hosts +/// probe only the executables returned here, then call again with `probes`. +pub(crate) fn candidates(input: &Value, validate_manifest: ManifestValidator) -> Validation { + fields( + input, + &[ + "registry", + "tool", + "platform", + "filesystemCaseSensitive", + "workspaceRoot", + "workspaceRoots", + "filesystem", + "minimumVersion", + "probes", + "systemInstallations", + "providerInstallations", + "fixed", + "projectCodeAllowed", + "environment", + ], + )?; + let registry = definitions::registry(&input["registry"], validate_manifest)?; + let tool = input["tool"] + .as_str() + .filter(|tool| lithe_plugin_model::is_tool_id(tool)) + .ok_or_else(|| reject("tool"))?; + let definition = enabled(®istry, tool).ok_or_else(|| reject("tool"))?; + let (snapshot, _) = prepare(input, definition)?; + if input + .get("projectCodeAllowed") + .is_some_and(|value| !value.is_boolean()) + { + return Err(reject("projectCodeAllowed")); + } + string_maps(input, &["environment"])?; + let mut shell = Vec::new(); + for candidate in definition["executables"] + .as_array() + .into_iter() + .flatten() + .filter(|value| value["kind"] == "environment" && applicable(value, snapshot.platform)) + { + let Some(variable) = candidate["variable"].as_str() else { + continue; + }; + let Some(home) = input["environment"][variable] + .as_str() + .filter(|value| !value.is_empty()) + else { + continue; + }; + let layout = candidate["path"].as_str(); + let mut row = json!({"candidateID":candidate["id"],"variable":variable,"home":home}); + if let Ok(checked) = snapshot.checked(home, layout) { + row["home"] = checked["home"].clone(); + row["executable"] = checked["executable"].clone(); + } + match snapshot.layout_selection(home, layout, "host:shell", "global") { + Ok(value) => row["version"] = value["version"].clone(), + Err(code) => row["reason"] = json!(code), + } + shell.push(row); + } + let mut reported = provider_installations(input, tool, ®istry)?; + reported.extend(system_installations(input, tool)?); + let set = automatic_set(&snapshot, &reported)?; + let mut installations: Vec<_> = set + .eligible + .values() + .map(|value| { + json!({"home":value["home"],"executable":value["executable"], + "version":value["version"],"provider":value["provider"]}) + }) + .collect(); + installations.sort_by(|left, right| { + let version = |value: &Value| value["version"].as_str().and_then(components); + version(right) + .cmp(&version(left)) + .then_with(|| left["home"].as_str().cmp(&right["home"].as_str())) + }); + let mut skipped: Vec<_> = set.skipped.into_values().collect(); + if let Some(value) = input.get("fixed") { + let fixed = FixedToolchainSelection::parse(value)?; + let executable = match &fixed { + FixedToolchainSelection::Installation { home } => snapshot + .checked(home, None) + .ok() + .and_then(|value| value["executable"].as_str().map(str::to_owned)), + FixedToolchainSelection::Executable { executable } => snapshot + .fact(executable, "file") + .ok() + .filter(|fact| fact["executable"] == true) + .map(|_| executable.clone()), + }; + if let Err(code) = snapshot.fixed_selection(&fixed) { + let mut row = json!({"reason":code}); + // A project program is not a probe target without the current action. + if code == "probeFailed" { + if let Some(executable) = executable { + row["executable"] = json!(executable); + } + } + skipped.push(row); + } + } + let (selected, conflict) = automatic_choice(&snapshot, set.eligible, None)?; + Ok( + json!({"tool":tool,"shell":shell,"installations":installations,"skipped":skipped, + "selected":selected.map(|value| value["home"].clone()), + "conflicts":conflict.into_iter().collect::>()}), + ) +} diff --git a/rust/crates/toolchain/src/resolution/discovery.rs b/rust/crates/toolchain/src/resolution/discovery.rs new file mode 100644 index 00000000..8d401176 --- /dev/null +++ b/rust/crates/toolchain/src/resolution/discovery.rs @@ -0,0 +1,65 @@ +//! The discovery plan the host runs before resolution. + +use super::*; + +/// Exposes both installation routes per tool: definition-owned scans the host expands, +/// and enabled providers the host queries. Native expansion and checks stay in hosts. +pub(crate) fn discovery_plan( + input: &Value, + validate_manifest: ManifestValidator, +) -> Validation { + fields(input, &["registry", "platform"])?; + let registry = definitions::registry(&input["registry"], validate_manifest)?; + let platform = input["platform"] + .as_str() + .ok_or_else(|| reject("platform"))?; + if !matches!(platform, "macos" | "windows" | "linux") { + return Err(reject("platform")); + } + let tools = registry["definitions"] + .as_array() + .into_iter() + .flatten() + .map(|entry| { + let definition = &entry["definition"]; + let scans: Vec<_> = definition["discovery"] + .as_array() + .into_iter() + .flatten() + .filter(|scan| applicable(scan, platform)) + .cloned() + .collect(); + let providers: Vec<_> = registry["providers"] + .as_array() + .into_iter() + .flatten() + .filter(|provider| { + provider["provider"]["tools"] + .as_array() + .is_some_and(|tools| tools.contains(&definition["id"])) + }) + .map(|provider| { + format!( + "{}:{}", + provider["pluginID"].as_str().unwrap_or(""), + provider["provider"]["id"].as_str().unwrap_or("") + ) + }) + .collect(); + // Login-shell home variables the host reads, with the layout each one selects. + let environment: Vec<_> = definition["executables"] + .as_array() + .into_iter() + .flatten() + .filter(|value| value["kind"] == "environment" && applicable(value, platform)) + .map(|value| json!({"variable":value["variable"],"path":value.get("path")})) + .collect(); + json!({"tool":definition["id"],"pluginID":entry["pluginID"],"scans":scans,"providers":providers, + "homeLayouts":definition.get("homeLayouts").cloned().unwrap_or(json!([])), + "executableLayouts":definition["executableLayouts"],"version":definition["version"], + "pathPrepend":definition.get("pathPrepend").cloned().unwrap_or(json!([])), + "environment":environment}) + }) + .collect::>(); + Ok(json!({"tools":tools,"limits":{"roots":256,"entries":4096,"ancestors":64}})) +} diff --git a/rust/crates/toolchain/src/resolution/installations.rs b/rust/crates/toolchain/src/resolution/installations.rs new file mode 100644 index 00000000..b6cd938a --- /dev/null +++ b/rust/crates/toolchain/src/resolution/installations.rs @@ -0,0 +1,76 @@ +//! System and provider installations offered as candidates. + +use super::*; + +/// Host-observed string maps (login-shell variables, command homes, explicit choices). +pub(crate) fn string_maps(input: &Value, keys: &[&str]) -> Validation<()> { + for key in keys { + if let Some(value) = input.get(*key) { + let map = value + .as_object() + .filter(|map| map.len() <= 256) + .ok_or_else(|| reject(key))?; + if map.values().any(|value| { + !value + .as_str() + .is_some_and(|value| value.is_empty() || bounded(value, 4096)) + }) { + return Err(reject(key)); + } + } + } + Ok(()) +} + +/// Homes the host found by expanding this tool's `discovery` scans. +pub(crate) fn system_installations(input: &Value, tool: &str) -> Validation> { + let mut installations = Vec::new(); + for value in array(input, "systemInstallations", 512)? { + fields(value, &["tool", "home"])?; + if value["tool"] == tool { + let home = value["home"] + .as_str() + .ok_or_else(|| reject("systemInstallations"))?; + installations.push(json!({"tool":tool,"home":home,"provider":SYSTEM_PROVIDER})); + } + } + Ok(installations) +} + +/// Homes `toolchains.decodeReply` accepted from a provider declaring `tool`, kept +/// under that provider's identity; one the registry does not list is refused. +pub(crate) fn provider_installations( + input: &Value, + tool: &str, + registry: &Value, +) -> Validation> { + let mut installations = Vec::new(); + for value in array(input, "providerInstallations", 512)? { + fields(value, &["tool", "home", "provider"])?; + if value["tool"] != tool { + continue; + } + let home = value["home"] + .as_str() + .ok_or_else(|| reject("providerInstallations"))?; + let provider = value["provider"] + .as_str() + .and_then(|provider| provider.rsplit_once(':')) + .filter(|(plugin, provider)| { + registry["providers"].as_array().is_some_and(|entries| { + entries.iter().any(|entry| { + entry["pluginID"] == *plugin + && entry["provider"]["id"] == *provider + && entry["provider"]["tools"] + .as_array() + .is_some_and(|tools| tools.iter().any(|known| known == tool)) + }) + }) + }) + .ok_or_else(|| reject("providerInstallations"))?; + installations.push( + json!({"tool":tool,"home":home,"provider":format!("{}:{}", provider.0, provider.1)}), + ); + } + Ok(installations) +} diff --git a/rust/crates/toolchain/src/resolution/launch.rs b/rust/crates/toolchain/src/resolution/launch.rs new file mode 100644 index 00000000..c821c9ad --- /dev/null +++ b/rust/crates/toolchain/src/resolution/launch.rs @@ -0,0 +1,112 @@ +//! Instantiating a toolchain's launch items. + +use super::*; + +/// Longest argument list or string the launch expansion accepts, in items and bytes. +const MAX_LAUNCH_ITEMS: usize = 1024; + +const MAX_LAUNCH_ITEM_BYTES: usize = 65536; + +/// Reads an optional bounded string argument that never carries a NUL. +fn launch_text<'a>(input: &'a Value, key: &str) -> Validation> { + match input.get(key) { + None | Some(Value::Null) => Ok(None), + Some(value) => value + .as_str() + .filter(|text| !text.is_empty() && text.len() <= 4096 && !text.contains('\0')) + .map(Some) + .ok_or_else(|| reject(key)), + } +} + +fn launch_list(input: &Value, key: &str) -> Validation> { + let items = array(input, key, MAX_LAUNCH_ITEMS)?; + items + .iter() + .map(|item| { + item.as_str() + .filter(|text| text.len() <= MAX_LAUNCH_ITEM_BYTES && !text.contains('\0')) + .map(str::to_owned) + .ok_or_else(|| reject(key)) + }) + .collect() +} + +fn launch_mismatch(message: &str) -> PluginRejection { + PluginRejection::new("plugin.toolchain.launchMismatch", message) +} + +/// Expands an installed definition's `launch` template for a selected executable. +/// +/// The definition alone says how its tool starts an entry or takes arguments, so a plugin and a +/// host never branch on a tool name. A part the caller supplies that the template cannot carry, +/// or a variable the caller leaves out, is `plugin.toolchain.launchMismatch`. +pub(crate) fn instantiate_launch( + input: &Value, + validate_manifest: ManifestValidator, +) -> Validation { + fields( + input, + &[ + "registry", + "tool", + "executable", + "home", + "entry", + "runtimeArguments", + "arguments", + ], + )?; + let registry = definitions::registry(&input["registry"], validate_manifest)?; + let tool = input["tool"].as_str().ok_or_else(|| reject("tool"))?; + let definition = enabled(®istry, tool).ok_or_else(|| reject("tool"))?; + let executable = launch_text(input, "executable")?.ok_or_else(|| reject("executable"))?; + let home = launch_text(input, "home")?; + let entry = launch_text(input, "entry")?; + let runtime_arguments = launch_list(input, "runtimeArguments")?; + let arguments = launch_list(input, "arguments")?; + let mut consumed = BTreeSet::new(); + let mut expanded = Vec::new(); + for token in definition["launch"].as_array().into_iter().flatten() { + let token = token.as_str().ok_or_else(|| reject("launch"))?; + match token { + "${executable}" => expanded.push(executable.to_owned()), + "${home}" => expanded.push( + home.ok_or_else(|| launch_mismatch("The tool's launch needs its home."))? + .to_owned(), + ), + "${entry}" => { + consumed.insert("entry"); + expanded.push( + entry + .ok_or_else(|| launch_mismatch("The tool's launch needs an entry."))? + .to_owned(), + ); + } + "${runtimeArguments}" => { + consumed.insert("runtimeArguments"); + expanded.extend(runtime_arguments.iter().cloned()); + } + "${arguments}" => { + consumed.insert("arguments"); + expanded.extend(arguments.iter().cloned()); + } + literal => expanded.push(literal.to_owned()), + } + } + if entry.is_some() && !consumed.contains("entry") { + return Err(launch_mismatch("The tool does not launch an entry.")); + } + if !runtime_arguments.is_empty() && !consumed.contains("runtimeArguments") { + return Err(launch_mismatch("The tool takes no runtime arguments.")); + } + if !arguments.is_empty() && !consumed.contains("arguments") { + return Err(launch_mismatch("The tool takes no program arguments.")); + } + let rest = expanded.split_off(1); + Ok(json!({ + "executable": expanded.into_iter().next().unwrap_or_default(), + "arguments": rest, + "versionArguments": definition["version"]["arguments"], + })) +} diff --git a/rust/crates/toolchain/src/resolution/probe.rs b/rust/crates/toolchain/src/resolution/probe.rs new file mode 100644 index 00000000..aca2a63d --- /dev/null +++ b/rust/crates/toolchain/src/resolution/probe.rs @@ -0,0 +1,120 @@ +//! Probe captures and version checks of a candidate. + +use super::*; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct Probe { + pub(crate) executable: String, + pub(crate) output: String, + pub(crate) exit_code: i32, +} + +pub(crate) fn fields(input: &Value, allowed: &[&str]) -> Validation<()> { + if !input + .as_object() + .is_some_and(|object| object.keys().all(|key| allowed.contains(&key.as_str()))) + { + return Err(reject("input")); + } + Ok(()) +} + +/// Numeric releases are tool-neutral; display aliases never enter comparison. +pub(crate) fn components(value: &str) -> Option> { + if value.is_empty() || value.len() > 64 { + return None; + } + let parts: Vec<_> = value.split('.').collect(); + if parts.len() > 8 { + return None; + } + let mut numbers = parts + .iter() + .map(|part| { + (!part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit())) + .then(|| part.parse().ok()) + .flatten() + }) + .collect::>>()?; + while numbers.len() > 1 && numbers.last() == Some(&0) { + numbers.pop(); + } + Some(numbers) +} + +pub(crate) fn reject(field: &str) -> PluginRejection { + PluginRejection::at( + "plugin.toolchain.invalidParams", + field, + "Invalid toolchain snapshot or native evidence.", + ) +} + +pub(crate) fn bounded(value: &str, max: usize) -> bool { + !value.is_empty() && value.len() <= max && !value.chars().any(char::is_control) +} + +pub(crate) fn array<'a>(input: &'a Value, key: &str, max: usize) -> Validation<&'a [Value]> { + match input.get(key) { + None => Ok(&[]), + Some(value) => value + .as_array() + .filter(|items| items.len() <= max) + .map(Vec::as_slice) + .ok_or_else(|| reject(key)), + } +} + +pub(crate) fn enabled<'a>(registry: &'a Value, tool: &str) -> Option<&'a Value> { + registry["definitions"] + .as_array()? + .iter() + .find(|entry| entry["definition"]["id"] == tool) + .map(|entry| &entry["definition"]) +} + +pub(crate) fn capture( + definition: &Value, + output: &str, + minimum: Option<&str>, +) -> Validation { + if output.len() > 128 * 1024 { + return Err(reject("output")); + } + let minimum = minimum + .map(|value| components(value).ok_or_else(|| reject("minimumVersion"))) + .transpose()?; + let expression = RegexBuilder::new(definition["version"]["pattern"].as_str().unwrap_or("")) + .size_limit(1024 * 1024) + .build() + .map_err(|_| reject("version.pattern"))?; + let captured = expression + .captures(output) + .and_then(|captures| captures.get(1)) + .map(|value| value.as_str()); + let version = captured.filter(|value| components(value).is_some()); + let satisfied = version.is_some_and(|version| { + minimum + .as_ref() + .is_none_or(|minimum| components(version).as_ref() >= Some(minimum)) + }); + Ok(json!({"satisfied":satisfied,"version":version})) +} + +/// Uses only the installed definition's regular expression, never a tool-name branch. +pub(crate) fn check_version( + input: &Value, + validate_manifest: ManifestValidator, +) -> Validation { + fields(input, &["registry", "tool", "output", "minimumVersion"])?; + let registry = definitions::registry(&input["registry"], validate_manifest)?; + let tool = input["tool"].as_str().ok_or_else(|| reject("tool"))?; + let definition = enabled(®istry, tool).ok_or_else(|| reject("tool"))?; + let output = input["output"].as_str().ok_or_else(|| reject("output"))?; + let minimum = input + .get("minimumVersion") + .map(|value| value.as_str().ok_or_else(|| reject("minimumVersion"))) + .transpose()?; + capture(definition, output, minimum) +} diff --git a/rust/crates/toolchain/src/resolution/project.rs b/rust/crates/toolchain/src/resolution/project.rs new file mode 100644 index 00000000..54cd60c7 --- /dev/null +++ b/rust/crates/toolchain/src/resolution/project.rs @@ -0,0 +1,181 @@ +//! Project-level toolchain choices and resolution preparation. + +use super::*; + +/// Project sources are merged before attempting a candidate; distance wins over kind. +pub(crate) struct ProjectChoice { + pub(crate) anchor: String, + pub(crate) category: u8, + pub(crate) native: bool, + pub(crate) value: Value, + pub(crate) candidate: bool, +} + +pub(crate) fn ancestors(directory: &str) -> Vec<&str> { + let mut result = vec![directory]; + let mut current = directory; + while current != "." && result.len() < 64 { + current = current.rsplit_once('/').map_or(".", |(parent, _)| parent); + result.push(current); + } + result +} + +pub(crate) fn project_selection( + snapshot: &Snapshot<'_>, + choice: &ProjectChoice, +) -> Result { + if !choice.candidate { + let home = choice.value["installation"] + .as_str() + .ok_or("requestedNotInstalled")?; + return snapshot.selection( + home, + choice.value["provider"].as_str().unwrap_or(""), + "project", + ); + } + if snapshot.input["projectCodeAllowed"] != true { + return Err("projectCodeRequiresAction"); + } + let path = choice.value["nativePath"].as_str().ok_or("unsafePath")?; + let file = snapshot.fact(path, "file").map_err(|_| "notFound")?; + if file["executable"] != true { + return Err("notExecutable"); + } + let canonical = file["canonicalPath"].as_str().ok_or("unsafePath")?; + if snapshot.key(canonical).map_err(|_| "unsafePath")? + != snapshot.key(path).map_err(|_| "unsafePath")? + { + // Native link-chain identities are not available in this Core-only slice. + return Err("unsafePath"); + } + let home = choice.value["nativeHome"].as_str(); + if let Some(home) = home { + let fact = snapshot.fact(home, "directory").map_err(|_| "unsafePath")?; + let canonical_home = fact["canonicalPath"].as_str().ok_or("unsafePath")?; + if snapshot.key(canonical_home).map_err(|_| "unsafePath")? + != snapshot.key(home).map_err(|_| "unsafePath")? + || !snapshot.roots.iter().any(|root| { + paths::within( + canonical_home, + root, + snapshot.platform, + snapshot.case_sensitive, + ) + }) + { + return Err("unsafePath"); + } + } + // A lexical project path must never turn into a provider-owned installation. + if !snapshot + .roots + .iter() + .any(|root| paths::within(path, root, snapshot.platform, snapshot.case_sensitive)) + { + return Err("unsafePath"); + } + if home.is_none() + && snapshot.definition["launch"] + .as_array() + .into_iter() + .flatten() + .any(|token| token == "${home}") + { + return Err("homeUnavailable"); + } + // Preserve the lexical interpreter path for project environments (including venv). + let version = match snapshot.version(path) { + Ok(version) => Some(version), + Err(code) if snapshot.minimum.is_some() => return Err(code), + Err(_) => None, + }; + snapshot.environment_selection( + home, + path, + version.as_deref(), + "host:project", + "project", + true, + ) +} + +/// Validates the native evidence shared by resolution and candidate listing: platform +/// and case policy, workspace roots, filesystem facts and bounded probe outputs. +pub(crate) fn prepare<'a>( + input: &'a Value, + definition: &'a Value, +) -> Validation<(Snapshot<'a>, String)> { + let platform = input["platform"] + .as_str() + .filter(|platform| matches!(*platform, "macos" | "windows" | "linux")) + .ok_or_else(|| reject("platform"))?; + let case_sensitive = input + .get("filesystemCaseSensitive") + .map(|value| { + value + .as_bool() + .ok_or_else(|| reject("filesystemCaseSensitive")) + }) + .transpose()? + .unwrap_or(platform != "windows"); + let root = input["workspaceRoot"] + .as_str() + .and_then(|root| paths::normalize(root, platform, true)) + .ok_or_else(|| reject("workspaceRoot"))?; + let mut roots = vec![root.clone()]; + for value in array(input, "workspaceRoots", 64)? { + roots.push( + value + .as_str() + .ok_or_else(|| reject("workspaceRoots"))? + .to_owned(), + ); + } + let facts = array(input, "filesystem", 16384)?; + let minimum = input + .get("minimumVersion") + .map(|value| { + value + .as_str() + .filter(|value| components(value).is_some()) + .ok_or_else(|| reject("minimumVersion")) + }) + .transpose()?; + let mut probes = BTreeMap::new(); + for value in array(input, "probes", 1024)? { + let probe: Probe = serde_json::from_value(value.clone()).map_err(|_| reject("probes"))?; + if probe.output.len() > 128 * 1024 { + return Err(reject("probes.output")); + } + let key = paths::normalize(&probe.executable, platform, case_sensitive) + .ok_or_else(|| reject("probes.executable"))?; + if probes.insert(key, probe).is_some() { + return Err(reject("probes")); + } + } + let mut snapshot = Snapshot { + input, + definition, + platform, + case_sensitive, + roots, + facts, + targets: Vec::new(), + probes, + minimum, + }; + snapshot.validate_facts()?; + snapshot.targets = query::implied_targets(facts, platform, case_sensitive) + .map_err(|_| reject("filesystem"))?; + if snapshot.key( + snapshot.fact(&root, "directory")?["canonicalPath"] + .as_str() + .ok_or_else(|| reject("workspaceRoot"))?, + )? != snapshot.key(&root)? + { + return Err(reject("workspaceRoot")); + } + Ok((snapshot, root)) +} diff --git a/rust/crates/toolchain/src/resolution/snapshot.rs b/rust/crates/toolchain/src/resolution/snapshot.rs new file mode 100644 index 00000000..a931d2fc --- /dev/null +++ b/rust/crates/toolchain/src/resolution/snapshot.rs @@ -0,0 +1,268 @@ +//! The resolution snapshot, its fixed selections and traces. + +use super::*; + +/// Provenance of homes found by expanding definition-owned `discovery` scans. +pub(crate) const SYSTEM_PROVIDER: &str = "host:system"; + +/// One canonical home reported by several routes stays one installation. A provider +/// answer outranks the host scan, then qualified provider IDs give a stable label; +/// the label never decides between different homes. +pub(crate) fn attribution(selection: &Value) -> (bool, &str) { + let provider = selection["provider"].as_str().unwrap_or(""); + (provider == SYSTEM_PROVIDER, provider) +} + +pub(crate) fn applicable(value: &Value, platform: &str) -> bool { + value.get("platforms").is_none_or(|values| { + values + .as_array() + .is_some_and(|values| values.iter().any(|value| value == platform)) + }) +} + +/// A host-side value snapshot is not a provider reply or a persisted launch permit. +pub(crate) struct Snapshot<'a> { + pub(crate) input: &'a Value, + pub(crate) definition: &'a Value, + pub(crate) platform: &'a str, + pub(crate) case_sensitive: bool, + pub(crate) roots: Vec, + pub(crate) facts: &'a [Value], + /// Resolved targets implied by link facts; canonical selections are looked up here. + pub(crate) targets: Vec, + pub(crate) probes: BTreeMap, + pub(crate) minimum: Option<&'a str>, +} + +impl Snapshot<'_> { + /// An explicit consumer choice precedes directory pins, but has exactly the + /// same native version and project-code boundary as other checked candidates. + pub(crate) fn fixed_selection( + &self, + fixed: &FixedToolchainSelection, + ) -> Result { + match fixed { + FixedToolchainSelection::Installation { home } => { + self.selection(home, "host:manual", "pinned") + } + FixedToolchainSelection::Executable { executable } => { + let fact = self.fact(executable, "file").map_err(|_| "notFound")?; + if fact["executable"] != true { + return Err("notExecutable"); + } + let canonical = fact["canonicalPath"].as_str().ok_or("unsafePath")?; + let project = self.roots.iter().any(|root| { + paths::within(executable, root, self.platform, self.case_sensitive) + || paths::within(canonical, root, self.platform, self.case_sensitive) + }); + if project && self.input["projectCodeAllowed"] != true { + return Err("projectCodeRequiresAction"); + } + if self.definition["launch"] + .as_array() + .into_iter() + .flatten() + .any(|token| token == "${home}") + { + return Err("homeUnavailable"); + } + let version = self.version(executable)?; + self.environment_selection( + None, + executable, + Some(&version), + "host:manual", + "pinned", + project, + ) + } + } + } + + pub(crate) fn key(&self, path: &str) -> Validation { + paths::normalize(path, self.platform, self.case_sensitive).ok_or_else(|| reject("path")) + } + + pub(crate) fn fact(&self, path: &str, kind: &str) -> Validation<&Value> { + let key = self.key(path)?; + self.facts + .iter() + .chain(&self.targets) + .find(|fact| { + fact["path"] + .as_str() + .and_then(|path| self.key(path).ok()) + .as_ref() + == Some(&key) + && fact["kind"] == kind + }) + .ok_or_else(|| reject("filesystem")) + } + + pub(crate) fn validate_facts(&self) -> Validation<()> { + let mut identities = BTreeSet::new(); + for fact in self.facts { + fields(fact, &["path", "canonicalPath", "kind", "executable"])?; + let path = fact["path"] + .as_str() + .ok_or_else(|| reject("filesystem.path"))?; + let canonical = fact["canonicalPath"] + .as_str() + .ok_or_else(|| reject("filesystem.canonicalPath"))?; + self.key(canonical)?; + if !identities.insert(self.key(path)?) + || !matches!( + fact["kind"].as_str(), + Some("file" | "directory" | "missing") + ) + || fact + .get("executable") + .is_some_and(|value| !value.is_boolean()) + { + return Err(reject("filesystem")); + } + } + for root in &self.roots { + self.fact(root, "directory")?; + } + Ok(()) + } + + pub(crate) fn checked(&self, home: &str, layout: Option<&str>) -> Validation { + query::validate_installation( + home, + self.definition, + self.facts, + &self.roots, + self.platform, + self.case_sensitive, + layout, + ) + } + + pub(crate) fn version(&self, executable: &str) -> Result { + let key = self.key(executable).map_err(|_| "unsafePath")?; + let probe = self.probes.get(&key).ok_or("probeFailed")?; + if probe.exit_code != 0 { + return Err("probeFailed"); + } + let checked = + capture(self.definition, &probe.output, self.minimum).map_err(|_| "probeFailed")?; + if checked["satisfied"] != true { + return Err("versionUnsatisfied"); + } + checked["version"] + .as_str() + .map(str::to_owned) + .ok_or("probeFailed") + } + + pub(crate) fn selection( + &self, + home: &str, + provider: &str, + layer: &str, + ) -> Result { + self.layout_selection(home, None, provider, layer) + } + + /// Selects `home` through one declared executable layout, or the first present one. + pub(crate) fn layout_selection( + &self, + home: &str, + layout: Option<&str>, + provider: &str, + layer: &str, + ) -> Result { + let checked = self.checked(home, layout).map_err(|_| "unsafePath")?; + let executable = checked["executable"].as_str().ok_or("unsafePath")?; + let version = self.version(executable)?; + self.environment_selection( + checked["home"].as_str(), + executable, + Some(&version), + provider, + layer, + false, + ) + } + + pub(crate) fn environment_selection( + &self, + home: Option<&str>, + executable: &str, + version: Option<&str>, + provider: &str, + layer: &str, + project: bool, + ) -> Result { + let mut environment = serde_json::Map::new(); + for export in self.definition["exports"].as_array().into_iter().flatten() { + let value = match export["value"].as_str() { + Some("${home}") => home, + Some("${executable}") => Some(executable), + _ => None, + }; + if let Some(value) = value { + environment.insert( + export["name"].as_str().ok_or("unsafePath")?.to_owned(), + json!(value), + ); + } + } + let mut prepend = Vec::new(); + if let Some(home) = home { + for template in self.definition["pathPrepend"] + .as_array() + .into_iter() + .flatten() + { + let suffix = template + .as_str() + .and_then(|template| template.strip_prefix("${home}")) + .ok_or("unsafePath")?; + let path = format!("{home}{suffix}"); + let fact = self.fact(&path, "directory").map_err(|_| "unsafePath")?; + let canonical = fact["canonicalPath"].as_str().ok_or("unsafePath")?; + if !paths::within(canonical, home, self.platform, self.case_sensitive) { + return Err("unsafePath"); + } + prepend.push(canonical.to_owned()); + } + } + Ok( + json!({"home":home,"executable":executable,"version":version,"provider":provider, + "layer":layer,"projectCode":project,"environment":environment,"pathPrepend":prepend}), + ) + } +} + +pub(crate) fn reason(code: &str, tool: &str) -> Value { + json!({"code":code,"tool":tool}) +} + +pub(crate) fn trace( + trace: &mut Vec, + id: &str, + layer: &str, + state: &str, + reason: Option, +) { + let mut row = json!({"candidateID":id,"layer":layer,"state":state}); + if let Some(reason) = reason { + row["reason"] = reason; + } + trace.push(row); +} + +pub(crate) fn output( + input: &Value, + outcome: Value, + trace: Vec, + unmet: Vec, + conflicts: Vec, +) -> Value { + json!({"operationID":input["operationID"],"tool":input["tool"],"directory":input["directory"], + "outcome":outcome,"trace":trace,"unmet":unmet,"conflicts":conflicts}) +} diff --git a/rust/crates/vcs/Cargo.toml b/rust/crates/vcs/Cargo.toml new file mode 100644 index 00000000..8b067c9f --- /dev/null +++ b/rust/crates/vcs/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "lithe-vcs" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "The unified VCS model for Lithe: provider declarations, root detection, request planning and reply validation" + +[dependencies] +lithe-plugin-model = { path = "../plugin-model" } +lithe-protocol = { path = "../protocol" } +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0", features = ["preserve_order"] } diff --git a/rust/crates/core/src/vcs/commands.rs b/rust/crates/vcs/src/commands.rs similarity index 93% rename from rust/crates/core/src/vcs/commands.rs rename to rust/crates/vcs/src/commands.rs index 9f989092..8b618f7a 100644 --- a/rust/crates/core/src/vcs/commands.rs +++ b/rust/crates/vcs/src/commands.rs @@ -9,10 +9,9 @@ use super::manifest::{built_in_git_markers, VcsProviderDeclaration, BUILT_IN_GIT use super::model::{is_root_relative, Capabilities}; use super::requests::{self, Operation}; use super::roots::{self, Mapping, MarkerSet}; -use crate::plugins::declarative::{ - decode_payload, is_official_namespace, outcome, validate_manifest_value, -}; -use crate::protocol::{CoreError, ErrorCode}; +use super::VcsManifest; +use lithe_plugin_model::{decode_payload, is_official_namespace, outcome}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::Deserialize; use serde_json::{json, Value}; use std::collections::BTreeMap; @@ -62,7 +61,10 @@ fn precedes(candidate: &Owner<'_>, current: &Owner<'_>) -> bool { /// `vcs.detectRoots`: registered providers, the roots of every VCS with a /// provider (and of the built-in Git), ambiguous directories, and which /// mappings applied. -pub(crate) fn vcs_detect_roots_command(payload: Value) -> Result { +/// +/// Manifests the plugin host's validation (`M::validate`) rejects are reported +/// in `rejected` instead of contributing providers. +pub fn vcs_detect_roots_command(payload: Value) -> Result { let request: DetectRootsRequest = decode_payload(payload, "vcs.detectRoots")?; let workspace = PathBuf::from(&request.workspace_root); if !workspace.is_absolute() || request.workspace_root.contains('\0') { @@ -78,7 +80,7 @@ pub(crate) fn vcs_detect_roots_command(payload: Value) -> Result manifests.push(manifest), Err(error) => rejected.push(json!({ "index": index, "error": error })), } @@ -189,7 +191,7 @@ struct OwnerOfRequest { /// `vcs.ownerOf`: the root that owns each path under the rule of /// `vcs.detectRoots`, so every host assigns files the same way. -pub(crate) fn vcs_owner_of_command(payload: Value) -> Result { +pub fn vcs_owner_of_command(payload: Value) -> Result { let request: OwnerOfRequest = decode_payload(payload, "vcs.ownerOf")?; if request.paths.len() > MAX_OWNER_PATHS { return Err(CoreError::new( @@ -245,7 +247,7 @@ struct DecodeReplyRequest { /// `vcs.decodeReply`: a provider's reply as the normalized result, or /// `plugin.vcs.invalidOutput`. -pub(crate) fn vcs_decode_reply_command(payload: Value) -> Result { +pub fn vcs_decode_reply_command(payload: Value) -> Result { let request: DecodeReplyRequest = decode_payload(payload, "vcs.decodeReply")?; let operation = Operation::parse(&request.operation).ok_or_else(|| { CoreError::new(ErrorCode::InvalidRequest, "Unknown VCS operation") diff --git a/rust/crates/core/src/vcs/mod.rs b/rust/crates/vcs/src/lib.rs similarity index 65% rename from rust/crates/core/src/vcs/mod.rs rename to rust/crates/vcs/src/lib.rs index 18dc2611..8e0f89fb 100644 --- a/rust/crates/core/src/vcs/mod.rs +++ b/rust/crates/vcs/src/lib.rs @@ -21,9 +21,18 @@ mod model; mod requests; mod roots; -pub(crate) use commands::*; -pub(crate) use manifest::{ +pub use commands::{vcs_decode_reply_command, vcs_detect_roots_command, vcs_owner_of_command}; +pub use manifest::{ activation_event, programs, validate_declarations, DeclarationContext, VcsProviderDeclaration, }; -pub(crate) use model::{is_vcs_id, negotiated_capabilities, Capabilities}; -pub(crate) use requests::{is_vcs_method, plan, Operation}; +pub use model::{is_vcs_id, negotiated_capabilities, Capabilities}; +pub use requests::{is_vcs_method, plan, Operation, PlannedRequest}; + +use lithe_plugin_model::PluginManifest; + +/// What `vcs.detectRoots` reads from one plugin manifest that the plugin host +/// validated; the host's manifest type implements it. +pub trait VcsManifest: PluginManifest { + /// The manifest's validated `vcsProviders`, sorted by VCS ID. + fn vcs_providers(&self) -> &[VcsProviderDeclaration]; +} diff --git a/rust/crates/core/src/vcs/manifest.rs b/rust/crates/vcs/src/manifest.rs similarity index 91% rename from rust/crates/core/src/vcs/manifest.rs rename to rust/crates/vcs/src/manifest.rs index 11b9831d..412bcb9b 100644 --- a/rust/crates/core/src/vcs/manifest.rs +++ b/rust/crates/vcs/src/manifest.rs @@ -8,11 +8,11 @@ //! belong to the running provider and are negotiated in `initialize`. use super::model::is_vcs_id; -use crate::plugins::declarative::{ - invalid, is_bare_command, is_safe_relative_path, sorted_unique, PluginRejection, ServerProcess, - ServerRole, Validation, +use lithe_plugin_model::process::{ServerProcess, ServerRole}; +use lithe_plugin_model::settings::DeclaredSettings; +use lithe_plugin_model::{ + invalid, is_bare_command, is_safe_relative_path, sorted_unique, PluginRejection, Validation, }; -use crate::plugins::settings::DeclaredSettings; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::BTreeSet; @@ -20,7 +20,7 @@ use std::collections::BTreeSet; /// The built-in VCS ID. Git is not part of the unified layer yet, but Core /// recognizes its roots so a nested working copy is assigned correctly, and /// no plugin may claim the ID. -pub(crate) const BUILT_IN_GIT: &str = "git"; +pub const BUILT_IN_GIT: &str = "git"; /// Most providers one plugin may declare. const MAX_PROVIDERS: usize = 8; @@ -36,10 +36,10 @@ const MAX_DISPLAY_NAME: usize = 64; /// One VCS a plugin implements. #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct VcsProviderDeclaration { +pub struct VcsProviderDeclaration { /// Stable lowercase VCS ID (`svn`, `hg`); the provider must answer the /// same ID in `initialize`. - pub(crate) id: String, + pub id: String, pub(crate) display_name: String, pub(crate) detection: VcsDetection, /// Root-relative files or directories whose change means the status may @@ -50,8 +50,9 @@ pub(crate) struct VcsProviderDeclaration { /// with every request. Absent for a provider that needs no client. #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) tool: Option, + /// The provider process and the launch fields every plugin server shares. #[serde(flatten)] - pub(crate) process: ServerProcess, + pub process: ServerProcess, /// Whether operations may contact a server; shown in the install review. pub(crate) may_use_network: bool, } @@ -94,7 +95,7 @@ pub(crate) struct VcsTool { /// The markers Core uses for the built-in Git support: a `.git` directory, /// or the `.git` file of a linked worktree or submodule. -pub(crate) fn built_in_git_markers() -> Vec { +pub fn built_in_git_markers() -> Vec { vec![ VcsMarker { name: ".git".to_string(), @@ -116,14 +117,15 @@ fn is_marker_name(value: &str) -> bool { } /// What the declaration context needs from the manifest. -pub(crate) struct DeclarationContext<'a> { - pub(crate) declared_settings: &'a DeclaredSettings, +pub struct DeclarationContext<'a> { + /// The manifest's validated settings, which launch templates may reference. + pub declared_settings: &'a DeclaredSettings, /// Whether tool names are host runtimes or tool-definition IDs. - pub(crate) runtime_tools: crate::plugins::declarative::RuntimeTools<'a>, + pub runtime_tools: lithe_plugin_model::process::RuntimeTools<'a>, } /// Validates every declaration and sorts them by ID. -pub(crate) fn validate_declarations( +pub fn validate_declarations( providers: &mut [VcsProviderDeclaration], context: &DeclarationContext<'_>, ) -> Validation<()> { @@ -247,7 +249,7 @@ impl VcsProviderDeclaration { /// Display data and requirements a host keeps for a provider; the /// process launch is planned separately. - pub(crate) fn descriptor(&self, plugin_id: &str) -> Value { + pub fn descriptor(&self, plugin_id: &str) -> Value { json!({ "vcs": self.id, "displayName": self.display_name, @@ -262,14 +264,14 @@ impl VcsProviderDeclaration { /// Activation event the host raises before starting a VCS provider, once a /// root of its VCS was detected. -pub(crate) fn activation_event(provider: &VcsProviderDeclaration) -> String { +pub fn activation_event(provider: &VcsProviderDeclaration) -> String { format!("onVcsRoot:{}", provider.id) } /// Install-review entries: the provider process with the runtimes it needs. /// The tool is part of the provider entry, so pointing it at another client /// is a new program the user must approve. -pub(crate) fn programs(provider: &VcsProviderDeclaration) -> Vec { +pub fn programs(provider: &VcsProviderDeclaration) -> Vec { let mut program = provider.process.program(ServerRole::VcsProvider); program["vcs"] = json!(provider.id); // `tool` already names a runtime launch's runtime. diff --git a/rust/crates/core/src/vcs/model.rs b/rust/crates/vcs/src/model.rs similarity index 99% rename from rust/crates/core/src/vcs/model.rs rename to rust/crates/vcs/src/model.rs index 8872f416..93cca893 100644 --- a/rust/crates/core/src/vcs/model.rs +++ b/rust/crates/vcs/src/model.rs @@ -8,7 +8,7 @@ //! Lists the host renders as sets are sorted here, and every list is capped, //! so both hosts show the same result for the same reply. -use crate::plugins::declarative::{PluginRejection, Validation}; +use lithe_plugin_model::{PluginRejection, Validation}; use serde_json::{json, Map, Value}; use std::collections::{BTreeMap, BTreeSet}; @@ -95,7 +95,7 @@ const STAGING: &[&str] = &["partial", "staged", "unstaged"]; const REVISION_KINDS: &[&str] = &["hash", "number", "opaque"]; /// Capabilities a provider negotiated, by ID, with their versions. -pub(crate) type Capabilities = BTreeMap; +pub type Capabilities = BTreeMap; fn invalid_output(message: impl Into) -> PluginRejection { PluginRejection::new(INVALID_OUTPUT, message) @@ -110,7 +110,7 @@ pub(crate) fn is_capability(id: &str) -> bool { /// understands, or `None` when a required one is missing or the value is /// not an object. Unknown IDs and versions are dropped: a newer provider may /// offer more, and the host then hides only what Core cannot validate. -pub(crate) fn negotiated_capabilities(value: &Value) -> Option { +pub fn negotiated_capabilities(value: &Value) -> Option { let offered = value.as_object()?; let capabilities: Capabilities = offered .iter() @@ -126,7 +126,7 @@ pub(crate) fn negotiated_capabilities(value: &Value) -> Option { /// Whether a stable VCS ID is well formed: a lowercase letter, then up to /// 31 lowercase letters, digits, or hyphens (`git`, `svn`, `hg`). -pub(crate) fn is_vcs_id(value: &str) -> bool { +pub fn is_vcs_id(value: &str) -> bool { let mut characters = value.chars(); characters .next() diff --git a/rust/crates/core/src/vcs/requests.rs b/rust/crates/vcs/src/requests.rs similarity index 93% rename from rust/crates/core/src/vcs/requests.rs rename to rust/crates/vcs/src/requests.rs index 4923fe42..26fd0c63 100644 --- a/rust/crates/core/src/vcs/requests.rs +++ b/rust/crates/vcs/src/requests.rs @@ -9,8 +9,8 @@ //! the VCS client with them. use super::model::{self, Capabilities, MAX_HISTORY_PAGE, MAX_MESSAGE_BYTES}; -use crate::plugins::declarative::Validation; -use crate::protocol::{CoreError, ErrorCode}; +use lithe_plugin_model::Validation; +use lithe_protocol::{CoreError, ErrorCode}; use serde::de::DeserializeOwned; use serde::Deserialize; use serde_json::{json, Value}; @@ -24,17 +24,28 @@ const DEFAULT_HISTORY_LIMIT: usize = 100; /// One operation of protocol version 1. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum Operation { +pub enum Operation { + /// `lithe/vcs/status`: file status of the working copy. Status, + /// `lithe/vcs/baseContent`: a file's content at the base revision. BaseContent, + /// `lithe/vcs/history`: one page of the root's history. History, + /// `lithe/vcs/fileHistory`: one page of a file's history. FileHistory, + /// `lithe/vcs/annotate`: the revision that last changed each line. Annotate, + /// `lithe/vcs/checkin`: records changes with a message. Checkin, + /// `lithe/vcs/rollback`: discards local changes to the named paths. Rollback, + /// `lithe/vcs/update`: updates the working copy to the latest or a named revision. Update, + /// `lithe/vcs/fileOperation`: adds, removes or moves files. FileOperation, + /// `lithe/vcs/resolve`: marks the named conflicted paths resolved. Resolve, + /// `lithe/vcs/cleanup`: recovers the working copy after an interrupted operation. Cleanup, } @@ -56,7 +67,7 @@ pub(crate) const OPERATIONS: [Operation; 11] = [ impl Operation { /// Parses the operation name a host sends, which is also the last /// segment of its method. - pub(crate) fn parse(name: &str) -> Option { + pub fn parse(name: &str) -> Option { OPERATIONS .into_iter() .find(|operation| operation.name() == name) @@ -107,7 +118,7 @@ impl Operation { /// Whether `method` belongs to the protocol; its replies are wrapped like /// command results so no LSP normalizer reinterprets them. -pub(crate) fn is_vcs_method(method: &str) -> bool { +pub fn is_vcs_method(method: &str) -> bool { OPERATIONS .into_iter() .any(|operation| operation.method() == method) @@ -308,15 +319,17 @@ fn operation_params(operation: Operation, value: Value) -> Result`. + pub method: &'static str, + /// The checked parameters, including the root and client tool. + pub params: Value, } /// Plans one request. `root` is the native path of the working-copy root and /// `tool` the client executable the host resolved for the provider; both are /// sent with every request so the provider never searches for either. -pub(crate) fn plan( +pub fn plan( operation: Operation, root: &str, tool: Option<&str>, diff --git a/rust/crates/core/src/vcs/roots.rs b/rust/crates/vcs/src/roots.rs similarity index 100% rename from rust/crates/core/src/vcs/roots.rs rename to rust/crates/vcs/src/roots.rs diff --git a/rust/crates/workspace/Cargo.toml b/rust/crates/workspace/Cargo.toml new file mode 100644 index 00000000..8cf36724 --- /dev/null +++ b/rust/crates/workspace/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "lithe-workspace" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Workspace host routing for Lithe Core: remote sessions and the local-or-remote filesystem" + +[features] +# Lets lithe-core's tests register an in-process host session and count host stat requests. +test-support = [] + +[dependencies] +lithe-diagnostics = { path = "../diagnostics" } +lithe-git-host = { path = "../git-host" } +lithe-protocol = { path = "../protocol" } +lithe-remote = { path = "../remote", default-features = false, features = ["client"] } +lithe-request-context = { path = "../request-context" } +rand = "0.8" +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0", features = ["preserve_order"] } +url = "2.5" diff --git a/rust/crates/core/src/remote/workspace_io.rs b/rust/crates/workspace/src/fs.rs similarity index 87% rename from rust/crates/core/src/remote/workspace_io.rs rename to rust/crates/workspace/src/fs.rs index df4458a0..f9c22864 100644 --- a/rust/crates/core/src/remote/workspace_io.rs +++ b/rust/crates/workspace/src/fs.rs @@ -1,6 +1,6 @@ //! Filesystem adapter for workspace engines, with no local fallback on remote errors. -use super::workspace::{self, Host}; +use crate::scope::{self as workspace, Host}; use lithe_remote::client::session::ClientError; use lithe_remote::protocol::{methods, ErrorCode}; use serde::de::DeserializeOwned; @@ -13,28 +13,28 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; // consumers (for example snapshot hashing) can still read larger files. const MAX_READ_BYTES: u64 = 32 * 1024 * 1024; -#[cfg(test)] +#[cfg(any(test, feature = "test-support"))] thread_local! { static STAT_REQUESTS: std::cell::Cell = const { std::cell::Cell::new(0) }; } /// Counts stat requests on the current test thread without retaining paths. -#[cfg(test)] +#[cfg(any(test, feature = "test-support"))] pub(crate) fn stat_requests_for_test() -> usize { STAT_REQUESTS.with(std::cell::Cell::get) } fn rpc(host: &Host, method: &str, params: Value) -> io::Result { - #[cfg(test)] + #[cfg(any(test, feature = "test-support"))] if method == methods::FS_STAT { STAT_REQUESTS.with(|count| count.set(count.get() + 1)); } - crate::protocol::cancellation::check().map_err(|error| io::Error::other(error.message))?; - let result = super::workspace_host::request_cancellable( + lithe_protocol::cancellation::check().map_err(|error| io::Error::other(error.message))?; + let result = crate::host::request_cancellable( &host.connection.session, method, params, Duration::from_secs(30), ); - crate::protocol::cancellation::check() + lithe_protocol::cancellation::check() .map_err(|error| io::Error::new(io::ErrorKind::Interrupted, error.message))?; let value = result.map_err(|error| { let kind = match &error { @@ -59,7 +59,7 @@ fn rpc(host: &Host, method: &str, params: Value) -> io::Res /// kinds assigned in `rpc`. Callers that tolerate unreadable paths must still /// stop on these, or a dead connection looks like a smaller project. Always /// false for local workspaces, where such kinds describe the path itself. -pub(crate) fn is_transport_failure(error: &io::Error) -> bool { +pub fn is_transport_failure(error: &io::Error) -> bool { workspace::is_remote() && matches!( error.kind(), @@ -75,35 +75,37 @@ fn path(host: &Host, path: &Path) -> io::Result { } /// Metadata retaining the host's file kind and permissions. -pub(crate) struct Metadata { +pub struct Metadata { kind: methods::FileKind, size: u64, mode: u32, modified: Option, } +// Mirrors `std::fs::Metadata`, which has `len` without `is_empty`. +#[allow(clippy::len_without_is_empty)] impl Metadata { /// Size in bytes as reported by the owning filesystem. - pub(crate) fn len(&self) -> u64 { + pub fn len(&self) -> u64 { self.size } /// Whether the entry itself is a regular file. - pub(crate) fn is_file(&self) -> bool { + pub fn is_file(&self) -> bool { self.kind == methods::FileKind::File } /// Whether the entry itself is a directory. - pub(crate) fn is_dir(&self) -> bool { + pub fn is_dir(&self) -> bool { self.kind == methods::FileKind::Directory } /// Kind of the entry itself, without following symbolic links. - pub(crate) fn file_type(&self) -> FileType { + pub fn file_type(&self) -> FileType { FileType(self.kind) } /// POSIX mode used by guarded Git snapshots. - pub(crate) fn mode(&self) -> u32 { + pub fn mode(&self) -> u32 { self.mode } /// Host modification time; absent timestamps are not treated as fresh output. - pub(crate) fn modified(&self) -> io::Result { + pub fn modified(&self) -> io::Result { self.modified.ok_or_else(|| { io::Error::new( io::ErrorKind::Unsupported, @@ -114,18 +116,18 @@ impl Metadata { } /// Filesystem entry kind independent of the client operating system. -pub(crate) struct FileType(methods::FileKind); +pub struct FileType(methods::FileKind); impl FileType { /// Whether the entry is a regular file, without following links. - pub(crate) fn is_file(&self) -> bool { + pub fn is_file(&self) -> bool { self.0 == methods::FileKind::File } /// Whether the entry is a directory, without following links. - pub(crate) fn is_dir(&self) -> bool { + pub fn is_dir(&self) -> bool { self.0 == methods::FileKind::Directory } /// Whether the entry is a symbolic link. - pub(crate) fn is_symlink(&self) -> bool { + pub fn is_symlink(&self) -> bool { self.0 == methods::FileKind::Symlink } } @@ -172,7 +174,7 @@ fn remote_metadata(metadata: methods::FileMetadata) -> Metadata { } /// Reads metadata of the entry itself on the workspace host. -pub(crate) fn symlink_metadata(path_value: impl AsRef) -> io::Result { +pub fn symlink_metadata(path_value: impl AsRef) -> io::Result { let path_value = path_value.as_ref(); match workspace::current() { Some(host) => rpc::( @@ -186,7 +188,7 @@ pub(crate) fn symlink_metadata(path_value: impl AsRef) -> io::Result) -> io::Result { +pub fn metadata(path_value: impl AsRef) -> io::Result { if workspace::is_remote() { let value = symlink_metadata(path_value.as_ref())?; if value.file_type().is_symlink() { @@ -199,7 +201,7 @@ pub(crate) fn metadata(path_value: impl AsRef) -> io::Result { } /// Tests existence without treating host failures as a missing path. -pub(crate) fn try_exists(path_value: impl AsRef) -> io::Result { +pub fn try_exists(path_value: impl AsRef) -> io::Result { match metadata(path_value) { Ok(_) => Ok(true), Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false), @@ -207,7 +209,7 @@ pub(crate) fn try_exists(path_value: impl AsRef) -> io::Result { } } /// Tests whether a path is a directory without suppressing host failures. -pub(crate) fn is_dir(path_value: impl AsRef) -> io::Result { +pub fn is_dir(path_value: impl AsRef) -> io::Result { match metadata(path_value) { Ok(value) => Ok(value.is_dir()), Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false), @@ -215,7 +217,7 @@ pub(crate) fn is_dir(path_value: impl AsRef) -> io::Result { } } /// Tests whether a path is a regular file on its owning filesystem. -pub(crate) fn is_file(path_value: impl AsRef) -> io::Result { +pub fn is_file(path_value: impl AsRef) -> io::Result { match metadata(path_value) { Ok(value) => Ok(value.is_file()), Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false), @@ -224,7 +226,7 @@ pub(crate) fn is_file(path_value: impl AsRef) -> io::Result { } /// Resolves links while preserving the native workspace URL identity. -pub(crate) fn canonicalize(path_value: impl AsRef) -> io::Result { +pub fn canonicalize(path_value: impl AsRef) -> io::Result { let path_value = path_value.as_ref(); match workspace::current() { Some(host) => { @@ -240,7 +242,7 @@ pub(crate) fn canonicalize(path_value: impl AsRef) -> io::Result } /// Reads a link's stored target without resolving it. -pub(crate) fn read_link(path_value: impl AsRef) -> io::Result { +pub fn read_link(path_value: impl AsRef) -> io::Result { match workspace::current() { Some(host) => { let metadata: methods::FileMetadata = rpc( @@ -260,7 +262,7 @@ pub(crate) fn read_link(path_value: impl AsRef) -> io::Result { } /// Streaming file reader; remote reads use bounded chunks rather than mirroring a file. -pub(crate) struct File(FileReader); +pub struct File(FileReader); enum FileReader { Local(std::fs::File), Remote { @@ -274,7 +276,7 @@ enum FileReader { } impl File { /// Opens a regular file on its owning host. - pub(crate) fn open(path_value: impl AsRef) -> io::Result { + pub fn open(path_value: impl AsRef) -> io::Result { match workspace::current() { Some(host) => { let metadata = metadata(path_value.as_ref())?; @@ -352,7 +354,7 @@ impl Read for File { } /// Reads file bytes through the selected host adapter. -pub(crate) fn read(path_value: impl AsRef) -> io::Result> { +pub fn read(path_value: impl AsRef) -> io::Result> { let path_value = path_value.as_ref(); if !workspace::is_remote() { return std::fs::read(path_value); @@ -377,12 +379,12 @@ pub(crate) fn read(path_value: impl AsRef) -> io::Result> { Ok(bytes) } /// Reads UTF-8 text through the selected host adapter. -pub(crate) fn read_to_string(path_value: impl AsRef) -> io::Result { +pub fn read_to_string(path_value: impl AsRef) -> io::Result { String::from_utf8(read(path_value)?) .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) } /// Atomically replaces remote content; local behavior retains standard filesystem semantics. -pub(crate) fn write(path_value: impl AsRef, bytes: impl AsRef<[u8]>) -> io::Result<()> { +pub fn write(path_value: impl AsRef, bytes: impl AsRef<[u8]>) -> io::Result<()> { match workspace::current() { Some(host) => { check_write_size(bytes.as_ref().len() as u64)?; @@ -398,7 +400,7 @@ pub(crate) fn write(path_value: impl AsRef, bytes: impl AsRef<[u8]>) -> io } } /// Writes and synchronizes a session record before publishing its manifest. -pub(crate) fn write_synced(path_value: &Path, bytes: &[u8]) -> io::Result<()> { +pub fn write_synced(path_value: &Path, bytes: &[u8]) -> io::Result<()> { if workspace::is_remote() { return write(path_value, bytes); } @@ -412,7 +414,7 @@ pub(crate) fn write_synced(path_value: &Path, bytes: &[u8]) -> io::Result<()> { file.sync_all() } /// Appends ignore rules, rejecting remote changes since the read used for this append. -pub(crate) fn append(path_value: &Path, bytes: &[u8]) -> io::Result<()> { +pub fn append(path_value: &Path, bytes: &[u8]) -> io::Result<()> { match workspace::current() { Some(host) => { let remote = path(&host, path_value)?; @@ -456,11 +458,7 @@ fn check_write_size(length: u64) -> io::Result<()> { } /// Creates one directory with optional permissions applied at creation. -pub(crate) fn create_dir_mode( - path_value: &Path, - recursive: bool, - mode: Option, -) -> io::Result<()> { +pub fn create_dir_mode(path_value: &Path, recursive: bool, mode: Option) -> io::Result<()> { match workspace::current() { Some(host) => rpc::( &host, @@ -481,15 +479,15 @@ pub(crate) fn create_dir_mode( } } /// Creates a directory without recursively creating parents. -pub(crate) fn create_dir(path_value: impl AsRef) -> io::Result<()> { +pub fn create_dir(path_value: impl AsRef) -> io::Result<()> { create_dir_mode(path_value.as_ref(), false, None) } /// Creates a directory and any missing parents. -pub(crate) fn create_dir_all(path_value: impl AsRef) -> io::Result<()> { +pub fn create_dir_all(path_value: impl AsRef) -> io::Result<()> { create_dir_mode(path_value.as_ref(), true, None) } /// Removes a disposable directory tree from its owning host. -pub(crate) fn remove_dir_all(path_value: impl AsRef) -> io::Result<()> { +pub fn remove_dir_all(path_value: impl AsRef) -> io::Result<()> { match workspace::current() { Some(host) => rpc::( &host, @@ -502,7 +500,7 @@ pub(crate) fn remove_dir_all(path_value: impl AsRef) -> io::Result<()> { } /// Deletes owned scratch state even after the user cancels its operation. -pub(crate) fn cleanup_directory(path_value: &Path) { +pub fn cleanup_directory(path_value: &Path) { let result = if let Some(host) = workspace::current() { path(&host, path_value).and_then(|path| { host.connection @@ -529,7 +527,7 @@ pub(crate) fn cleanup_directory(path_value: &Path) { if let Err(error) = result { if error.kind() != io::ErrorKind::NotFound { // Scratch names can identify user paths; log only the error category. - crate::diagnostics::core_events::cleanup_failed( + lithe_diagnostics::core_events::cleanup_failed( "app.workspace.scratch_cleanup_failed", &format!("{:?}", error.kind()), ); @@ -537,14 +535,14 @@ pub(crate) fn cleanup_directory(path_value: &Path) { } } /// Renames a file on its owning host, replacing an existing target. -pub(crate) fn rename(from: impl AsRef, to: impl AsRef) -> io::Result<()> { +pub fn rename(from: impl AsRef, to: impl AsRef) -> io::Result<()> { match workspace::current() { Some(host) => rpc::(&host, methods::FS_RENAME, json!({"from": path(&host, from.as_ref())?, "to": path(&host, to.as_ref())?, "overwrite": true})).map(|_| ()), None => std::fs::rename(from, to), } } /// Copies a file on the host without transferring its content through the client. -pub(crate) fn copy(from: impl AsRef, to: impl AsRef) -> io::Result { +pub fn copy(from: impl AsRef, to: impl AsRef) -> io::Result { match workspace::current() { Some(host) => { rpc::( @@ -559,26 +557,26 @@ pub(crate) fn copy(from: impl AsRef, to: impl AsRef) -> io::Result std::ffi::OsString { + pub fn file_name(&self) -> std::ffi::OsString { self.path.file_name().unwrap_or_default().to_owned() } /// Entry path in native workspace identity. - pub(crate) fn path(&self) -> PathBuf { + pub fn path(&self) -> PathBuf { self.path.clone() } /// File type, without following symbolic links. - pub(crate) fn file_type(&self) -> io::Result { + pub fn file_type(&self) -> io::Result { Ok(FileType(self.kind)) } } /// Lists a directory through the selected workspace adapter. -pub(crate) fn read_dir( +pub fn read_dir( path_value: impl AsRef, ) -> io::Result>> { let path_value = path_value.as_ref(); @@ -622,7 +620,7 @@ pub(crate) fn read_dir( } /// Allocates a private host scratch directory; its caller owns deletion. -pub(crate) fn remote_temp_directory(prefix: &str) -> io::Result> { +pub fn remote_temp_directory(prefix: &str) -> io::Result> { workspace::current() .map(|host| { rpc::( diff --git a/rust/crates/core/src/remote/workspace_host.rs b/rust/crates/workspace/src/host.rs similarity index 95% rename from rust/crates/core/src/remote/workspace_host.rs rename to rust/crates/workspace/src/host.rs index bdb973b7..2d7065c9 100644 --- a/rust/crates/core/src/remote/workspace_host.rs +++ b/rust/crates/workspace/src/host.rs @@ -4,11 +4,11 @@ //! failed creation, and caller disconnect all close the remote process tree; //! ordinary host requests cannot create an unobserved streaming resource. -use super::sessions::{lookup, request_error}; -use crate::git::execution_events; -use crate::protocol::{cancellation, CoreError, ErrorCode}; +use crate::sessions::{lookup, request_error}; +use lithe_protocol::{cancellation, CoreError, ErrorCode}; use lithe_remote::client::session::{ClientError, Event, RemoteSession}; use lithe_remote::protocol::methods; +use lithe_request_context::events as execution_events; use serde::Deserialize; use serde_json::{json, Value}; use std::collections::VecDeque; @@ -21,7 +21,7 @@ const CLEANUP_TIMEOUT: Duration = Duration::from_secs(2); const EARLY_EVENT_LIMIT: usize = 8 * 1024 * 1024; /// Waits for finite host work while preserving the owning Core cancellation. -pub(crate) fn request_cancellable( +pub fn request_cancellable( session: &RemoteSession, method: &str, params: Value, @@ -41,7 +41,7 @@ pub(crate) fn request_cancellable( /// One host operation. Parameters retain the versioned remote protocol shape. #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct Request { +pub struct Request { session_id: String, #[serde(default)] method: String, @@ -52,7 +52,7 @@ pub(crate) struct Request { } /// Forwards finite host operations and controls of already observed resources. -pub(crate) fn request(request: Request) -> Result { +pub fn request(request: Request) -> Result { if !allowed_method(&request.method) { return Err(CoreError::new( ErrorCode::InvalidRequest, @@ -111,24 +111,24 @@ fn allowed_method(method: &str) -> bool { } /// Starts a process with a subscription installed before any output can arrive. -pub(crate) fn run_process(request: Request) -> Result { +pub fn run_process(request: Request) -> Result { run_resource(request, "process", methods::PROCESS_SPAWN) } /// Starts a PTY with the same subscription, cancellation and cleanup ownership. -pub(crate) fn run_terminal(request: Request) -> Result { +pub fn run_terminal(request: Request) -> Result { run_resource(request, "terminal", methods::TERMINAL_OPEN) } /// Owns a recursive watch until cancellation, disconnect, or root removal. -pub(crate) fn run_watch(request: Request) -> Result { +pub fn run_watch(request: Request) -> Result { run_resource(request, "watch", methods::WATCH_START) } /// Bounded byte capture for tool probes; long-lived backends use streaming calls. #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct CaptureRequest { +pub struct CaptureRequest { session_id: String, params: methods::SpawnParams, timeout_milliseconds: u64, @@ -136,7 +136,7 @@ pub(crate) struct CaptureRequest { } /// Captures one process without leaking a child on timeout or excessive output. -pub(crate) fn capture_process(request: CaptureRequest) -> Result { +pub fn capture_process(request: CaptureRequest) -> Result { if !(1..=30_000).contains(&request.timeout_milliseconds) || !(1..=methods::MAX_TRANSFER_BYTES as usize).contains(&request.max_output_bytes) || request.params.pipe_stdin @@ -217,11 +217,11 @@ impl Drop for Resource<'_> { // An exited process, stopped watch or closed terminal is already gone // on the host: expected missing. The resource owner keeps the primary // result; WARN reports any other failed cleanup. - if !crate::diagnostics::core_events::remote_resource_already_gone(&error) { - crate::diagnostics::core_events::recovery_failed( + if !error.is_resource_already_gone() { + lithe_diagnostics::core_events::recovery_failed( "remote.resource.cleanup_failed", self.family, - &crate::diagnostics::core_events::client_failure_category(&error), + &error.failure_category(), "remote", ); } @@ -262,7 +262,7 @@ fn run_on_session( } /// Runs an owned host process with byte-preserving output and optional input. -pub(crate) fn process_observing( +pub fn process_observing( session: &RemoteSession, params: methods::SpawnParams, input: Option<&[u8]>, diff --git a/rust/crates/core/src/remote/workspace_host/tests.rs b/rust/crates/workspace/src/host/tests.rs similarity index 100% rename from rust/crates/core/src/remote/workspace_host/tests.rs rename to rust/crates/workspace/src/host/tests.rs diff --git a/rust/crates/workspace/src/lib.rs b/rust/crates/workspace/src/lib.rs new file mode 100644 index 00000000..b8ed089b --- /dev/null +++ b/rust/crates/workspace/src/lib.rs @@ -0,0 +1,28 @@ +//! Where a Core request's files and tools live: the local machine, or a host +//! reached through a remote development session. +//! +//! A request that names a remote workspace installs its [`Host`] for the +//! request's duration ([`Scope::begin`]); [`fs`] then reads and writes on that +//! host and never falls back to the local placeholder folder, and +//! [`remote_path`] / [`local_path`] translate schema-owned paths between the +//! two namespaces. Without a host every call is the local filesystem. +//! +//! The session side owns the long-lived connections: [`probe`] checks that a +//! saved SSH connection reaches a usable host, [`sessions`] opens and keeps the +//! connections, [`host`] forwards finite requests and owned streaming resources +//! (processes, terminals, watches), and [`upload`] streams a verified file to +//! the host. Domain semantics stay in their engines (Git, build, language +//! servers); this crate only supplies the IO boundary. + +pub mod fs; +pub mod host; +pub mod probe; +mod scope; +pub mod sessions; +#[cfg(feature = "test-support")] +pub mod test_support; +pub mod upload; + +pub use scope::{ + current, enter, identity_key, is_remote, local_path, remote_path, Host, Scope, Workspace, +}; diff --git a/rust/crates/core/src/remote/mod.rs b/rust/crates/workspace/src/probe.rs similarity index 91% rename from rust/crates/core/src/remote/mod.rs rename to rust/crates/workspace/src/probe.rs index e41743ef..78e56019 100644 --- a/rust/crates/core/src/remote/mod.rs +++ b/rust/crates/workspace/src/probe.rs @@ -9,20 +9,14 @@ //! //! Note: .agents/notes/implemented/feature/2026-10-24-remote-ssh-connections.md -use crate::git::execution_events; -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use lithe_remote::client::bootstrap::{parse_probe, probe_command}; use lithe_remote::client::{Hop, SshHop, TransportChain}; +use lithe_request_context::events as execution_events; use serde::{Deserialize, Serialize}; use std::process::{Command, ExitStatus}; use std::time::{Duration, Instant}; -pub(crate) mod sessions; -pub(crate) mod upload; -pub(crate) mod workspace; -pub(crate) mod workspace_host; -pub(crate) mod workspace_io; - /// Seconds OpenSSH waits for the TCP connection when the profile sets none. const DEFAULT_CONNECT_TIMEOUT_SECONDS: u32 = 15; @@ -37,7 +31,7 @@ const DIAGNOSTICS_LIMIT: usize = 4 * 1024; /// OpenSSH ask follow-up questions, such as a one-time code. #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)] #[serde(rename_all = "camelCase")] -pub(crate) enum Authentication { +pub enum Authentication { /// OpenSSH's own order: keys and agent first, then interactive methods. #[default] Auto, @@ -53,7 +47,7 @@ pub(crate) enum Authentication { /// authentication are sent; names and colors stay in the host. #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct SshConnection { +pub struct SshConnection { /// Host name, address, or a `Host` alias from the user's SSH configuration. host: String, /// `None` keeps the port from the SSH configuration (22 by default). @@ -75,9 +69,10 @@ pub(crate) struct SshConnection { keep_alive_seconds: Option, } +/// `remote.probe`: the saved connection to check. #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct ProbeRequest { +pub struct ProbeRequest { connection: SshConnection, } @@ -85,7 +80,7 @@ pub(crate) struct ProbeRequest { /// errors, so the host can show a specific explanation for each. #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] -pub(crate) enum ProbeStatus { +pub enum ProbeStatus { /// A shell ran on the host and a server build exists for it. Connected, /// The host answered, but no server build exists for its platform. @@ -100,32 +95,34 @@ pub(crate) enum ProbeStatus { Failed, } +/// Platform facts the host's shell reported. #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] -pub(crate) struct ProbedHost { +pub struct ProbedHost { /// `uname -s`, for example `Linux` or `Darwin`. - system: String, + pub(crate) system: String, /// `uname -m`, for example `x86_64` or `arm64`. - machine: String, - home_directory: Option, + pub(crate) machine: String, + pub(crate) home_directory: Option, /// Rust target of the server build for this host, or `None`. - server_target: Option<&'static str>, + pub(crate) server_target: Option<&'static str>, /// Facts for display, such as the distribution and memory. - details: lithe_remote::client::bootstrap::HostDetails, + pub(crate) details: lithe_remote::client::bootstrap::HostDetails, } +/// `remote.probe` result: the outcome, and the host when its shell ran. #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] -pub(crate) struct ProbeResult { - status: ProbeStatus, +pub struct ProbeResult { + pub(crate) status: ProbeStatus, /// Present when the host's shell ran the probe. - host: Option, + pub(crate) host: Option, /// Redacted tail of OpenSSH's standard error. - diagnostics: String, + pub(crate) diagnostics: String, } /// Probes the host named by the request's connection. -pub(crate) fn probe(request: ProbeRequest) -> Result { +pub fn probe(request: ProbeRequest) -> Result { probe_with(request, None) } @@ -167,7 +164,7 @@ fn probe_with(request: ProbeRequest, ssh_program: Option<&str>) -> Result= deadline { stop = Some(CoreError::new( @@ -225,11 +222,11 @@ fn probe_with(request: ProbeRequest, ssh_program: Option<&str>) -> Result) -> bool { +pub fn respond(request_id: &str, answer: Option) -> bool { lithe_git_host::authentication::respond(request_id, answer) } -fn ssh_hop(connection: &SshConnection) -> Result { +pub(crate) fn ssh_hop(connection: &SshConnection) -> Result { let invalid = |reason: &str| CoreError::new(ErrorCode::InvalidRequest, reason.to_string()); let host = connection.host.trim(); if host.is_empty() || host.contains('@') { @@ -310,7 +307,7 @@ fn ssh_hop(connection: &SshConnection) -> Result { /// Interprets the finished probe. OpenSSH reports connection failures only /// as text, so the categories match its stable English messages. -fn classify(exit: Option, stdout: &[u8], stderr: &[u8]) -> ProbeResult { +pub(crate) fn classify(exit: Option, stdout: &[u8], stderr: &[u8]) -> ProbeResult { let stderr_text = String::from_utf8_lossy(stderr); let diagnostics = diagnostics(&stderr_text); if let Some(report) = parse_probe(stdout) { @@ -375,7 +372,7 @@ fn classify(exit: Option, stdout: &[u8], stderr: &[u8]) -> ProbeResu /// Keeps the end of the output, where OpenSSH prints the reason it gave up, /// and removes credentials and home directory paths. -fn diagnostics(stderr: &str) -> String { +pub(crate) fn diagnostics(stderr: &str) -> String { let trimmed = stderr.trim(); let mut start = trimmed.len().saturating_sub(DIAGNOSTICS_LIMIT); while !trimmed.is_char_boundary(start) { diff --git a/rust/crates/core/src/remote/tests.rs b/rust/crates/workspace/src/probe/tests.rs similarity index 94% rename from rust/crates/core/src/remote/tests.rs rename to rust/crates/workspace/src/probe/tests.rs index 2910ad18..2e377f9a 100644 --- a/rust/crates/core/src/remote/tests.rs +++ b/rust/crates/workspace/src/probe/tests.rs @@ -1,9 +1,13 @@ use super::*; #[cfg(unix)] -use crate::tests::support::temporary_root; +use crate::sessions; use serde_json::json; #[cfg(unix)] use std::path::{Path, PathBuf}; +#[cfg(unix)] +use std::sync::atomic::{AtomicU64, Ordering}; +#[cfg(unix)] +use std::sync::OnceLock; fn connection(value: serde_json::Value) -> SshConnection { serde_json::from_value(value).expect("valid connection") @@ -340,6 +344,7 @@ fn connecting_without_a_matching_server_reports_the_platform() { assert_eq!(value["host"]["system"], local_uname("-s")); } +#[cfg(unix)] #[test] fn unknown_server_targets_are_rejected() { let request: sessions::ConnectRequest = serde_json::from_value(json!({ @@ -352,6 +357,7 @@ fn unknown_server_targets_are_rejected() { assert!(matches!(error.code, ErrorCode::InvalidRequest), "{error:?}"); } +#[cfg(unix)] #[test] fn closed_connections_cannot_be_used() { let request: sessions::ReadDirRequest = @@ -366,6 +372,7 @@ fn closed_connections_cannot_be_used() { } // Application exit closes whatever is open and must be safe with nothing open. +#[cfg(unix)] #[test] fn closing_all_connections_with_none_open_succeeds() { let connections = std::sync::Mutex::new(std::collections::HashMap::new()); @@ -374,6 +381,7 @@ fn closing_all_connections_with_none_open_succeeds() { // ssh splits an option value on whitespace, so a key path with spaces must // reach it quoted. +#[cfg(unix)] #[test] fn private_key_paths_with_spaces_are_quoted() { let command = command_line(json!({ @@ -386,3 +394,21 @@ fn private_key_paths_with_spaces_are_quoted() { Some("\"/Users/me/My Keys/id_rsa\"") ); } + +/// A scratch directory no other fixture shares, even across processes. +#[cfg(unix)] +fn temporary_root(label: &str) -> PathBuf { + static NONCE: OnceLock = OnceLock::new(); + static SEQUENCE: AtomicU64 = AtomicU64::new(0); + let nonce = NONCE.get_or_init(|| { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be valid") + .as_nanos() + }); + let sequence = SEQUENCE.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "lithe-workspace-{label}-{}-{nonce}-{sequence}", + std::process::id() + )) +} diff --git a/rust/crates/core/src/remote/workspace.rs b/rust/crates/workspace/src/scope.rs similarity index 71% rename from rust/crates/core/src/remote/workspace.rs rename to rust/crates/workspace/src/scope.rs index 7d9d81ce..e3e707cd 100644 --- a/rust/crates/core/src/remote/workspace.rs +++ b/rust/crates/workspace/src/scope.rs @@ -3,8 +3,8 @@ //! A remote request never falls back to the placeholder filesystem or local //! executables. Git continues to own Git semantics; this boundary supplies IO. -use super::sessions::{lookup, Connection}; -use crate::protocol::{CoreError, ErrorCode}; +use crate::sessions::{lookup, Connection}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::Deserialize; use std::cell::RefCell; use std::path::{Path, PathBuf}; @@ -13,50 +13,32 @@ use std::sync::Arc; /// Host and path identity supplied by the native workspace registry. #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct Workspace { +pub struct Workspace { session_id: String, local_root: PathBuf, remote_root: PathBuf, } +/// The connected host a request's workspace lives on, and its path mapping. #[derive(Clone)] -pub(crate) struct Host { - pub(crate) connection: Arc, +pub struct Host { + /// Session that reaches the host. + pub connection: Arc, workspace: Workspace, } thread_local! { static HOST: RefCell> = const { RefCell::new(None) }; } /// Restores the caller's host for nested Core requests and error paths. -pub(crate) struct Scope(Option); +pub struct Scope(Option); impl Scope { - pub(crate) fn begin(workspace: Option, command: &str) -> Result { + /// Installs the host of `workspace` for the current request, or no host + /// when `workspace` is `None`. Fails when a root is relative or climbs + /// with `..`, or when the session is not connected. + pub fn begin(workspace: Option) -> Result { let host = workspace .map(|workspace| { - if !(command.starts_with("git.") - || command.starts_with("build.") - || matches!( - command, - "workspace.repositories" - | "workspace.snapshot" - | "workspace.listDirectories" - | "workspace.search" - | "workspace.searchEverywhere" - | "file.read" - | "file.write" - | "workspace.searchIndex.warm" - | "workspace.searchIndex.update" - | "workspace.searchIndex.invalidate" - | "workspace.replacePreview" - | "lsp.startServer" - )) - { - return Err(CoreError::new( - ErrorCode::NotSupported, - "This command has no remote workspace adapter", - )); - } if !valid_root(&workspace.local_root) || workspace.local_root.file_name().is_none() || !valid_root(&workspace.remote_root) @@ -77,7 +59,7 @@ impl Scope { } /// Installs an already validated session owner on a backend worker. -pub(crate) fn enter(host: Option) -> Scope { +pub fn enter(host: Option) -> Scope { Scope(HOST.with(|current| current.replace(host))) } @@ -94,18 +76,19 @@ impl Drop for Scope { } } -pub(crate) fn current() -> Option { +/// The host installed for the current request, if any. +pub fn current() -> Option { HOST.with(|host| host.borrow().clone()) } /// Whether filesystem and tool operations belong to a connected workspace host. -pub(crate) fn is_remote() -> bool { +pub fn is_remote() -> bool { current().is_some() } impl Host { /// Maps schema-owned file URIs while preserving host identity and escaping. - pub(crate) fn map_uri(&self, value: &str, outbound: bool) -> Option { + pub fn map_uri(&self, value: &str, outbound: bool) -> Option { let uri = url::Url::parse(value).ok()?; if uri.scheme() != "file" || uri @@ -127,7 +110,10 @@ impl Host { .ok() .map(|uri| uri.to_string()) } - pub(crate) fn remote_path(&self, path: &Path) -> PathBuf { + + /// Translates a placeholder path to the host path it stands for; paths + /// outside the workspace stay as they are. + pub fn remote_path(&self, path: &Path) -> PathBuf { if let Ok(suffix) = path.strip_prefix(self.host_paths_root()) { return Path::new("/").join(suffix); } @@ -139,7 +125,7 @@ impl Host { .unwrap_or_else(|_| path.to_owned()) } - pub(super) fn local_path(&self, path: &Path) -> PathBuf { + pub(crate) fn local_path(&self, path: &Path) -> PathBuf { if path == self.workspace.remote_root { return self.workspace.local_root.clone(); } @@ -165,27 +151,27 @@ impl Host { self.workspace.local_root.with_file_name(name) } - pub(super) fn identity(&self) -> &str { + pub(crate) fn identity(&self) -> &str { &self.workspace.session_id } } /// Projects host paths back onto the native workspace's URL identity. -pub(crate) fn local_path(path: &Path) -> PathBuf { +pub fn local_path(path: &Path) -> PathBuf { current() .map(|host| host.local_path(path)) .unwrap_or_else(|| path.to_owned()) } /// Translates a schema-owned path to its host; opaque argv and text stay untouched. -pub(crate) fn remote_path(path: &Path) -> PathBuf { +pub fn remote_path(path: &Path) -> PathBuf { current() .map(|host| host.remote_path(path)) .unwrap_or_else(|| path.to_owned()) } /// Includes host identity in process-local repository coordination keys. -pub(crate) fn identity_key(path: &Path) -> PathBuf { +pub fn identity_key(path: &Path) -> PathBuf { current() .map(|host| PathBuf::from(host.identity()).join(path.strip_prefix("/").unwrap_or(path))) .unwrap_or_else(|| path.to_owned()) diff --git a/rust/crates/core/src/remote/sessions.rs b/rust/crates/workspace/src/sessions.rs similarity index 91% rename from rust/crates/core/src/remote/sessions.rs rename to rust/crates/workspace/src/sessions.rs index 5d7b70b2..2f94424c 100644 --- a/rust/crates/core/src/remote/sessions.rs +++ b/rust/crates/workspace/src/sessions.rs @@ -7,14 +7,14 @@ //! it by id. OpenSSH runs in its own process tree, which closing a connection //! ends. -use super::{classify, diagnostics, ssh_hop, ProbeStatus, ProbedHost, SshConnection}; -use crate::git::execution_events; -use crate::protocol::{CoreError, ErrorCode}; +use crate::probe::{classify, diagnostics, ssh_hop, ProbeStatus, ProbedHost, SshConnection}; use lithe_git_host::ProcessTree; +use lithe_protocol::{CoreError, ErrorCode}; use lithe_remote::client::launch::{launch, launch_command, LaunchError, Launched}; use lithe_remote::client::session::{ClientError, ConnectOptions}; use lithe_remote::client::{Hop, RemoteSession, ServerArtifact, ServerTarget, TransportChain}; use lithe_remote::protocol::methods; +use lithe_request_context::events as execution_events; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, VecDeque}; use std::io::Read; @@ -54,9 +54,11 @@ struct ServerInput { path: String, } +/// `remote.connect`: the connection, the server builds Core may install, +/// and where to install them. #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct ConnectRequest { +pub struct ConnectRequest { connection: SshConnection, servers: Vec, server_version: String, @@ -65,9 +67,10 @@ pub(crate) struct ConnectRequest { install_root: Option, } +/// The session `remote.connect` opened. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] -pub(crate) struct OpenedSession { +pub struct OpenedSession { id: String, /// Whether this connection uploaded the server. uploaded: bool, @@ -76,9 +79,10 @@ pub(crate) struct OpenedSession { home_directory: Option, } +/// `remote.connect` result. Connection failures are results, not errors. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] -pub(crate) struct ConnectResult { +pub struct ConnectResult { /// `connected` only when `session` is set. status: ProbeStatus, session: Option, @@ -87,28 +91,27 @@ pub(crate) struct ConnectResult { diagnostics: String, } -pub(crate) struct Connection { - pub(crate) session: RemoteSession, - pub(crate) git_tool: Mutex>, - transport: ConnectionTransport, +/// One open host connection in the registry. +pub struct Connection { + /// Framed workspace session to the host server. + pub session: RemoteSession, + /// Git executable path and version found on the host, once resolved. + pub git_tool: Mutex>, + pub(crate) transport: ConnectionTransport, } /// Owns the native transport independently of the framed workspace session. -enum ConnectionTransport { +pub(crate) enum ConnectionTransport { Ssh { child: Mutex, tree: ProcessTree, }, - #[cfg(test)] + #[cfg(any(test, feature = "test-support"))] InProcess, } -#[cfg(all(test, unix))] -#[path = "sessions_integration_tests.rs"] -mod integration_tests; - impl Connection { - fn close(&self) { + pub(crate) fn close(&self) { if let Err(error) = self.session.request_value( methods::SERVER_SHUTDOWN, serde_json::Value::Null, @@ -116,10 +119,10 @@ impl Connection { ) { // A peer that already closed needs no shutdown; other failures use WARN. if !matches!(error, ClientError::Disconnected { .. }) { - crate::diagnostics::core_events::recovery_failed( + lithe_diagnostics::core_events::recovery_failed( "remote.session.cleanup_failed", "shutdown", - &crate::diagnostics::core_events::client_failure_category(&error), + &error.failure_category(), "remote", ); } @@ -145,7 +148,7 @@ impl Connection { record_cleanup_io("wait", &error); } } - #[cfg(test)] + #[cfg(any(test, feature = "test-support"))] ConnectionTransport::InProcess => {} } } @@ -154,7 +157,7 @@ impl Connection { /// Records one failed SSH transport cleanup step. The caller keeps its own /// result; WARN reports a process that may outlive the session. fn record_cleanup_io(stage: &str, error: &std::io::Error) { - crate::diagnostics::core_events::recovery_failed( + lithe_diagnostics::core_events::recovery_failed( "remote.session.cleanup_failed", stage, &format!("{:?}", error.kind()), @@ -195,12 +198,12 @@ impl Drop for ConnectingGuard { } } -fn registry() -> &'static Mutex>> { +pub(crate) fn registry() -> &'static Mutex>> { static CONNECTIONS: OnceLock>>> = OnceLock::new(); CONNECTIONS.get_or_init(Mutex::default) } -pub(super) fn lookup(id: &str) -> Result, CoreError> { +pub(crate) fn lookup(id: &str) -> Result, CoreError> { registry() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) @@ -215,12 +218,12 @@ pub(super) fn lookup(id: &str) -> Result, CoreError> { } /// Opens a connection and registers it. -pub(crate) fn connect(request: ConnectRequest) -> Result { +pub fn connect(request: ConnectRequest) -> Result { connect_with(request, None) } /// `ssh_program` replaces the `ssh` found on `PATH`; tests use a stand-in. -pub(super) fn connect_with( +pub(crate) fn connect_with( request: ConnectRequest, ssh_program: Option<&str>, ) -> Result { @@ -327,7 +330,7 @@ pub(super) fn connect_with( Err(error) }; let (result, output, input) = loop { - if let Err(error) = crate::protocol::cancellation::check() { + if let Err(error) = lithe_protocol::cancellation::check() { return abandon(error, &mut child); } if CLOSING.load(Ordering::SeqCst) { @@ -393,7 +396,7 @@ pub(super) fn connect_with( .take(); if let Some(reader) = reader { if reader.join().is_err() { - crate::diagnostics::core_events::recovery_failed( + lithe_diagnostics::core_events::recovery_failed( "remote.session.diagnostics_failed", "reader", "reader_panicked", @@ -521,16 +524,17 @@ pub(super) fn connect_with( }) } +/// `remote.readDir`: one directory on a connected host. #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct ReadDirRequest { +pub struct ReadDirRequest { session_id: String, /// Absolute path on the remote host. path: String, } /// Lists a remote directory; the result is the protocol's `fs.readDir`. -pub(crate) fn read_dir(request: ReadDirRequest) -> Result { +pub fn read_dir(request: ReadDirRequest) -> Result { let connection = lookup(&request.session_id)?; connection .session @@ -549,16 +553,17 @@ const WRITE_BYTES_PER_SECOND: u64 = 64 * 1024; /// Largest file opened for editing; bigger files stay on the host. const MAX_EDIT_BYTES: u64 = 16 * 1024 * 1024; +/// `remote.readFile`: one file on a connected host. #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct ReadFileRequest { +pub struct ReadFileRequest { session_id: String, path: String, } /// Reads a whole file for the editor: `{ data (base64), size, modifiedMs }`. /// The version identifies the read for a later conflict-checked write. -pub(crate) fn read_file(request: ReadFileRequest) -> Result { +pub fn read_file(request: ReadFileRequest) -> Result { let connection = lookup(&request.session_id)?; let metadata = connection .session @@ -588,9 +593,10 @@ pub(crate) fn read_file(request: ReadFileRequest) -> Result Result { +pub fn write_file(request: WriteFileRequest) -> Result { let connection = lookup(&request.session_id)?; let content = methods::decode_bytes(&request.data).map_err(|error| { CoreError::new( @@ -658,14 +664,15 @@ pub(crate) fn write_file(request: WriteFileRequest) -> Result bool { +pub fn disconnect(request: DisconnectRequest) -> bool { let connection = registry() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) @@ -679,7 +686,7 @@ pub(crate) fn disconnect(request: DisconnectRequest) -> bool { /// Closes every open connection, for application exit. Connections close in /// parallel so a dead host costs one grace period, not one per connection. -pub(crate) fn close_all() -> usize { +pub fn close_all() -> usize { CLOSING.store(true, Ordering::SeqCst); let count = close_registered(); // Attempts still opening notice the flag within one poll interval and @@ -692,13 +699,13 @@ pub(crate) fn close_all() -> usize { } /// Closes the registered connections in parallel. -pub(super) fn close_registered() -> usize { +pub(crate) fn close_registered() -> usize { close_registry(registry()) } /// Drains the owned registry, allowing isolated lifecycle tests without closing /// unrelated sessions concurrently exercising the application registry. -pub(super) fn close_registry(connections: &Mutex>>) -> usize { +pub(crate) fn close_registry(connections: &Mutex>>) -> usize { let connections: Vec> = connections .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) @@ -713,7 +720,7 @@ pub(super) fn close_registry(connections: &Mutex for closer in closers { if closer.join().is_err() { // Parallel close owns recovery; WARN reports a distinct worker panic. - crate::diagnostics::core_events::recovery_failed( + lithe_diagnostics::core_events::recovery_failed( "remote.session.cleanup_failed", "closer", "closer_panicked", @@ -725,7 +732,7 @@ pub(super) fn close_registry(connections: &Mutex } /// Translates host transport failures without losing their stable remote category. -pub(crate) fn request_error(session_id: &str, error: ClientError) -> CoreError { +pub fn request_error(session_id: &str, error: ClientError) -> CoreError { match error { ClientError::Remote(error) => { let code = match error.code { @@ -799,7 +806,7 @@ fn keep_tail( Ok(0) => return, Err(error) => { // Once per reader completion; WARN explains truncated launch evidence. - crate::diagnostics::core_events::recovery_failed( + lithe_diagnostics::core_events::recovery_failed( "remote.session.diagnostics_failed", "stderr", &format!("{:?}", error.kind()), @@ -820,7 +827,7 @@ fn keep_tail( /// Unguessable connection id, so one host request cannot address a /// connection it did not open by counting. -fn nonce() -> String { +pub(crate) fn nonce() -> String { use rand::RngCore; let mut bytes = [0_u8; 16]; rand::rngs::OsRng.fill_bytes(&mut bytes); diff --git a/rust/crates/workspace/src/test_support.rs b/rust/crates/workspace/src/test_support.rs new file mode 100644 index 00000000..2f6e7b20 --- /dev/null +++ b/rust/crates/workspace/src/test_support.rs @@ -0,0 +1,47 @@ +//! Hooks for `lithe-core`'s end-to-end tests, which drive real Core commands +//! against an in-process host server instead of an SSH child. + +use crate::sessions::{lookup, nonce, registry, Connection, ConnectionTransport}; +use lithe_remote::client::RemoteSession; +use std::sync::{Arc, Mutex}; + +/// Registers `session` as a connected host and returns its session id. +pub fn register_in_process(session: RemoteSession) -> String { + let id = nonce(); + registry().lock().unwrap().insert( + id.clone(), + Arc::new(Connection { + session, + git_tool: Mutex::new(None), + transport: ConnectionTransport::InProcess, + }), + ); + id +} + +/// The registered connection for `id`. +pub fn connection(id: &str) -> Arc { + lookup(id).unwrap() +} + +/// Shuts the session down but keeps it registered, as a dropped link does. +pub fn drop_link(id: &str) { + lookup(id).unwrap().close(); +} + +/// Removes the session from the registry and shuts it down. +pub fn unregister(id: &str) { + if let Some(connection) = registry().lock().unwrap().remove(id) { + connection.close(); + } +} + +/// A fresh unguessable id, the same shape as session ids. +pub fn unique_id() -> String { + nonce() +} + +/// Host stat requests issued on the current thread so far. +pub fn stat_requests() -> usize { + crate::fs::stat_requests_for_test() +} diff --git a/rust/crates/core/src/remote/upload.rs b/rust/crates/workspace/src/upload.rs similarity index 91% rename from rust/crates/core/src/remote/upload.rs rename to rust/crates/workspace/src/upload.rs index e82413e3..8c383010 100644 --- a/rust/crates/core/src/remote/upload.rs +++ b/rust/crates/workspace/src/upload.rs @@ -1,9 +1,9 @@ //! Streams a native source file to verified host staging without enlarging frames. //! The caller owns package trust; this adapter owns transfer integrity and cleanup. -use super::sessions::{lookup, request_error}; -use super::workspace_host::request_cancellable; -use crate::protocol::{cancellation, CoreError, ErrorCode}; +use crate::host::request_cancellable; +use crate::sessions::{lookup, request_error}; +use lithe_protocol::{cancellation, CoreError, ErrorCode}; use lithe_remote::protocol::methods; use serde::Deserialize; use serde_json::{json, Value}; @@ -16,7 +16,7 @@ const STEP: Duration = Duration::from_secs(30); /// Native upload source and host destination, plus caller-verified expected hash. #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct Request { +pub struct Request { session_id: String, source_path: String, path: String, @@ -25,7 +25,7 @@ pub(crate) struct Request { } /// Uses handshake facts without running another shell or using the local platform. -pub(crate) fn host_info(session_id: &str) -> Result { +pub fn host_info(session_id: &str) -> Result { let connection = lookup(session_id)?; serde_json::to_value(&connection.session.hello().host).map_err(|error| { CoreError::new(ErrorCode::ParseFailed, "Invalid host capabilities") @@ -34,7 +34,7 @@ pub(crate) fn host_info(session_id: &str) -> Result { } /// Uploads one bounded file; any failure aborts its connection-owned staging inode. -pub(crate) fn upload(request: Request) -> Result { +pub fn upload(request: Request) -> Result { cancellation::check()?; if !Path::new(&request.source_path).is_absolute() { return Err(CoreError::new( @@ -129,9 +129,9 @@ pub(crate) fn upload(request: Request) -> Result { if !matches!(&error, lithe_remote::client::session::ClientError::Remote(remote) if remote.code == lithe_remote::protocol::ErrorCode::NotFound) { - crate::diagnostics::core_events::cleanup_failed( + lithe_diagnostics::core_events::cleanup_failed( "remote.upload.abort_failed", - &crate::diagnostics::core_events::client_failure_category(&error), + &error.failure_category(), ); } } diff --git a/rust/fixtures/debug/v1-baseline.json b/rust/fixtures/debug/v1-baseline.json index 2d1be66b..2b6aad95 100644 --- a/rust/fixtures/debug/v1-baseline.json +++ b/rust/fixtures/debug/v1-baseline.json @@ -10,7 +10,7 @@ "evidence": [ { "platform": "rust", - "path": "rust/crates/core/src/debug/engine.rs", + "path": "rust/crates/debug/src/engine/tests.rs", "test": "initialize_launch_breakpoints_and_inspection_are_reduced_in_order" }, { @@ -39,7 +39,7 @@ "evidence": [ { "platform": "rust", - "path": "rust/crates/core/src/debug/engine.rs", + "path": "rust/crates/debug/src/engine/tests.rs", "test": "initialize_launch_breakpoints_and_inspection_are_reduced_in_order" }, { @@ -62,7 +62,7 @@ "evidence": [ { "platform": "rust", - "path": "rust/crates/core/src/debug/engine.rs", + "path": "rust/crates/debug/src/engine/tests.rs", "test": "source_breakpoint_binding_preserves_requested_identity_and_rejects_stale_replies", "fixture": "rust/fixtures/debug/breakpoint-binding-v1.json" }, @@ -92,13 +92,13 @@ "evidence": [ { "platform": "rust", - "path": "rust/crates/core/src/debug/engine.rs", + "path": "rust/crates/debug/src/engine/tests.rs", "test": "variable_paging_is_forwarded_and_normalized_from_shared_fixture", "fixture": "rust/fixtures/debug/variable-paging-v1.json" }, { "platform": "rust", - "path": "rust/crates/core/src/debug/engine.rs", + "path": "rust/crates/debug/src/engine/tests.rs", "test": "variable_paging_arguments_reject_invalid_combinations" }, { @@ -131,7 +131,7 @@ "evidence": [ { "platform": "rust", - "path": "rust/crates/core/src/debug/engine.rs", + "path": "rust/crates/debug/src/engine/tests.rs", "test": "disconnect_policy_matches_shared_fixture", "fixture": "rust/fixtures/debug/disconnect-policy-v1.json" }, @@ -160,18 +160,18 @@ "evidence": [ { "platform": "rust", - "path": "rust/crates/core/src/debug/engine.rs", + "path": "rust/crates/debug/src/engine/tests.rs", "test": "run_in_terminal_request_is_normalized_completed_and_stale_safe", "fixture": "rust/fixtures/debug/run-in-terminal-v1.json" }, { "platform": "rust", - "path": "rust/crates/core/src/debug/engine.rs", + "path": "rust/crates/debug/src/engine/tests.rs", "test": "malformed_run_in_terminal_request_gets_an_explicit_failure_response" }, { "platform": "rust", - "path": "rust/crates/core/src/debug/engine.rs", + "path": "rust/crates/debug/src/engine/tests.rs", "test": "unknown_server_request_gets_an_explicit_failure_response" }, { @@ -194,18 +194,18 @@ "evidence": [ { "platform": "rust", - "path": "rust/crates/core/src/debug/engine.rs", + "path": "rust/crates/debug/src/engine/tests.rs", "test": "start_debugging_request_is_normalized_completed_and_stale_safe", "fixture": "rust/fixtures/debug/start-debugging-v1.json" }, { "platform": "rust", - "path": "rust/crates/core/src/debug/engine.rs", + "path": "rust/crates/debug/src/engine/tests.rs", "test": "start_debugging_failure_and_unsupported_hosts_answer_the_adapter" }, { "platform": "rust", - "path": "rust/crates/core/src/debug/engine.rs", + "path": "rust/crates/debug/src/engine/tests.rs", "test": "disconnect_fails_a_pending_start_debugging_request" }, { @@ -215,12 +215,12 @@ }, { "platform": "rust", - "path": "rust/crates/core/src/debug/engine.rs", + "path": "rust/crates/debug/src/engine/tests.rs", "test": "child_sessions_stop_with_the_parent_and_leave_the_tree_when_destroyed" }, { "platform": "rust", - "path": "rust/crates/core/src/debug/engine.rs", + "path": "rust/crates/debug/src/engine/tests.rs", "test": "destroying_a_parent_destroys_its_whole_child_tree" }, { @@ -240,7 +240,7 @@ "evidence": [ { "platform": "rust", - "path": "rust/crates/core/src/debug/engine.rs", + "path": "rust/crates/debug/src/engine/tests.rs", "test": "node_source_mapped_locations_pass_through_unchanged", "fixture": "rust/fixtures/debug/node-source-map-v1.json" } @@ -253,18 +253,18 @@ "evidence": [ { "platform": "rust", - "path": "rust/crates/core/src/debug/engine.rs", + "path": "rust/crates/debug/src/engine/tests.rs", "test": "hot_replace_normalizes_success_empty_and_error_bodies", "fixture": "rust/fixtures/debug/hot-code-replace-v1.json" }, { "platform": "rust", - "path": "rust/crates/core/src/debug/engine.rs", + "path": "rust/crates/debug/src/engine/tests.rs", "test": "hot_replace_requires_the_declaration_and_preserves_paused_state" }, { "platform": "rust", - "path": "rust/crates/core/src/debug/engine.rs", + "path": "rust/crates/debug/src/engine/tests.rs", "test": "declared_hot_code_replace_sends_the_declared_request_and_reads_its_fields" }, { @@ -293,7 +293,7 @@ }, { "platform": "rust", - "path": "rust/crates/core/src/debug/engine.rs", + "path": "rust/crates/debug/src/engine/tests.rs", "test": "stepping_filters_are_normalized_projected_and_mark_stack_frames" } ], @@ -306,12 +306,12 @@ "evidence": [ { "platform": "rust", - "path": "rust/crates/core/src/debug/engine.rs", + "path": "rust/crates/debug/src/engine/tests.rs", "test": "cancelled_operation_forwards_dap_cancel_and_ignores_late_response" }, { "platform": "rust", - "path": "rust/crates/core/src/debug/engine.rs", + "path": "rust/crates/debug/src/engine/tests.rs", "test": "stale_reference_fixture_matches_core_behavior", "fixture": "rust/fixtures/debug/stale-reference-v1.json" }, @@ -340,7 +340,7 @@ "evidence": [ { "platform": "rust", - "path": "rust/crates/core/src/debug/engine.rs", + "path": "rust/crates/debug/src/engine/tests.rs", "test": "initialize_launch_breakpoints_and_inspection_are_reduced_in_order" }, { @@ -373,27 +373,27 @@ "evidence": [ { "platform": "rust", - "path": "rust/crates/core/src/debug/engine.rs", + "path": "rust/crates/debug/src/engine/tests.rs", "test": "data_breakpoint_identity_and_verification_are_correlated" }, { "platform": "rust", - "path": "rust/crates/core/src/debug/engine.rs", + "path": "rust/crates/debug/src/engine/tests.rs", "test": "variable_mutation_is_capability_gated_and_correlated" }, { "platform": "rust", - "path": "rust/crates/core/src/debug/engine.rs", + "path": "rust/crates/debug/src/engine/tests.rs", "test": "advanced_execution_controls_are_capability_gated_and_correlated" }, { "platform": "rust", - "path": "rust/crates/core/src/debug/engine.rs", + "path": "rust/crates/debug/src/engine/tests.rs", "test": "smart_step_and_goto_targets_are_normalized_before_targeted_execution" }, { "platform": "rust", - "path": "rust/crates/core/src/debug/engine.rs", + "path": "rust/crates/debug/src/engine/tests.rs", "test": "exception_information_is_capability_gated_and_normalized", "fixture": "rust/fixtures/debug/exception-info-v1.json" }, @@ -417,7 +417,7 @@ "evidence": [ { "platform": "rust", - "path": "rust/crates/core/src/debug/endpoint.rs", + "path": "rust/crates/debug/src/endpoint.rs", "test": "dap_endpoint_contract_fixtures_match_validation_and_consent_classification", "fixture": "rust/fixtures/debug/adapter-endpoints-v1.json" }, @@ -441,7 +441,7 @@ "evidence": [ { "platform": "rust", - "path": "rust/crates/core/src/debug/transport_lifecycle.rs", + "path": "rust/crates/debug/src/transport_lifecycle.rs", "test": "lifecycle_fixture_scenarios_match_expected_steps", "fixture": "rust/fixtures/debug/transport-lifecycle-v1.json" }, @@ -489,7 +489,7 @@ "evidence": [ { "platform": "rust", - "path": "rust/crates/core/src/debug/variable_paging.rs", + "path": "rust/crates/debug/src/variable_paging.rs", "test": "variable_page_plan_matches_the_shared_fixture", "fixture": "rust/fixtures/debug/variable-page-plan-v1.json" } @@ -503,7 +503,7 @@ "evidence": [ { "platform": "rust", - "path": "rust/crates/core/src/debug/disassembly_paging.rs", + "path": "rust/crates/debug/src/disassembly_paging.rs", "test": "disassembly_paging_matches_the_shared_fixture", "fixture": "rust/fixtures/debug/disassembly-paging-v1.json" } diff --git a/rust/fixtures/modules/built-in-v1.json b/rust/fixtures/modules/built-in-v1.json deleted file mode 100644 index d4a529e1..00000000 --- a/rust/fixtures/modules/built-in-v1.json +++ /dev/null @@ -1,154 +0,0 @@ -{ - "version": 1, - "modules": [ - { - "id": "dev.lithe.agent-conversation", - "displayName": "Agent Conversation", - "scope": "application", - "defaultState": "disabled", - "activationPolicy": "onDemand", - "sleepPolicy": { "kind": "never" }, - "dependencies": [], - "capabilities": ["dev.lithe.capability.agent-conversation"], - "contributions": [ - { "id": "agent.conversation", "kind": "toolWindow", "actionID": "agent.conversation.toggle", "rendererID": "agent.conversation" } - ], - "required": false - }, - { - "id": "dev.lithe.ai-assistance", - "displayName": "AI Assistance", - "scope": "application", - "defaultState": "disabled", - "activationPolicy": "onDemand", - "sleepPolicy": { "kind": "whenIdle", "afterSeconds": 300 }, - "dependencies": [], - "capabilities": [ - "dev.lithe.capability.ai-commit-message", - "dev.lithe.capability.ai-pull-request-description" - ], - "contributions": [ - { "id": "ai.commit-message", "kind": "command" }, - { "id": "ai.pull-request-description", "kind": "command" }, - { "id": "ai.settings", "kind": "settings" } - ], - "required": false - }, - { - "id": "dev.lithe.debug", - "displayName": "Debug", - "scope": "workspace", - "defaultState": "enabled", - "activationPolicy": "onDemand", - "sleepPolicy": { "kind": "whenIdle", "afterSeconds": 600 }, - "dependencies": ["dev.lithe.execution", "dev.lithe.language-intelligence", "dev.lithe.workspace"], - "capabilities": ["dev.lithe.capability.debug-workspace"], - "contributions": [ - { "id": "debug.session", "kind": "toolWindow", "actionID": "debug.toggle", "rendererID": "debug.session" } - ], - "required": false - }, - { - "id": "dev.lithe.execution", - "displayName": "Build / Run / Test", - "scope": "workspace", - "defaultState": "enabled", - "activationPolicy": "onDemand", - "sleepPolicy": { "kind": "whenIdle", "afterSeconds": 600 }, - "dependencies": ["dev.lithe.workspace"], - "capabilities": ["dev.lithe.capability.execution-workspace"], - "contributions": [ - { "id": "execution.run", "kind": "toolWindow", "actionID": "execution.run.toggle", "rendererID": "execution.run" }, - { "id": "execution.tests", "kind": "toolWindow", "actionID": "execution.tests.toggle", "rendererID": "execution.tests" } - ], - "required": false - }, - { - "id": "dev.lithe.git", - "displayName": "Git Review", - "scope": "workspace", - "defaultState": "enabled", - "activationPolicy": "onDemand", - "sleepPolicy": { "kind": "whenIdle", "afterSeconds": 600 }, - "dependencies": ["dev.lithe.workspace"], - "capabilities": ["dev.lithe.capability.git-workspace"], - "contributions": [ - { "id": "git.changes", "kind": "toolWindow", "actionID": "git.changes.show", "rendererID": "git.changes" }, - { "id": "git.log", "kind": "toolWindow", "actionID": "git.log.toggle", "rendererID": "git.log" } - ], - "required": false - }, - { - "id": "dev.lithe.language-intelligence", - "displayName": "Language Intelligence", - "scope": "workspace", - "defaultState": "enabled", - "activationPolicy": "onDemand", - "sleepPolicy": { "kind": "whenIdle", "afterSeconds": 600 }, - "dependencies": ["dev.lithe.workspace"], - "capabilities": ["dev.lithe.capability.language-intelligence"], - "contributions": [ - { "id": "language.problems", "kind": "toolWindow", "actionID": "language.problems.toggle", "rendererID": "language.problems" }, - { "id": "language.settings", "kind": "settings" } - ], - "required": false - }, - { - "id": "dev.lithe.local-history", - "displayName": "Local History", - "scope": "workspace", - "defaultState": "enabled", - "activationPolicy": "onDemand", - "sleepPolicy": { "kind": "whenIdle", "afterSeconds": 600 }, - "dependencies": ["dev.lithe.workspace"], - "capabilities": ["dev.lithe.capability.history-workspace"], - "contributions": [ - { "id": "history.local", "kind": "toolWindow" } - ], - "required": false - }, - { - "id": "dev.lithe.search", - "displayName": "Search & Index", - "scope": "workspace", - "defaultState": "enabled", - "activationPolicy": "onDemand", - "sleepPolicy": { "kind": "whenIdle", "afterSeconds": 600 }, - "dependencies": ["dev.lithe.workspace"], - "capabilities": ["dev.lithe.capability.search-workspace"], - "contributions": [ - { "id": "search.workspace", "kind": "toolWindow", "actionID": "search.workspace.show", "rendererID": "search.workspace" } - ], - "required": false - }, - { - "id": "dev.lithe.terminal", - "displayName": "Terminal", - "scope": "workspace", - "defaultState": "enabled", - "activationPolicy": "onDemand", - "sleepPolicy": { "kind": "whenIdle", "afterSeconds": 600 }, - "dependencies": ["dev.lithe.workspace"], - "capabilities": ["dev.lithe.capability.terminal-workspace"], - "contributions": [ - { "id": "terminal.sessions", "kind": "toolWindow", "actionID": "terminal.toggle", "rendererID": "terminal.sessions" } - ], - "required": false - }, - { - "id": "dev.lithe.workspace", - "displayName": "Workspace Foundation", - "scope": "workspace", - "defaultState": "enabled", - "activationPolicy": "eager", - "sleepPolicy": { "kind": "never" }, - "dependencies": [], - "capabilities": ["dev.lithe.capability.workspace-foundation"], - "contributions": [ - { "id": "github.pull-requests", "kind": "toolWindow", "actionID": "github.pull-requests.show", "rendererID": "github.pull-requests" }, - { "id": "workspace.project", "kind": "toolWindow", "actionID": "workspace.project.show", "rendererID": "workspace.project" } - ], - "required": true - } - ] -} diff --git a/rust/logging/README.md b/rust/logging/README.md index 70f778e1..58f7e2fe 100644 --- a/rust/logging/README.md +++ b/rust/logging/README.md @@ -39,7 +39,7 @@ | `error` | `any Error` | 展开为 ``、`_code`、`_domain`,字段名通常就是 `error`;消息脱敏并限长至 1 KiB,code 可以是整数或稳定 token | | `text` | `String` | 只用于无法结构化的上游诊断(stderr 行、LSP logMessage)和经 Core 集中格式化函数脱敏的外部进程 argv;脱敏、去除控制字符、限长至 1 KiB | -源码、编辑缓冲区、文件内容、提交说明正文、环境变量值、进程的 stdout 与 stdin、凭据、终端历史和协议正文没有对应类型,不得塞进 `text`、`error`、token、路径字段或消息模板。外部进程的 argv 只经过 `rust/crates/core/src/git/command_trace.rs` 的 `format_arguments`,stderr 只保留脱敏后的末尾几行(第五节)。迁移旧日志时拆开拼接消息,只保留结构化的时间、计数、受控结果词和错误类别。`text` 的理由不能写成“方便调试”。 +源码、编辑缓冲区、文件内容、提交说明正文、环境变量值、进程的 stdout 与 stdin、凭据、终端历史和协议正文没有对应类型,不得塞进 `text`、`error`、token、路径字段或消息模板。外部进程的 argv 只经过 `rust/crates/git/src/command_trace.rs` 的 `format_arguments`,stderr 只保留脱敏后的末尾几行(第五节)。迁移旧日志时拆开拼接消息,只保留结构化的时间、计数、受控结果词和错误类别。`text` 的理由不能写成“方便调试”。 Swift 的事件值是**未经脱敏的内存输入**,不能自行序列化落盘。Core 先对 `error`、`text` 输入脱敏、删除控制字符、按 UTF-8 字符边界截断到 1024 字节,再由 `catalog::validate_fields(id, fields)` 校验磁盘键展开后的类型、必填、未知字段、长度和错误组件完整性;这一步校验形状,不代替隐私脱敏。macOS 的跨边界适配器连接 Core 的脱敏、格式化和写入路径,不得另写 Swift 格式化或隐私策略。 @@ -69,15 +69,15 @@ Swift 的事件值是**未经脱敏的内存输入**,不能自行序列化落 | `vcs.git.command.finished` / `.traced` / `.failed` | `vcs/git` | info / debug / warn | `command`、`arguments`、`source`,可选 `repository`、`remote`、`exit_code`、`stderr`,`duration`;finished 与 traced 可带 `cancelled`,failed 可带 `error` | - `app.action.performed` 由 `UserActionTrace` 在最外层用户入口记录,owner 为 `LitheModuleAPI`。`action` 是稳定的操作 id,`surface` 是 `UserActionSurface` 的取值。它的 op 绑定到 `UserActionContext.current`,桥接层据此给 Core 请求附上 `origin`。 -- Core 请求轨迹在 `rust/crates/core/src/runtime/dispatcher.rs` 的请求分发处记录,实现在 `rust/crates/core/src/diagnostics/request_trace.rs`。用户来源的成功或取消记 finished,后台或未知来源的成功或取消记 traced,失败记 failed。`request_trace::is_hot` 列出的按键、每帧、轮询和监听批次方法,以及全部 `diagnostics.*` 和 `core.ping`,不记录。 -- Git 进程轨迹在 `rust/crates/core/src/git/command_trace.rs` 的执行点记录,本地与远程工作区都经过这里。可见命令指经 `execute_git` 执行、作为 invocation 返回给宿主并显示在 Git 控制台的命令;内部读取指 Core 经 `capture_git` 执行、自己解析输出的命令。来源为用户的可见命令,成功或取消记 finished;其余成功或取消记 traced,包括用户操作中的内部读取;失败记 failed。退出码表示“回答”的情形(`exit_is_answer`),以及来源不是用户的内部读取的非零退出,不算失败;后者由调用方判断含义,调用方返回错误时由请求轨迹记录失败。 +- Core 请求轨迹在 `rust/crates/core/src/runtime/dispatcher.rs` 的请求分发处记录,实现在 `rust/crates/core/src/runtime/request_trace.rs`。用户来源的成功或取消记 finished,后台或未知来源的成功或取消记 traced,失败记 failed。`request_trace::is_hot` 列出的按键、每帧、轮询和监听批次方法,以及全部 `diagnostics.*` 和 `core.ping`,不记录。 +- Git 进程轨迹在 `rust/crates/git/src/command_trace.rs` 的执行点记录,本地与远程工作区都经过这里。可见命令指经 `execute_git` 执行、作为 invocation 返回给宿主并显示在 Git 控制台的命令;内部读取指 Core 经 `capture_git` 执行、自己解析输出的命令。来源为用户的可见命令,成功或取消记 finished;其余成功或取消记 traced,包括用户操作中的内部读取;失败记 failed。退出码表示“回答”的情形(`exit_is_answer`),以及来源不是用户的内部读取的非零退出,不算失败;后者由调用方判断含义,调用方返回错误时由请求轨迹记录失败。 - `arguments` 由 `format_arguments` 生成:提交说明值换成 ``,`-c` 中凭据和个人身份键的值换成 ``,URL 用户信息和共享脱敏规则覆盖的内容被改写,超过 1 KiB 时在末尾截断并以 `…` 标记。 - `stderr` 只在失败或非零退出时出现:取进程最后 16 KiB 输出中最后 8 个非空行,逐行脱敏后用 ` | ` 连接,超过 1 KiB 时丢弃最早的内容并以 `…` 标记。 - Core 轨迹的失败固定为 WARN,因为 Core 不知道宿主会重试、降级还是放弃。宿主提示的事实都在同一 op 的 Core 失败轨迹里时,提示用 `LogRecordReference(channel:event:op:)` 引用它,宿主不再记录;宿主只为降级、放弃、重试耗尽、残留等 Core 不掌握的事实记录自己的终态。 ## 六、Core 事件队列与宿主写入 -- Rust 侧没有请求事件流可用的事实(执行轨迹、析构、后台清理、辅助 crate 失败)进入 `diagnostics::core_events` 的进程级有界队列,最多 256 条。`record` 校验 owner、规范化并脱敏字段后入队,不做文件 I/O。队列满时新事实只计入丢弃数;同一批次里事件、通道、字段和 op 都相同的事实只保留一条,其余计入丢弃数。 +- Rust 侧没有请求事件流可用的事实(执行轨迹、析构、后台清理、辅助 crate 失败)进入 `lithe-diagnostics`(`rust/crates/diagnostics`)`core_events` 的进程级有界队列,最多 256 条。`record` 校验 owner、规范化并脱敏字段后入队,不做文件 I/O。队列满时新事实只计入丢弃数;同一批次里事件、通道、字段和 op 都相同的事实只保留一条,其余计入丢弃数。 - DEBUG 事实在入队前过滤:宿主通过 `diagnostics.configureCoreEvents { debug, roots }` 告诉 Core 详细诊断是否开启、开启哪些通道根,`roots` 省略表示全部根,默认关闭。未开启的 DEBUG 事实直接返回成功,不入队、不计入丢弃数。构造字段前先调用 `core_events::accepts(level, root)`,被拒绝的事实只花一次分支判断。 - `MacLogStore` 在详细诊断状态变化时调用 `configureCoreEvents`,只在开关或通道根集合变化时发送;调用失败时记录一次 `app.logging.diagnostics_failed`。 - 写入器在每次写入批次、flush 和退出时排空一次。没有其他宿主活动时,由 Core 的唤醒回调安排排空:drain 之后的第一条事实或第一次丢弃计数触发一次回调,同一个 drain 周期内的后续变化合并。只有组合根创建的应用写入器调用 `MacLogStore.observeCoreFacts()` 安装回调,回调复用宿主事件已有的 100 毫秒单次排空调度;`finishSession` 先移除回调,再做最后一次排空。回调不得重入 Core。不得为 Core 队列增加空闲轮询或独立写入器。 @@ -89,7 +89,7 @@ Swift 的事件值是**未经脱敏的内存输入**,不能自行序列化落 - stderr 读取器限制每行 4 KiB、每秒 200 行,之后再走 `text` 的 1 KiB 脱敏规范化。与 v1 相比唯一的兼容变化是从任意 trim 的读取块改为逐行文本:保留行内空格,移除 LF 和末尾 CR,并应用已声明的长度上限和限流。 - 上游明确给出的严重度通过独立的目录事件保留:`window/logMessage` 和 `window/showMessage` 的 type 1/2/3/4 分别为 ERROR/WARN/INFO/DEBUG,未知类型为 DEBUG。未分类的 stderr 是 DEBUG 排障证据,流名称不表示警告;实际的进程或协议失败由 `*.session.failed` 记录 ERROR,正常关闭仍是生命周期状态。 - 插件自身使用独立协商的 `litheLogging` 0 和 `lithe/log`,由 Core 把四种受控事实映射到插件目录事件,不能再用 stderr 或任意自由消息代替。协议字段、op、限额和 fixture 以 [插件契约](../../shared/plugins/contract/v1/README.md#structured-plugin-diagnostics) 为准。 -- 声明式贡献没有插件进程,不能发 `lithe/log`。实际执行它的一方通过 `diagnostics.recordPluginOperation` 代记每次尝试的一条终态,事件和通道与 SDK 插件相同:`plugins.operation.finished` 或 `plugins.operation.failed`,写入 `plugins/{plugin}/host`。平台层只上报观察到的阶段和耗时,级别和 `host.*` 失败类别只由 Core 按阶段决定;取消、停止和过期结果不记录。宿主当前代记下表四种贡献,唯一来源是 `rust/crates/core/src/diagnostics/plugin_operations.rs` 的 `CONTRIBUTIONS`;输入、阶段到类别的对应和 -32064 见 [Rust Core API](../contracts/rust-core-api.md#engine-logging-v2-handoff-pr3)。 +- 声明式贡献没有插件进程,不能发 `lithe/log`。实际执行它的一方通过 `diagnostics.recordPluginOperation` 代记每次尝试的一条终态,事件和通道与 SDK 插件相同:`plugins.operation.finished` 或 `plugins.operation.failed`,写入 `plugins/{plugin}/host`。平台层只上报观察到的阶段和耗时,级别和 `host.*` 失败类别只由 Core 按阶段决定;取消、停止和过期结果不记录。宿主当前代记下表四种贡献,唯一来源是 `rust/crates/diagnostics/src/plugin_operations.rs` 的 `CONTRIBUTIONS`;输入、阶段到类别的对应和 -32064 见 [Rust Core API](../contracts/rust-core-api.md#engine-logging-v2-handoff-pr3)。 | operation | 声明来源 | 记录方 | 可能的失败阶段 | | --- | --- | --- | --- | @@ -109,7 +109,7 @@ Swift 的事件值是**未经脱敏的内存输入**,不能自行序列化落 ## 八、辅助 crate - `rust/crates/git-host`、`rust/crates/ide-host` 和 `rust/crates/remote` 的客户端链接进 Core,各自通过 `failure_log` 模块交出失败事实,依赖方向保持不变。sink 未注入时只增加原子计数,不写 stderr。 -- Core 的 `diagnostics/helper_failures` 是唯一的安装处。JSON dispatcher、独立 ACP FFI open、直接 IDE dispatcher 和 AskPass 入口在调用辅助 crate 前都经过它的 `Once`。sink 只转换标量字段,之后走相同的目录校验、脱敏和 256 条 Core 队列。这些事件的 owner 都是 `lithe-core`,错误只保留固定摘要和类别。 +- Core 的 `rust/crates/core/src/runtime/helper_failures.rs` 是唯一的安装处。JSON dispatcher、独立 ACP FFI open、直接 IDE dispatcher 和 AskPass 入口在调用辅助 crate 前都经过它的 `Once`。sink 只转换标量字段,之后走相同的目录校验、脱敏和 256 条 Core 队列。这些事件的 owner 都是 `lithe-core`,错误只保留固定摘要和类别。 - 辅助 crate 的收尾只记录丢失数据、能力或清理结果的意外错误。已断开的连接、已退出的进程,以及远程关闭或取消时的 Disconnected、Timeout,在类型化辅助函数里豁免;ACP Cancel 的 InternalError 无法区分正常断连与序列化失败,按预期缺失豁免。转发 accept 与 bridge 失败可能逐连接发生,目录 rate 为 12;`rust/crates/remote/src/client/forward.rs` 在发送侧让每一族失败在进程内每 5 秒最多记录一条,并发转发连接也共用这个限制,正常断连不占日志配额。强制终止后进程仍未退出的最终超时保留 WARN。 ## 九、目录遍历与清理事件 diff --git a/rust/logging/events/agent.json b/rust/logging/events/agent.json index 052ad22b..d18bda37 100644 --- a/rust/logging/events/agent.json +++ b/rust/logging/events/agent.json @@ -1,161 +1,5 @@ { "version": 1, "root": "agent", - "events": [ - { - "id": "agent.images.cleanup_failed", - "channel": "agent", - "level": "warn", - "message": "Expired pasted image cleanup failed", - "fields": { - "count": { - "type": "int", - "required": true, - "description": "Number of expired image removals that failed" - }, - "error": { - "type": "error", - "required": true, - "description": "Underlying failure at this operation boundary, preserving stable domain and code" - } - }, - "rate": 60, - "owner": "Lithe" - }, - { - "id": "agent.skills.discovery_failed", - "channel": "agent", - "level": "warn", - "message": "Agent skill discovery failed", - "fields": { - "stage": { - "type": "token", - "required": true, - "description": "Skill discovery step that failed, listing or directories" - }, - "error": { - "type": "error", - "required": true, - "description": "Underlying failure at this operation boundary, preserving stable domain and code" - } - }, - "rate": 60, - "owner": "Lithe" - }, - { - "id": "agent.statistics.decode_failed", - "channel": "agent", - "level": "warn", - "message": "Agent statistics used defaults", - "fields": { - "error": { - "type": "error", - "required": true, - "description": "Underlying failure at this operation boundary, preserving stable domain and code" - } - }, - "rate": 60, - "owner": "LitheAgentConversationModule" - }, - { - "id": "agent.observation.start_failed", - "channel": "agent", - "level": "warn", - "message": "Agent model-call observation is unavailable", - "fields": { - "stage": { - "type": "token", - "required": true, - "description": "Controlled agent.observation.start_failed operation stage; excludes paths and peer input" - }, - "error": { - "type": "error", - "required": true, - "description": "Fixed summary and stable failure category from agent.observation.start_failed; never error display text" - } - }, - "rate": 60, - "owner": "lithe-core" - }, - { - "id": "agent.history.prepare_failed", - "channel": "agent", - "level": "warn", - "message": "Codex history context reporting is unavailable", - "fields": { - "stage": { - "type": "token", - "required": true, - "description": "Controlled agent.history.prepare_failed operation stage; excludes paths and peer input" - }, - "error": { - "type": "error", - "required": true, - "description": "Fixed summary and stable failure category from agent.history.prepare_failed; never error display text" - } - }, - "rate": 60, - "owner": "lithe-core" - }, - { - "id": "agent.skills.cleanup_failed", - "channel": "agent", - "level": "warn", - "message": "Agent skill staging cleanup failed", - "fields": { - "stage": { - "type": "token", - "required": true, - "description": "Controlled agent.skills.cleanup_failed operation stage; excludes paths and peer input" - }, - "error": { - "type": "error", - "required": true, - "description": "Fixed summary and stable failure category from agent.skills.cleanup_failed; never error display text" - } - }, - "rate": 60, - "owner": "lithe-core" - }, - { - "id": "agent.retry.cleanup_failed", - "channel": "agent", - "level": "warn", - "message": "Codex retry launcher cleanup failed", - "fields": { - "stage": { - "type": "token", - "required": true, - "description": "Controlled agent.retry.cleanup_failed operation stage; excludes paths and peer input" - }, - "error": { - "type": "error", - "required": true, - "description": "Fixed summary and stable failure category from agent.retry.cleanup_failed; never error display text" - } - }, - "rate": 60, - "owner": "lithe-core" - }, - { - "id": "agent.host.operation_failed", - "channel": "agent", - "level": "warn", - "message": "Agent host operation failed", - "fields": { - "stage": { - "type": "token", - "required": true, - "description": "Controlled agent.host.operation_failed operation stage; excludes paths and peer input" - }, - "error": { - "type": "error", - "required": true, - "description": "Fixed summary and stable failure category from agent.host.operation_failed; never error display text" - } - }, - "rate": 60, - "owner": "lithe-core" - } - ] + "events": [] } diff --git a/scripts/rust-core/README.md b/scripts/rust-core/README.md index b5b11475..d77c4da0 100644 --- a/scripts/rust-core/README.md +++ b/scripts/rust-core/README.md @@ -2,10 +2,8 @@ Run `scripts/rust-core/verify-rust-core-comments.sh` from any directory. It checks module documentation, English comments, unsafe API safety sections, exported -Rustdoc, and intra-doc links in every crate under `rust/crates/`. A crate in the -script's `missing_docs_pending` list keeps every check except `missing_docs`; each -entry names the issue that adds the missing documentation and is removed when -that crate passes. +Rustdoc, and intra-doc links in every crate under `rust/crates/`. Every crate +must pass `missing_docs`; there is no exemption list. The small `comment-lexer` helper uses pinned `rustc_lexer` tokens to distinguish comments from ordinary, raw, and byte string literals. It does not parse source diff --git a/scripts/rust-core/verify-rust-core-comments.sh b/scripts/rust-core/verify-rust-core-comments.sh index d5d8013d..aa1f7b63 100755 --- a/scripts/rust-core/verify-rust-core-comments.sh +++ b/scripts/rust-core/verify-rust-core-comments.sh @@ -110,27 +110,12 @@ fi # Let Rust understand visibility instead of treating pub items in private # modules as exported APIs. This covers public functions, types, variants, and # fields without requiring documentation for every internal helper. -# -# Crates listed here still lack public documentation. They keep the link check -# but skip missing_docs until the linked issue lands; remove an entry once its -# crate passes, and never add a crate to make a new gap pass. -missing_docs_pending=( - # https://github.com/1lck/Lithe-IDEA/issues/1863 - rust/crates/remote/Cargo.toml -) for manifest in "${crate_manifests[@]}"; do - lints=(-D rustdoc::broken_intra_doc_links) - pending=false - for entry in "${missing_docs_pending[@]}"; do - [[ "$entry" == "$manifest" ]] && pending=true - done - if [[ "$pending" == false ]]; then - lints+=(-D missing_docs) - fi cargo rustdoc --quiet \ --manifest-path "$manifest" \ --lib -- \ - "${lints[@]}" + -D rustdoc::broken_intra_doc_links \ + -D missing_docs done printf '%s\n' "Rust Core comment verification passed" diff --git a/shared/plugins/contract/v1/README.md b/shared/plugins/contract/v1/README.md index 7ac177af..62fb578c 100644 --- a/shared/plugins/contract/v1/README.md +++ b/shared/plugins/contract/v1/README.md @@ -37,8 +37,8 @@ This directory is the cross-platform source of truth: | `fixtures/marketplace/` | Marketplace index, availability, source URL, and install review golden cases | Validation and template expansion are implemented once in Rust Core -(`rust/crates/core/src/plugins/`, with build systems in -`rust/crates/core/src/build/`) and exposed through the +(`rust/crates/plugin-manifest/` and the `rust/crates/plugin-*` crates beside +it, with build systems in `rust/crates/build/src/`) and exposed through the `plugins.declarative.*` commands in [`rust/contracts/rust-core-api.md`](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/rust/contracts/rust-core-api.md). Hosts must not reimplement them. @@ -1040,7 +1040,7 @@ Optional metadata applies within **one candidate set owned by one plugin**: conflicting metadata are manifest errors. Filter platforms only after sorting, so anchors remain valid even if they do not apply to the current platform. -`plugins::executables` owns the reusable source types and deterministic order; +`lithe_plugin_model::executables` owns the reusable source types and deterministic order; `ExecutableCandidateResolver` receives file-system, Setting (already validated by Core), Environment, Runtime and command-discovery capabilities from the host. Results distinguish `skip`, `resolved` and `terminalFailure`; plugins declare data @@ -1265,7 +1265,7 @@ the plugin, sorted by `kind`, language, and tool: | `packagedLanguageServer` | `languageID`, `path`, `arguments`, `validationArguments` | Executable inside the package, with its launch arguments and validation probe | | `systemLanguageServer` | `languageID`, `commands`, `arguments`, `validationArguments` | Command resolved by host tool discovery | | `runtimeLanguageServer` | `languageID`, `tool`, `packagedRuntime`, `entry`, `runtimeArguments`, `arguments`, `validationArguments` | Package entry started by a runtime; `packagedRuntime` is `null` for a discovered runtime | -| `buildSystem` | `buildSystemID`, `invocation`, `options`, `tasks`, `run`, `test`, `model`, `dependencies`, `hostIntegration`?, `provider`? | Every command line the build system can plan; `provider` is the shared form, which sends build requests to a language server | +| `buildSystem` | `buildSystemID`, `invocation`, `options`, `tasks`, `run`, `test`, `model`, `dependencies`, `provider`? | Every command line the build system can plan; `provider` is the shared form, which sends build requests to a language server | | `packagedBuildProvider`, `systemBuildProvider`, `runtimeBuildProvider` | the matching language-server fields with `buildSystemID`, `executesProjectCode`, `mayUseNetwork` instead of `languageID` | A build provider with a process of its own | | `packagedVcsProvider`, `systemVcsProvider`, `runtimeVcsProvider` | the matching language-server fields with `vcs`, `vcsTool`, `mayUseNetwork` instead of `languageID` | A VCS provider process; `vcsTool` is the declared VCS client or `null` | | `runtime` | `tool`, `minimumVersion`? | Runtime the host discovers and passes to a language server, build provider, or VCS provider | @@ -1366,13 +1366,12 @@ cleanup, or bundle hashes in a real workflow. Maven, …) as data. Every build tool gets the same model and the same commands: Core validates the declaration, detects instances, decodes the model, and expands every command line; the host only runs the processes Core -planned and passes their output back. A community plugin therefore needs no +planned and passes their output back. A plugin therefore needs no host change to add a build tool, and no host code branches on a build-system ID. One complete fixture covers each plugin-described model source: `build-static-file` reads a project file, `build-command-stdout` decodes a command's standard output, and `build-command-output-file` decodes a file the -command writes; the reserved `hostIntegration` source has no fixture, because -Core implements no integration. They use Cargo, Go, and Gradle as realistic inputs; +command writes. They use Cargo, Go, and Gradle as realistic inputs; they are not those plugins. `build-provider` and `build-provider-language-server` show the two forms of a build provider, and `fixtures/build-systems/build-plans.json` is the golden list of plans and decoded outputs every host gets from them. @@ -1392,12 +1391,12 @@ declaration are relative, use `/`, and stay inside their base directory | `runtimeDependencies` | `node` and `go` are host-managed: only `id` and `minimumVersion`, discovered with the user's runtime settings; `build.invocationPlan` returns the probe arguments Core reads their version with, so hosts keep no per-runtime probe. Any other runtime without `executables` names the [tool definition](toolchains/README.md) with the same ID, so its `id` must be a valid tool-definition ID and it declares only `minimumVersion`: `build.invocationPlan` returns it with `hostManaged: false`, `version: null`, and the single candidate `{ "kind": "toolchain", "toolchainID": , "root": }`, whose resolution checks `minimumVersion`, and `build.checkToolVersion` for it is `plugin.build.unavailable` because Core reads no version. Any other runtime describes its own `executables` and `version` probe, so the runtime list is open; a runtime whose only candidate is a `toolchain` candidate may omit the probe, since the definition checks the version. | | `options` | Build dimensions the user chooses: `boolean`, `singleSelect`, `multiSelect`, `text`, `file`, `directory`. Each contributes `arguments` and `environment` (with `${value}`) when it has a value. Choices are declared, come from the model (`choicesFromModel`), or are free (`allowCustom`). | | `provider` | The process that answers model and dependency requests when no command prints them; see [Build providers](#build-providers). | -| `model` | `static` (a `toml` file read during detection, no process), `command` (a command whose output Core decodes; it states `executesProjectCode` and `mayUseNetwork`), `provider`, or `hostIntegration`. | +| `model` | `static` (a `toml` file read during detection, no process), `command` (a command whose output Core decodes; it states `executesProjectCode` and `mayUseNetwork`), or `provider`. | | `tasks` | The task catalog. `scopes` say what a task runs on (`workspace`, `project`, `target`); `targetTags` restrict targets; `requiresUserArguments` makes the host ask for arguments. A `fromModel` task is the template for tasks the model lists. | | `run` | Run entries from model targets (`fromTargets.tags`, with `arguments`) or model tasks (`fromTasks`, reusing the template task's arguments). | -| `test` | How tests run: `scopes`, `targetTags`, the `filters` (`suite`, `case`) it can select, and `results` (`junitXml` or `litheTestResults` report globs relative to the project, or `hostIntegration`). A workspace run reads the globs under the root and every model project. A requested filter must reach the command line through a template whose conditions hold; a `case` guarded by `suiteSelected` therefore needs its suite. | +| `test` | How tests run: `scopes`, `targetTags`, the `filters` (`suite`, `case`) it can select, and `results` (`junitXml` or `litheTestResults` report globs relative to the project). A workspace run reads the globs under the root and every model project. A requested filter must reach the command line through a template whose conditions hold; a `case` guarded by `suiteSelected` therefore needs its suite. | | `diagnostics` | `lineMatcher` patterns with capture-group numbers, or `litheDiagnostics` lines. | -| `dependencies` | A command (with `scopes`) whose output Core decodes, as a flat list through a `map` or as a tree from `litheDependencies`; `provider` (with `scopes`, `workspace` and `project` only); or `hostIntegration`. | +| `dependencies` | A command (with `scopes`) whose output Core decodes, as a flat list through a `map` or as a tree from `litheDependencies`; or `provider` (with `scopes`, `workspace` and `project` only). | | `sync` | Files whose change makes the model stale, relative to the root and to each project. | | `layout` | `excluded` directories relative to each project, added to every project as `excluded` content roots. | @@ -1576,8 +1575,8 @@ Canonical examples: `fixtures/build-providers/build-provider-execution.plugin.js A build system's `test` may declare `source: "provider"`. It keeps the existing scopes, target tags and supported filters, but `arguments` must be `[]`. It requires a plugin-owned Provider process; `results` may be omitted or use `junitXml`/`litheTestResults` -reports. `hostIntegration` is refused: the plugin owns test execution and -report production, and the host reuses the existing report decoder. +reports: the plugin owns test execution and report production, and the host +reuses the existing report decoder. `build.testPlan` returns `provider: { method: "lithe/build/test", params }` and the usual `results` metadata instead of argv. The effective @@ -2133,17 +2132,17 @@ A host that declares `buildProviders` keeps providers apart: `hostIntegration` is reserved. Core implements no build-system host integration: every build tool is described by its plugin's data and backend, and no host code branches on a build-system ID. Any `hostIntegration` name is -`plugin.integration.unsupported`, and a model, test-result, or dependency source -of `hostIntegration` is refused with it. Build systems use the declarative -contract, a canonical format, or a `provider` instead. +refused with `plugin.integration.unsupported` at +`buildSystems[].hostIntegration`. Model, test-result, and dependency sources +have no `hostIntegration` kind: build systems use the declarative contract, a +canonical format, or a `provider`. ### Commands The `build.*` Core commands (see `rust/contracts/rust-core-api.md`) take the manifest, the build-system ID, and a request, and revalidate the manifest every time. `build.detect` walks the workspace once for all enabled plugins -and returns instances, with the model of `static` and `hostIntegration` -sources. `build.invocationPlan` and `build.checkToolVersion` resolve the +and returns instances, with the model of `static` sources. `build.invocationPlan` and `build.checkToolVersion` resolve the executable; `build.resolveOptions` checks stored option values and returns the configuration fingerprint; `build.modelPlan`, `build.taskPlan`, `build.testPlan`, and `build.dependencyPlan` return argv plans, or provider @@ -2659,8 +2658,8 @@ same plugin, for example from that plugin's own window, and another plugin can call only an [exported function](#cross-plugin-calls). Functions complement the typed domain protocols; they never replace them. The protocol is implemented in -Rust Core (`rust/crates/core/src/lsp/interface/plugin_function.rs`, -`function_relay.rs`). +Rust Core (`rust/crates/plugin-rpc/src/` and +`rust/crates/plugin-function-relay/src/lib.rs`). A host starts every build provider process as a session of the plugin that owns it and invokes its functions from that plugin's web pages (the `functions.*` bridge methods below). A provider served by a shared language @@ -2700,7 +2699,7 @@ Two rules keep these directions from forming a loop: ### Declarative or backend implementation - A domain operation has exactly one implementation, chosen in the manifest - (`source`: `static`, `command`, `hostIntegration`, or `provider`). Core plans + (`source`: `static`, `command`, or `provider`). Core plans only that one. There is no runtime fallback from a backend to a template or back: the two may differ in meaning, and a fallback would hide the failure. - A backend implements a domain operation through its typed method and Core's diff --git a/shared/plugins/contract/v1/plugin-manifest.schema.json b/shared/plugins/contract/v1/plugin-manifest.schema.json index faba06b6..48fce9d5 100644 --- a/shared/plugins/contract/v1/plugin-manifest.schema.json +++ b/shared/plugins/contract/v1/plugin-manifest.schema.json @@ -1459,22 +1459,7 @@ "additionalProperties": false, "required": ["id"], "properties": { - "id": { "type": "string", "minLength": 1 }, - "capabilities": { - "description": "Process-backed features of the language a host integration would provide.", - "type": "array", - "uniqueItems": true, - "items": { "enum": ["debug", "run", "testing"] } - }, - "resources": { - "type": "object", - "additionalProperties": { - "oneOf": [ - { "type": "string", "minLength": 1 }, - { "type": "array", "items": { "type": "string", "minLength": 1 } } - ] - } - } + "id": { "type": "string", "minLength": 1 } } } } @@ -2859,18 +2844,6 @@ } } }, - { - "type": "object", - "additionalProperties": false, - "required": [ - "source" - ], - "properties": { - "source": { - "const": "hostIntegration" - } - } - }, { "description": "The build provider answers lithe/build/model with a litheBuildModel document.", "type": "object", @@ -3157,18 +3130,6 @@ } } }, - { - "type": "object", - "additionalProperties": false, - "required": [ - "decoder" - ], - "properties": { - "decoder": { - "const": "hostIntegration" - } - } - }, { "type": "object", "additionalProperties": false, @@ -3321,18 +3282,6 @@ } } }, - { - "type": "object", - "additionalProperties": false, - "required": [ - "source" - ], - "properties": { - "source": { - "const": "hostIntegration" - } - } - }, { "description": "The build provider answers lithe/build/dependencies with a litheDependencies document.", "type": "object", diff --git a/shared/plugins/guides/build-tool-integration.md b/shared/plugins/guides/build-tool-integration.md index 40dd078a..e94add22 100644 --- a/shared/plugins/guides/build-tool-integration.md +++ b/shared/plugins/guides/build-tool-integration.md @@ -186,15 +186,13 @@ Core 的计划接口本身不启动进程、不监视文件、不持久化选择 只说明声明和规划正确,仍要在真实宿主里验收。 不要把内部 dispatcher 命令写成社区插件可以调用的宿主脚本 API。 -`languageServers[].hostIntegration` 与构建系统 `model.source: "hostIntegration"` 都是保留字段。 +`languageServers[].hostIntegration` 与 `buildSystems[].hostIntegration` 都是保留字段。 Core 不实现任何宿主集成,也不按语言、服务器或构建工具的 ID 走特殊逻辑,所以任何 ID 都返回 `plugin.integration.unsupported`,见[语言服务器侧](../contract/v1/README.md#language-server-host-integrations) 与[构建系统侧](../contract/v1/README.md#host-integrations)的契约。语言的运行、测试与调试由插件自己的 `executionProviders`、`buildSystems`、`buildProviders` 和后端提供;构建插件用 `static`、`command` 或 `provider` 模型。 -两侧都不是作者自己命名就能新增的机制:集成 ID 必须是宿主已经实现的。 - ### 6. 验证完整工作流 打包与清单校验完成后,还应在目标平台、实际宿主中完成: @@ -418,7 +416,7 @@ A fixture that produces the right command only shows that the declaration and pl you still have to accept it in a real host. Do not present internal dispatcher commands as a host scripting API that community plugins can call. -`languageServers[].hostIntegration` and the build-system `model.source: "hostIntegration"` are both +`languageServers[].hostIntegration` and `buildSystems[].hostIntegration` are both reserved. Core implements no host integration and runs no special logic for a language, server, or build-tool ID, so every ID returns `plugin.integration.unsupported`; see the contract for the [language-server side](../contract/v1/README.md#language-server-host-integrations) and the @@ -426,8 +424,6 @@ build-tool ID, so every ID returns `plugin.integration.unsupported`; see the con come from the plugin's own `executionProviders`, `buildSystems`, `buildProviders`, and backend; build plugins use the `static`, `command`, or `provider` models. -Neither side lets an author add an integration by picking a name: the ID must be one the host already implements. - ### 6. Verify the complete workflow After packaging and manifest validation, also do the following on the target platform in a real host: diff --git a/shared/plugins/reference/capabilities-and-versions.md b/shared/plugins/reference/capabilities-and-versions.md index 0b6dab66..754c0829 100644 --- a/shared/plugins/reference/capabilities-and-versions.md +++ b/shared/plugins/reference/capabilities-and-versions.md @@ -6,7 +6,7 @@ 本页把插件会碰到的所有版本号放在一起:`pluginAPIVersion`、清单格式、18 个能力、进程协议和文档格式。**它们现在的版本全部是 `0`。** 规范以 [`contract/v1/README.md`](../contract/v1/README.md) 的 [Versioning](../contract/v1/README.md#versioning)、[Capabilities](../contract/v1/README.md#capabilities)、[Host capabilities](../contract/v1/README.md#host-capabilities) 和 [Publishing](../contract/v1/README.md#publishing) 为准;本页与契约不一致时以契约为准,并请到 [Lithe-core-rust 的 Issues](https://github.com/Lithe-IDEA/Lithe-core-rust/issues) 反馈。 -Core 列以本仓库 `main` 上的 Core 源码为准([`capabilities.rs`](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/rust/crates/core/src/plugins/capabilities.rs) 的 `SUPPORTED_CAPABILITIES`,[`declarative.rs`](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/rust/crates/core/src/plugins/declarative.rs) 的 `SUPPORTED_PLUGIN_API_VERSIONS`);Lithe 桌面端列以桌面端的[声明与说明](https://github.com/1lck/Lithe-IDEA/blob/preview/desktop/internal/pluginhost/pluginhost.go)为准。Core 能校验不等于宿主能运行:桌面端声明了 12 个能力,另外 6 个没有声明,需要它们的插件不会被提供安装。 +Core 列以本仓库 `main` 上的 Core 源码为准([`capabilities.rs`](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/rust/crates/plugin-model/src/capabilities.rs) 的 `SUPPORTED_CAPABILITIES`,[`plugin-manifest/src/lib.rs`](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/rust/crates/plugin-manifest/src/lib.rs) 的 `SUPPORTED_PLUGIN_API_VERSIONS`);Lithe 桌面端列以桌面端的[声明与说明](https://github.com/1lck/Lithe-IDEA/blob/preview/desktop/internal/pluginhost/pluginhost.go)为准。Core 能校验不等于宿主能运行:桌面端声明了 12 个能力,另外 6 个没有声明,需要它们的插件不会被提供安装。 ### 四类版本号 @@ -109,7 +109,7 @@ Node.js SDK 0.5.0、Java SDK 0.5.0 和 Rust SDK 0.4.0 说的是版本 `0` 的协 This page puts every version number a plugin meets in one place: `pluginAPIVersion`, the manifest format, the 18 capabilities, the process protocols, and the document formats. **Every one of them is `0`.** The normative text is [Versioning](../contract/v1/README.md#versioning), [Capabilities](../contract/v1/README.md#capabilities), [Host capabilities](../contract/v1/README.md#host-capabilities), and [Publishing](../contract/v1/README.md#publishing) in [`contract/v1/README.md`](../contract/v1/README.md). Where this page disagrees with the contract, the contract wins; please report it in the [Lithe-core-rust issues](https://github.com/Lithe-IDEA/Lithe-core-rust/issues). -The Core column follows the Core source on this repository's `main` (`SUPPORTED_CAPABILITIES` in [`capabilities.rs`](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/rust/crates/core/src/plugins/capabilities.rs), `SUPPORTED_PLUGIN_API_VERSIONS` in [`declarative.rs`](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/rust/crates/core/src/plugins/declarative.rs)); the Lithe desktop column follows the desktop client's [declarations and notes](https://github.com/1lck/Lithe-IDEA/blob/preview/desktop/internal/pluginhost/pluginhost.go). Validating a capability is not running it: the desktop client declares 12 capabilities and does not declare the other 6, so a plugin that needs one of those is not offered for installation. +The Core column follows the Core source on this repository's `main` (`SUPPORTED_CAPABILITIES` in [`capabilities.rs`](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/rust/crates/plugin-model/src/capabilities.rs), `SUPPORTED_PLUGIN_API_VERSIONS` in [`plugin-manifest/src/lib.rs`](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/rust/crates/plugin-manifest/src/lib.rs)); the Lithe desktop column follows the desktop client's [declarations and notes](https://github.com/1lck/Lithe-IDEA/blob/preview/desktop/internal/pluginhost/pluginhost.go). Validating a capability is not running it: the desktop client declares 12 capabilities and does not declare the other 6, so a plugin that needs one of those is not offered for installation. ### Four kinds of version diff --git a/shared/plugins/reference/domains/build-providers.md b/shared/plugins/reference/domains/build-providers.md index 906f429f..25585565 100644 --- a/shared/plugins/reference/domains/build-providers.md +++ b/shared/plugins/reference/domains/build-providers.md @@ -40,7 +40,7 @@ | --- | --- | --- | --- | --- | | 项目模型和依赖 | `model.source`、`dependencies.source` 为 `provider` | `lithe/build/model`、`lithe/build/dependencies` | [Build providers](../../contract/v1/README.md#build-providers) | `build-provider`、`build-provider-language-server` | | 任务执行 | `tasks[].source: "provider"`,`arguments` 必须是 `[]` | `lithe/build/execute`、`lithe/build/progress` | [Provider task execution](../../contract/v1/README.md#provider-task-execution) | `build-provider-execution`、`build-execution-protocol.json` | -| 测试执行 | `test.source: "provider"`,`arguments` 必须是 `[]`,不能用 `hostIntegration` 报告 | `lithe/build/test` | [Provider test execution](../../contract/v1/README.md#provider-test-execution) | `build-provider-tests`、`build-test-protocol.json` | +| 测试执行 | `test.source: "provider"`,`arguments` 必须是 `[]` | `lithe/build/test` | [Provider test execution](../../contract/v1/README.md#provider-test-execution) | `build-provider-tests`、`build-test-protocol.json` | | 测试发现 | provider 测试声明 `discovery: true`,同时需要 `suite` 和 `case` 过滤器 | `lithe/build/discoverTests` | [Test discovery](../../contract/v1/README.md#test-discovery) | `build-test-discovery.json` | | 运行准备 | `run[].source: "provider"`,用 `fromTargets.tags`;`execution` 为 `application`(默认)、`service` 或 `task` | `lithe/build/prepareRun` | [Provider run preparation](../../contract/v1/README.md#provider-run-preparation) | `build-provider-run-preparation`、`build-run-preparation.json` | | 类型化运行选项 | 服务和任务的准备带 `launchOptions`(`execution`、`runtimeArguments`、`arguments`、`environment`,可选 `entrypoint`、`workingDirectory`) | 扩展 `lithe/build/prepareRun` | [Typed run options and service/task preparation](../../contract/v1/README.md#typed-run-options-and-servicetask-preparation) | `build-provider-run-options`、`build-run-options.json` | @@ -159,7 +159,7 @@ Every feature belongs to `buildProviders` `0` and uses the same `litheBuild` pro | --- | --- | --- | --- | --- | | Project model and dependencies | `model.source` and `dependencies.source` set to `provider` | `lithe/build/model`, `lithe/build/dependencies` | [Build providers](../../contract/v1/README.md#build-providers) | `build-provider`, `build-provider-language-server` | | Task execution | `tasks[].source: "provider"`; `arguments` must be `[]` | `lithe/build/execute`, `lithe/build/progress` | [Provider task execution](../../contract/v1/README.md#provider-task-execution) | `build-provider-execution`, `build-execution-protocol.json` | -| Test execution | `test.source: "provider"`; `arguments` must be `[]`, and `hostIntegration` reports are refused | `lithe/build/test` | [Provider test execution](../../contract/v1/README.md#provider-test-execution) | `build-provider-tests`, `build-test-protocol.json` | +| Test execution | `test.source: "provider"`; `arguments` must be `[]` | `lithe/build/test` | [Provider test execution](../../contract/v1/README.md#provider-test-execution) | `build-provider-tests`, `build-test-protocol.json` | | Test discovery | A provider test declares `discovery: true`, which needs both `suite` and `case` filters | `lithe/build/discoverTests` | [Test discovery](../../contract/v1/README.md#test-discovery) | `build-test-discovery.json` | | Run preparation | `run[].source: "provider"` with `fromTargets.tags`; `execution` is `application` (default), `service`, or `task` | `lithe/build/prepareRun` | [Provider run preparation](../../contract/v1/README.md#provider-run-preparation) | `build-provider-run-preparation`, `build-run-preparation.json` | | Typed run options | Service and task preparation carry `launchOptions` (`execution`, `runtimeArguments`, `arguments`, `environment`, optional `entrypoint` and `workingDirectory`) | Extends `lithe/build/prepareRun` | [Typed run options and service/task preparation](../../contract/v1/README.md#typed-run-options-and-servicetask-preparation) | `build-provider-run-options`, `build-run-options.json` | diff --git a/shared/plugins/reference/domains/manifest.md b/shared/plugins/reference/domains/manifest.md index f800e0ec..709d0e25 100644 --- a/shared/plugins/reference/domains/manifest.md +++ b/shared/plugins/reference/domains/manifest.md @@ -20,7 +20,7 @@ | --- | --- | --- | | `languages` | `languages` | [语言](languages.md) | | `languageServers` | `languageServers`;`launch.kind` 为 `backend` 时还需要 `backend` | [语言](languages.md) | -| `languageServers[].hostIntegration` | `languageServers`;所有插件都可以声明 | [语言](languages.md) | +| `languageServers[].hostIntegration` | 保留字段:任何 ID 都返回 `plugin.integration.unsupported` | [语言](languages.md) | | `executionProviders` | `executionProviders`(桌面端未声明) | [语言](languages.md) | | `outputLinkMatchers` | `outputLinkMatchers`;匹配器带 `resolve` 时还需要 `backend` | [语言](languages.md) | | `settings`、`settingActions` | `settings` | [设置](settings.md) | @@ -132,7 +132,7 @@ Every plugin package has a `plugin.json` at its root. It says who the plugin is, | --- | --- | --- | | `languages` | `languages` | [Languages](languages.md) | | `languageServers` | `languageServers`; plus `backend` when `launch.kind` is `backend` | [Languages](languages.md) | -| `languageServers[].hostIntegration` | `languageServers`; any plugin may declare one | [Languages](languages.md) | +| `languageServers[].hostIntegration` | Reserved: every ID returns `plugin.integration.unsupported` | [Languages](languages.md) | | `executionProviders` | `executionProviders` (not declared by the desktop host) | [Languages](languages.md) | | `outputLinkMatchers` | `outputLinkMatchers`; plus `backend` when a matcher has `resolve` | [Languages](languages.md) | | `settings`, `settingActions` | `settings` | [Settings](settings.md) | diff --git a/shared/plugins/reference/protocol-changelog.md b/shared/plugins/reference/protocol-changelog.md index 07df8627..fe5d80f3 100644 --- a/shared/plugins/reference/protocol-changelog.md +++ b/shared/plugins/reference/protocol-changelog.md @@ -2,7 +2,7 @@ 本页记录插件协议和 API 的每次修改:哪天、哪个 PR、谁改的、改了什么。只有中文。 -- **收录范围**:Lithe-core-rust 仓库 `main` 分支上的 PR,只要它修改了下面三处中的任意一处:插件契约 `shared/plugins/contract/v1/`、Core 能力表 [`capabilities.rs`](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/rust/crates/core/src/plugins/capabilities.rs)、插件函数协议 [`plugin_function.rs`](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/rust/crates/core/src/lsp/interface/plugin_function.rs)。三处都没改的 PR 不收录。 +- **收录范围**:Lithe-core-rust 仓库 `main` 分支上的 PR,只要它修改了下面三处中的任意一处:插件契约 `shared/plugins/contract/v1/`、Core 能力表 [`capabilities.rs`](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/rust/crates/plugin-model/src/capabilities.rs)、插件函数协议 [`plugin-rpc/src/`](https://github.com/Lithe-IDEA/Lithe-core-rust/tree/main/rust/crates/plugin-rpc/src)。三处都没改的 PR 不收录。 - **日期**:PR 合入 `main` 的日期。 - **修改人**:PR 的作者(GitHub 账号)。 - **每一版的内容和宿主现状**:见[能力与协议版本对照](capabilities-and-versions.md)。字段和行为以 [`contract/v1/README.md`](../contract/v1/README.md) 为准。 @@ -129,6 +129,12 @@ ## 时间线(新的在上) +### 2026-11-14 + +| PR | 修改人 | API 编号 | 协议 / 能力 | 改了什么 | +| --- | --- | --- | --- | --- | +| [#28](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/28) | [@xiaoyumuxi](https://github.com/xiaoyumuxi) | — | `pluginAPIVersion` 0 内的说明修正 | 清单 schema 删除构建系统 `model`、`test.results`、`dependencies` 里不可达的 `source: "hostIntegration"` 分支,以及语言服务器 `hostIntegration` 下的 `capabilities`、`resources`。`hostIntegration` 字段仍是保留字段:构建系统和语言服务器写了任何值,都以 `plugin.integration.unsupported` 拒绝,字段路径不变;`model`、`test.results`、`dependencies` 写 `source: "hostIntegration"` 时按未知来源以 `plugin.manifest.invalid` 拒绝(原先同时写了 `hostIntegration` 名字时报 `plugin.integration.unsupported`)。README 与指南同步删掉这些来源和“社区插件”说法。能力表移到 `rust/crates/plugin-model/src/capabilities.rs`,插件函数协议移到 `rust/crates/plugin-rpc/src/`,跨插件调用规则移到 `rust/crates/plugin-function-relay/`,内容不变。 | + ### 2026-11-13 | PR | 修改人 | API 编号 | 协议 / 能力 | 改了什么 | @@ -143,7 +149,7 @@ ## 维护 -修改 `shared/plugins/contract/v1/`、`rust/crates/core/src/plugins/capabilities.rs` 或 `rust/crates/core/src/lsp/interface/plugin_function.rs` 的 Core PR,在**同一个 PR** 里更新本页: +修改 `shared/plugins/contract/v1/`、`rust/crates/plugin-model/src/capabilities.rs` 或 `rust/crates/plugin-rpc/src/` 的 Core PR,在**同一个 PR** 里更新本页: 1. 把这个 PR 补到时间线最上面(按合入日期分组):PR、修改人、API 编号、协议 / 能力、改了什么。新增的版本号要加粗,并写进“按协议查首次出现”里对应的表:例如给 `buildProviders` 加版本 `1`,就在能力表里为它加一行,并在进程协议表里同步 `litheBuild`。一个 PR 只给一个能力加一个版本。 2. “API 编号”填这个 PR 涉及的全部 API-01–44 编号,取自 PR 描述里的“原计划编号”。PR 没写时按 [API 实施计划](https://github.com/1lck/Lithe-IDEA/blob/preview/docs/development/macos-plugin-api-plan.md) 判断,不要自编新号;不属于任何编号的写 —。 diff --git a/shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java b/shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java index 5daa8364..0d987438 100644 --- a/shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java +++ b/shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java @@ -6,7 +6,7 @@ /** * Wire constants and validation shared with Lithe's Rust Core - * ({@code rust/crates/core/src/lsp/interface/plugin_function.rs} in the Lithe + * ({@code rust/crates/plugin-rpc/src/} in the Lithe * repository) and the Node.js * SDK. The SDK checks the same rules locally so a plugin author sees a mistake * when registering a handler instead of as a refused session at runtime. diff --git a/shared/plugins/sdk/node/src/protocol.mjs b/shared/plugins/sdk/node/src/protocol.mjs index 5e0ca0ae..54a5365c 100644 --- a/shared/plugins/sdk/node/src/protocol.mjs +++ b/shared/plugins/sdk/node/src/protocol.mjs @@ -1,6 +1,6 @@ // Wire constants and validation shared with Lithe's Rust Core -// (rust/crates/core/src/lsp/interface/plugin_function.rs and -// rust/crates/core/src/vcs/ in the Lithe repository). The SDK checks the +// (rust/crates/plugin-rpc/src/ and +// rust/crates/vcs/ in the Lithe repository). The SDK checks the // same rules locally so a plugin author sees a mistake when registering a // handler instead of as a refused session at runtime. diff --git a/shared/plugins/sdk/rust/plugin-sdk/src/protocol.rs b/shared/plugins/sdk/rust/plugin-sdk/src/protocol.rs index 9f057b95..caa2b24e 100644 --- a/shared/plugins/sdk/rust/plugin-sdk/src/protocol.rs +++ b/shared/plugins/sdk/rust/plugin-sdk/src/protocol.rs @@ -1,5 +1,5 @@ //! Wire constants and validation shared with Lithe's Rust Core -//! (`rust/crates/core/src/lsp/interface/plugin_function.rs` in the Lithe +//! (`rust/crates/plugin-rpc/src/` in the Lithe //! repository). The SDK checks the same rules locally so a plugin author sees //! a mistake when declaring a function instead of as a refused session.