diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 5c36cb3..43f0a3a 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1255,6 +1255,7 @@ dependencies = [ name = "lithe-plugin-lifecycle" version = "0.1.0" dependencies = [ + "lithe-plugin-model", "lithe-protocol", "serde", "serde_json", diff --git a/rust/contracts/rust-core-api.md b/rust/contracts/rust-core-api.md index 6dddd43..be3a972 100644 --- a/rust/contracts/rust-core-api.md +++ b/rust/contracts/rust-core-api.md @@ -4214,17 +4214,23 @@ successfully started session merely because other plugins failed discovery. This is metadata selection, not manifest validation, approval or admission. Shared cases are in `rust/fixtures/plugins/installed-versions.json`. -`plugins.installed.lifecycle` accepts `{ installed: [{ pluginID, version }], -choices?: [{ pluginID, enabled }], action?: { kind, pluginID } }`, with at most -4096 installed versions and 4096 choices. `installed` lists every installed -version and `choices` every enablement choice the user made; an empty ID or -version, a repeated version or a second choice for one plugin is -`invalid_request`. `kind` is `confirmInstall`, `enable`, `disable` or +`plugins.installed.lifecycle` accepts `{ installed: [{ pluginID, version, +languageServers? }], choices?: [{ pluginID, enabled }], action?: { kind, +pluginID } }`, with at most 4096 installed versions and 4096 choices. +`installed` lists every installed version and `choices` every enablement choice +the user made; an empty ID or version, a repeated version or a second choice for +one plugin is `invalid_request`. `languageServers` is the `languageID` of every +language server the version's manifest declares, at most 256 non-empty IDs of at +most 256 bytes, none listed twice, or `invalid_request`. `kind` is `confirmInstall`, `enable`, `disable` or `uninstall`. It returns `{ plugins: [{ pluginID, enabled }], changed, steps, rejection? }`. `plugins` lists every installed plugin once, ordered by ID, with its enablement once the steps ran; an uninstalled plugin is left out. Enablement is per plugin, not per version, and a plugin without a choice is -disabled. A choice for a plugin that is not installed is kept for a later +disabled. One enabled plugin serves a language: a plugin whose choice enables it +is still reported disabled when a plugin before it by ID is enabled and serves +one of its languages. A plugin serves the languages of its newest installed +version, compared as `major.minor.patch`; a version that is not one ranks below +every one that is. A choice for a plugin that is not installed is kept for a later installation. `changed` says the steps change the choice for the action's plugin. An action on a plugin with no installed version returns `rejection: "plugin.lifecycle.notInstalled"`, no steps and the unchanged @@ -4237,11 +4243,19 @@ backend and returns once they ended or gave up, `{ kind: "removeVersion", version }` removes one installed version and then forgets that version's approval record (the record stays when the removal failed), and `{ kind: "removeState" }` removes the state directory the plugin's programs -wrote. A failed step does not stop the steps after it; the host reports every +wrote. `{ kind: "displacePlugin", pluginID, languageIDs }` is the one step on +another plugin: the host records the choice `enabled: false` for `pluginID`, then +stops its programs, and tells the user it turned that plugin off because the +action's plugin serves `languageIDs` too. A failed step does not stop the steps after it; the host reports every failure. `confirmInstall` records `enabled: true` only when the plugin has no choice, so a plugin disabled before an update stays disabled. `enable` records `true` unless already chosen. `disable` records `false` unless already chosen and then stops the programs, so nothing starts the plugin again while it stops. +An `enable` or `confirmInstall` that leaves the plugin enabled ends with one +`displacePlugin` for every other plugin, in ID order, whose choice enables it +and which serves one of the same languages, naming the shared languages; this is +what settles two plugins that choices made before the rule both enable. A +`disable` displaces nothing, so the other plugin's choice can take over. `uninstall` stops the programs, removes every installed version in version text order, forgets the choice if there is one and removes the state, so a later installation starts as a first one. Shared cases are in diff --git a/rust/crates/plugin-lifecycle/Cargo.toml b/rust/crates/plugin-lifecycle/Cargo.toml index 83bb329..3ad2ffb 100644 --- a/rust/crates/plugin-lifecycle/Cargo.toml +++ b/rust/crates/plugin-lifecycle/Cargo.toml @@ -6,6 +6,7 @@ license = "Apache-2.0" description = "Enablement of installed Lithe plugins and the ordered steps of confirming an install, enabling, disabling and uninstalling one" [dependencies] +lithe-plugin-model = { path = "../plugin-model" } lithe-protocol = { path = "../protocol" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/rust/crates/plugin-lifecycle/src/lib.rs b/rust/crates/plugin-lifecycle/src/lib.rs index 36106b6..53f3e8a 100644 --- a/rust/crates/plugin-lifecycle/src/lib.rs +++ b/rust/crates/plugin-lifecycle/src/lib.rs @@ -14,10 +14,20 @@ //! running program; then it removes every installed version, because the plugin page shows one //! row per plugin and an older version left behind would keep the plugin installed; then it //! forgets the choice and removes the state the programs wrote, so a later installation starts -//! as a first one. Fixtures live in `rust/fixtures/plugins/installed-lifecycle.json`. +//! as a first one. +//! +//! A language has one language server: two plugins that both serve it would race for every +//! document of it, and neither would know the other answers too. So enabling a plugin, or +//! confirming its installation while it is enabled, turns off every other enabled plugin whose +//! newest installed version serves one of the same languages, and the host records that and +//! stops their programs. Choices made before this rule can still enable two such plugins; +//! enablement then keeps the first by ID and reports the others disabled, without changing the +//! choices, so enabling one of them later is what settles it. Fixtures live in +//! `rust/fixtures/plugins/installed-lifecycle.json`. use std::collections::{BTreeMap, BTreeSet}; +use lithe_plugin_model::PluginVersion; use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -25,6 +35,13 @@ use serde_json::Value; /// Upper bound of installed versions and of choices in one request. const MAX_ENTRIES: usize = 4096; +/// Upper bound of the languages one installed version serves; a manifest declares at most one +/// language server per language it declares. +const MAX_LANGUAGE_SERVERS: usize = 256; + +/// Upper bound of one language ID, in bytes. +const MAX_LANGUAGE_ID_BYTES: usize = 256; + /// Rejection of an action on a plugin that has no installed version: enabling it would /// silently enable a later installation, and there is nothing to uninstall. pub const NOT_INSTALLED: &str = "plugin.lifecycle.notInstalled"; @@ -48,6 +65,10 @@ struct InstalledVersion { #[serde(rename = "pluginID")] plugin_id: String, version: String, + /// The `languageID` of every language server this version's manifest declares; only the + /// plugin's newest installed version counts, since it is the one that runs. + #[serde(default, rename = "languageServers")] + language_servers: Vec, } /// One plugin and whether it is enabled: a choice in the request, a plugin's enablement in the @@ -96,6 +117,15 @@ enum Step { RemoveVersion { version: String }, /// Remove the state directory the plugin's programs wrote. RemoveState, + /// Turn another plugin off because the action's plugin now serves `language_ids` too: + /// record the choice `enabled: false` for `plugin_id`, then stop its programs. Unlike the + /// other steps it is on `plugin_id`, not on the action's plugin. + DisplacePlugin { + #[serde(rename = "pluginID")] + plugin_id: String, + #[serde(rename = "languageIDs")] + language_ids: Vec, + }, } #[derive(Debug, Serialize)] @@ -135,6 +165,7 @@ pub fn command(payload: Value) -> Result { if entry.plugin_id.is_empty() || entry.version.is_empty() { return Err(invalid("An installed plugin has an empty ID or version")); } + validate_language_servers(&entry.language_servers)?; if !versions .entry(&entry.plugin_id) .or_default() @@ -152,9 +183,10 @@ pub fn command(payload: Value) -> Result { return Err(invalid("A plugin has two choices")); } } + let served = served_languages(&request.installed); let Some(action) = &request.action else { return Ok(encode(Response { - plugins: enablement(&versions, &choices, None), + plugins: enablement(&versions, &choices, &served, None), changed: false, steps: Vec::new(), rejection: None, @@ -168,7 +200,7 @@ pub fn command(payload: Value) -> Result { let id = action.plugin_id.as_str(); let Some(installed) = versions.get(id) else { return Ok(encode(Response { - plugins: enablement(&versions, &choices, None), + plugins: enablement(&versions, &choices, &served, None), changed: false, steps: Vec::new(), rejection: Some(NOT_INSTALLED), @@ -214,30 +246,124 @@ pub fn command(payload: Value) -> Result { if let Some(enabled) = after { choices.insert(id, enabled); } + if after == Some(true) { + steps.extend(displace_rivals(id, &served, &mut choices)); + } Ok(encode(Response { - plugins: enablement(&versions, &choices, uninstalled), + plugins: enablement(&versions, &choices, &served, uninstalled), changed: after != chosen, steps, rejection: None, })) } -/// Every installed plugin but `left_out`, by ID, enabled only when its choice says so. +/// Every installed plugin but `left_out`, by ID, enabled only when its choice says so and no +/// plugin before it by ID that is enabled already serves one of its languages. fn enablement( versions: &BTreeMap<&str, BTreeSet<&str>>, choices: &BTreeMap<&str, bool>, + served: &BTreeMap<&str, BTreeSet<&str>>, left_out: Option<&str>, ) -> Vec { + let none = BTreeSet::new(); + let mut claimed: BTreeSet<&str> = BTreeSet::new(); versions .keys() .filter(|id| Some(**id) != left_out) - .map(|id| Choice { - plugin_id: (*id).to_string(), - enabled: choices.get(id).copied().unwrap_or(false), + .map(|id| { + let languages = served.get(id).unwrap_or(&none); + let enabled = + choices.get(id).copied().unwrap_or(false) && languages.is_disjoint(&claimed); + if enabled { + claimed.extend(languages.iter().copied()); + } + Choice { + plugin_id: (*id).to_string(), + enabled, + } + }) + .collect() +} + +/// The languages each installed plugin's newest version serves. +/// +/// Versions are compared as `major.minor.patch`; a version that is not one ranks below every +/// one that is, and of two such versions the first listed counts. +fn served_languages(installed: &[InstalledVersion]) -> BTreeMap<&str, BTreeSet<&str>> { + let mut newest: BTreeMap<&str, (Option, &InstalledVersion)> = BTreeMap::new(); + for entry in installed { + let version = PluginVersion::parse(&entry.version); + let current = newest.entry(&entry.plugin_id).or_insert((version, entry)); + if version > current.0 { + *current = (version, entry); + } + } + newest + .into_iter() + .map(|(id, (_, entry))| { + ( + id, + entry.language_servers.iter().map(String::as_str).collect(), + ) }) .collect() } +/// Turns off, in `choices`, every other enabled plugin that serves one of `id`'s languages, and +/// returns the steps that make the host do the same, in plugin ID order. +fn displace_rivals<'a>( + id: &str, + served: &BTreeMap<&'a str, BTreeSet<&'a str>>, + choices: &mut BTreeMap<&'a str, bool>, +) -> Vec { + let Some(languages) = served.get(id).filter(|languages| !languages.is_empty()) else { + return Vec::new(); + }; + let mut steps = Vec::new(); + for (other, other_languages) in served { + if *other == id || choices.get(other) != Some(&true) { + continue; + } + let shared: Vec = languages + .intersection(other_languages) + .map(|language| (*language).to_string()) + .collect(); + if shared.is_empty() { + continue; + } + choices.insert(other, false); + steps.push(Step::DisplacePlugin { + plugin_id: (*other).to_string(), + language_ids: shared, + }); + } + steps +} + +/// Rejects a malformed `languageServers` list: too long, an empty or overlong ID, or an ID +/// listed twice, which a valid manifest never produces. +fn validate_language_servers(languages: &[String]) -> Result<(), CoreError> { + if languages.len() > MAX_LANGUAGE_SERVERS { + return Err(invalid( + "An installed plugin version serves too many languages", + )); + } + let mut seen = BTreeSet::new(); + for language in languages { + if language.is_empty() || language.len() > MAX_LANGUAGE_ID_BYTES { + return Err(invalid( + "An installed plugin version serves an empty or overlong language ID", + )); + } + if !seen.insert(language.as_str()) { + return Err(invalid( + "An installed plugin version serves a language twice", + )); + } + } + Ok(()) +} + fn invalid(message: &str) -> CoreError { CoreError::new(ErrorCode::InvalidRequest, message) } diff --git a/rust/fixtures/plugins/installed-lifecycle.json b/rust/fixtures/plugins/installed-lifecycle.json index d235169..d738dbf 100644 --- a/rust/fixtures/plugins/installed-lifecycle.json +++ b/rust/fixtures/plugins/installed-lifecycle.json @@ -1,5 +1,5 @@ { - "description": "plugins.installed.lifecycle: installed plugins' enablement and the ordered steps of a lifecycle action. Hosts carry the steps out on the action's plugin.", + "description": "plugins.installed.lifecycle: installed plugins' enablement and the ordered steps of a lifecycle action. Hosts carry the steps out on the action's plugin, except displacePlugin, which names the plugin it turns off. One enabled plugin serves a language: enabling or confirming the installation of a plugin turns off every other enabled plugin whose newest version serves one of the same languages.", "cases": [ { "name": "a plugin without a choice is disabled, per plugin not per version", @@ -148,6 +148,313 @@ ] } }, + { + "name": "enabling a plugin turns off every other enabled plugin that serves one of its languages", + "request": { + "installed": [ + { + "pluginID": "complete", + "version": "1.2.0", + "languageServers": ["java", "jsp"] + }, + { + "pluginID": "lightweight", + "version": "0.0.2", + "languageServers": ["java"] + }, + { + "pluginID": "templates", + "version": "1.0.0", + "languageServers": ["jsp", "html"] + }, + { + "pluginID": "unrelated", + "version": "0.1.3", + "languageServers": ["go"] + }, + { "pluginID": "plain", "version": "1.0.0" } + ], + "choices": [ + { "pluginID": "complete", "enabled": false }, + { "pluginID": "lightweight", "enabled": true }, + { "pluginID": "templates", "enabled": true }, + { "pluginID": "unrelated", "enabled": true }, + { "pluginID": "plain", "enabled": true } + ], + "action": { "kind": "enable", "pluginID": "complete" } + }, + "response": { + "plugins": [ + { "pluginID": "complete", "enabled": true }, + { "pluginID": "lightweight", "enabled": false }, + { "pluginID": "plain", "enabled": true }, + { "pluginID": "templates", "enabled": false }, + { "pluginID": "unrelated", "enabled": true } + ], + "changed": true, + "steps": [ + { "kind": "recordChoice", "enabled": true }, + { + "kind": "displacePlugin", + "pluginID": "lightweight", + "languageIDs": ["java"] + }, + { + "kind": "displacePlugin", + "pluginID": "templates", + "languageIDs": ["jsp"] + } + ] + } + }, + { + "name": "confirming an installation turns off an enabled plugin serving the same language", + "request": { + "installed": [ + { + "pluginID": "lightweight", + "version": "0.0.2", + "languageServers": ["java"] + }, + { + "pluginID": "complete", + "version": "1.2.0", + "languageServers": ["java"] + } + ], + "choices": [{ "pluginID": "lightweight", "enabled": true }], + "action": { "kind": "confirmInstall", "pluginID": "complete" } + }, + "response": { + "plugins": [ + { "pluginID": "complete", "enabled": true }, + { "pluginID": "lightweight", "enabled": false } + ], + "changed": true, + "steps": [ + { "kind": "recordChoice", "enabled": true }, + { + "kind": "displacePlugin", + "pluginID": "lightweight", + "languageIDs": ["java"] + } + ] + } + }, + { + "name": "confirming an update of an enabled plugin turns off a rival it now shares a language with", + "request": { + "installed": [ + { "pluginID": "complete", "version": "1.0.0" }, + { + "pluginID": "complete", + "version": "1.1.0", + "languageServers": ["java"] + }, + { + "pluginID": "lightweight", + "version": "0.0.2", + "languageServers": ["java"] + } + ], + "choices": [ + { "pluginID": "complete", "enabled": true }, + { "pluginID": "lightweight", "enabled": true } + ], + "action": { "kind": "confirmInstall", "pluginID": "complete" } + }, + "response": { + "plugins": [ + { "pluginID": "complete", "enabled": true }, + { "pluginID": "lightweight", "enabled": false } + ], + "changed": false, + "steps": [ + { + "kind": "displacePlugin", + "pluginID": "lightweight", + "languageIDs": ["java"] + } + ] + } + }, + { + "name": "confirming an update of a disabled plugin turns nothing off", + "request": { + "installed": [ + { + "pluginID": "complete", + "version": "1.2.0", + "languageServers": ["java"] + }, + { + "pluginID": "lightweight", + "version": "0.0.2", + "languageServers": ["java"] + } + ], + "choices": [ + { "pluginID": "complete", "enabled": false }, + { "pluginID": "lightweight", "enabled": true } + ], + "action": { "kind": "confirmInstall", "pluginID": "complete" } + }, + "response": { + "plugins": [ + { "pluginID": "complete", "enabled": false }, + { "pluginID": "lightweight", "enabled": true } + ], + "changed": false, + "steps": [] + } + }, + { + "name": "only the newest installed version's language servers count", + "request": { + "installed": [ + { + "pluginID": "complete", + "version": "1.2.0", + "languageServers": ["java"] + }, + { + "pluginID": "lightweight", + "version": "0.0.1", + "languageServers": ["java"] + }, + { + "pluginID": "lightweight", + "version": "0.0.10", + "languageServers": [] + }, + { + "pluginID": "unversioned", + "version": "next", + "languageServers": ["java"] + }, + { "pluginID": "unversioned", "version": "0.0.1" } + ], + "choices": [ + { "pluginID": "lightweight", "enabled": true }, + { "pluginID": "unversioned", "enabled": true } + ], + "action": { "kind": "enable", "pluginID": "complete" } + }, + "response": { + "plugins": [ + { "pluginID": "complete", "enabled": true }, + { "pluginID": "lightweight", "enabled": true }, + { "pluginID": "unversioned", "enabled": true } + ], + "changed": true, + "steps": [{ "kind": "recordChoice", "enabled": true }] + } + }, + { + "name": "choices made before the rule enable only the first plugin by ID that serves a language", + "request": { + "installed": [ + { + "pluginID": "complete", + "version": "1.2.0", + "languageServers": ["java"] + }, + { + "pluginID": "lightweight", + "version": "0.0.2", + "languageServers": ["java"] + }, + { + "pluginID": "unrelated", + "version": "0.1.3", + "languageServers": ["go"] + } + ], + "choices": [ + { "pluginID": "complete", "enabled": true }, + { "pluginID": "lightweight", "enabled": true }, + { "pluginID": "unrelated", "enabled": true } + ] + }, + "response": { + "plugins": [ + { "pluginID": "complete", "enabled": true }, + { "pluginID": "lightweight", "enabled": false }, + { "pluginID": "unrelated", "enabled": true } + ], + "changed": false, + "steps": [] + } + }, + { + "name": "enabling the plugin the earlier choices left off turns the other off", + "request": { + "installed": [ + { + "pluginID": "complete", + "version": "1.2.0", + "languageServers": ["java"] + }, + { + "pluginID": "lightweight", + "version": "0.0.2", + "languageServers": ["java"] + } + ], + "choices": [ + { "pluginID": "complete", "enabled": true }, + { "pluginID": "lightweight", "enabled": true } + ], + "action": { "kind": "enable", "pluginID": "lightweight" } + }, + "response": { + "plugins": [ + { "pluginID": "complete", "enabled": false }, + { "pluginID": "lightweight", "enabled": true } + ], + "changed": false, + "steps": [ + { + "kind": "displacePlugin", + "pluginID": "complete", + "languageIDs": ["java"] + } + ] + } + }, + { + "name": "disabling the plugin that serves a language leaves the other's choice to take over", + "request": { + "installed": [ + { + "pluginID": "complete", + "version": "1.2.0", + "languageServers": ["java"] + }, + { + "pluginID": "lightweight", + "version": "0.0.2", + "languageServers": ["java"] + } + ], + "choices": [ + { "pluginID": "complete", "enabled": true }, + { "pluginID": "lightweight", "enabled": true } + ], + "action": { "kind": "disable", "pluginID": "complete" } + }, + "response": { + "plugins": [ + { "pluginID": "complete", "enabled": false }, + { "pluginID": "lightweight", "enabled": true } + ], + "changed": true, + "steps": [ + { "kind": "recordChoice", "enabled": false }, + { "kind": "stopPrograms" } + ] + } + }, { "name": "an action on a plugin that is not installed is rejected", "request": { @@ -168,6 +475,8 @@ { "name": "an empty installed version", "request": { "installed": [{ "pluginID": "example", "version": "" }] } }, { "name": "a repeated installed version", "request": { "installed": [{ "pluginID": "example", "version": "1.0.0" }, { "pluginID": "example", "version": "1.0.0" }] } }, { "name": "two choices for one plugin", "request": { "installed": [], "choices": [{ "pluginID": "example", "enabled": true }, { "pluginID": "example", "enabled": false }] } }, - { "name": "an unknown action", "request": { "installed": [], "action": { "kind": "reinstall", "pluginID": "example" } } } + { "name": "an unknown action", "request": { "installed": [], "action": { "kind": "reinstall", "pluginID": "example" } } }, + { "name": "an empty language server ID", "request": { "installed": [{ "pluginID": "example", "version": "1.0.0", "languageServers": [""] }] } }, + { "name": "a language served twice by one version", "request": { "installed": [{ "pluginID": "example", "version": "1.0.0", "languageServers": ["java", "java"] }] } } ] } diff --git a/shared/plugins/contract/v1/README.md b/shared/plugins/contract/v1/README.md index e25f376..4a427ea 100644 --- a/shared/plugins/contract/v1/README.md +++ b/shared/plugins/contract/v1/README.md @@ -299,6 +299,13 @@ activation event, and a plugin may consist of grammars alone. | `hostIntegration` | Reserved. Core implements no host integration, so any ID is refused; see [Language server host integrations](#language-server-host-integrations). | | `requestMethods`, `commands`, `serializedCommands`, `forwardNotifications`, `state`, `remote` | What the plugin backend may ask of the running server and how the host keeps its state; see [Plugin-owned sessions](#plugin-owned-sessions). | +One enabled plugin serves a language. Enabling a plugin, or confirming the +installation of an enabled one, turns off every other enabled plugin whose newest +installed version declares a language server for one of the same languages; the +host tells the user which plugin it turned off and why. Two plugins can serve the +same language as alternatives, for example a lightweight navigation server and a +complete one, but never at the same time, so neither has to know the other. + The host owns the language-server process lifecycle, the LSP session, and stopping the process when the provider is disabled. A build provider with its own process is the same kind of process: it declares `launch`, `arguments`, diff --git a/shared/plugins/guides/lsp-plugin-lifecycle.md b/shared/plugins/guides/lsp-plugin-lifecycle.md index 6944fe6..73035cc 100644 --- a/shared/plugins/guides/lsp-plugin-lifecycle.md +++ b/shared/plugins/guides/lsp-plugin-lifecycle.md @@ -23,6 +23,8 @@ - 不声明 `executionProviders`:`requires` 里有它的插件不会被提供安装。因此本文的示例只含语言服务器。 - 安装:从插件市场(含自行添加的 HTTPS 索引源)或从磁盘选择 zip,两条路径都先显示安装审查,由用户确认; 安装、启用、停用和卸载立即生效,不需要重启 Lithe。 +- 同一语言只由一个已启用的插件提供语言服务器:启用或安装你的插件时,另一个也为该语言声明了语言服务器的已启用插件会被停用, + 宿主会告诉用户停用了哪个插件。可以给同一语言做轻量版和完整版两个插件,但用户同一时间只会用其中一个。 - 各能力的支持情况见[版本对照](../reference/capabilities-and-versions.md)。Lua 模板目前只提供 macOS arm64、x64 两个包。 ### 1. 先确定插件与宿主各自负责什么 @@ -501,6 +503,9 @@ What the desktop does that matters here: - It does not declare `executionProviders`: a plugin whose `requires` lists it is not offered for installation. The examples on this page therefore contain only the language server. - Installation: from the plugin marketplace (including HTTPS index sources you add yourself) or from a zip on disk; both paths show an install review for the user to confirm. Install, enable, disable, and uninstall take effect immediately, with no Lithe restart. +- One enabled plugin serves a language: enabling or installing your plugin turns off any other enabled plugin that + declares a language server for the same language, and the host tells the user which plugin it turned off. You can ship a + lightweight and a complete plugin for one language, but a user runs only one of them at a time. - For what each capability supports, see the [version tables](../reference/capabilities-and-versions.md). The Lua template ships only macOS arm64 and x64 packages. ### 1. Decide what the plugin owns and what the host owns diff --git a/shared/plugins/reference/protocol-changelog.md b/shared/plugins/reference/protocol-changelog.md index a6a0033..f8b38ef 100644 --- a/shared/plugins/reference/protocol-changelog.md +++ b/shared/plugins/reference/protocol-changelog.md @@ -133,6 +133,7 @@ | PR | 修改人 | API 编号 | 协议 / 能力 | 改了什么 | | --- | --- | --- | --- | --- | +| [#39](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/39) | [@1lck](https://github.com/1lck) | — | `pluginAPIVersion` 0 内的补充 | 同一语言只由一个已启用的插件提供语言服务器:启用插件、或确认安装后插件处于启用状态时,其他已启用且最新版本为同一语言声明了语言服务器的插件会被停用,宿主告诉用户停用了哪个插件。宿主命令 `plugins.installed.lifecycle` 的 `installed[]` 新增可选 `languageServers`,新增步骤 `{ kind: "displacePlugin", pluginID, languageIDs }`;规则之前已同时启用的插件按 ID 只保留第一个,等用户启用其中一个时再定下来。清单格式不变,不需要新能力版本。 | | [#36](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/36) | [@xiaoyumuxi](https://github.com/xiaoyumuxi) | API-37 | `pluginAPIVersion` 0 内的补充 | 插件包可以带 `l10n/<语言>.json`(新增 [`plugin-l10n.schema.json`](../contract/v1/plugin-l10n.schema.json)),按设置 ID、选项值、分组原文和按钮 ID 翻译设置的标题、说明、占位符、单位、选项标题、分组标题和按钮文字;ID、值、分组和顺序不变,没翻译的文字显示清单原文。宿主命令 `plugins.declarative.settingsState` 新增可选 `language`、`localizations`,返回 `localization`(候选文件、选中的文件、被拒绝的文件);候选按语言标签逐级截短(`zh-Hans-CN` → `zh-Hans` → `zh`),选第一个通过检查的文件,有问题的文件整份不用,以新错误码 `plugin.settings.invalidLocalization` 报告。不需要新能力版本,不读翻译的宿主照常显示原文。 | | [#35](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/35) | [@xiaoyumuxi](https://github.com/xiaoyumuxi) | — | `pluginAPIVersion` 0 内的补充 | `build.detect`(`buildSystems[].detection`)还会跳过工作区的忽略路径:`.gitignore` 语法、相对工作区根、只对目录生效,存在团队构建选项 `.lithe/build/options.json` 或本机构建选项的 `ignoredPaths` 中,本机的整份替换团队的;都没有时默认 `[".*"]`,即所有名字以 `.` 开头的目录(如 `.claude/worktrees`)。这些规则先于 Git 规则判断,`!` 可以放回 Git 忽略的目录,共享扫描策略排除的缓存和构建输出仍然跳过。新增宿主命令 `build.editIgnoredPaths` 和错误码 `plugin.build.invalidIgnoredPath`(字段 `ignoredPaths[<序号>]`);`build.resolveStoredOptions` 返回 `ignoredPaths: { patterns, source }`。自动运行项发现、指纹扫描和 Node 清单发现不受影响。 | | [#33](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/33) | [@xiaoyumuxi](https://github.com/xiaoyumuxi) | — | `pluginAPIVersion` 0 内的补充 | `build.detect`(`buildSystems[].detection`)和自动运行项发现不再把被 Git 忽略的目录当作候选根目录。规则来自工作区内途经的 `.gitignore`,以及根目录在工作区内的仓库的 `info/exclude`(链接工作树读主仓库共享的那份),嵌套仓库另起一套规则;不读工作区根以上的目录、`core.excludesFile` 和索引,所以被忽略目录下已跟踪的清单文件也不再被发现。契约 README 同时写明检测最多向下 6 层。指纹扫描和 Node 清单发现仍然遍历被忽略的目录。 |