Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 22 additions & 8 deletions rust/contracts/rust-core-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions rust/crates/plugin-lifecycle/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
142 changes: 134 additions & 8 deletions rust/crates/plugin-lifecycle/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,34 @@
//! 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;

/// 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";
Expand All @@ -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<String>,
}

/// One plugin and whether it is enabled: a choice in the request, a plugin's enablement in the
Expand Down Expand Up @@ -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<String>,
},
}

#[derive(Debug, Serialize)]
Expand Down Expand Up @@ -135,6 +165,7 @@ pub fn command(payload: Value) -> Result<Value, CoreError> {
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()
Expand All @@ -152,9 +183,10 @@ pub fn command(payload: Value) -> Result<Value, CoreError> {
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,
Expand All @@ -168,7 +200,7 @@ pub fn command(payload: Value) -> Result<Value, CoreError> {
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),
Expand Down Expand Up @@ -214,30 +246,124 @@ pub fn command(payload: Value) -> Result<Value, CoreError> {
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<Choice> {
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<PluginVersion>, &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<Step> {
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<String> = 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)
}
Expand Down
Loading
Loading