diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1579dd7..1acf0c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,8 @@ jobs: node-version: 24 cache: npm cache-dependency-path: ui/package-lock.json + - name: Verify SDK source provenance + run: node scripts/verify-sdk-source.mjs - name: Install page dependencies working-directory: ui run: npm ci @@ -87,7 +89,7 @@ jobs: npm ci --prefix ui node scripts/package.mjs - name: Run the backend through Rust Core - run: node scripts/e2e.mjs + run: node scripts/e2e.mjs --source https://github.com/1lck/Lithe-IDEA.git --ref preview - name: Check the archive holds the package run: | id=$(node -p "require('./plugin.json').id") diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 529546e..64257b8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,7 +43,7 @@ jobs: - name: Build the package run: node scripts/package.mjs - name: Run the backend through Rust Core - run: node scripts/e2e.mjs + run: node scripts/e2e.mjs --source https://github.com/1lck/Lithe-IDEA.git --ref preview - name: Verify the archive users download run: | id=$(node -p "require('./plugin.json').id") diff --git a/README.md b/README.md index d0cf336..9c1381c 100644 --- a/README.md +++ b/README.md @@ -85,8 +85,9 @@ cargo clippy --locked --manifest-path backend/Cargo.toml --all-targets -- -D war `--host-only` builds only this Mac's architecture. Install the folder or the zip in Lithe with **Install from Disk** to try it. -`node scripts/e2e.mjs` runs `e2e/database_plugin.rs` against Lithe's Rust -Core at the commit `e2e/lithe-core.json` pins: Core launches the packaged +`node scripts/e2e.mjs --source --ref ` runs +`e2e/database_plugin.rs` against an explicit Lithe Core with `litheLogging` v1. +Core launches the packaged backend as a host does, and the test answers its credential requests and drives a SQLite connection through every page function. `--source ` uses a local Lithe checkout instead of cloning. @@ -112,3 +113,30 @@ otherwise. ## License Apache-2.0 + +## 宿主日志接入 + +后端使用官方 Rust SDK 的结构化 `lithe/log` 通知,协商 +`experimental.litheLogging` v1,不另建文件写入器、后台任务或 stderr 回退。 +macOS 宿主通过共享事件目录把后端事实写入会话通道 +`plugins//host.log`,统一决定级别、脱敏、限长、轮转和保留。 +stdout 保持协议帧专用;日志不写插件包或 app bundle,不影响签名和增量更新。 + +每个函数的最终成功或失败只由 SDK 记录一次,携带注册函数 ID、单调耗时、稳定 +安全分类及宿主原始 operation ID;取消不当作失败。列表和历史抑制成功诊断; +`database.call` 按已经验证的方法区分,metadata、分页浏览和探测只抑制当前调用的 +成功,显式查询和写入保留 INFO 终态,所有失败和真实降级继续记录。 +返回行因插件传输限额被裁剪时,`diagnostics().degraded` 在本次后端生命周期内 +记录一次 `result_size_limited`,重复裁剪保持静默。 +诊断不含 SQL、连接串、服务器地址、用户名、密码、返回行、配置内容或文件路径。 +通道建立前的启动失败通过进程退出码由宿主记录,不输出异步运行时错误正文。 + +`vendor/lithe-plugin-sdk/` 为同批官方 SDK 源码快照,Cargo 只使用仓库相对路径。 +真实基线与逐文件 SHA-256 见 `source-provenance.json`,不伪造已发布版本或 Git SHA。 +插件不强制要求 `${logDir}`,也不保留旧日志兼容写入器;需要支持新日志协议的宿主 +负责结构化通知的展示和持久化。 + +新的日志端到端验证要求 Core 已包含 `litheLogging` v1;`scripts/e2e.mjs` 必须显式传入 +`--source` 和 `--ref`,不再默认运行旧的 `e2e/lithe-core.json` 基线。CI 明确选择主仓 +`preview`,需要先合并宿主日志 API;跨仓发布时再把验证引用固定到合并后的精确提交。 +宿主改动未合并时这项检查会真实失败,不跳过,也不把旧协议运行当作新 API 验收。 diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 375c881..cbd09b5 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1521,7 +1521,6 @@ dependencies = [ [[package]] name = "lithe-plugin-sdk" version = "0.1.0" -source = "git+https://github.com/Lithe-IDEA/lithe-plugin-docs?rev=5da0524f4751425c216bf5162c9fc1eb1dddd22f#5da0524f4751425c216bf5162c9fc1eb1dddd22f" dependencies = [ "serde_json", ] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 536d4c1..5f482ce 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -10,9 +10,8 @@ publish = false [workspace.dependencies] serde_json = "1" -# The official SDK, pinned to a commit; Cargo.lock records it and -# `cargo build --locked` refuses anything else. -lithe-plugin-sdk = { git = "https://github.com/Lithe-IDEA/lithe-plugin-docs", rev = "5da0524f4751425c216bf5162c9fc1eb1dddd22f" } +# The official SDK source snapshot and its verified provenance are vendored. +lithe-plugin-sdk = { path = "../vendor/lithe-plugin-sdk" } [profile.release] lto = "thin" diff --git a/backend/crates/backend/src/main.rs b/backend/crates/backend/src/main.rs index ae64ea7..e73a0fa 100644 --- a/backend/crates/backend/src/main.rs +++ b/backend/crates/backend/src/main.rs @@ -12,10 +12,12 @@ use lithe_plugin_sdk::Backend; use std::path::PathBuf; fn main() { + let mut backend = Backend::new("lithe-database").version(env!("CARGO_PKG_VERSION")); let state = match state_directory() { Ok(state) => state, - Err(message) => { - eprintln!("{message}"); + Err(_) => { + // Before listen there is no protocol output. The host records + // this startup exit through the process lifecycle. std::process::exit(2); } }; @@ -26,12 +28,10 @@ fn main() { .build() { Ok(runtime) => runtime, - Err(error) => { - eprintln!("The async runtime could not start: {error}"); + Err(_) => { std::process::exit(1); } }; - let mut backend = Backend::new("lithe-database").version(env!("CARGO_PKG_VERSION")); service::Service::new(store::Store::new(state), runtime).install(&mut backend); std::process::exit(backend.listen()); } diff --git a/backend/crates/backend/src/service.rs b/backend/crates/backend/src/service.rs index 715fc84..bfdd074 100644 --- a/backend/crates/backend/src/service.rs +++ b/backend/crates/backend/src/service.rs @@ -21,6 +21,7 @@ use serde_json::json; use std::fs; use std::io::{self, Read, Write}; use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use tokio::runtime::Runtime; use tokio_util::sync::CancellationToken; @@ -114,11 +115,17 @@ const KINDS: &[&str] = &[ pub struct Service { store: Store, runtime: Runtime, + /// A bounded warning flag; repeated large queries do not flood diagnostics. + result_limit_reported: AtomicBool, } impl Service { pub fn new(store: Store, runtime: Runtime) -> Arc { - Arc::new(Self { store, runtime }) + Arc::new(Self { + store, + runtime, + result_limit_reported: AtomicBool::new(false), + }) } /// Declares every function on `backend`. @@ -147,6 +154,16 @@ impl Service { declare(backend, "history.append", Self::append_history); declare(backend, "connections.previewDBX", Self::preview_dbx); declare(backend, "connections.importDBX", Self::import_dbx); + for id in [ + "workspace.load", + "history.list", + "history.append", + "connections.previewDBX", + ] { + backend + .suppress_success_diagnostics(id) + .expect("registered function ID"); + } } /// `{ capabilities, profiles, folders }`; profiles never carry passwords. @@ -288,8 +305,31 @@ impl Service { if !ENGINE_METHODS.contains(&method) { return Err(invalid(format!("{method:?} is not a database method.")).into()); } + // One public function serves both explicit queries and UI browsing. + // Keep query/write results at INFO; only this invocation's successful + // read/probe is quiet, while its failures and degradations still emit. + if matches!( + method, + "checkWriteProtection" + | "describeTable" + | "diagnostics" + | "listDatabases" + | "listForeignKeys" + | "listIndexes" + | "listObjects" + | "listTables" + | "pageTable" + | "nacosGetConfig" + | "nacosListConfigs" + | "nacosListInstances" + | "nacosListServices" + | "redisGetKey" + | "redisScan" + ) { + context.suppress_success_diagnostics(); + } let value = self.run_on_profile(¶ms, method, params["params"].clone(), context)?; - Ok(fit_result(value)?) + self.fit_result(value, context) } /// Runs an export method on `profileID` and writes the file it produced @@ -326,7 +366,21 @@ impl Service { }; method_params.insert("data".into(), Value::String(BASE64.encode(bytes))); let value = self.run_on_profile(¶ms, method, Value::Object(method_params), context)?; - Ok(fit_result(value)?) + self.fit_result(value, context) + } + + fn fit_result(&self, value: Value, context: &Context) -> Result { + let value = fit_result(value)?; + if value["sizeLimited"] == true && !self.result_limit_reported.swap(true, Ordering::Relaxed) + { + // This is a degraded successful result. Failed calls remain owned + // by the SDK, so the same error is not recorded twice. + context + .diagnostics() + .degraded(context.function_id(), "result_size_limited", None) + .expect("registered operation and static diagnostic code"); + } + Ok(value) } /// Runs `method` on the saved profile `call.profileID` with its current @@ -584,8 +638,7 @@ fn engine_failure(code: &str, message: String) -> PluginError { pub(crate) fn credential_failure(error: SdkError) -> FunctionError { if error.code() == sdk_codes::CALL_CANCELLED { FunctionError::Cancelled - } else if error.code() == sdk_codes::NOT_NEGOTIATED || error.code().starts_with("plugin.host.") - { + } else if error.code().starts_with("plugin.host.") { PluginError::new( "database.credentialsUnavailable", format!( diff --git a/e2e/database_plugin.rs b/e2e/database_plugin.rs index f29395e..69631a6 100644 --- a/e2e/database_plugin.rs +++ b/e2e/database_plugin.rs @@ -281,6 +281,7 @@ impl Host { json!({ "providerId": format!("pluginBackend:{plugin_id}"), "pluginId": plugin_id, + "role": "backend", "executablePath": executable, "arguments": plan["arguments"], "rootUri": format!("file://{}/", state.display()), @@ -387,6 +388,45 @@ impl Host { .iter() .find(|event| event["operationId"] == operation && event["type"] == "requestCompleted") { + if !matches!( + function, + "workspace.load" + | "history.list" + | "history.append" + | "connections.previewDBX" + ) || completed.get("error").is_some() + { + let records = core("diagnostics.drainCoreEvents", json!({})); + let event = if completed.get("error").is_some() { + "plugins.operation.failed" + } else { + "plugins.operation.finished" + }; + let terminal: Vec<_> = records["events"] + .as_array() + .unwrap() + .iter() + .filter(|record| record["event"] == event && record["op"] == operation) + .collect(); + assert_eq!( + terminal.len(), + 1, + "typed terminal for {operation}: {records}" + ); + assert_eq!( + terminal[0]["channel"], + format!("plugins/{}/host", self.plugin_id) + ); + assert_eq!( + terminal[0]["level"], + if completed.get("error").is_some() { + "ERROR" + } else { + "INFO" + } + ); + assert_eq!(terminal[0]["fields"]["operation"], function); + } return completed.clone(); } let remaining = deadline.saturating_duration_since(Instant::now()); diff --git a/scripts/e2e.mjs b/scripts/e2e.mjs index 1300669..ab6b7fd 100644 --- a/scripts/e2e.mjs +++ b/scripts/e2e.mjs @@ -1,12 +1,12 @@ // Runs e2e/database_plugin.rs against Lithe's Rust Core with the package // scripts/package.mjs staged in dist/. // -// node scripts/e2e.mjs [--ref ] [--source ] +// node scripts/e2e.mjs --ref --source // // Core lives in the Lithe repository, so the scenario is copied into // rust/lithe-core/tests/ of a disposable Lithe checkout in .artifacts/lithe -// (only rust/ and shared/ are checked out) and run there with cargo. By -// default that checkout is the commit e2e/lithe-core.json pins. +// (only rust/ and shared/ are checked out) and run there with cargo. +// Both --source and --ref are required; Core must support litheLogging v1. import { spawn } from 'node:child_process'; import { access, copyFile, mkdir, readFile } from 'node:fs/promises'; import { join, resolve } from 'node:path'; @@ -41,7 +41,9 @@ async function checkoutCore(source, ref) { } const { values } = parseArgs({ options: { ref: { type: 'string' }, source: { type: 'string' } } }); -const pin = JSON.parse(await readFile(join(root, 'e2e/lithe-core.json'), 'utf8')); +if (!values.source || !values.ref) { + throw new Error('Structured plugin logging requires an explicit new Core checkout: pass --source --ref containing litheLogging v1.'); +} const manifest = JSON.parse(await readFile(join(root, 'plugin.json'), 'utf8')); const pkg = join(root, 'dist', manifest.id); try { @@ -52,7 +54,7 @@ try { // git runs inside the checkout, so a local source must be absolute. const source = values.source && !/^[a-z]+:\/\//.test(values.source) ? resolve(values.source) : values.source; -await checkoutCore(source ?? `${pin.repository}.git`, values.ref ?? pin.revision); +await checkoutCore(source, values.ref); await copyFile(join(root, 'e2e', `${TEST}.rs`), join(work, 'rust/lithe-core/tests', `${TEST}.rs`)); await run('cargo', ['test', '--locked', '--manifest-path', join(work, 'rust/Cargo.toml'), '-p', 'lithe-core', '--test', TEST, '--', '--nocapture'], { diff --git a/scripts/package.mjs b/scripts/package.mjs index 9ee65f9..68897ce 100644 --- a/scripts/package.mjs +++ b/scripts/package.mjs @@ -19,6 +19,8 @@ import { createHash } from 'node:crypto'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; +await import('./verify-sdk-source.mjs'); + const root = fileURLToPath(new URL('..', import.meta.url)); const BINARY = 'lithe-database-backend'; const TARGETS = { arm64: 'aarch64-apple-darwin', x86_64: 'x86_64-apple-darwin' }; diff --git a/scripts/verify-sdk-source.mjs b/scripts/verify-sdk-source.mjs new file mode 100644 index 0000000..83c6631 --- /dev/null +++ b/scripts/verify-sdk-source.mjs @@ -0,0 +1,31 @@ +// Verifies the reviewed official SDK source snapshot before builds/packages. +import { createHash } from 'node:crypto'; +import { readdir, readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { join } from 'node:path'; + +const directory = fileURLToPath(new URL('../vendor/lithe-plugin-sdk/', import.meta.url)); +const provenance = JSON.parse(await readFile(join(directory, 'source-provenance.json'), 'utf8')); +if (provenance.repository !== 'https://github.com/Lithe-IDEA/lithe-plugin-docs' + || !/^[a-f0-9]{40}$/.test(provenance.baseRevision) + || typeof provenance.localChanges !== 'string' || !provenance.localChanges.trim()) { + throw new Error('Invalid SDK source provenance.'); +} +const actual = []; +async function inspect(relative = '') { + for (const entry of await readdir(join(directory, relative), { withFileTypes: true })) { + const name = relative ? `${relative}/${entry.name}` : entry.name; + if (entry.isSymbolicLink()) throw new Error(`SDK snapshot contains a symbolic link: ${name}`); + if (entry.isDirectory()) await inspect(name); + else if (entry.isFile() && name !== 'source-provenance.json') actual.push(name); + else if (!entry.isFile()) throw new Error(`SDK snapshot contains a special file: ${name}`); + } +} +await inspect(); +const expected = Object.keys(provenance.files).sort(); +if (JSON.stringify(actual.sort()) !== JSON.stringify(expected)) throw new Error('SDK snapshot file list differs from provenance.'); +for (const name of expected) { + const digest = createHash('sha256').update(await readFile(join(directory, name))).digest('hex'); + if (digest !== provenance.files[name]) throw new Error(`SDK source hash differs: ${name}`); +} +console.log(`Verified ${expected.length} SDK source files against ${provenance.baseRevision} plus declared local changes.`); diff --git a/vendor/lithe-plugin-sdk/Cargo.toml b/vendor/lithe-plugin-sdk/Cargo.toml new file mode 100644 index 0000000..207a247 --- /dev/null +++ b/vendor/lithe-plugin-sdk/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "lithe-plugin-sdk" +description = "Official SDK for Lithe plugin backends: plugin functions and host services over the LSP base protocol." +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +rust-version = "1.87" +publish = false + +[dependencies] +serde_json = "1" diff --git a/vendor/lithe-plugin-sdk/LICENSE b/vendor/lithe-plugin-sdk/LICENSE new file mode 100644 index 0000000..69bef1d --- /dev/null +++ b/vendor/lithe-plugin-sdk/LICENSE @@ -0,0 +1,203 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or + Object form, that is based on (or derived from) the Work and + for which the editorial revisions, annotations, elaborations, + or other modifications represent, as a whole, an original work + of authorship. For the purposes of this License, Derivative Works + shall not include works that remain separable from, or merely link + (or bind by name) to the interfaces of, the Work and Derivative Works + thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of + its distribution, then any Derivative Works that You + distribute must include a readable copy of the attribution + notices contained within such NOTICE file, excluding those + notices that do not pertain to any part of the Derivative + Works, in at least one of the following places: within a + NOTICE text file distributed as part of the Derivative Works; + within the Source form or documentation, if provided along with + the Derivative Works; or, within a display generated by the + Derivative Works, if and wherever such third-party notices + normally appear. The contents of the NOTICE file are for + informational purposes only and do not modify the License. + You may add Your own attribution notices within Derivative + Works that You distribute, alongside or as an addendum to + the NOTICE text from the Work, provided that such additional + attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing + the origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as + a result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[ ]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/vendor/lithe-plugin-sdk/source-provenance.json b/vendor/lithe-plugin-sdk/source-provenance.json new file mode 100644 index 0000000..623ddde --- /dev/null +++ b/vendor/lithe-plugin-sdk/source-provenance.json @@ -0,0 +1,19 @@ +{ + "repository": "https://github.com/Lithe-IDEA/lithe-plugin-docs", + "baseRevision": "29b245c0e1d77c761d9032a3144a301d1818bfc7", + "sourcePath": "sdk/rust/plugin-sdk", + "localChanges": "Uncommitted structured litheLogging v1 SDK implementation prepared with this plugin migration. Cargo manifest workspace fields resolved for a standalone source snapshot. This snapshot is not a published SDK revision.", + "files": { + "Cargo.toml": "e13404507b2ed388139c81960372e292cc10a90b4c0b016284a2bf3e4a3c5db0", + "LICENSE": "93ff573f89d32394501fcc82767e73b1824add861f088bbd3eec9e0afae20d21", + "src/backend.rs": "efea43279ab98186d874b8d2339874a9ead409ad31d9292453023a3d8f343a78", + "src/diagnostics.rs": "b60deef7a72f931312c8277c23850191e766068070f719250ccf191b32f9e344", + "src/error.rs": "bd7088b04b11b6bf86e8ac440e2da8b2e37b4eccb36bd2b5459c634cb190c76d", + "src/framing.rs": "5d61561b1e82d74cab39dbf5074a1f8e68f1ba93fea5a67990189cce9193a365", + "src/host.rs": "cd16fd48d96e0863d32ba997a4e4393e0bdc433d05ab8b87761bd7690a12bf50", + "src/lib.rs": "245d1c2b27d10e38ad879fbaf68efc9648516ffb8c40c4b22e5344cb14e4fe11", + "src/panic_boundary.rs": "ac41a053e9fc1f7996b154ced6424b2ab580780544505758a39f226b1c859905", + "src/protocol.rs": "38692d0cbffb01954e80a0eaf9fd337331ea5517f9e1121c36fb38faf181abd0", + "src/stdio.rs": "8ebda3d08a03438f926e49f48050faf90b9725e9338ee1742a0793ab0f5e2e0a" + } +} diff --git a/vendor/lithe-plugin-sdk/src/backend.rs b/vendor/lithe-plugin-sdk/src/backend.rs new file mode 100644 index 0000000..8daa1ed --- /dev/null +++ b/vendor/lithe-plugin-sdk/src/backend.rs @@ -0,0 +1,1260 @@ +//! The plugin backend session: one process the host starts from the plugin's +//! manifest and talks to over stdio with the LSP base protocol. The SDK owns +//! everything Lithe manages uniformly -- the lifecycle, protocol negotiation, +//! cancellation, error codes, and the `lithe/*` namespace -- and leaves the +//! plugin free to decide what its handlers do. +//! +//! Contract: `contract/v1/README.md` ("Plugin backend", "Plugin functions", +//! and "Host services"). + +use crate::diagnostics::{Diagnostics, OPERATIONS}; +use crate::error::{ + cancelled_response, internal_response, response_error, sdk_codes, Cancelled, FunctionError, + SdkError, +}; +use crate::framing::{read_frame, write_frame, Frame}; +use crate::host::{Channel, Credentials, Host, Outcome, Outgoing, WorkspaceContext}; +use crate::panic_boundary::catch as catch_unwind; +use crate::protocol::{ + bounded_text, error_codes, is_command_id, is_function_id, methods, CONTEXT_VERSION, + FUNCTIONS_VERSIONS, MAX_FUNCTIONS, MAX_MESSAGE_BYTES, MAX_RESULT_BYTES, +}; +use serde_json::{json, Map, Value}; +use std::any::Any; +use std::collections::{BTreeMap, HashMap}; +use std::io::{self, BufReader, Read, Write}; +use std::panic::AssertUnwindSafe; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Condvar, Mutex, MutexGuard, PoisonError}; +use std::thread; +use std::time::{Duration, Instant}; + +type Handler = Arc Result + Send + Sync>; +type InitializeHook = Box Result<(), FunctionError> + Send>; +type ShutdownHook = Box; +type CommandHandler = + Arc) -> Result<(), FunctionError> + Send + Sync>; +type NotificationHandler = Arc Result<(), FunctionError> + Send + Sync>; +type Answer = Result; + +/// `Idle` → `Listening` → `Initializing` → `Ready` → `ShuttingDown` → `Exited`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Phase { + Idle, + Listening, + Initializing, + Ready, + ShuttingDown, + Exited, +} + +pub(crate) struct State { + pub(crate) phase: Phase, + pub(crate) functions: BTreeMap, + pub(crate) logging: bool, + pub(crate) logging_ready: bool, + pub(crate) early_diagnostics: Vec, + quiet_success: std::collections::HashSet, + /// The negotiated plugin-function protocol version. + functions_version: Option, + /// Host requests being handled, by JSON-RPC ID. + incoming: HashMap, + /// Requests this backend sent to the host, by JSON-RPC ID. + pub(crate) outgoing: HashMap, + pub(crate) channel: Channel, +} + +struct Running { + function_id: String, + cancellation: Cancellation, +} + +/// Everything handler threads share with the session. Locks are taken in the +/// order `registrations`, `capabilities`, `state`, `output`, never the other +/// way round. +pub(crate) struct Shared { + name: String, + pub(crate) state: Mutex, + output: Mutex>>, + /// Serializes register/unregister so the host sees them in call order. + registrations: Mutex<()>, + pub(crate) next_request: AtomicU64, + /// Signalled when a host-request slot frees or a waiting request must + /// give up; waits on `state`. + pub(crate) slots: Condvar, + /// The session's `getCapabilities` answer. + pub(crate) capabilities: Mutex>, +} + +pub(crate) fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(PoisonError::into_inner) +} + +/// The JSON-RPC ID as a map key; numbers and strings never collide. +fn request_key(id: &Value) -> String { + id.to_string() +} + +fn rpc_error(code: i64, message: impl Into) -> Value { + json!({ "code": code, "message": message.into() }) +} + +/// A plugin backend. Declare functions and hooks, then call +/// [`listen`](Self::listen). +/// +/// ```no_run +/// use lithe_plugin_sdk::{Backend, PluginError}; +/// use serde_json::json; +/// +/// let mut backend = Backend::new("example-backend").version("1.0.0"); +/// backend +/// .function("snapshot.refresh", |params, context| { +/// context.progress("Refreshing", Some(10)); +/// let name = params["name"].as_str().ok_or_else(|| { +/// PluginError::new("example.invalidName", "\"name\" must be a string.") +/// })?; +/// Ok(json!({ "refreshed": name })) +/// }) +/// .expect("a valid function ID"); +/// std::process::exit(backend.listen()); +/// ``` +pub struct Backend { + shared: Arc, + version: Option, + initialize_hooks: Vec, + shutdown_hooks: Vec, + commands: HashMap, + contexts_changed: Option, + host_notifications: HashMap, +} + +impl Backend { + /// `name` is reported as `serverInfo.name` and prefixes log lines. + pub fn new(name: impl Into) -> Self { + Self { + shared: Arc::new(Shared { + name: name.into(), + state: Mutex::new(State { + phase: Phase::Idle, + functions: BTreeMap::new(), + logging: false, + logging_ready: false, + early_diagnostics: Vec::new(), + quiet_success: std::collections::HashSet::new(), + functions_version: None, + incoming: HashMap::new(), + outgoing: HashMap::new(), + channel: Channel::default(), + }), + output: Mutex::new(None), + registrations: Mutex::new(()), + next_request: AtomicU64::new(1), + slots: Condvar::new(), + capabilities: Mutex::new(None), + }), + version: None, + initialize_hooks: Vec::new(), + shutdown_hooks: Vec::new(), + commands: HashMap::new(), + contexts_changed: None, + host_notifications: HashMap::new(), + } + } + + /// Omits successful terminal diagnostics for a registered high-frequency operation. + pub fn suppress_success_diagnostics(&mut self, operation: &str) -> Result<&mut Self, SdkError> { + let mut state = lock(&self.shared.state); + if !OPERATIONS.contains(&operation) && !state.functions.contains_key(operation) { + return Err(SdkError::new( + "sdk.logging.invalidOperation", + "Suppress a registered operation.", + )); + } + state.quiet_success.insert(operation.to_string()); + drop(state); + Ok(self) + } + + /// Reported as `serverInfo.version`. + pub fn version(mut self, version: impl Into) -> Self { + self.version = Some(version.into()); + self + } + + /// Declares a function offered from the start, in the `initialize` + /// answer. To add one later, use [`BackendHandle::register_function`]. + /// + /// Each call runs on a thread of its own, so a handler may block; it + /// should stop early when its [`Context::cancellation`] fires. + pub fn function(&mut self, id: &str, handler: F) -> Result<&mut Self, SdkError> + where + F: Fn(Value, &Context) -> Result + Send + Sync + 'static, + { + let mut state = lock(&self.shared.state); + check_new_function(id, &state.functions)?; + state.functions.insert(id.to_string(), Arc::new(handler)); + drop(state); + Ok(self) + } + + /// Runs before the `initialize` answer. A hook may read the host's + /// params and add functions; an error refuses `initialize`, mapped like + /// a handler's. + pub fn on_initialize(&mut self, hook: F) -> &mut Self + where + F: FnMut(&mut Initialize) -> Result<(), FunctionError> + Send + 'static, + { + self.initialize_hooks.push(Box::new(hook)); + self + } + + /// Runs on `shutdown`, after running calls were cancelled. + pub fn on_shutdown(&mut self, hook: F) -> &mut Self + where + F: FnMut() + Send + 'static, + { + self.shutdown_hooks.push(Box::new(hook)); + self + } + + /// Handles `lithe/host/commands/didRun` for one of the plugin's commands + /// (protocol 3), with the workspace context the command ran in. It is an + /// event: the host waits for no result, and a failure is only logged. + /// Each run has a thread of its own. A later handler for the same ID + /// replaces the earlier one. + /// + /// `command_id` is the command's ID from the manifest, 1-128 of + /// `[a-z0-9.-]`. + pub fn on_command(&mut self, command_id: &str, handler: F) -> Result<&mut Self, SdkError> + where + F: Fn(&str, Option) -> Result<(), FunctionError> + Send + Sync + 'static, + { + if !is_command_id(command_id) { + return Err(SdkError::new( + "sdk.command.invalidID", + format!("Command ID {command_id:?} must be 1-128 of [a-z0-9.-]."), + )); + } + self.commands + .insert(command_id.to_string(), Arc::new(handler)); + Ok(self) + } + + /// Handles `lithe/host/workspace/didChangeContexts` (protocol 3) with + /// `{ revision }`; call `host().workspace().list_contexts()` for the + /// contexts. + pub fn on_contexts_changed(&mut self, handler: F) -> &mut Self + where + F: Fn(Value) -> Result<(), FunctionError> + Send + Sync + 'static, + { + self.contexts_changed = Some(Arc::new(handler)); + self + } + + /// Handles a `lithe/host/*` notification a later host adds. Commands, + /// context changes, and subscription events have their own APIs. + pub fn on_host_notification( + &mut self, + method: &str, + handler: F, + ) -> Result<&mut Self, SdkError> + where + F: Fn(Value) -> Result<(), FunctionError> + Send + Sync + 'static, + { + if !method.starts_with(methods::HOST_PREFIX) { + return Err(SdkError::new( + "sdk.method.invalid", + format!("{method} is not a host notification."), + )); + } + if [ + methods::DID_CHANGE_CONTEXTS, + methods::SUBSCRIPTION_NOTIFY, + methods::COMMAND_DID_RUN, + ] + .contains(&method) + { + return Err(SdkError::new( + "sdk.method.reserved", + format!("{method} is routed by the SDK; use on_command, on_contexts_changed, or Host::subscribe."), + )); + } + self.host_notifications + .insert(method.to_string(), Arc::new(handler)); + Ok(self) + } + + /// A handle for other threads: runtime registration, host services, and + /// logging. + pub fn handle(&self) -> BackendHandle { + BackendHandle { + shared: Arc::clone(&self.shared), + } + } + + /// Serves the host on stdin and stdout until it sends `exit` or closes + /// stdin, and returns the process exit code. Stdout carries only + /// protocol frames: on Unix the SDK moves the protocol to a duplicate of + /// the stdout descriptor and points stdout at stderr, so a stray + /// `println!`, in the plugin or a dependency, lands in the host's log + /// instead of corrupting the stream. + pub fn listen(self) -> i32 { + let output = crate::stdio::protocol_output(); + self.serve(io::stdin().lock(), output) + } + + /// Serves the host on the given streams; [`listen`](Self::listen) uses + /// stdin and stdout. + pub fn serve(mut self, input: impl Read, output: impl Write + Send + 'static) -> i32 { + lock(&self.shared.state).phase = Phase::Listening; + *lock(&self.shared.output) = Some(Box::new(output)); + let mut reader = BufReader::new(input); + loop { + match read_frame(&mut reader) { + Ok(None) => { + let code = i32::from(self.shared.phase() != Phase::ShuttingDown); + return self.shared.finish(code); + } + Err(_) => { + self.handle().diagnostics().terminal( + "notification", + Some(&FunctionError::internal("framing_error")), + Instant::now(), + ); + return self.shared.finish(1); + } + Ok(Some(Frame::Malformed)) => self.shared.respond( + &Value::Null, + Err(rpc_error( + error_codes::PARSE_ERROR, + "The message is not valid JSON.", + )), + ), + Ok(Some(Frame::Message(message))) => { + if let Some(code) = self.dispatch(message) { + return self.shared.finish(code); + } + } + } + } + } + + /// Handles one message; returns the exit code once the host sent `exit`. + fn dispatch(&mut self, message: Value) -> Option { + let Value::Object(message) = message else { + // Valid JSON that is not a message object, such as `null` or a batch. + self.shared.respond( + &Value::Null, + Err(rpc_error( + error_codes::INVALID_REQUEST, + "The message is not a JSON-RPC object.", + )), + ); + return None; + }; + let id = message.get("id").filter(|id| !id.is_null()).cloned(); + let params = message.get("params").cloned().unwrap_or(Value::Null); + match (message.get("method").and_then(Value::as_str), id) { + (None, Some(id)) => { + self.shared.settle_outgoing(&id, &message); + None + } + (None, None) => None, + (Some(method), Some(id)) => { + self.handle_request(id, method, params); + None + } + (Some(method), None) => self.handle_notification(method, ¶ms), + } + } + + fn handle_request(&mut self, id: Value, method: &str, params: Value) { + match method { + methods::INITIALIZE => self.initialize(id, params), + methods::SHUTDOWN => self.shutdown(id), + _ => match self.shared.phase() { + Phase::Ready if method == methods::FUNCTION_INVOKE => { + Shared::invoke(&self.shared, id, ¶ms) + } + Phase::Ready => self.shared.respond( + &id, + Err(rpc_error( + error_codes::METHOD_NOT_FOUND, + format!("Unsupported method: {method}"), + )), + ), + Phase::Idle | Phase::Listening | Phase::Initializing => self.shared.respond( + &id, + Err(rpc_error( + error_codes::SERVER_NOT_INITIALIZED, + "The backend is not initialized.", + )), + ), + Phase::ShuttingDown | Phase::Exited => self.shared.respond( + &id, + Err(rpc_error( + error_codes::INVALID_REQUEST, + "The backend is shutting down.", + )), + ), + }, + } + } + + fn handle_notification(&mut self, method: &str, params: &Value) -> Option { + match method { + methods::EXIT => Some(i32::from(self.shared.phase() != Phase::ShuttingDown)), + methods::CANCEL_REQUEST => { + let key = request_key(¶ms["id"]); + let running = lock(&self.shared.state) + .incoming + .get(&key) + .map(|running| running.cancellation.clone()); + if let Some(cancellation) = running { + cancellation.cancel(); + } + None + } + method if method.starts_with(methods::HOST_PREFIX) => { + self.host_notification(method, params); + None + } + // `initialized` and anything else a plugin backend is not sent. + _ => None, + } + } + + /// Routes a host notification. Core checked its shape and the session's + /// grant; one this SDK does not know is ignored, as the contract asks. + fn host_notification(&self, method: &str, params: &Value) { + if self.shared.functions_version() < Some(CONTEXT_VERSION) || !params.is_object() { + return; + } + match method { + methods::SUBSCRIPTION_NOTIFY => self.shared.deliver(params), + methods::COMMAND_DID_RUN => { + let command_id = params["commandID"].as_str().unwrap_or_default(); + let Some(handler) = self.commands.get(command_id).cloned() else { + return self.shared.failed("notification", "command_unhandled"); + }; + let command_id = command_id.to_string(); + let context = WorkspaceContext::from_reference(¶ms["context"]); + self.shared + .notify_handler(method, move || handler(&command_id, context)); + } + _ => { + let handler = if method == methods::DID_CHANGE_CONTEXTS { + self.contexts_changed.as_ref() + } else { + self.host_notifications.get(method) + }; + if let Some(handler) = handler.cloned() { + let params = params.clone(); + self.shared.notify_handler(method, move || handler(params)); + } + } + } + } + + fn initialize(&mut self, id: Value, params: Value) { + { + let mut state = lock(&self.shared.state); + if state.phase != Phase::Listening { + drop(state); + return self.shared.respond( + &id, + Err(rpc_error( + error_codes::INVALID_REQUEST, + "initialize was already received.", + )), + ); + } + state.phase = Phase::Initializing; + } + let logging = params + .pointer("/capabilities/experimental/litheLogging/versions") + .and_then(Value::as_array) + .is_some_and(|versions| versions.iter().any(|v| v.as_u64() == Some(1))); + lock(&self.shared.state).logging = logging; + lock(&self.shared.state).logging_ready = false; + lock(&self.shared.state).early_diagnostics.clear(); + if !logging { + lock(&self.shared.state).phase = Phase::Listening; + return self.shared.respond( + &id, + Err(rpc_error( + error_codes::INVALID_REQUEST, + "The backend requires litheLogging version 1.", + )), + ); + } + let offered = params + .pointer("/capabilities/experimental/litheFunctions/versions") + .and_then(Value::as_array); + let functions_version = FUNCTIONS_VERSIONS.into_iter().find(|version| { + offered + .is_some_and(|offered| offered.iter().any(|entry| entry.as_u64() == Some(*version))) + }); + let mut initialize = Initialize { + params, + functions_version, + existing: lock(&self.shared.state).functions.keys().cloned().collect(), + added: BTreeMap::new(), + }; + for hook in &mut self.initialize_hooks { + let outcome = catch_unwind(AssertUnwindSafe(|| hook(&mut initialize))); + let error = match outcome { + Ok(Ok(())) => continue, + Ok(Err(error)) => response_error(&error), + Err(_) => internal_response(), + }; + lock(&self.shared.state).phase = Phase::Listening; + return self.shared.respond(&id, Err(error)); + } + + // The state stays locked until the answer is written, so a function + // registered from another thread cannot overtake it. + let mut state = lock(&self.shared.state); + state.functions.extend(initialize.added); + state.functions_version = functions_version; + let mut experimental = Map::new(); + experimental.insert("litheLogging".into(), json!({ "version": 1 })); + match functions_version { + Some(version) => { + let functions: Vec<_> = state + .functions + .keys() + .map(|id| json!({ "id": id })) + .collect(); + experimental.insert( + "litheFunctions".into(), + json!({ "version": version, "functions": functions }), + ); + } + None if !state.functions.is_empty() => {} + None => {} + } + let mut server_info = json!({ "name": self.shared.name }); + if let Some(version) = &self.version { + server_info["version"] = json!(version); + } + state.phase = Phase::Ready; + self.shared.respond( + &id, + Ok(json!({ "capabilities": { "experimental": experimental }, "serverInfo": server_info })), + ); + state.logging_ready = true; + let early = std::mem::take(&mut state.early_diagnostics); + drop(state); + for params in early { + self.shared + .write(&json!({"jsonrpc":"2.0", "method":"lithe/log", "params":params})); + } + } + + fn shutdown(&mut self, id: Value) { + let running = { + let mut state = lock(&self.shared.state); + if !matches!( + state.phase, + Phase::Listening | Phase::Initializing | Phase::Ready + ) { + drop(state); + return self.shared.respond( + &id, + Err(rpc_error( + error_codes::INVALID_REQUEST, + "shutdown was already received.", + )), + ); + } + state.phase = Phase::ShuttingDown; + state.running_cancellations() + }; + running.iter().for_each(Cancellation::cancel); + for hook in &mut self.shutdown_hooks { + if catch_unwind(AssertUnwindSafe(&mut *hook)).is_err() { + self.shared.failed("shutdown", "panic"); + } + } + self.shared.respond(&id, Ok(Value::Null)); + } +} + +impl State { + fn running_cancellations(&self) -> Vec { + self.incoming + .values() + .map(|running| running.cancellation.clone()) + .collect() + } + + fn require_functions(&self) -> Result<(), SdkError> { + if self.phase == Phase::Exited { + Err(SdkError::new( + sdk_codes::SESSION_CLOSED, + "The session ended.", + )) + } else if self.phase == Phase::Ready && self.functions_version.is_some() { + Ok(()) + } else { + Err(SdkError::new( + "sdk.functions.unavailable", + "The host has not negotiated plugin functions.", + )) + } + } +} + +impl Shared { + fn phase(&self) -> Phase { + lock(&self.state).phase + } + + pub(crate) fn functions_version(&self) -> Option { + lock(&self.state).functions_version + } + + pub(crate) fn failed(&self, operation: &str, code: &str) { + self.write( + &json!({ "jsonrpc": "2.0", "method": "lithe/log", "params": { + "kind": "operationFailed", "operation": operation, "code": code, "durationMs": 0 + }}), + ); + } + + fn finish(&self, code: i32) -> i32 { + let running = { + let mut state = lock(&self.state); + state.phase = Phase::Exited; + self.close_channel(&mut state); + state.running_cancellations() + }; + running.iter().for_each(Cancellation::cancel); + *lock(&self.output) = None; + code + } + + /// Runs a notification handler off the reader thread; a failure is + /// logged. + fn notify_handler( + self: &Arc, + method: &str, + handler: impl FnOnce() -> Result<(), FunctionError> + Send + 'static, + ) { + let shared = Arc::clone(self); + let method = method.to_string(); + let spawned = thread::Builder::new() + .name(format!("lithe-notification-{method}")) + .spawn(move || match catch_unwind(AssertUnwindSafe(handler)) { + Ok(Ok(())) => {} + Ok(Err(FunctionError::Cancelled)) => {} + Ok(Err(error)) => shared.failed("notification", failure_kind(&error)), + Err(_) => shared.failed("notification", "panic"), + }); + if spawned.is_err() { + self.failed("notification", "worker_start_failed"); + } + } + + pub(crate) fn write(&self, message: &Value) { + let mut output = lock(&self.output); + let Some(writer) = output.as_mut() else { + return; + }; + if write_frame(writer.as_mut(), message).is_err() { + // Every later write would fail the same way. + *output = None; + drop(output); + // The protocol pipe is gone; the host owns the process terminal fact. + } + } + + fn respond(&self, id: &Value, answer: Answer) { + self.write(&match answer { + Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }), + Err(error) => json!({ "jsonrpc": "2.0", "id": id, "error": error }), + }); + } + + fn invoke(shared: &Arc, id: Value, params: &Value) { + let function_id = params["functionID"] + .as_str() + .unwrap_or_default() + .to_string(); + let operation_id = params["operationID"].clone(); + let call_params = match params.get("params") { + None | Some(Value::Null) => json!({}), + Some(params) => params.clone(), + }; + let key = request_key(&id); + let cancellation = Cancellation::new(); + let handler = { + let mut state = lock(&shared.state); + let workspace_context = state + .functions_version + .filter(|version| *version >= CONTEXT_VERSION) + .and_then(|_| WorkspaceContext::from_reference(¶ms["context"])); + let handler = state + .functions_version + .and_then(|_| state.functions.get(&function_id).cloned()); + if handler.is_some() { + state.incoming.insert( + key.clone(), + Running { + function_id: function_id.clone(), + cancellation: cancellation.clone(), + }, + ); + } + handler.map(|handler| (handler, workspace_context)) + }; + let Some((handler, workspace_context)) = handler else { + return shared.respond( + &id, + Err(rpc_error( + error_codes::INVALID_PARAMS, + format!("Function {function_id} is not registered."), + )), + ); + }; + let worker = Arc::clone(shared); + let thread_key = key.clone(); + let thread_id = id.clone(); + let spawned = thread::Builder::new() + .name(format!("lithe-function-{function_id}")) + .spawn(move || { + // Ends host requests the call left behind, on clones of its host. + let ended = Cancellation::new(); + let diagnostic_operation_id = + crate::diagnostics::request_operation_id(&operation_id, &thread_id); + let context = Context { + shared: Arc::clone(&worker), + function_id, + operation_id, + diagnostic_operation_id, + host: Host::new( + Arc::clone(&worker), + vec![cancellation.clone(), ended.clone()], + ), + cancellation, + workspace_context, + reporting: AtomicBool::new(true), + quiet_success: AtomicBool::new(false), + }; + let started = Instant::now(); + let outcome = catch_unwind(AssertUnwindSafe(|| handler(call_params, &context))); + context.reporting.store(false, Ordering::SeqCst); + ended.cancel(); + let answer = worker.function_answer(&context, outcome, started); + lock(&worker.state).incoming.remove(&thread_key); + worker.respond(&thread_id, answer); + }); + if spawned.is_err() { + lock(&shared.state).incoming.remove(&key); + shared.failed("function-call", "worker_start_failed"); + shared.respond(&id, Err(internal_response())); + } + } + + /// The answer to one call. A cancelled call answers as cancelled whatever + /// the handler returned, and only failures the host never sees in full + /// are logged with a fixed category, without their private contents. + fn function_answer( + &self, + context: &Context, + outcome: Result, Box>, + started: Instant, + ) -> Answer { + let cancelled = context.cancellation.is_cancelled(); + let function = &context.function_id; + match outcome { + _ if cancelled => Err(cancelled_response()), + Ok(Ok(value)) => { + let size = serde_json::to_vec(&value).map_or(usize::MAX, |bytes| bytes.len()); + if size > MAX_RESULT_BYTES { + context.diagnostics().terminal( + function, + Some(&FunctionError::internal("result_limit")), + started, + ); + Err(internal_response()) + } else { + let quiet = context.quiet_success.load(Ordering::SeqCst) + || lock(&self.state).quiet_success.contains(function); + if !quiet { + context.diagnostics().terminal(function, None, started); + } + Ok(value) + } + } + Ok(Err(error)) => { + if !matches!(error, FunctionError::Cancelled) { + context + .diagnostics() + .terminal(function, Some(&error), started); + } + Err(response_error(&error)) + } + Err(_) => { + let _ = context.diagnostics().record( + "operationFailed", + function, + Some("panic"), + None, + Some(started.elapsed().as_millis().min(9_007_199_254_740_991) as u64), + ); + Err(internal_response()) + } + } + } + + /// A `lithe/function/*` request; it takes no host-request slot. + fn request(self: &Arc, method: &str, params: Value) -> Outcome { + self.exchange(method, params, false, &[], None) + } +} + +fn check_new_function(id: &str, functions: &BTreeMap) -> Result<(), SdkError> { + if !is_function_id(id) { + return Err(SdkError::new( + "sdk.function.invalidID", + format!("Function ID {id:?} must be dot-separated lowerCamelCase segments of at most 128 bytes."), + )); + } + if functions.contains_key(id) { + return Err(SdkError::new( + "sdk.function.duplicate", + format!("Function {id} is already declared."), + )); + } + if functions.len() >= MAX_FUNCTIONS { + return Err(SdkError::new( + "sdk.function.limit", + format!("A backend serves at most {MAX_FUNCTIONS} functions."), + )); + } + Ok(()) +} + +// Failure values can contain user data. Never format their messages or panic payloads +// into diagnostics; preserve the existing response contract separately. +fn failure_kind(error: &FunctionError) -> &'static str { + match error { + FunctionError::Plugin(_) => "plugin_error", + FunctionError::Internal(_) => "internal_error", + FunctionError::Cancelled => "cancelled", + } +} + +/// What a hook sees of `initialize`, before the backend answers it. +pub struct Initialize { + params: Value, + functions_version: Option, + existing: Vec, + added: BTreeMap, +} + +impl Initialize { + /// The host's `initialize` params. + pub fn params(&self) -> &Value { + &self.params + } + + /// The plugin-function protocol version the backend will answer, or + /// `None` when the host offered none this SDK speaks. + pub fn functions_version(&self) -> Option { + self.functions_version + } + + /// The `rootUri` as a path. For a plugin backend this is the plugin's + /// `${stateDir}`, also the working directory. + pub fn root_path(&self) -> Option { + self.params["rootUri"].as_str().and_then(file_uri_path) + } + + /// Adds a function to the initial list, for example one that depends on + /// state found at startup. + pub fn function(&mut self, id: &str, handler: F) -> Result<(), SdkError> + where + F: Fn(Value, &Context) -> Result + Send + Sync + 'static, + { + if self.existing.iter().any(|existing| existing == id) { + return Err(SdkError::new( + "sdk.function.duplicate", + format!("Function {id} is already declared."), + )); + } + if self.existing.len() + self.added.len() >= MAX_FUNCTIONS { + return Err(SdkError::new( + "sdk.function.limit", + format!("A backend serves at most {MAX_FUNCTIONS} functions."), + )); + } + check_new_function(id, &self.added)?; + self.added.insert(id.to_string(), Arc::new(handler)); + Ok(()) + } +} + +/// The path of a `file:` URI, percent-decoded. +fn file_uri_path(uri: &str) -> Option { + let rest = uri.strip_prefix("file://")?; + let path = rest.strip_prefix("localhost").unwrap_or(rest); + if !path.starts_with('/') { + return None; + } + let bytes = path.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' && index + 2 < bytes.len() { + let hex = std::str::from_utf8(&bytes[index + 1..index + 3]).ok()?; + decoded.push(u8::from_str_radix(hex, 16).ok()?); + index += 3; + } else { + decoded.push(bytes[index]); + index += 1; + } + } + let mut path = String::from_utf8(decoded).ok()?; + // `/C:/Users` on Windows. + if cfg!(windows) && path.as_bytes().get(2) == Some(&b':') { + path.remove(0); + } + while path.len() > 1 && path.ends_with('/') { + path.pop(); + } + Some(PathBuf::from(path)) +} + +/// One running function call. +pub struct Context { + shared: Arc, + function_id: String, + operation_id: Value, + diagnostic_operation_id: Option, + cancellation: Cancellation, + host: Host, + workspace_context: Option, + reporting: AtomicBool, + quiet_success: AtomicBool, +} + +impl Context { + /// Omits only this call's successful terminal fact; failures remain visible. + pub fn suppress_success_diagnostics(&self) { + self.quiet_success.store(true, Ordering::SeqCst); + } + + /// Structured diagnostics correlated with the host's original operation ID. + pub fn diagnostics(&self) -> Diagnostics { + Diagnostics { + shared: Arc::clone(&self.shared), + operation_id: self.diagnostic_operation_id.clone(), + } + } + + pub fn function_id(&self) -> &str { + &self.function_id + } + + /// Fires when the host cancels the call, withdraws its function, or + /// shuts the backend down. Core has already ended the call by then, so a + /// handler should stop its work and return promptly. + pub fn cancellation(&self) -> &Cancellation { + &self.cancellation + } + + /// `Err(Cancelled)` once the call is cancelled, for use with `?`. + pub fn check_cancelled(&self) -> Result<(), Cancelled> { + self.cancellation.check() + } + + /// Reports progress of this call; the host shows the latest report. + /// `message` is cut to 1024 bytes and `percentage` to 100. Reports after + /// the call ended are dropped. + pub fn progress(&self, message: &str, percentage: Option) { + if !self.reporting.load(Ordering::SeqCst) { + return; + } + let mut report = json!({ + "operationID": self.operation_id, + "message": bounded_text(message, MAX_MESSAGE_BYTES), + }); + if let Some(percentage) = percentage { + report["percentage"] = json!(percentage.min(100)); + } + self.shared.write( + &json!({ "jsonrpc": "2.0", "method": methods::FUNCTION_PROGRESS, "params": report }), + ); + } + + /// Host services for this call. A request still waiting for the host + /// when the call is cancelled or ends fails with `sdk.call.cancelled`, + /// which `?` turns into a cancelled answer, and from protocol 3 the SDK + /// cancels it on the wire. + pub fn host(&self) -> &Host { + &self.host + } + + /// The `{ contextID, generation }` of the window the call came from + /// (protocol 3); `None` for calls without one, such as from the plugin's + /// own UI, and before protocol 3. + pub fn workspace_context(&self) -> Option<&WorkspaceContext> { + self.workspace_context.as_ref() + } + + /// `host().credentials()`. + pub fn credentials(&self) -> Credentials { + self.host.credentials() + } + + pub fn backend(&self) -> BackendHandle { + BackendHandle { + shared: Arc::clone(&self.shared), + } + } +} + +/// A clonable handle to a backend for any thread. +#[derive(Clone)] +pub struct BackendHandle { + shared: Arc, +} + +impl BackendHandle { + /// Structured facts outside a call; no operation ID is fabricated. + pub fn diagnostics(&self) -> Diagnostics { + Diagnostics { + shared: Arc::clone(&self.shared), + operation_id: None, + } + } + + /// The negotiated plugin-function protocol version; `None` before + /// `initialize` or when the host offered none this SDK speaks. + pub fn functions_version(&self) -> Option { + self.shared.functions_version() + } + + /// IDs of the functions the backend currently serves, sorted. + pub fn function_ids(&self) -> Vec { + lock(&self.shared.state).functions.keys().cloned().collect() + } + + /// Adds a function after `initialize` and waits until the host accepted + /// it. The handler is installed first, because the host may invoke it as + /// soon as its registry changed. + pub fn register_function(&self, id: &str, handler: F) -> Result<(), SdkError> + where + F: Fn(Value, &Context) -> Result + Send + Sync + 'static, + { + let _order = lock(&self.shared.registrations); + { + let mut state = lock(&self.shared.state); + state.require_functions()?; + check_new_function(id, &state.functions)?; + state.functions.insert(id.to_string(), Arc::new(handler)); + } + let registered = self.shared.request( + methods::FUNCTION_REGISTER, + json!({ "functions": [{ "id": id }] }), + ); + if registered.is_err() { + lock(&self.shared.state).functions.remove(id); + } + registered.map(drop) + } + + /// Withdraws a function and waits until the host accepted it. The host + /// ends the function's running calls as revoked, and the SDK cancels + /// them. A handler must not withdraw its own function: that revokes the + /// call it runs in. + pub fn unregister_function(&self, id: &str) -> Result<(), SdkError> { + let _order = lock(&self.shared.registrations); + { + let state = lock(&self.shared.state); + state.require_functions()?; + if !state.functions.contains_key(id) { + return Err(SdkError::new( + "sdk.function.unknown", + format!("Function {id} is not registered."), + )); + } + } + self.shared + .request(methods::FUNCTION_UNREGISTER, json!({ "ids": [id] }))?; + let running: Vec<_> = { + let mut state = lock(&self.shared.state); + state.functions.remove(id); + state.quiet_success.remove(id); + state + .incoming + .values() + .filter(|running| running.function_id == id) + .map(|running| running.cancellation.clone()) + .collect() + }; + running.iter().for_each(Cancellation::cancel); + Ok(()) + } + + /// Host services, for work outside a function call; usable once the + /// session is ready. + pub fn host(&self) -> Host { + Host::new(Arc::clone(&self.shared), Vec::new()) + } + + /// `host().credentials()`. + pub fn credentials(&self) -> Credentials { + self.host().credentials() + } +} + +/// The cancellation of one call, or of work a plugin may stop for its own +/// reasons (see [`Host::with_cancellation`]). Clones observe the same token. +#[derive(Clone)] +pub struct Cancellation(Arc); + +struct CancellationInner { + state: Mutex, + changed: Condvar, +} + +struct CancellationState { + cancelled: bool, + next_watch: u64, + callbacks: Vec<(u64, Box)>, +} + +impl Default for Cancellation { + fn default() -> Self { + Self::new() + } +} + +impl Cancellation { + pub fn new() -> Self { + Self(Arc::new(CancellationInner { + state: Mutex::new(CancellationState { + cancelled: false, + next_watch: 0, + callbacks: Vec::new(), + }), + changed: Condvar::new(), + })) + } + + pub fn is_cancelled(&self) -> bool { + lock(&self.0.state).cancelled + } + + /// `Err(Cancelled)` once cancelled, for use with `?`. + pub fn check(&self) -> Result<(), Cancelled> { + if self.is_cancelled() { + Err(Cancelled) + } else { + Ok(()) + } + } + + /// Blocks for at most `timeout`, and returns whether the call was + /// cancelled. Use it instead of `thread::sleep` between retries. + pub fn wait_timeout(&self, timeout: Duration) -> bool { + let state = lock(&self.0.state); + let (state, _) = self + .0 + .changed + .wait_timeout_while(state, timeout, |state| !state.cancelled) + .unwrap_or_else(PoisonError::into_inner); + state.cancelled + } + + /// Runs `callback` once, when the call is cancelled, or right away when + /// it already is: for example to cancel a query a driver is running. + /// The callback runs on the thread that cancels, so it must not block. + pub fn on_cancel(&self, callback: impl FnOnce() + Send + 'static) { + let _ = self.watch(callback); + } + + /// Cancels: wakes waiters and runs the callbacks, once. + pub fn cancel(&self) { + let callbacks = { + let mut state = lock(&self.0.state); + if state.cancelled { + return; + } + state.cancelled = true; + std::mem::take(&mut state.callbacks) + }; + self.0.changed.notify_all(); + for (_, callback) in callbacks { + callback(); + } + } + + /// Like [`on_cancel`](Self::on_cancel), but removable with + /// [`unwatch`](Self::unwatch); `None` when already cancelled, after + /// running `callback`. + pub(crate) fn watch(&self, callback: impl FnOnce() + Send + 'static) -> Option { + let mut state = lock(&self.0.state); + if state.cancelled { + drop(state); + callback(); + return None; + } + let watch = state.next_watch; + state.next_watch += 1; + state.callbacks.push((watch, Box::new(callback))); + Some(watch) + } + + pub(crate) fn unwatch(&self, watch: u64) { + lock(&self.0.state) + .callbacks + .retain(|(other, _)| *other != watch); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::mpsc; + + #[test] + fn file_uris_become_paths() { + assert_eq!( + file_uri_path("file:///var/state/"), + Some(PathBuf::from("/var/state")) + ); + assert_eq!( + file_uri_path("file://localhost/a%20b/%C3%A9"), + Some(PathBuf::from("/a b/é")) + ); + assert_eq!(file_uri_path("file:///"), Some(PathBuf::from("/"))); + assert_eq!(file_uri_path("https://example.com/"), None); + assert_eq!(file_uri_path("file:///bad%zz"), None); + } + + #[test] + fn cancellation_wakes_waiters_and_runs_callbacks_once() { + let cancellation = Cancellation::new(); + assert!(!cancellation.wait_timeout(Duration::ZERO)); + let (sender, receiver) = mpsc::channel(); + let early = sender.clone(); + cancellation.on_cancel(move || early.send("registered").unwrap()); + let waiter = { + let cancellation = cancellation.clone(); + thread::spawn(move || cancellation.wait_timeout(Duration::from_secs(10))) + }; + cancellation.cancel(); + cancellation.cancel(); + assert!(waiter.join().unwrap()); + cancellation.on_cancel(move || sender.send("late").unwrap()); + let received: Vec<_> = receiver.try_iter().collect(); + assert_eq!(received, ["registered", "late"]); + assert_eq!(cancellation.check(), Err(Cancelled)); + } +} diff --git a/vendor/lithe-plugin-sdk/src/diagnostics.rs b/vendor/lithe-plugin-sdk/src/diagnostics.rs new file mode 100644 index 0000000..1fe0f6f --- /dev/null +++ b/vendor/lithe-plugin-sdk/src/diagnostics.rs @@ -0,0 +1,168 @@ +//! Typed plugin facts transported to the host's litheLogging 1 catalog. +use crate::backend::{lock, Shared}; +use crate::{FunctionError, SdkError}; +use serde_json::json; +use std::sync::Arc; +use std::time::Instant; + +pub(crate) const MAX_INTEGER: u64 = 9_007_199_254_740_991; +pub(crate) const OPERATIONS: &[&str] = &[ + "initialize", + "shutdown", + "notification", + "function-call", + "result-encoding", + "lithe/build/model", + "lithe/build/dependencies", + "lithe/build/execute", + "lithe/build/test", + "lithe/build/discoverTests", + "lithe/build/prepareRun", + "lithe/build/prepareDebug", + "lithe/vcs/status", + "lithe/vcs/baseContent", + "lithe/vcs/history", + "lithe/vcs/fileHistory", + "lithe/vcs/annotate", + "lithe/vcs/checkin", + "lithe/vcs/rollback", + "lithe/vcs/update", + "lithe/vcs/fileOperation", + "lithe/vcs/resolve", + "lithe/vcs/cleanup", +]; +fn token(value: &str, maximum: usize) -> bool { + !value.is_empty() + && value.len() <= maximum + && value + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"._:/-$".contains(&c)) +} + +pub(crate) fn request_operation_id( + operation_id: &serde_json::Value, + request_id: &serde_json::Value, +) -> Option { + if let Some(id) = operation_id.as_str().filter(|id| token(id, 128)) { + return Some(id.to_string()); + } + let fallback = if let Some(id) = request_id.as_str() { + id.to_string() + } else if let Some(id) = request_id + .as_i64() + .filter(|id| id.unsigned_abs() <= MAX_INTEGER) + { + id.to_string() + } else if let Some(id) = request_id.as_u64().filter(|id| *id <= MAX_INTEGER) { + id.to_string() + } else { + return None; + }; + token(&fallback, 128).then_some(fallback) +} +/// A lightweight handle using the existing protocol writer, without a queue or file I/O. +#[derive(Clone)] +pub struct Diagnostics { + pub(crate) shared: Arc, + pub(crate) operation_id: Option, +} +impl Diagnostics { + /// Records a handled degradation; code is a controlled category, never an error message. + pub fn degraded( + &self, + operation: &str, + code: &str, + count: Option, + ) -> Result<(), SdkError> { + self.record("degraded", operation, Some(code), count, None) + } + /// Records a real recovery, with the same controlled operation and category. + pub fn recovered( + &self, + operation: &str, + code: &str, + count: Option, + ) -> Result<(), SdkError> { + self.record("recovered", operation, Some(code), count, None) + } + pub(crate) fn terminal( + &self, + operation: &str, + error: Option<&FunctionError>, + started: Instant, + ) { + let code = error.map(|error| match error { + FunctionError::Plugin(error) => error.code(), + FunctionError::Cancelled => "cancelled", + FunctionError::Internal(_) => "internal_error", + }); + let _ = self.record( + if error.is_some() { + "operationFailed" + } else { + "operationFinished" + }, + operation, + code, + None, + Some(started.elapsed().as_millis().min(u128::from(MAX_INTEGER)) as u64), + ); + } + pub(crate) fn record( + &self, + kind: &str, + operation: &str, + code: Option<&str>, + count: Option, + duration: Option, + ) -> Result<(), SdkError> { + let state = lock(&self.shared.state); + if !OPERATIONS.contains(&operation) && !state.functions.contains_key(operation) { + return Err(SdkError::new( + "sdk.logging.invalidOperation", + "Diagnostics need a registered operation.", + )); + } + let enabled = state.logging; + drop(state); + if code.is_some_and(|code| !token(code, 64)) { + return Err(SdkError::new( + "sdk.logging.invalidCode", + "Diagnostics need a controlled code.", + )); + } + if count.is_some_and(|count| count > MAX_INTEGER) { + return Err(SdkError::new( + "sdk.logging.invalidCount", + "Diagnostics need a safe count.", + )); + } + if !enabled { + return Ok(()); + } + let mut params = json!({ "kind": kind, "operation": if operation.len() <= 64 {operation} else {"function-call"} }); + if let Some(code) = code { + params["code"] = json!(code); + } + if let Some(count) = count { + params["count"] = json!(count); + } + if let Some(duration) = duration { + params["durationMs"] = json!(duration); + } + if let Some(id) = self.operation_id.as_deref().filter(|id| token(id, 128)) { + params["operationId"] = json!(id); + } + let mut state = lock(&self.shared.state); + if !state.logging_ready { + if state.early_diagnostics.len() < 8 { + state.early_diagnostics.push(params); + } + return Ok(()); + } + drop(state); + self.shared + .write(&json!({ "jsonrpc": "2.0", "method": "lithe/log", "params": params })); + Ok(()) + } +} diff --git a/vendor/lithe-plugin-sdk/src/error.rs b/vendor/lithe-plugin-sdk/src/error.rs new file mode 100644 index 0000000..207440f --- /dev/null +++ b/vendor/lithe-plugin-sdk/src/error.rs @@ -0,0 +1,314 @@ +//! The three ways a call can fail, and the JSON-RPC error each becomes. + +use crate::protocol::{ + bounded_text, error_codes, is_plugin_error_code, methods, MAX_MESSAGE_BYTES, +}; +use serde_json::{json, Map, Value}; +use std::fmt; + +/// A failure a handler reports on purpose. Core ends the call with the stable +/// `plugin.function.failed` code and forwards `code` as `pluginErrorCode`, so +/// the plugin's own UI can tell its failures apart. Lithe never branches on +/// it. The message is shown to the user as written, so it must not contain +/// secrets, stack traces, or machine paths. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PluginError { + code: String, + message: String, +} + +impl PluginError { + /// `code` is an identifier of at most 64 bytes, such as + /// `db.connectionFailed`; Core keeps the first 1024 bytes of `message`. + /// + /// # Panics + /// + /// When `code` does not start with a letter, uses characters other than + /// letters, digits, `.`, `_`, and `-`, or is longer than 64 bytes. In a + /// handler, that panic ends the call as an internal error. + pub fn new(code: impl Into, message: impl Into) -> Self { + let code = code.into(); + assert!( + is_plugin_error_code(&code), + "Plugin error code {code:?} must start with a letter, use letters, digits, \".\", \"_\" or \"-\", and be at most 64 bytes." + ); + Self { + code, + message: message.into(), + } + } + + pub fn code(&self) -> &str { + &self.code + } + + pub fn message(&self) -> &str { + &self.message + } +} + +impl fmt::Display for PluginError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}: {}", self.code, self.message) + } +} + +impl std::error::Error for PluginError {} + +/// A handler that noticed its call was cancelled; see +/// [`Cancellation::check`](crate::Cancellation::check). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Cancelled; + +impl fmt::Display for Cancelled { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("The call was cancelled.") + } +} + +impl std::error::Error for Cancelled {} + +/// An SDK usage error, or a host request that failed. `code` is the host's +/// `error.data.code` (such as `plugin.host.contextExpired`) or one of the +/// SDK's own `sdk.*` codes. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SdkError { + code: String, + message: String, + rpc_code: Option, + details: Option>, +} + +/// SDK error codes a backend may want to tell apart. +pub mod sdk_codes { + /// The call that made the host request, or a token passed to + /// [`Host::with_cancellation`](crate::Host::with_cancellation), was + /// cancelled first. + pub const CALL_CANCELLED: &str = "sdk.call.cancelled"; + /// The session ended before the host answered. + pub const SESSION_CLOSED: &str = "sdk.session.closed"; +} + +/// Stable host failure codes of plugin-function protocol 3 and their +/// JSON-RPC numbers. A later host adds codes after `-32021`. +pub const HOST_ERROR_CODES: [(&str, i64); 16] = [ + ("plugin.host.unavailable", -32601), + ("plugin.host.invalidParams", -32602), + ("plugin.host.failed", -32603), + ("plugin.host.cancelled", -32800), + ("plugin.host.permissionDenied", -32010), + ("plugin.host.contextExpired", -32011), + ("plugin.host.authorityUnsupported", -32012), + ("plugin.host.versionConflict", -32013), + ("plugin.host.limitExceeded", -32014), + ("plugin.host.timeout", -32015), + ("plugin.host.notFound", -32016), + ("plugin.host.readOnly", -32017), + ("plugin.host.unsupportedOption", -32018), + ("plugin.host.preconditionFailed", -32019), + ("plugin.host.busy", -32020), + ("plugin.host.rateLimited", -32021), +]; + +impl SdkError { + pub(crate) fn new(code: impl Into, message: impl Into) -> Self { + Self { + code: code.into(), + message: message.into(), + rpc_code: None, + details: None, + } + } + + /// The answer Core gives a host request it does not offer. + pub(crate) fn unavailable(message: impl Into) -> Self { + Self { + rpc_code: Some(-32601), + ..Self::new("plugin.host.unavailable", message) + } + } + + /// The failure for a JSON-RPC error answer. A host method without a + /// known code is a host failure: from version 3 a number names one code, + /// and an unknown number is `plugin.host.failed`. + pub(crate) fn from_response(method: &str, error: &Value) -> Self { + let rpc_code = error["code"].as_i64(); + let code = match error["data"]["code"].as_str() { + Some(code) => code, + None if method.starts_with(methods::HOST_PREFIX) => HOST_ERROR_CODES + .iter() + .find(|(_, number)| Some(*number) == rpc_code) + .map_or("plugin.host.failed", |(code, _)| code), + None => "sdk.host.error", + }; + Self { + code: code.to_string(), + message: error["message"] + .as_str() + .map_or_else(|| format!("{method} failed."), str::to_string), + rpc_code, + details: error["data"]["details"].as_object().cloned(), + } + } + + pub fn code(&self) -> &str { + &self.code + } + + pub fn message(&self) -> &str { + &self.message + } + + /// The JSON-RPC error code of a host response, such as `-32601`. + pub fn rpc_code(&self) -> Option { + self.rpc_code + } + + /// The `error.data.details` object a version 3 host may add, such as + /// `{ "option": … }` or `{ "retryAfterMilliseconds": … }`. + pub fn details(&self) -> Option<&Map> { + self.details.as_ref() + } +} + +impl fmt::Display for SdkError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}: {}", self.code, self.message) + } +} + +impl std::error::Error for SdkError {} + +/// How a function handler fails. `?` converts every error type a handler +/// typically meets: a [`PluginError`] keeps its code, [`Cancelled`] (and a +/// host request cut short by cancellation) answers as cancelled, and anything +/// else becomes a generic internal error; automatic diagnostics omit its contents. +#[derive(Debug)] +pub enum FunctionError { + Plugin(PluginError), + Cancelled, + /// Retained in memory; automatic diagnostics record only the internal-error category. + Internal(String), +} + +impl FunctionError { + pub fn internal(details: impl fmt::Display) -> Self { + Self::Internal(details.to_string()) + } +} + +impl fmt::Display for FunctionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Plugin(error) => error.fmt(formatter), + Self::Cancelled => Cancelled.fmt(formatter), + Self::Internal(details) => formatter.write_str(details), + } + } +} + +impl From for FunctionError { + fn from(error: PluginError) -> Self { + Self::Plugin(error) + } +} + +impl From for FunctionError { + fn from(_: Cancelled) -> Self { + Self::Cancelled + } +} + +impl From for FunctionError { + fn from(error: SdkError) -> Self { + if error.code == sdk_codes::CALL_CANCELLED { + Self::Cancelled + } else { + Self::Internal(error.to_string()) + } + } +} + +impl From for FunctionError { + fn from(error: std::io::Error) -> Self { + Self::Internal(error.to_string()) + } +} + +impl From for FunctionError { + fn from(error: serde_json::Error) -> Self { + Self::Internal(error.to_string()) + } +} + +pub(crate) fn cancelled_response() -> Value { + json!({ "code": error_codes::REQUEST_CANCELLED, "message": "The request was cancelled." }) +} + +/// The JSON-RPC error object for a handler failure. Only a [`PluginError`] +/// carries a plugin code; anything else is a generic internal error, so +/// stack traces and machine paths never reach the host's UI. +pub(crate) fn response_error(error: &FunctionError) -> Value { + match error { + FunctionError::Cancelled => cancelled_response(), + FunctionError::Plugin(error) => json!({ + "code": error_codes::REQUEST_FAILED, + "message": bounded_text(&error.message, MAX_MESSAGE_BYTES), + "data": { "code": error.code }, + }), + FunctionError::Internal(_) => internal_response(), + } +} + +pub(crate) fn internal_response() -> Value { + json!({ "code": error_codes::INTERNAL_ERROR, "message": "The plugin backend failed unexpectedly." }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_plugin_errors_reach_the_host_with_their_message_and_code() { + let plugin = response_error(&PluginError::new("db.locked", "é".repeat(600)).into()); + assert_eq!(plugin["code"], -32000); + assert_eq!(plugin["data"]["code"], "db.locked"); + assert_eq!(plugin["message"].as_str().unwrap().len(), 1024); + let internal = response_error(&FunctionError::internal("/home/me/secret: No such file")); + assert_eq!(internal, internal_response()); + assert_eq!(response_error(&Cancelled.into())["code"], -32800); + let cut_short: FunctionError = SdkError::new(sdk_codes::CALL_CANCELLED, "x").into(); + assert!(matches!(cut_short, FunctionError::Cancelled)); + } + + #[test] + fn host_failures_keep_their_code_or_take_it_from_the_number() { + let coded = SdkError::from_response( + methods::CREDENTIALS_GET, + &json!({ "code": -32010, "message": "No.", "data": { "code": "plugin.host.permissionDenied", "details": { "a": 1 } } }), + ); + assert_eq!(coded.code(), "plugin.host.permissionDenied"); + assert_eq!(coded.rpc_code(), Some(-32010)); + assert_eq!(coded.details(), json!({ "a": 1 }).as_object()); + for (code, number) in HOST_ERROR_CODES { + let error = + SdkError::from_response(methods::CREDENTIALS_GET, &json!({ "code": number })); + assert_eq!(error.code(), code); + assert_eq!(error.message(), "lithe/host/credentials/get failed."); + } + let later = SdkError::from_response( + methods::CREDENTIALS_GET, + &json!({ "code": -32050, "message": "x", "data": { "details": ["not", "an", "object"] } }), + ); + assert_eq!(later.code(), "plugin.host.failed"); + assert_eq!(later.details(), None); + let other = SdkError::from_response(methods::FUNCTION_REGISTER, &json!({ "code": -32602 })); + assert_eq!(other.code(), "sdk.host.error"); + } + + #[test] + #[should_panic(expected = "must start with a letter")] + fn an_invalid_plugin_error_code_is_a_programming_error() { + let _ = PluginError::new("1bad", "message"); + } +} diff --git a/vendor/lithe-plugin-sdk/src/framing.rs b/vendor/lithe-plugin-sdk/src/framing.rs new file mode 100644 index 0000000..9eb203b --- /dev/null +++ b/vendor/lithe-plugin-sdk/src/framing.rs @@ -0,0 +1,170 @@ +//! LSP base-protocol framing: `Content-Length` headers followed by a UTF-8 +//! JSON body. Lithe talks to every plugin backend this way, so the SDK owns +//! the framing and plugin code never writes to the protocol stream itself. + +use serde_json::Value; +use std::fmt; +use std::io::{self, BufRead, Read, Write}; + +/// Largest header block accepted from the host. +const MAX_HEADER_BYTES: usize = 8192; +/// Largest body accepted from the host. Larger frames end the session. +pub(crate) const MAX_FRAME_BYTES: usize = 64 * 1024 * 1024; + +/// One frame read from the host. +#[derive(Debug, PartialEq)] +pub(crate) enum Frame { + Message(Value), + /// A body that is not JSON; the framing position is intact. + Malformed, +} + +/// A framing error the session cannot recover from. +#[derive(Debug)] +pub(crate) struct FramingError(pub String); + +impl fmt::Display for FramingError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +impl From for FramingError { + fn from(error: io::Error) -> Self { + Self(format!("The input failed: {error}")) + } +} + +/// Reads the next frame, or `None` when the input ended between frames. +pub(crate) fn read_frame(reader: &mut impl BufRead) -> Result, FramingError> { + let mut header = Vec::new(); + let mut length = None; + loop { + let mut line = Vec::new(); + let remaining = (MAX_HEADER_BYTES + 1).saturating_sub(header.len()) as u64; + reader + .by_ref() + .take(remaining) + .read_until(b'\n', &mut line)?; + if line.is_empty() { + if header.is_empty() { + return Ok(None); + } + return Err(FramingError( + "The input ended inside a message header.".into(), + )); + } + header.extend_from_slice(&line); + // The same bound whether the header arrived whole or in pieces. + if header.len() > MAX_HEADER_BYTES { + return Err(FramingError("The message header is too long.".into())); + } + if !line.ends_with(b"\n") { + return Err(FramingError( + "The input ended inside a message header.".into(), + )); + } + let text = String::from_utf8_lossy(&line); + let text = text.trim_end_matches(['\r', '\n']); + if text.is_empty() { + break; + } + if length.is_none() { + length = content_length(text)?; + } + } + let length = length + .ok_or_else(|| FramingError("A message has no valid Content-Length header.".into()))?; + let mut body = vec![0; length]; + reader.read_exact(&mut body)?; + Ok(Some(match serde_json::from_slice(&body) { + Ok(message) => Frame::Message(message), + Err(_) => Frame::Malformed, + })) +} + +/// The length of a `Content-Length` line, `None` for any other header. +fn content_length(line: &str) -> Result, FramingError> { + let Some((name, value)) = line.split_once(':') else { + return Ok(None); + }; + if !name.trim().eq_ignore_ascii_case("content-length") { + return Ok(None); + } + let value = value.trim(); + let invalid = || FramingError("A message has no valid Content-Length header.".into()); + if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(invalid()); + } + let length: usize = value.parse().map_err(|_| invalid())?; + if length > MAX_FRAME_BYTES { + return Err(FramingError(format!( + "A message of {length} bytes exceeds the {MAX_FRAME_BYTES}-byte limit." + ))); + } + Ok(Some(length)) +} + +/// Writes one JSON-RPC message as a complete frame and flushes it. +pub(crate) fn write_frame(writer: &mut dyn Write, message: &Value) -> io::Result<()> { + let body = serde_json::to_vec(message)?; + let mut frame = format!("Content-Length: {}\r\n\r\n", body.len()).into_bytes(); + frame.extend_from_slice(&body); + writer.write_all(&frame)?; + writer.flush() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::io::BufReader; + + fn frames(input: &[u8]) -> Vec, String>> { + let mut reader = BufReader::with_capacity(3, input); + let mut frames = Vec::new(); + loop { + let frame = read_frame(&mut reader).map_err(|error| error.0); + let done = !matches!(frame, Ok(Some(_))); + frames.push(frame); + if done { + return frames; + } + } + } + + #[test] + fn decodes_consecutive_frames_and_reports_malformed_bodies_in_place() { + let mut input = Vec::new(); + write_frame(&mut input, &json!({"id": 1})).unwrap(); + input.extend_from_slice(b"content-length: 3\r\nContent-Type: x\r\n\r\nnot"); + write_frame(&mut input, &json!({"id": "é"})).unwrap(); + assert_eq!( + frames(&input), + [ + Ok(Some(Frame::Message(json!({"id": 1})))), + Ok(Some(Frame::Malformed)), + Ok(Some(Frame::Message(json!({"id": "é"})))), + Ok(None), + ] + ); + } + + #[test] + fn refuses_frames_it_cannot_delimit() { + let oversized = format!("Content-Length: {}\r\n\r\n", MAX_FRAME_BYTES + 1); + let long_header = format!("X: {}\r\n\r\n", "a".repeat(MAX_HEADER_BYTES)); + for (input, expected) in [ + ("Content-Type: x\r\n\r\n{}", "no valid Content-Length"), + ("Content-Length: -1\r\n\r\n", "no valid Content-Length"), + (oversized.as_str(), "exceeds"), + (long_header.as_str(), "too long"), + ("Content-Length: 2\r\n", "inside a message header"), + ("Content-Length: 4\r\n\r\n{}", "input failed"), + ] { + let last = frames(input.as_bytes()).pop().unwrap(); + let error = last.expect_err(input); + assert!(error.contains(expected), "{input:?}: {error}"); + } + } +} diff --git a/vendor/lithe-plugin-sdk/src/host.rs b/vendor/lithe-plugin-sdk/src/host.rs new file mode 100644 index 0000000..6a95364 --- /dev/null +++ b/vendor/lithe-plugin-sdk/src/host.rs @@ -0,0 +1,1125 @@ +//! Host services of plugin-function protocols 2 and 3: the `lithe/host/*` +//! requests a backend sends to Lithe, the subscriptions some of them create, +//! and the [`Host`] handlers use. The backend owns the wire; this module owns +//! what Core expects of a well-behaved client -- at most 16 unanswered host +//! requests, cancellation through `$/cancelRequest` from version 3, and +//! subscription events routed by ID. +//! +//! Contract: `contract/v1/README.md`, "Host services (protocol version 2)" +//! and the version 3 sections after it. + +use crate::backend::{lock, Cancellation, Phase, Shared, State}; +use crate::error::{sdk_codes, SdkError}; +use crate::protocol::{ + generation, is_credential_key, methods, CONTEXT_VERSION, HOST_SERVICES_VERSION, + MAX_CREDENTIAL_SECRET_BYTES, MAX_EARLY_EVENTS, MAX_PENDING_HOST_REQUESTS, +}; +use serde_json::{json, Map, Value}; +use std::collections::{HashMap, VecDeque}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{self, RecvTimeoutError}; +use std::sync::{Arc, Mutex, PoisonError, Weak}; +use std::time::Duration; + +pub(crate) type Outcome = Result; + +const CREDENTIAL_METHODS: [&str; 3] = [ + methods::CREDENTIALS_GET, + methods::CREDENTIALS_SET, + methods::CREDENTIALS_DELETE, +]; + +/// How a request this backend sent ended for its caller. +pub(crate) enum Settled { + Answered(Outcome), + Cancelled, +} + +/// One request this backend sent to the host, until the host answers it. +pub(crate) struct Outgoing { + method: String, + sender: mpsc::Sender, + /// Whether it holds one of the 16 host-request slots. A cancelled + /// request keeps its slot until the host answers. + slot: bool, + /// Where the events of the subscription it creates go. + route: Option, +} + +/// The session's host-request state, guarded by the state lock. +#[derive(Default)] +pub(crate) struct Channel { + in_flight: usize, + /// Requests waiting for a free slot, in call order. + waiting: VecDeque, + next_ticket: u64, + /// Subscribing requests not yet answered; early events wait only while + /// there are some. + subscribing: usize, + routes: HashMap, + early: VecDeque<(String, u64, Value)>, +} + +pub(crate) struct Route { + live: Arc, + events: mpsc::Sender, + last: u64, +} + +impl Route { + fn emit(&mut self, subscription_id: &str, sequence: u64, event: Value) { + let gap = self.last.checked_add(1) != Some(sequence); + self.last = sequence; + let _ = self.events.send(Event { + subscription_id: subscription_id.to_string(), + sequence, + gap, + event, + }); + } +} + +fn closed() -> SdkError { + SdkError::new(sdk_codes::SESSION_CLOSED, "The session ended.") +} + +fn cancelled() -> SdkError { + SdkError::new(sdk_codes::CALL_CANCELLED, "The request was cancelled.") +} + +/// Why `state` refuses a new request, if it does. +fn refusal(state: &State, scope: &[Cancellation]) -> Option { + match state.phase { + Phase::Ready => scope.iter().any(Cancellation::is_cancelled).then(cancelled), + Phase::Exited => Some(closed()), + _ => Some(SdkError::new( + "sdk.session.notReady", + "The session is not initialized.", + )), + } +} + +/// Wakes a request whose scope was cancelled, waiting for a slot or an answer. +fn wake(shared: &Weak, sender: &mpsc::Sender) { + let _ = sender.send(Settled::Cancelled); + if let Some(shared) = shared.upgrade() { + // Taking the lock orders this wake-up after the waiter's last check. + let _state = lock(&shared.state); + shared.slots.notify_all(); + } +} + +impl Shared { + /// Sends a request to the host and waits for its answer, the end of the + /// session, or the cancellation of a token in `scope`. A request with a + /// `slot` first waits its turn among the 16 the host accepts at once. + pub(crate) fn exchange( + self: &Arc, + method: &str, + params: Value, + slot: bool, + scope: &[Cancellation], + route: Option, + ) -> Outcome { + let id = format!("sdk-{}", self.next_request.fetch_add(1, Ordering::Relaxed)); + let (sender, receiver) = mpsc::channel(); + let watches: Vec<_> = scope + .iter() + .filter_map(|token| { + let shared = Arc::downgrade(self); + let sender = sender.clone(); + let watch = token.watch(move || wake(&shared, &sender))?; + Some((token, watch)) + }) + .collect(); + let live = route.as_ref().map(|route| Arc::clone(&route.live)); + let outcome = self.send_and_wait(&id, method, params, slot, scope, route, sender, receiver); + for (token, watch) in watches { + token.unwatch(watch); + } + match outcome { + Settled::Answered(outcome) => outcome, + Settled::Cancelled => { + let pending = { + let mut state = lock(&self.state); + if let Some(live) = &live { + // Answered just before the cancellation won. + state + .channel + .routes + .retain(|_, route| !Arc::ptr_eq(&route.live, live)); + } + state.outgoing.get_mut(&id).map(|entry| entry.route = None) + }; + if pending.is_some() && self.functions_version() >= Some(CONTEXT_VERSION) { + self.write(&json!({ + "jsonrpc": "2.0", + "method": methods::CANCEL_REQUEST, + "params": { "id": id }, + })); + } + // A cancelled mutation may already have taken effect; it is + // never sent again. + Err(cancelled()) + } + } + } + + #[allow(clippy::too_many_arguments)] + fn send_and_wait( + &self, + id: &str, + method: &str, + params: Value, + slot: bool, + scope: &[Cancellation], + route: Option, + sender: mpsc::Sender, + receiver: mpsc::Receiver, + ) -> Settled { + let mut state = lock(&self.state); + if slot { + let ticket = state.channel.next_ticket; + state.channel.next_ticket += 1; + state.channel.waiting.push_back(ticket); + loop { + if let Some(error) = refusal(&state, scope) { + state.channel.waiting.retain(|waiting| *waiting != ticket); + self.slots.notify_all(); + return Settled::Answered(Err(error)); + } + if state.channel.in_flight < MAX_PENDING_HOST_REQUESTS + && state.channel.waiting.front() == Some(&ticket) + { + break; + } + state = self + .slots + .wait(state) + .unwrap_or_else(PoisonError::into_inner); + } + state.channel.waiting.pop_front(); + state.channel.in_flight += 1; + self.slots.notify_all(); + } else if let Some(error) = refusal(&state, scope) { + return Settled::Answered(Err(error)); + } + state.outgoing.insert( + id.to_string(), + Outgoing { + method: method.to_string(), + sender, + slot, + route, + }, + ); + drop(state); + let mut message = json!({ "jsonrpc": "2.0", "id": id, "method": method }); + if !params.is_null() { + message["params"] = params; + } + self.write(&message); + receiver + .recv() + .unwrap_or_else(|_| Settled::Answered(Err(closed()))) + } + + /// Settles the request a host response answers. + pub(crate) fn settle_outgoing(&self, id: &Value, message: &Map) { + let Some(id) = id.as_str() else { + return; + }; + let mut state = lock(&self.state); + let Some(entry) = state.outgoing.remove(id) else { + return; + }; + if entry.slot { + state.channel.in_flight -= 1; + self.slots.notify_all(); + } + let mut outcome = match message.get("error").filter(|error| !error.is_null()) { + Some(error) => Err(SdkError::from_response(&entry.method, error)), + None => Ok(message.get("result").cloned().unwrap_or(Value::Null)), + }; + if let (Ok(result), Some(mut route)) = (&outcome, entry.route) { + match result["subscriptionID"] + .as_str() + .filter(|id| is_credential_key(id)) + { + Some(subscription_id) => { + let channel = &mut state.channel; + let (early, rest) = channel + .early + .drain(..) + .partition::, _>(|(id, _, _)| id == subscription_id); + channel.early = rest; + // Events the host sent before this answer. + for (_, sequence, event) in early { + route.emit(subscription_id, sequence, event); + } + channel.routes.insert(subscription_id.to_string(), route); + } + None => { + outcome = Err(SdkError::new( + "sdk.host.invalidResult", + format!("{} answered without a subscriptionID.", entry.method), + )) + } + } + } + drop(state); + let _ = entry.sender.send(Settled::Answered(outcome)); + } + + /// Routes one `lithe/host/subscriptions/notify`. + pub(crate) fn deliver(&self, params: &Value) { + let Some(subscription_id) = params["subscriptionID"].as_str() else { + return; + }; + let sequence = params["sequence"].as_u64().unwrap_or(0); + let event = params.get("event").cloned().unwrap_or(Value::Null); + let mut state = lock(&self.state); + let channel = &mut state.channel; + if let Some(route) = channel.routes.get_mut(subscription_id) { + route.emit(subscription_id, sequence, event); + } else if channel.subscribing > 0 { + if channel.early.len() < MAX_EARLY_EVENTS { + channel + .early + .push_back((subscription_id.to_string(), sequence, event)); + } else { + drop(state); + self.failed("notification", "subscription_queue_full"); + } + } + // Otherwise the subscription was disposed; nothing follows a disposal. + } + + /// Ends every host request and subscription with the session. Runs with + /// the state locked. + pub(crate) fn close_channel(&self, state: &mut State) { + for (_, entry) in state.outgoing.drain() { + let _ = entry.sender.send(Settled::Answered(Err(closed()))); + } + for (_, route) in state.channel.routes.drain() { + route.live.store(false, Ordering::SeqCst); + } + state.channel.early.clear(); + self.slots.notify_all(); + } + + /// Stops routing a subscription's events at once and asks the host to + /// dispose it. `live` limits the first step to one subscription object. + fn dispose( + self: &Arc, + subscription_id: &str, + live: Option<&Arc>, + ) -> Result<(), SdkError> { + let ready = { + let mut state = lock(&self.state); + let routes = &mut state.channel.routes; + let owned = routes + .get(subscription_id) + .is_some_and(|route| live.is_none_or(|live| Arc::ptr_eq(&route.live, live))); + if owned { + if let Some(route) = routes.remove(subscription_id) { + route.live.store(false, Ordering::SeqCst); + } + } + state.phase == Phase::Ready + }; + if !ready { + return Ok(()); + } + Host::new(Arc::clone(self), Vec::new()) + .request( + methods::SUBSCRIPTIONS_DISPOSE, + json!({ "subscriptionID": subscription_id }), + ) + .map(drop) + } +} + +/// Host services (`lithe/host/*`) of plugin-function protocols 2 and 3: +/// credentials from version 2; workspace contexts, files, documents, the +/// editor, previews, the browser, messages, and capability discovery from +/// version 3. Every method blocks until the host answers; failures the host +/// reports carry its `error.data.code`, such as `plugin.host.contextExpired`. +/// +/// [`BackendHandle::host`](crate::BackendHandle::host) is usable once the +/// session is ready. A handler's [`Context::host`](crate::Context::host) +/// belongs to its call: requests still unanswered when the call is cancelled +/// or ends fail with `sdk.call.cancelled` and, from version 3, are cancelled +/// on the wire. At most 16 requests are on the wire at once; the rest wait +/// their turn. +#[derive(Clone)] +pub struct Host { + shared: Arc, + scope: Vec, +} + +impl Host { + pub(crate) fn new(shared: Arc, scope: Vec) -> Self { + Self { shared, scope } + } + + /// The negotiated plugin-function protocol version; `None` before + /// `initialize` or when the host offered none this SDK speaks. + pub fn version(&self) -> Option { + self.shared.functions_version() + } + + /// The same services, with requests also cancelled by `cancellation`, + /// for work a plugin may stop for its own reasons. + pub fn with_cancellation(&self, cancellation: &Cancellation) -> Host { + let mut scope = self.scope.clone(); + scope.push(cancellation.clone()); + Self::new(Arc::clone(&self.shared), scope) + } + + /// Sends any `lithe/host/*` request, including methods a later host + /// adds; [`supports`](Self::supports) says whether the session may call + /// it. `Value::Null` sends no params. + pub fn request(&self, method: &str, params: Value) -> Result { + self.send(method, params, None) + } + + /// Sends a request that answers `{ subscriptionID }` and routes the + /// subscription's events to the returned [`Subscription`]. + pub fn subscribe(&self, method: &str, params: Value) -> Result { + let (events, receiver) = mpsc::channel(); + let live = Arc::new(AtomicBool::new(true)); + let route = Route { + live: Arc::clone(&live), + events, + last: 0, + }; + lock(&self.shared.state).channel.subscribing += 1; + let result = self.send(method, params, Some(route)); + { + let mut state = lock(&self.shared.state); + let channel = &mut state.channel; + channel.subscribing -= 1; + if channel.subscribing == 0 { + channel.early.clear(); + } + } + let result = result?; + Ok(Subscription { + id: result["subscriptionID"] + .as_str() + .unwrap_or_default() + .to_string(), + shared: Arc::clone(&self.shared), + live, + events: Mutex::new(receiver), + }) + } + + /// `{ protocolVersion, backendAuthority, services, limits }`, asked once + /// per session; needs protocol 3. + pub fn capabilities(&self) -> Result { + if self.version() < Some(CONTEXT_VERSION) { + return Err(SdkError::unavailable( + "Capability discovery needs plugin-function protocol 3.", + )); + } + // Held during the request, so concurrent callers share one answer. + let mut cached = lock(&self.shared.capabilities); + if let Some(capabilities) = &*cached { + return Ok(capabilities.clone()); + } + let capabilities = self.request(methods::GET_CAPABILITIES, Value::Null)?; + if !capabilities.is_object() { + return Err(SdkError::new( + "sdk.host.invalidResult", + "lithe/host/getCapabilities answered with something other than an object.", + )); + } + *cached = Some(capabilities.clone()); + Ok(capabilities) + } + + /// Whether this session may call `method`: from + /// [`capabilities`](Self::capabilities) on protocol 3, the credential + /// methods on protocol 2. A missing grant still fails the request. + pub fn supports(&self, method: &str) -> Result { + match self.version() { + Some(version) if version >= CONTEXT_VERSION => { + let capabilities = self.capabilities()?; + Ok(capabilities["services"] + .as_object() + .is_some_and(|services| { + services.values().any(|service| { + service["methods"].as_array().is_some_and(|methods| { + methods.iter().any(|entry| entry.as_str() == Some(method)) + }) + }) + })) + } + Some(version) if version >= HOST_SERVICES_VERSION => { + Ok(CREDENTIAL_METHODS.contains(&method)) + } + _ => Ok(false), + } + } + + pub fn credentials(&self) -> Credentials { + Credentials { host: self.clone() } + } + + pub fn subscriptions(&self) -> Subscriptions { + Subscriptions { host: self.clone() } + } + + pub fn workspace(&self) -> Workspace { + Workspace { host: self.clone() } + } + + pub fn documents(&self) -> Documents { + Documents { host: self.clone() } + } + + pub fn editor(&self) -> Editor { + Editor { host: self.clone() } + } + + pub fn preview(&self) -> Preview { + Preview { host: self.clone() } + } + + pub fn browser(&self) -> Browser { + Browser { host: self.clone() } + } + + pub fn terminal(&self) -> Terminal { + Terminal { host: self.clone() } + } + + pub fn window(&self) -> Window { + Window { host: self.clone() } + } + + fn send(&self, method: &str, params: Value, route: Option) -> Outcome { + if !method.starts_with(methods::HOST_PREFIX) { + return Err(SdkError::new( + "sdk.host.method", + format!("{method} is not a host method; host requests are lithe/host/*."), + )); + } + if let Some(error) = refusal(&lock(&self.shared.state), &[]) { + return Err(error); + } + if self.version() < Some(HOST_SERVICES_VERSION) { + // The answer Core would give, without the round trip. + return Err(SdkError::unavailable( + "The host did not negotiate plugin-function protocol 2 or later, which offers host services.", + )); + } + if self.scope.iter().any(Cancellation::is_cancelled) { + return Err(cancelled()); + } + self.shared + .exchange(method, params, true, &self.scope, route) + } + + /// A method whose params name a workspace context; answers an object or + /// `Value::Null`. + fn scoped(&self, method: &str, context: &WorkspaceContext, params: Value) -> Outcome { + let params = with_context(context, params)?; + match self.request(method, params)? { + result @ (Value::Null | Value::Object(_)) => Ok(result), + _ => Err(SdkError::new( + "sdk.host.invalidResult", + format!("{method} answered with something other than an object."), + )), + } + } +} + +/// `params` with `context` added; params are an object or `Value::Null`. +fn with_context(context: &WorkspaceContext, params: Value) -> Result { + let mut params = match params { + Value::Null => Map::new(), + Value::Object(params) => params, + _ => { + return Err(SdkError::new( + "sdk.host.invalidParams", + "Host request params are an object.", + )) + } + }; + params.insert("context".into(), context.to_value()); + Ok(Value::Object(params)) +} + +/// A workspace context reference, `{ contextID, generation }`: the window a +/// call or command ran in, or one from `listContexts`. A context is replaced +/// when its window's folders change; requests with an old generation fail +/// with `plugin.host.contextExpired`. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct WorkspaceContext { + context_id: String, + generation: u64, +} + +impl WorkspaceContext { + /// `context_id` is 1-128 ASCII letters, digits, `.`, `_`, `:` or `-`; + /// `generation` is positive. + pub fn new(context_id: impl Into, generation: u64) -> Result { + Self::from_value(&json!({ "contextID": context_id.into(), "generation": generation })) + } + + /// The reference of a context reference or of a full context from + /// `listContexts` or `getContext`, so either can be passed on. + pub fn from_value(value: &Value) -> Result { + let context_id = value["contextID"] + .as_str() + .filter(|id| is_credential_key(id)); + match (context_id, generation(&value["generation"])) { + (Some(context_id), Some(generation)) => Ok(Self { + context_id: context_id.to_string(), + generation, + }), + _ => Err(SdkError::new( + "sdk.host.invalidContext", + "A context is { contextID, generation }: an ID of 1-128 ASCII letters, digits, \".\", \"_\", \":\" or \"-\", and a positive generation.", + )), + } + } + + /// The `context` of an invocation or command: exactly a reference. + pub(crate) fn from_reference(value: &Value) -> Option { + if value.as_object()?.len() != 2 { + return None; + } + Self::from_value(value).ok() + } + + pub fn context_id(&self) -> &str { + &self.context_id + } + + pub fn generation(&self) -> u64 { + self.generation + } + + /// `{ "contextID": …, "generation": … }`. + pub fn to_value(&self) -> Value { + json!({ "contextID": self.context_id, "generation": self.generation }) + } +} + +/// One `lithe/host/subscriptions/notify`. +#[derive(Clone, Debug, PartialEq)] +pub struct Event { + subscription_id: String, + sequence: u64, + gap: bool, + event: Value, +} + +impl Event { + pub fn subscription_id(&self) -> &str { + &self.subscription_id + } + + /// Starts at 1 and grows by one per event of the subscription. + pub fn sequence(&self) -> u64 { + self.sequence + } + + /// True when events before this one were lost, so state built from + /// earlier events should be refreshed, for example by rescanning. + pub fn gap(&self) -> bool { + self.gap + } + + /// The event, usually an object. + pub fn event(&self) -> &Value { + &self.event + } + + pub fn into_event(self) -> Value { + self.event + } +} + +/// A host subscription, such as a file watch or registered editor actions. +/// Events queue in order until read; dropping the subscription does not +/// dispose it on the host, [`dispose`](Self::dispose) does. +pub struct Subscription { + id: String, + shared: Arc, + live: Arc, + events: Mutex>, +} + +impl Subscription { + pub fn id(&self) -> &str { + &self.id + } + + /// True once disposed here or the session ended. + pub fn is_disposed(&self) -> bool { + !self.live.load(Ordering::SeqCst) + } + + /// The next event, waiting for it; `None` once disposed or the session + /// ended. + pub fn recv(&self) -> Option { + let event = lock(&self.events).recv().ok()?; + (!self.is_disposed()).then_some(event) + } + + /// The next event, waiting at most `timeout`; `Disconnected` once + /// disposed or the session ended. + pub fn recv_timeout(&self, timeout: Duration) -> Result { + if self.is_disposed() { + return Err(RecvTimeoutError::Disconnected); + } + let event = lock(&self.events).recv_timeout(timeout)?; + if self.is_disposed() { + Err(RecvTimeoutError::Disconnected) + } else { + Ok(event) + } + } + + /// Stops routing events at once and asks the host to dispose the + /// subscription. After the session ended there is nothing to ask. + pub fn dispose(&self) -> Result<(), SdkError> { + self.shared.dispose(&self.id, Some(&self.live)) + } +} + +/// The host's `credentials` service (protocol 2): secrets private to this +/// plugin, kept in the platform's secret store, never in `${stateDir}`. It +/// needs the `credentials.store` permission; without it every request fails +/// with `plugin.host.permissionDenied`. +pub struct Credentials { + host: Host, +} + +impl Credentials { + /// The secret stored under `key`, or `None`. + pub fn get(&self, key: &str) -> Result, SdkError> { + check_credential_key(key)?; + match self + .host + .request(methods::CREDENTIALS_GET, json!({ "key": key }))? + { + Value::Null => Ok(None), + Value::Object(mut result) => match result.remove("secret") { + Some(Value::String(secret)) => Ok(Some(secret)), + _ => Err(missing_secret()), + }, + _ => Err(missing_secret()), + } + } + + /// Stores `secret` (at most 16 KiB) under `key`, replacing any value. + pub fn set(&self, key: &str, secret: &str) -> Result<(), SdkError> { + check_credential_key(key)?; + if secret.len() > MAX_CREDENTIAL_SECRET_BYTES { + return Err(SdkError::new( + "sdk.credentials.invalidSecret", + "A secret is a string of at most 16 KiB.", + )); + } + self.host + .request( + methods::CREDENTIALS_SET, + json!({ "key": key, "secret": secret }), + ) + .map(drop) + } + + /// Removes the secret under `key`; succeeds when nothing was stored. + pub fn delete(&self, key: &str) -> Result<(), SdkError> { + check_credential_key(key)?; + self.host + .request(methods::CREDENTIALS_DELETE, json!({ "key": key })) + .map(drop) + } +} + +fn check_credential_key(key: &str) -> Result<(), SdkError> { + if is_credential_key(key) { + Ok(()) + } else { + Err(SdkError::new( + "sdk.credentials.invalidKey", + "A credential key is 1-128 ASCII letters, digits, \".\", \"_\", \":\" or \"-\".", + )) + } +} + +fn missing_secret() -> SdkError { + SdkError::new( + "sdk.host.invalidResult", + "The host answered credentials/get without a secret.", + ) +} + +pub struct Subscriptions { + host: Host, +} + +impl Subscriptions { + /// Disposes a subscription by ID; also succeeds for an unknown or + /// disposed one. + pub fn dispose(&self, subscription_id: &str) -> Result<(), SdkError> { + self.host.shared.dispose(subscription_id, None) + } +} + +/// Workspace contexts and files (protocol 3). Every method but +/// [`list_contexts`](Self::list_contexts) names a context: the call's +/// [`Context::workspace_context`](crate::Context::workspace_context), or one +/// from `list_contexts` read with [`WorkspaceContext::from_value`]. +pub struct Workspace { + host: Host, +} + +impl Workspace { + /// `{ revision, contexts }`. + pub fn list_contexts(&self) -> Result { + self.host + .scoped_without_context(methods::WORKSPACE_LIST_CONTEXTS) + } + + pub fn get_context(&self, context: &WorkspaceContext) -> Result { + self.host + .scoped(methods::WORKSPACE_GET_CONTEXT, context, Value::Null) + } + + /// `{ folders }`. + pub fn get_folders(&self, context: &WorkspaceContext) -> Result { + self.host + .scoped(methods::WORKSPACE_GET_FOLDERS, context, Value::Null) + } + + /// `params`: `resource, cursor?, limit?`. + pub fn read_directory( + &self, + context: &WorkspaceContext, + params: Value, + ) -> Result { + self.host + .scoped(methods::WORKSPACE_READ_DIRECTORY, context, params) + } + + /// `params`: `resource`. + pub fn stat(&self, context: &WorkspaceContext, params: Value) -> Result { + self.host.scoped(methods::WORKSPACE_STAT, context, params) + } + + /// `params`: `resource, maximumBytes?`. + pub fn read_file(&self, context: &WorkspaceContext, params: Value) -> Result { + self.host + .scoped(methods::WORKSPACE_READ_FILE, context, params) + } + + /// Watches `resource, recursive?`; events are + /// `{ rootID, changedPaths, requiresRescan }`. + pub fn watch( + &self, + context: &WorkspaceContext, + params: Value, + ) -> Result { + self.host + .subscribe(methods::WORKSPACE_WATCH, with_context(context, params)?) + } + + /// `params`: root `resource, query, fileMask?, cursor?, limit?`. + pub fn find_files(&self, context: &WorkspaceContext, params: Value) -> Result { + self.host + .scoped(methods::WORKSPACE_FIND_FILES, context, params) + } + + /// `params`: root `resource, pattern`, and optional search options. + pub fn find_text(&self, context: &WorkspaceContext, params: Value) -> Result { + self.host + .scoped(methods::WORKSPACE_FIND_TEXT, context, params) + } +} + +impl Host { + fn scoped_without_context(&self, method: &str) -> Outcome { + match self.request(method, json!({}))? { + result @ (Value::Null | Value::Object(_)) => Ok(result), + _ => Err(SdkError::new( + "sdk.host.invalidResult", + format!("{method} answered with something other than an object."), + )), + } + } +} + +/// Open documents, read from their live buffers (protocol 3). +pub struct Documents { + host: Host, +} + +impl Documents { + /// `{ documents }`. + pub fn list(&self, context: &WorkspaceContext) -> Result { + self.host + .scoped(methods::DOCUMENTS_LIST, context, Value::Null) + } + + /// `params`: `documentID`; answers `{ document, text }`. + pub fn get_snapshot( + &self, + context: &WorkspaceContext, + params: Value, + ) -> Result { + self.host + .scoped(methods::DOCUMENTS_GET_SNAPSHOT, context, params) + } +} + +/// The text editor (protocol 3). +pub struct Editor { + host: Host, +} + +impl Editor { + /// The foreground text tab's document summary, or `Value::Null`. + pub fn get_active(&self, context: &WorkspaceContext) -> Result { + self.host + .scoped(methods::EDITOR_GET_ACTIVE, context, Value::Null) + } + + /// `params`: `documentID`. + pub fn get_selections( + &self, + context: &WorkspaceContext, + params: Value, + ) -> Result { + self.host + .scoped(methods::EDITOR_GET_SELECTIONS, context, params) + } + + /// `params`: `documentID, expectedVersion, edits`. + pub fn apply_edits( + &self, + context: &WorkspaceContext, + params: Value, + ) -> Result { + self.host + .scoped(methods::EDITOR_APPLY_EDITS, context, params) + } + + /// `params`: `documentID, expectedVersion, selections, replacements`. + pub fn replace_selections( + &self, + context: &WorkspaceContext, + params: Value, + ) -> Result { + self.host + .scoped(methods::EDITOR_REPLACE_SELECTIONS, context, params) + } + + /// `params`: `documentID, expectedVersion, at, text`. + pub fn insert_text( + &self, + context: &WorkspaceContext, + params: Value, + ) -> Result { + self.host + .scoped(methods::EDITOR_INSERT_TEXT, context, params) + } + + /// `params`: `resource, activation?, range?, tab?`. + pub fn open(&self, context: &WorkspaceContext, params: Value) -> Result { + self.host.scoped(methods::EDITOR_OPEN, context, params) + } + + /// `params`: `resource, activation?, range?, tab?`. + pub fn reveal(&self, context: &WorkspaceContext, params: Value) -> Result { + self.host.scoped(methods::EDITOR_REVEAL, context, params) + } + + /// Registers editor actions (`id, function, selector`) that invoke one + /// of the backend's functions; disposing the subscription removes them. + pub fn register_actions( + &self, + context: &WorkspaceContext, + params: Value, + ) -> Result { + self.host.subscribe( + methods::EDITOR_REGISTER_ACTIONS, + with_context(context, params)?, + ) + } +} + +/// HTML previews (protocol 3). +pub struct Preview { + host: Host, +} + +impl Preview { + /// `params`: HTML `resource, presentation?, range?`. + pub fn open_html(&self, context: &WorkspaceContext, params: Value) -> Result { + self.host + .scoped(methods::PREVIEW_OPEN_HTML, context, params) + } +} + +/// The system browser (protocol 3). +pub struct Browser { + host: Host, +} + +impl Browser { + /// `params`: exactly one of an HTTP(S) `url` or an HTML `resource`. + pub fn open_external( + &self, + context: &WorkspaceContext, + params: Value, + ) -> Result { + self.host + .scoped(methods::BROWSER_OPEN_EXTERNAL, context, params) + } +} + +/// Messages in the window (protocol 3). `params`: `text, actions?`; answers +/// `{ selectedIndex }`, `Value::Null` on dismissal. +pub struct Window { + host: Host, +} + +impl Window { + pub fn show_information( + &self, + context: &WorkspaceContext, + params: Value, + ) -> Result { + self.host + .scoped(methods::WINDOW_SHOW_INFORMATION, context, params) + } + + pub fn show_warning( + &self, + context: &WorkspaceContext, + params: Value, + ) -> Result { + self.host + .scoped(methods::WINDOW_SHOW_WARNING, context, params) + } + + pub fn show_error(&self, context: &WorkspaceContext, params: Value) -> Result { + self.host + .scoped(methods::WINDOW_SHOW_ERROR, context, params) + } +} + +/// Integrated native terminals owned by this backend session and workspace. +/// Permission `terminal.create`; discover each method with `Host::supports`. +/// Input and output stay in the host terminal, not in the plugin's web page. +pub struct Terminal { + host: Host, +} + +impl Terminal { + /// Launches executable and arguments directly. Params: `executable`, optional + /// `arguments`, `title`, `cwd` resource, `environment`, and `show` (true). + pub fn create(&self, context: &WorkspaceContext, params: Value) -> Result { + self.host.scoped(methods::TERMINAL_CREATE, context, params) + } + + /// Shows an owned terminal in its original window without raising the window. + pub fn show(&self, context: &WorkspaceContext, terminal_id: &str) -> Result { + self.host.scoped( + methods::TERMINAL_SHOW, + context, + json!({ "terminalID": terminal_id }), + ) + } + + /// Stops the native process, keeping a bounded display suffix for reopen. + /// `retained_context_bytes` overrides the create-time choice (0..=262144). + pub fn close( + &self, + context: &WorkspaceContext, + terminal_id: &str, + retained_context_bytes: Option, + ) -> Result { + let mut params = json!({ "terminalID": terminal_id }); + if let Some(bytes) = retained_context_bytes { + params["retainedContextBytes"] = json!(bytes); + } + self.host.scoped(methods::TERMINAL_CLOSE, context, params) + } + + /// Starts the saved argv again and restores plain display history, never input. + pub fn reopen(&self, context: &WorkspaceContext, terminal_id: &str) -> Result { + self.host.scoped( + methods::TERMINAL_REOPEN, + context, + json!({ "terminalID": terminal_id }), + ) + } + + /// Permanently forgets an owned handle and its history, also closing it if live. + pub fn dispose( + &self, + context: &WorkspaceContext, + terminal_id: &str, + ) -> Result { + self.host.scoped( + methods::TERMINAL_DISPOSE, + context, + json!({ "terminalID": terminal_id }), + ) + } + + /// One exit event per process generation, including the latest recorded + /// outcome when subscribing after exit. + /// Disposing the subscription stops notifications, not the terminal itself. + pub fn on_exit( + &self, + context: &WorkspaceContext, + terminal_id: &str, + ) -> Result { + self.host.subscribe( + methods::TERMINAL_ON_EXIT, + with_context(context, json!({ "terminalID": terminal_id }))?, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn contexts_are_references_or_full_contexts() { + let full = json!({ "contextID": "w1", "generation": 2, "name": "orders" }); + let context = WorkspaceContext::from_value(&full).unwrap(); + assert_eq!( + context.to_value(), + json!({ "contextID": "w1", "generation": 2 }) + ); + assert_eq!(WorkspaceContext::new("w1", 2).unwrap(), context); + // An invocation's context is exactly a reference. + assert_eq!(WorkspaceContext::from_reference(&full), None); + assert_eq!( + WorkspaceContext::from_reference(&context.to_value()), + Some(context) + ); + for invalid in [ + json!({ "contextID": "w1" }), + json!({ "contextID": "w1", "generation": 0 }), + json!({ "contextID": "../w", "generation": 1 }), + json!(null), + ] { + let error = WorkspaceContext::from_value(&invalid).unwrap_err(); + assert_eq!(error.code(), "sdk.host.invalidContext", "{invalid}"); + } + assert!(WorkspaceContext::new("", 1).is_err()); + } +} diff --git a/vendor/lithe-plugin-sdk/src/lib.rs b/vendor/lithe-plugin-sdk/src/lib.rs new file mode 100644 index 0000000..cd989b8 --- /dev/null +++ b/vendor/lithe-plugin-sdk/src/lib.rs @@ -0,0 +1,44 @@ +//! Official SDK for Lithe plugin backends written in Rust. +//! +//! A plugin backend is the one long-lived process a plugin declares under +//! `backend` in `plugin.json`; Lithe starts it when one of the plugin's own +//! web pages first calls a function. It speaks the LSP base protocol on +//! stdio and implements, from +//! [plugin contract v1](https://github.com/Lithe-IDEA/lithe-plugin-docs/blob/main/contract/v1/README.md): +//! +//! - **`litheFunctions` 1 to 3**: plugin functions with progress, +//! cancellation, and registration at runtime; +//! - **[`Host`] services** of versions 2 and 3: the plugin's own +//! [`Credentials`] in the platform's secret store from version 2; from +//! version 3 workspace contexts, files, documents, the editor, previews, +//! the browser, window messages, capability discovery, subscriptions, and +//! the commands and context changes the host notifies. +//! +//! The SDK owns the wire format, negotiation, cancellation, and error +//! mapping; plugin code supplies handlers. Each call runs on a thread of its +//! own, so handlers may block. A handler returns `Ok(value)` with a JSON +//! result, a [`PluginError`] the plugin's UI can tell apart by its code, or +//! any other error, which reaches the host only as a generic failure while +//! automatic diagnostics omit its contents. A panic ends only its call; +//! SDK catch boundaries suppress raw panic payload printing. +//! +//! See [`Backend`] for a minimal backend. + +mod backend; +mod diagnostics; +mod error; +mod framing; +mod host; +mod panic_boundary; +mod protocol; +mod stdio; + +pub use backend::{Backend, BackendHandle, Cancellation, Context, Initialize}; +pub use diagnostics::Diagnostics; +pub use error::{sdk_codes, Cancelled, FunctionError, PluginError, SdkError, HOST_ERROR_CODES}; +pub use host::{ + Browser, Credentials, Documents, Editor, Event, Host, Preview, Subscription, Subscriptions, + Terminal, Window, Workspace, WorkspaceContext, +}; +pub use protocol::{is_command_id, is_credential_key, is_function_id, is_plugin_error_code}; +pub use serde_json::Value; diff --git a/vendor/lithe-plugin-sdk/src/panic_boundary.rs b/vendor/lithe-plugin-sdk/src/panic_boundary.rs new file mode 100644 index 0000000..c788cea --- /dev/null +++ b/vendor/lithe-plugin-sdk/src/panic_boundary.rs @@ -0,0 +1,29 @@ +//! Suppresses payload printing only for panics owned by an SDK catch boundary. +use std::any::Any; +use std::cell::Cell; +use std::panic::{self, UnwindSafe}; +use std::sync::Once; + +thread_local! { static CAUGHT: Cell = const { Cell::new(false) }; } +static INSTALL: Once = Once::new(); + +pub(crate) fn catch( + operation: impl FnOnce() -> T + UnwindSafe, +) -> Result> { + INSTALL.call_once(|| { + let previous = panic::take_hook(); + panic::set_hook(Box::new(move |info| { + if !CAUGHT.with(Cell::get) { + previous(info); + } + })); + }); + struct Restore(bool); + impl Drop for Restore { + fn drop(&mut self) { + CAUGHT.with(|active| active.set(self.0)); + } + } + let _restore = Restore(CAUGHT.with(|active| active.replace(true))); + panic::catch_unwind(operation) +} diff --git a/vendor/lithe-plugin-sdk/src/protocol.rs b/vendor/lithe-plugin-sdk/src/protocol.rs new file mode 100644 index 0000000..1cf3655 --- /dev/null +++ b/vendor/lithe-plugin-sdk/src/protocol.rs @@ -0,0 +1,229 @@ +//! Wire constants and validation shared with Lithe's Rust Core +//! (`rust/lithe-core/src/lsp/interface/plugin_function.rs` 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. + +use serde_json::Value; + +/// Plugin-function protocol versions this SDK answers, newest first. +/// Version 2 keeps every rule of version 1 and adds host services; version 3 +/// adds workspace contexts, capability discovery, host notifications, and +/// `$/cancelRequest` for host requests. +pub const FUNCTIONS_VERSIONS: [u64; 3] = [3, 2, 1]; +/// First plugin-function protocol version with host services. +pub const HOST_SERVICES_VERSION: u64 = 2; +/// First plugin-function protocol version with workspace contexts. +pub const CONTEXT_VERSION: u64 = 3; + +pub(crate) mod methods { + pub const INITIALIZE: &str = "initialize"; + pub const SHUTDOWN: &str = "shutdown"; + pub const EXIT: &str = "exit"; + pub const CANCEL_REQUEST: &str = "$/cancelRequest"; + pub const FUNCTION_INVOKE: &str = "lithe/function/invoke"; + pub const FUNCTION_REGISTER: &str = "lithe/function/register"; + pub const FUNCTION_UNREGISTER: &str = "lithe/function/unregister"; + pub const FUNCTION_PROGRESS: &str = "lithe/function/progress"; + pub const TERMINAL_CREATE: &str = "lithe/host/terminal/create"; + pub const TERMINAL_SHOW: &str = "lithe/host/terminal/show"; + pub const TERMINAL_CLOSE: &str = "lithe/host/terminal/close"; + pub const TERMINAL_REOPEN: &str = "lithe/host/terminal/reopen"; + pub const TERMINAL_DISPOSE: &str = "lithe/host/terminal/dispose"; + pub const TERMINAL_ON_EXIT: &str = "lithe/host/terminal/onExit"; + pub const HOST_PREFIX: &str = "lithe/host/"; + pub const CREDENTIALS_GET: &str = "lithe/host/credentials/get"; + pub const CREDENTIALS_SET: &str = "lithe/host/credentials/set"; + pub const CREDENTIALS_DELETE: &str = "lithe/host/credentials/delete"; + pub const GET_CAPABILITIES: &str = "lithe/host/getCapabilities"; + pub const SUBSCRIPTIONS_DISPOSE: &str = "lithe/host/subscriptions/dispose"; + pub const WORKSPACE_LIST_CONTEXTS: &str = "lithe/host/workspace/listContexts"; + pub const WORKSPACE_GET_CONTEXT: &str = "lithe/host/workspace/getContext"; + pub const WORKSPACE_GET_FOLDERS: &str = "lithe/host/workspace/getFolders"; + pub const WORKSPACE_READ_DIRECTORY: &str = "lithe/host/workspace/readDirectory"; + pub const WORKSPACE_STAT: &str = "lithe/host/workspace/stat"; + pub const WORKSPACE_READ_FILE: &str = "lithe/host/workspace/readFile"; + pub const WORKSPACE_WATCH: &str = "lithe/host/workspace/watch"; + pub const WORKSPACE_FIND_FILES: &str = "lithe/host/workspace/findFiles"; + pub const WORKSPACE_FIND_TEXT: &str = "lithe/host/workspace/findText"; + pub const DOCUMENTS_LIST: &str = "lithe/host/documents/list"; + pub const DOCUMENTS_GET_SNAPSHOT: &str = "lithe/host/documents/getSnapshot"; + pub const EDITOR_REGISTER_ACTIONS: &str = "lithe/host/editor/registerActions"; + pub const EDITOR_GET_ACTIVE: &str = "lithe/host/editor/getActive"; + pub const EDITOR_GET_SELECTIONS: &str = "lithe/host/editor/getSelections"; + pub const EDITOR_APPLY_EDITS: &str = "lithe/host/editor/applyEdits"; + pub const EDITOR_REPLACE_SELECTIONS: &str = "lithe/host/editor/replaceSelections"; + pub const EDITOR_INSERT_TEXT: &str = "lithe/host/editor/insertText"; + pub const EDITOR_OPEN: &str = "lithe/host/editor/open"; + pub const EDITOR_REVEAL: &str = "lithe/host/editor/reveal"; + pub const PREVIEW_OPEN_HTML: &str = "lithe/host/preview/openHTML"; + pub const BROWSER_OPEN_EXTERNAL: &str = "lithe/host/browser/openExternal"; + pub const WINDOW_SHOW_INFORMATION: &str = "lithe/host/window/showInformation"; + pub const WINDOW_SHOW_WARNING: &str = "lithe/host/window/showWarning"; + pub const WINDOW_SHOW_ERROR: &str = "lithe/host/window/showError"; + pub const DID_CHANGE_CONTEXTS: &str = "lithe/host/workspace/didChangeContexts"; + pub const SUBSCRIPTION_NOTIFY: &str = "lithe/host/subscriptions/notify"; + pub const COMMAND_DID_RUN: &str = "lithe/host/commands/didRun"; +} + +/// JSON-RPC and LSP error codes the SDK answers with. +pub(crate) mod error_codes { + pub const PARSE_ERROR: i64 = -32700; + pub const INVALID_REQUEST: i64 = -32600; + pub const METHOD_NOT_FOUND: i64 = -32601; + pub const INVALID_PARAMS: i64 = -32602; + pub const INTERNAL_ERROR: i64 = -32603; + pub const SERVER_NOT_INITIALIZED: i64 = -32002; + pub const REQUEST_FAILED: i64 = -32000; + pub const REQUEST_CANCELLED: i64 = -32800; +} + +pub(crate) const MAX_FUNCTIONS: usize = 256; +const MAX_FUNCTION_ID_BYTES: usize = 128; +const MAX_PLUGIN_ERROR_CODE_BYTES: usize = 64; +pub(crate) const MAX_MESSAGE_BYTES: usize = 1024; +pub(crate) const MAX_RESULT_BYTES: usize = 1 << 20; +const MAX_CREDENTIAL_KEY_BYTES: usize = 128; +pub(crate) const MAX_CREDENTIAL_SECRET_BYTES: usize = 16 * 1024; +/// Host requests a backend may have unanswered at once; later ones wait. +pub(crate) const MAX_PENDING_HOST_REQUESTS: usize = 16; +/// Events kept for subscriptions whose answer has not been read yet. +pub(crate) const MAX_EARLY_EVENTS: usize = 128; +const MAX_COMMAND_ID_BYTES: usize = 128; +/// The largest integer JSON carries exactly. +const MAX_SAFE_INTEGER: u64 = (1 << 53) - 1; + +/// 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()) + }) +} + +/// The plugin error code alphabet Core forwards as `pluginErrorCode`. +pub 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() || "._-".contains(character)) +} + +/// A credential key: 1–128 ASCII letters, digits, `.`, `_`, `:`, or `-`. +pub fn is_credential_key(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_CREDENTIAL_KEY_BYTES + && value + .chars() + .all(|character| character.is_ascii_alphanumeric() || "._:-".contains(character)) +} + +/// A command ID from the plugin's manifest: 1–128 of `[a-z0-9.-]`. +pub fn is_command_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_COMMAND_ID_BYTES + && value.chars().all(|character| { + character.is_ascii_lowercase() || character.is_ascii_digit() || ".-".contains(character) + }) +} + +/// A context generation: a positive integer JSON carries exactly. +pub(crate) fn generation(value: &Value) -> Option { + let generation = match value.as_u64() { + Some(number) => number, + None => { + let number = value.as_f64()?; + if number.fract() != 0.0 || !(1.0..=MAX_SAFE_INTEGER as f64).contains(&number) { + return None; + } + number as u64 + } + }; + (1..=MAX_SAFE_INTEGER) + .contains(&generation) + .then_some(generation) +} + +/// Cuts `value` to `limit` UTF-8 bytes without splitting a character. +pub(crate) fn bounded_text(value: &str, limit: usize) -> String { + if value.len() <= limit { + return value.to_string(); + } + let mut end = limit; + while !value.is_char_boundary(end) { + end -= 1; + } + value[..end].to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn function_ids_are_dot_separated_lower_camel_case() { + assert!(is_function_id("snapshot.refresh")); + assert!(is_function_id("query.runAll2")); + for invalid in [ + "", + "Snapshot", + "snapshot.", + ".a", + "a..b", + "a-b", + "a.B", + "1a", + ] { + assert!(!is_function_id(invalid), "{invalid}"); + } + assert!(!is_function_id(&"a".repeat(129))); + } + + #[test] + fn plugin_error_codes_and_credential_keys_use_their_alphabets() { + assert!(is_plugin_error_code("db.connection-failed_2")); + assert!(!is_plugin_error_code("2db")); + assert!(!is_plugin_error_code(&"a".repeat(65))); + assert!(is_credential_key("connection:prod.password")); + assert!(!is_credential_key("")); + assert!(!is_credential_key("has space")); + assert!(!is_credential_key(&"k".repeat(129))); + assert!(is_command_id("db.refresh-all2")); + for invalid in ["", "DB.refresh", "db_refresh", "db refresh"] { + assert!(!is_command_id(invalid), "{invalid}"); + } + assert!(!is_command_id(&"a".repeat(129))); + } + + #[test] + fn generations_are_positive_integers_json_carries_exactly() { + use serde_json::json; + assert_eq!(generation(&json!(2)), Some(2)); + assert_eq!(generation(&json!(2.0)), Some(2)); + assert_eq!(generation(&json!(MAX_SAFE_INTEGER)), Some(MAX_SAFE_INTEGER)); + for invalid in [ + json!(0), + json!(-1), + json!(1.5), + json!("2"), + json!(null), + json!(MAX_SAFE_INTEGER + 1), + ] { + assert_eq!(generation(&invalid), None, "{invalid}"); + } + } + + #[test] + fn bounded_text_never_splits_a_character() { + assert_eq!(bounded_text("abc", 8), "abc"); + assert_eq!(bounded_text("aé", 2), "a"); + assert_eq!(bounded_text("日本語", 7), "日本"); + } +} diff --git a/vendor/lithe-plugin-sdk/src/stdio.rs b/vendor/lithe-plugin-sdk/src/stdio.rs new file mode 100644 index 0000000..cd7b591 --- /dev/null +++ b/vendor/lithe-plugin-sdk/src/stdio.rs @@ -0,0 +1,31 @@ +//! Keeps stdout for protocol frames only. + +use std::io::{self, Write}; + +/// The stream the protocol is written to. On Unix this is a duplicate of +/// the original stdout descriptor, and descriptor 1 is pointed at stderr, so +/// output printed by plugin code or its dependencies cannot corrupt the +/// protocol. Elsewhere it is stdout itself, and plugin code must not print. +pub(crate) fn protocol_output() -> Box { + #[cfg(unix)] + { + use std::os::fd::FromRawFd; + extern "C" { + fn dup(fd: i32) -> i32; + fn dup2(source: i32, target: i32) -> i32; + } + // SAFETY: `dup` and `dup2` only operate on the process's descriptor + // table. The duplicate is a fresh descriptor that nothing else owns, + // so the `File` becomes its single owner. + unsafe { + let protocol = dup(1); + if protocol >= 0 { + if dup2(2, 1) < 0 { + std::process::exit(1); + } + return Box::new(std::fs::File::from_raw_fd(protocol)); + } + } + } + Box::new(io::stdout()) +}