diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..a9d13237 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/.artifacts/ +/rust/target/ +/scripts/rust-core/comment-lexer/target/ diff --git a/rust/Cargo.lock b/rust/Cargo.lock index edff320f..08b93543 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -505,6 +505,25 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.22" @@ -979,6 +998,19 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +[[package]] +name = "globset" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98b0d85522dc0a824cfebbc9594ff4a9b289c568bea0370319dfbdf20ccdd2d" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1266,6 +1298,22 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ignore" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -1487,6 +1535,7 @@ dependencies = [ "base64 0.22.1", "chrono", "comrak", + "ignore", "lithe-agent-host", "lithe-core-rules", "lithe-git-host", @@ -2401,6 +2450,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schemars" version = "0.9.0" @@ -3272,6 +3330,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -3433,6 +3501,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/rust/lithe-core/Cargo.toml b/rust/lithe-core/Cargo.toml index 61d87703..9295377c 100644 --- a/rust/lithe-core/Cargo.toml +++ b/rust/lithe-core/Cargo.toml @@ -37,6 +37,7 @@ similar = { version = "3.2", default-features = false, features = ["std"] } zip = { version = "2.4", default-features = false, features = ["deflate"] } url = "2.5" unicode-normalization = "0.1" +ignore = "=0.4.33" [target.'cfg(unix)'.dev-dependencies] lithe-remote = { path = "../lithe-remote", default-features = false, features = ["server"] } diff --git a/rust/lithe-core/src/debug/engine.rs b/rust/lithe-core/src/debug/engine.rs index e2ff0683..c9aa4ccd 100644 --- a/rust/lithe-core/src/debug/engine.rs +++ b/rust/lithe-core/src/debug/engine.rs @@ -1374,7 +1374,11 @@ impl DebugSession { } } PendingRequest::Launch { operation_id } => { - self.transition(DebugSessionState::Running); + // Adapters may emit stopped or terminated before this acknowledgement. + // A late response completes the operation, but cannot resume or resurrect it. + if self.state == DebugSessionState::Launching { + self.transition(DebugSessionState::Running); + } self.emit(DebugEventBody::OperationCompleted { operation_id, result: DebugOperationResult::Acknowledged { @@ -3161,6 +3165,62 @@ mod tests { serde_json::from_slice(&bytes[body_start..]).unwrap() } + #[test] + fn delayed_launch_response_preserves_a_newer_stopped_or_terminal_event() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/debug/launch-event-order.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let session_id = case["id"].as_str().unwrap(); + let event = case["event"].clone(); + let expected = case["state"].clone(); + create_session(child_request(session_id, None)).unwrap(); + struct Cleanup<'a>(&'a str); + impl Drop for Cleanup<'_> { + fn drop(&mut self) { + // log-exempt: Expected absence if the test already disposed this process-free session. + let _ = destroy_session(SessionRequest { + session_id: self.0.into(), + }); + } + } + let _cleanup = Cleanup(session_id); + receive_messages( + session_id, + vec![response_message(1, "initialize", json!({}))], + ); + let launching = launch(LaunchRequest { + session_id: session_id.into(), + operation_id: "launch".into(), + configuration: DebugLaunchConfiguration { + name: "native target".into(), + request: DebugRequestKind::Launch, + arguments: Map::new(), + stepping_filters: None, + }, + }) + .unwrap(); + let frame = decode_frame(&launching.outbound_frames[0]); + let stopped = receive_messages(session_id, vec![event]); + assert_eq!(serde_json::to_value(stopped.state).unwrap(), expected); + // LLDB can report the first stop before acknowledging the launch request. + let acknowledged = receive_messages( + session_id, + vec![response_message( + frame["seq"].as_i64().unwrap(), + "launch", + json!({}), + )], + ); + assert_eq!( + serde_json::to_value(acknowledged.state).unwrap(), + expected, + "a response must not resume or resurrect the target" + ); + } + } + #[test] fn dap_logging_keeps_catalog_identity_and_cancellation_private() { let adapter = "lldb+private-adapter"; diff --git a/rust/lithe-core/src/lsp/document_identity.rs b/rust/lithe-core/src/lsp/document_identity.rs new file mode 100644 index 00000000..9899fcad --- /dev/null +++ b/rust/lithe-core/src/lsp/document_identity.rs @@ -0,0 +1,132 @@ +//! Lexical workspace identities for diagnostics; the host owns native file I/O. + +use crate::lsp::{ + workspace_edit_plan::native_path, workspace_scope::WorkspaceScope, DocumentPathPlatform, + MAX_PATH_BYTES, +}; +use crate::protocol::{CoreError, ErrorCode}; +use serde::Deserialize; +use serde_json::{json, Value}; +use url::Url; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Request { + workspace_root: String, + /// File URI, absolute native path, or workspace-relative document path. + input: String, + platform: DocumentPathPlatform, +} + +/// Resolves one server-supplied identity to a native path and workspace-relative +/// display path. Non-file URIs, ambiguous drive-relative paths and outside targets +/// return a null document. The result grants no permission to follow symlinks. +pub(crate) fn resolve_command(payload: Value) -> Result { + let request: Request = serde_json::from_value(payload).map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid document identity request", + ) + .with_details(error.to_string()) + })?; + let platform = if request.platform.is_windows_path(&request.workspace_root) { + DocumentPathPlatform::Windows + } else { + DocumentPathPlatform::Posix + }; + let scope = WorkspaceScope::new(&request.workspace_root, platform)?; + let windows = matches!(platform, DocumentPathPlatform::Windows); + let missing = || json!({"document": null}); + if request.input.is_empty() + || request.input.len() > MAX_PATH_BYTES + || request.input.contains('\0') + { + return Ok(missing()); + } + // URL parsing handles localhost, drive authorities and escaped components; + // reuse workspace-edit path decoding instead of maintaining another decoder. + let normalized; + let input = if request + .input + .get(..5) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("file:")) + { + let Ok(url) = Url::parse(&request.input) else { + return Ok(missing()); + }; + if (!windows && url.host_str().is_some()) + || !url.username().is_empty() + || url.port().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Ok(missing()); + } + normalized = url.to_string(); + normalized.as_str() + } else { + &request.input + }; + let Some(mut path) = native_path(input) else { + return Ok(missing()); + }; + if windows { + path = path.replace('\\', "/"); + let bytes = path.as_bytes(); + let drive = bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && bytes[2] == b'/'; + if !drive && !path.starts_with("//") { + if path.starts_with('/') || path.contains(':') { + return Ok(missing()); + } + path = format!( + "{}/{}", + request.workspace_root.trim_end_matches(['/', '\\']), + path + ); + } + } else if !path.starts_with('/') { + path = format!("{}/{}", request.workspace_root.trim_end_matches('/'), path); + } + let Ok(uri) = platform.document_uri(&path) else { + return Ok(missing()); + }; + let Some(relative) = scope.relative_path(&path)? else { + return Ok(missing()); + }; + let Some(path) = native_path(&uri) else { + return Ok(missing()); + }; + let path = if windows { + path.replace('/', "\\") + } else { + path + }; + Ok(json!({"document": {"path": path, "relativePath": relative}})) +} + +#[cfg(test)] +mod tests { + use serde_json::{json, Value}; + + #[test] + fn matches_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/lsp/document-identity-v1.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let response: Value = serde_json::from_str(&crate::execute_json( + &json!({"command":"lsp.documentIdentity", "payload":case["request"]}).to_string(), + )) + .unwrap(); + if let Some(code) = case.get("error") { + assert_eq!(&response["error"]["code"], code, "{case}"); + } else { + assert_eq!(response["data"], case["expected"], "{case}: {response}"); + } + } + } +} diff --git a/rust/lithe-core/src/lsp/interface/client.rs b/rust/lithe-core/src/lsp/interface/client.rs index ae65cc3a..8d4166f6 100644 --- a/rust/lithe-core/src/lsp/interface/client.rs +++ b/rust/lithe-core/src/lsp/interface/client.rs @@ -1740,21 +1740,20 @@ fn parse_lsp_position_value(value: &Value) -> Option { })) } +/// Decodes a file URI using native drive/UNC syntax; opaque inputs retain +/// their percent-decoded spelling for existing non-file consumers. pub(crate) fn file_path_from_uri(uri: &str) -> String { file_path_for_uri(uri).unwrap_or_else(|| percent_decode(uri)) } fn file_path_for_uri(uri: &str) -> Option { - let (scheme, remainder) = uri.split_once(':')?; + let (scheme, _) = uri.split_once(':')?; if !scheme.eq_ignore_ascii_case("file") { return None; } - let path = if remainder.starts_with("///") { - &remainder[2..] - } else { - remainder - }; - Some(percent_decode(path)) + // Workspace edits already decode percent escapes, UNC authorities and the + // leading slash of file:///C:/... without depending on the build host OS. + crate::lsp::workspace_edit_plan::native_path(uri) } /// Decodes `%XX` escapes in a URI component, keeping the input when the diff --git a/rust/lithe-core/src/lsp/interface/document_uri.rs b/rust/lithe-core/src/lsp/interface/document_uri.rs index e5214ffe..20087a6f 100644 --- a/rust/lithe-core/src/lsp/interface/document_uri.rs +++ b/rust/lithe-core/src/lsp/interface/document_uri.rs @@ -5,9 +5,9 @@ use serde::Deserialize; use serde_json::{json, Value}; use url::Url; -// A Windows extended-length path can contain 32,767 UTF-16 units; its UTF-8 -// representation may use more bytes, so do not reject valid non-ASCII paths. -const MAX_PATH_BYTES: usize = 4 * 32_767; +/// Largest UTF-8 spelling of a Windows extended-length path (32,767 UTF-16 +/// units); shared by native path and server document identity validation. +pub(crate) const MAX_PATH_BYTES: usize = 4 * 32_767; #[derive(Deserialize)] #[serde(deny_unknown_fields)] diff --git a/rust/lithe-core/src/lsp/interface/engine.rs b/rust/lithe-core/src/lsp/interface/engine.rs index cf59bb48..f827c65a 100644 --- a/rust/lithe-core/src/lsp/interface/engine.rs +++ b/rust/lithe-core/src/lsp/interface/engine.rs @@ -60,6 +60,9 @@ static ENGINE: OnceLock = OnceLock::new(); #[path = "engine_function_relay.rs"] mod relay_hub; use relay_hub::RelayHub; +#[path = "engine_function_provider.rs"] +mod function_provider; +pub use function_provider::function_provider_command; #[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] diff --git a/rust/lithe-core/src/lsp/interface/engine_function_provider.rs b/rust/lithe-core/src/lsp/interface/engine_function_provider.rs new file mode 100644 index 00000000..48620f31 --- /dev/null +++ b/rust/lithe-core/src/lsp/interface/engine_function_provider.rs @@ -0,0 +1,172 @@ +//! Read-only function routing within the requesting workspace and native owner. +//! Runtime registration remains authoritative; native facts only restrict which +//! locally owned clients, and installed versions, may receive the result. + +use super::engine; +use crate::protocol::{CoreError, ErrorCode}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use url::Url; + +const MAX_CANDIDATES: usize = 64; +const MAX_ID_BYTES: usize = 1024; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Candidate { + #[serde(rename = "sessionID")] + session_id: String, + version: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Request { + #[serde(rename = "pluginID")] + plugin_id: String, + version: String, + function: String, + /// Canonical file URI supplied by the native workspace owner. + root_uri: String, + candidates: Vec, +} + +// Only the engine constructs these facts in production. Tests supply inert +// snapshots to cover selection independently of process scheduling. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct RuntimeFact { + #[serde(rename = "sessionID")] + session_id: String, + #[serde(rename = "pluginID")] + plugin_id: String, + root_uri: String, + serves: bool, +} + +fn invalid() -> CoreError { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid function-provider selection request", + ) +} + +fn root_identity(uri: &str) -> Result { + if uri.len() > crate::lsp::MAX_PATH_BYTES || uri.contains('\0') { + return Err(invalid()); + } + let parsed = Url::parse(uri).map_err(|_| invalid())?; + if parsed.scheme() != "file" + || parsed.query().is_some() + || parsed.fragment().is_some() + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.port().is_some() + { + return Err(invalid()); + } + // Both inputs originate in lsp.documentUri at session creation and workspace + // selection. This does not resolve filesystem aliases or follow symlinks. + Ok(parsed.to_string().trim_end_matches('/').to_owned()) +} + +fn candidates(request: &Request) -> Result, CoreError> { + if request.candidates.len() > MAX_CANDIDATES { + return Err(invalid()); + } + for value in [&request.plugin_id, &request.version, &request.function] { + if value.is_empty() || value.len() > MAX_ID_BYTES || value.contains('\0') { + return Err(invalid()); + } + } + root_identity(&request.root_uri)?; + let mut result = BTreeMap::new(); + for candidate in &request.candidates { + if candidate.session_id.is_empty() + || candidate.session_id.len() > MAX_ID_BYTES + || candidate.version.len() > MAX_ID_BYTES + || result + .insert(candidate.session_id.as_str(), candidate.version.as_str()) + .is_some() + { + return Err(invalid()); + } + } + Ok(result) +} + +fn select(request: &Request, facts: &[RuntimeFact]) -> Result, CoreError> { + let candidates = candidates(request)?; + let root = root_identity(&request.root_uri)?; + for (id, version) in candidates { + if version != request.version { + continue; + } + if facts.iter().any(|fact| { + fact.session_id == id + && fact.plugin_id == request.plugin_id + && fact.serves + && root_identity(&fact.root_uri).ok().as_deref() == Some(root.as_str()) + }) { + return Ok(Some(id.to_owned())); + } + } + Ok(None) +} + +/// Selects only an already running, registered provider among native-owned +/// clients. It never starts a process, consumes events, or crosses workspaces. +pub fn function_provider_command(payload: Value) -> Result { + let request: Request = serde_json::from_value(payload).map_err(|_| invalid())?; + candidates(&request)?; + let sessions = { + let registry = engine() + .sessions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + request + .candidates + .iter() + .filter_map(|candidate| registry.get(&candidate.session_id).cloned()) + .collect::>() + }; + // Release the registry lock before inspecting any session's protocol state. + let facts = sessions + .iter() + .map(|session| RuntimeFact { + session_id: session.id.clone(), + plugin_id: session.plugin_id.clone().unwrap_or_default(), + root_uri: session.root_uri.clone(), + serves: session.serves_function(&request.function), + }) + .collect::>(); + Ok(json!({"sessionID":select(&request,&facts)?})) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn function_provider_selection_matches_shared_workspace_fixture() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/plugins/settings-function-provider.json" + ))) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let request: Request = serde_json::from_value(case["request"].clone()).unwrap(); + let facts: Vec = serde_json::from_value(case["facts"].clone()).unwrap(); + let result = select(&request, &facts); + if case["invalid"] == true { + assert!(result.is_err(), "{case}") + } else { + assert_eq!( + serde_json::to_value(result.unwrap()).unwrap(), + case["sessionID"], + "{case}" + ) + } + } + } +} diff --git a/rust/lithe-core/src/lsp/interface/engine_function_relay.rs b/rust/lithe-core/src/lsp/interface/engine_function_relay.rs index 95d242ce..1c8a89b5 100644 --- a/rust/lithe-core/src/lsp/interface/engine_function_relay.rs +++ b/rust/lithe-core/src/lsp/interface/engine_function_relay.rs @@ -638,7 +638,7 @@ pub(super) fn observe_functions_changed(session: &RuntimeSession) { impl RuntimeSession { /// Whether a call to `function_id` can be written to this session now. - fn serves_function(&self, function_id: &str) -> bool { + pub(super) fn serves_function(&self, function_id: &str) -> bool { let Ok(state) = self.lock_state() else { return false; }; diff --git a/rust/lithe-core/src/lsp/interface/mod.rs b/rust/lithe-core/src/lsp/interface/mod.rs index e86822b3..cb3a7c5b 100644 --- a/rust/lithe-core/src/lsp/interface/mod.rs +++ b/rust/lithe-core/src/lsp/interface/mod.rs @@ -22,7 +22,9 @@ mod types; mod vcs_provider; pub(crate) use client::*; -pub(crate) use document_uri::{document_uri_command, resolve_request_paths, DocumentPathPlatform}; +pub(crate) use document_uri::{ + document_uri_command, resolve_request_paths, DocumentPathPlatform, MAX_PATH_BYTES, +}; pub(crate) use engine::*; pub(crate) use engine_logging::{resolve_engine_log_subject, ResolveEngineLogSubjectRequest}; pub(crate) use function_relay::RelaySnapshot; diff --git a/rust/lithe-core/src/lsp/mod.rs b/rust/lithe-core/src/lsp/mod.rs index 5bea638b..37f8fb2b 100644 --- a/rust/lithe-core/src/lsp/mod.rs +++ b/rust/lithe-core/src/lsp/mod.rs @@ -1,5 +1,6 @@ //! Language tooling is split by responsibility while this facade keeps the Core command API stable. +pub(crate) mod document_identity; pub(crate) mod host_policy; pub(crate) mod interface; mod languages; diff --git a/rust/lithe-core/src/lsp/tests.rs b/rust/lithe-core/src/lsp/tests.rs index 5ec1f79f..8fc0eebc 100644 --- a/rust/lithe-core/src/lsp/tests.rs +++ b/rust/lithe-core/src/lsp/tests.rs @@ -426,6 +426,14 @@ fn builtin_navigation_prefers_declarations_and_finds_references() { #[test] fn file_uri_paths_decode_spaces_and_utf8_characters() { + assert_eq!( + file_path_from_uri("file:///C:/work/Main.java"), + "C:/work/Main.java" + ); + assert_eq!( + file_path_from_uri("file:///C%3A/work/A%20B.java"), + "C:/work/A B.java" + ); assert_eq!( file_path_from_uri("file:///tmp/go%20project/%E4%B8%AD%E6%96%87/main.go"), "/tmp/go project/中文/main.go" diff --git a/rust/lithe-core/src/lsp/workspace_edit_plan.rs b/rust/lithe-core/src/lsp/workspace_edit_plan.rs index c3c75d56..f67feaae 100644 --- a/rust/lithe-core/src/lsp/workspace_edit_plan.rs +++ b/rust/lithe-core/src/lsp/workspace_edit_plan.rs @@ -47,10 +47,11 @@ struct Target { edits: Vec, } -/// Native path named by a workspace-edit key, or `None` for a non-file URI. +/// Native path named by an LSP document or workspace-edit key; `None` for a +/// non-file URI. Shared with navigation results and diagnostic placement. /// Core's URI projection keeps the leading slash of `file:///C:/...`; strip it /// so drive paths parse as Windows paths. -fn native_path(key: &str) -> Option { +pub(super) fn native_path(key: &str) -> Option { let path = match key.split_once(':') { // A one-letter "scheme" is a drive letter, not a URI scheme. Some((scheme, rest)) diff --git a/rust/lithe-core/src/plugins/declarative.rs b/rust/lithe-core/src/plugins/declarative.rs index ae23b96e..66b9b857 100644 --- a/rust/lithe-core/src/plugins/declarative.rs +++ b/rust/lithe-core/src/plugins/declarative.rs @@ -119,7 +119,7 @@ const HOST_CAPABILITY_TESTING: &str = "testing"; /// CPU architectures a host may report for `${arch}`. const KNOWN_ARCHITECTURES: &[&str] = &["arm64", "x86_64"]; /// Most settings one plugin may declare, so its pages stay pages. -const MAX_SETTINGS: usize = 32; +pub(crate) const MAX_SETTINGS: usize = 32; /// Prefix of a launch template variable that names a plugin setting. const SETTING_VARIABLE_PREFIX: &str = "setting:"; @@ -3790,6 +3790,7 @@ fn backend_resolved_launch_plan( } let settings = manifest.effective_settings(request.layers()); let mut plan = json!({ + "owner": {"pluginID":manifest.id,"version":manifest.version}, "launch": { "kind": "backend", "function": function, @@ -4374,6 +4375,7 @@ fn server_launch_plan( let validation_arguments = expand_all(&process.validation_arguments, &mut requirements); let mut plan = json!({ + "owner": {"pluginID":manifest.id,"version":manifest.version}, "launch": launch, "arguments": arguments, "validationArguments": validation_arguments, @@ -5023,6 +5025,138 @@ pub fn resolve_settings_command(payload: Value) -> Result { )) } +#[path = "settings_surfaces.rs"] +mod settings_surfaces; +pub use settings_surfaces::{ + settings_catalog_command, settings_delivery_command, settings_navigation_command, +}; + +/// Installed package identity captured by the native registration adapter. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SettingsOwner { + #[serde(rename = "pluginID")] + plugin_id: String, + version: String, +} + +/// Complete stored setting documents, captured by the native storage adapter. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SettingsStatePayload { + manifest: Value, + /// Optional native registration facts; this is identity binding, not admission. + owner: Option, + /// Missing files are null; an existing empty or malformed file is an error. + global_document: Option, + project_document: Option, +} + +/// `plugins.declarative.settingsState`: checked UI rows and raw declared launch +/// layers. Reading a state neither edits storage nor admits plugin capabilities. +pub fn settings_state_command(payload: Value) -> Result { + let payload: SettingsStatePayload = + decode_payload(payload, "plugins.declarative.settingsState")?; + Ok(outcome(validate_manifest_value(payload.manifest).and_then( + |manifest| { + if payload.owner.as_ref().is_some_and(|owner| { + owner.plugin_id != manifest.id || owner.version != manifest.version + }) { + return Err(PluginRejection::at( + "plugin.settings.ownerChanged", + "owner", + "The manifest does not match the captured installed package identity.", + )); + } + super::settings::document_state( + &manifest.id, + &manifest.settings, + &manifest.setting_actions, + payload.global_document.as_deref(), + payload.project_document.as_deref(), + ) + .map(|mut state| { + state["metadata"] = json!({"displayName": manifest.display_name, "version":manifest.version, "vendor":manifest.vendor.display_name}); + ("state", state) + }) + }, + ))) +} + +/// Native facts for one explicitly selected installed setting action. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SettingsActionPayload { + manifest: Value, + owner: SettingsOwner, + #[serde(rename = "actionID")] + action_id: String, + /// Captured by the native confirmation UI, not inferred from saved preferences. + confirmed: bool, + /// Current native enablement choice; a stored draft cannot activate a plugin. + enabled: bool, + /// Describes the requesting window; runtime ownership is checked on invocation. + workspace_available: bool, +} + +/// Plans a declared settings action without starting or admitting its provider. +/// Native callers retain package/project ownership and confirmation checks. +pub fn settings_action_plan_command(payload: Value) -> Result { + let payload: SettingsActionPayload = + decode_payload(payload, "plugins.declarative.settingsActionPlan")?; + Ok(outcome(validate_manifest_value(payload.manifest).and_then( + |manifest| { + if payload.owner.plugin_id != manifest.id || payload.owner.version != manifest.version { + return Err(PluginRejection::at( + "plugin.settings.ownerChanged", + "owner", + "The installed package identity changed before the action was planned.", + )); + } + super::settings::plan_action( + &manifest.id, + &manifest.setting_actions, + &payload.action_id, + payload.confirmed, + payload.workspace_available, + payload.enabled, + ) + .map(|mut plan| { + plan["version"] = json!(manifest.version); + ("plan", plan) + }) + }, + ))) +} + +/// One layer and one atomic edit batch; the host owns its captured byte baseline. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SettingsEditPayload { + manifest: Value, + scope: super::settings::SettingScope, + document: Option, + edits: Vec, +} + +/// `plugins.declarative.editSettings`: validates all edits and returns complete +/// text, deletion eligibility and required effects, without performing any I/O. +pub fn edit_settings_command(payload: Value) -> Result { + let payload: SettingsEditPayload = decode_payload(payload, "plugins.declarative.editSettings")?; + Ok(outcome(validate_manifest_value(payload.manifest).and_then( + |manifest| { + super::settings::edit_document( + &manifest.id, + &manifest.settings, + payload.scope, + payload.document.as_deref(), + payload.edits, + ) + .map(|edit| ("edit", edit)) + }, + ))) +} + /// `plugins.declarative.workspaceConfiguration`: a language server's /// `workspaceConfiguration` expanded with the current values, which the host /// sends to the running server after an `immediate` setting changed. diff --git a/rust/lithe-core/src/plugins/installed.rs b/rust/lithe-core/src/plugins/installed.rs new file mode 100644 index 00000000..5fc0f4f9 --- /dev/null +++ b/rust/lithe-core/src/plugins/installed.rs @@ -0,0 +1,115 @@ +//! Selection of native installed-version facts without filesystem access. +//! +//! Core reuses its existing compatibility version comparator. The host keeps +//! paths and maps the returned indices back to the native records it supplied. + +use super::PluginVersion; +use crate::protocol::{CoreError, ErrorCode}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet}; + +/// Bound one metadata inventory and every returned selection/diagnostic list. +const MAX_INSTALLED_ENTRIES: usize = 4096; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct InstalledFact { + #[serde(rename = "pluginID")] + plugin_id: String, + version: String, + enabled: bool, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SelectionRequest { + entries: Vec, + #[serde(default)] + include_disabled: bool, +} + +/// Selects the numeric newest version per opaque native plugin identity. +/// +/// Invalid and duplicate records are returned as indexed diagnostics while +/// healthy plugins remain available. Enablement filters the selected version, +/// never reviving an older enabled record behind a disabled newest one. +pub fn select_command(payload: Value) -> Result { + let request: SelectionRequest = serde_json::from_value(payload).map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid installed plugin facts.") + .with_details(error.to_string()) + })?; + if request.entries.len() > MAX_INSTALLED_ENTRIES { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Installed plugin inventory exceeds its limit.", + )); + } + let mut newest: BTreeMap<&str, (usize, PluginVersion, bool)> = BTreeMap::new(); + let mut seen = BTreeSet::new(); + let mut rejected = Vec::new(); + for (index, entry) in request.entries.iter().enumerate() { + if entry.plugin_id.is_empty() + || entry.plugin_id.len() > 256 + || entry.plugin_id.trim() != entry.plugin_id + { + rejected.push(json!({"index":index,"code":"plugin.installed.invalidIdentity"})); + continue; + } + let Some(version) = super::declarative::is_semantic_version(&entry.version) + .then(|| PluginVersion::parse(&entry.version)) + .flatten() + else { + rejected.push(json!({"index":index,"code":"plugin.installed.invalidVersion"})); + continue; + }; + if !seen.insert((&entry.plugin_id, &entry.version)) { + rejected.push(json!({"index":index,"code":"plugin.installed.duplicateVersion"})); + continue; + } + let selected = newest + .entry(&entry.plugin_id) + .or_insert((index, version, entry.enabled)); + if version > selected.1 { + *selected = (index, version, entry.enabled); + } + } + let selected: Vec<_> = newest + .into_values() + .filter(|(_, _, enabled)| request.include_disabled || *enabled) + .map(|(index, _, _)| index) + .collect(); + Ok(json!({"selected":selected,"rejected":rejected,"maximumEntries":MAX_INSTALLED_ENTRIES})) +} + +#[cfg(test)] +mod tests { + use serde_json::{json, Value}; + + #[test] + fn shared_installed_version_fixture_uses_the_existing_numeric_comparator() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/plugins/installed-versions.json" + ))) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let response: Value = serde_json::from_str(&crate::execute_json(&json!({ + "command":"plugins.installed.select", "payload":{ + "entries":case["entries"], "includeDisabled":case["includeDisabled"].as_bool().unwrap_or(false) + } + }).to_string())).unwrap(); + assert_eq!(response["ok"], true, "case {}: {response}", case["name"]); + assert_eq!( + response["data"]["selected"], case["selected"], + "case {}", + case["name"] + ); + assert_eq!( + response["data"]["rejected"], case["rejected"], + "case {}", + case["name"] + ); + } + } +} diff --git a/rust/lithe-core/src/plugins/mod.rs b/rust/lithe-core/src/plugins/mod.rs index 31a792d3..d6d7400a 100644 --- a/rust/lithe-core/src/plugins/mod.rs +++ b/rust/lithe-core/src/plugins/mod.rs @@ -12,6 +12,8 @@ pub(crate) mod executables; pub(crate) mod grammar; /// Public host-method catalog and authorization for platform dispatchers. pub(crate) mod host_api; +/// Numeric version selection over bounded native installation facts. +pub mod installed; pub(crate) mod keymap_import; /// Session fields of plugin-owned language servers and the launch their /// backend resolves. diff --git a/rust/lithe-core/src/plugins/settings.rs b/rust/lithe-core/src/plugins/settings.rs index e2be5533..8cf4c8f6 100644 --- a/rust/lithe-core/src/plugins/settings.rs +++ b/rust/lithe-core/src/plugins/settings.rs @@ -15,10 +15,17 @@ //! //! Note: the decision is recorded in .agents/notes/implemented/architecture/2026-10-21-plugin-settings-surfaces.md +#[path = "settings_documents.rs"] +mod documents; +pub(crate) use documents::{document_state, edit_document, SettingsEdit}; +#[path = "settings_pages.rs"] +mod pages; +pub(crate) use pages::page_sections; + use super::declarative::{invalid, PluginRejection, Validation}; use regex::Regex; use serde::{Deserialize, Serialize}; -use serde_json::Value; +use serde_json::{json, Value}; use std::collections::{BTreeMap, BTreeSet}; /// A whole number within a declared range. @@ -77,7 +84,7 @@ pub(crate) enum SettingEffect { /// The settings page that shows a setting besides the plugin's own page. /// A plugin may add a setting to a language or build system another plugin /// declares; the host shows the page while any plugin contributes to it. -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) enum SettingPage { /// Languages & Frameworks › . @@ -117,6 +124,43 @@ pub(crate) struct SettingAction { confirm: bool, } +/// Resolves one declared action without native I/O or runtime admission. Only +/// an explicit native confirmation allows a confirmation-required invocation. +pub(crate) fn plan_action( + plugin_id: &str, + actions: &[SettingAction], + action_id: &str, + confirmed: bool, + workspace_available: bool, + enabled: bool, +) -> Validation { + let action = actions + .iter() + .find(|action| action.id == action_id) + .ok_or_else(|| { + PluginRejection::at( + "plugin.settings.actionUnavailable", + "actionID", + "The installed plugin does not declare this setting action.", + ) + })?; + let mut plan = json!({"pluginID":plugin_id,"actionID":action.id,"title":action.title}); + if !enabled { + plan["kind"] = json!("unavailable"); + plan["reason"] = json!("pluginDisabled"); + } else if !workspace_available { + plan["kind"] = json!("unavailable"); + plan["reason"] = json!("projectRequired"); + } else if action.confirm && !confirmed { + plan["kind"] = json!("confirmationRequired"); + } else { + plan["kind"] = json!("invoke"); + plan["function"] = json!(action.function); + plan["params"] = json!({}); + } + Ok(plan) +} + /// Most actions one plugin may declare. pub(crate) const MAX_SETTING_ACTIONS: usize = 16; @@ -934,3 +978,7 @@ mod tests { assert!(wrong.validate("settings[0]", 2).is_err()); } } + +#[cfg(test)] +#[path = "settings_document_tests.rs"] +mod document_tests; diff --git a/rust/lithe-core/src/plugins/settings_document_tests.rs b/rust/lithe-core/src/plugins/settings_document_tests.rs new file mode 100644 index 00000000..ad711928 --- /dev/null +++ b/rust/lithe-core/src/plugins/settings_document_tests.rs @@ -0,0 +1,377 @@ +//! Consumer-facing command regressions for plugin settings document ownership. + +use serde_json::{json, Value}; + +fn manifest() -> Value { + serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/contracts/plugins/v1/fixtures/settings/settings-v2.plugin.json" + ))) + .unwrap() +} + +fn execute(command: &str, payload: Value) -> Value { + serde_json::from_str(&crate::execute_json( + &json!({ + "command": command, "payload": payload + }) + .to_string(), + )) + .unwrap() +} + +#[test] +fn settings_owner_mismatch_reports_the_owner_field_without_exposing_state() { + let declared = manifest(); + for owner in [ + json!({"pluginID":"dev.example.other", "version":declared["version"]}), + json!({"pluginID":declared["id"], "version":"999.0.0"}), + ] { + let response = execute( + "plugins.declarative.settingsState", + json!({"manifest":declared, "owner":owner}), + ); + let data = &response["data"]; + assert_eq!(data["status"], "rejected", "{response}"); + assert_eq!(data["error"]["code"], "plugin.settings.ownerChanged"); + assert_eq!(data["error"]["field"], "owner"); + assert_eq!( + data["error"]["message"], + "The manifest does not match the captured installed package identity." + ); + assert!(data.get("state").is_none()); + } +} + +#[test] +fn settings_state_dispatches_with_core_owned_sources_and_normalized_scope() { + let result = execute( + "plugins.declarative.settingsState", + json!({ + "manifest": manifest(), + "globalDocument": "{\"dev.lithe.fixture.settings-v2\":{\"dialyzer\":false}}", + "projectDocument": "{\"dev.lithe.fixture.settings-v2\":{\"mixEnv\":\"test\"}}" + }), + ); + assert_eq!(result["ok"], true, "{result}"); + assert_eq!(result["data"]["status"], "ok", "{result}"); + let state = &result["data"]["state"]; + assert_eq!(state["pluginID"], "dev.lithe.fixture.settings-v2"); + let rows = state["rows"].as_array().unwrap(); + let dialyzer = rows + .iter() + .find(|row| row["declaration"]["id"] == "dialyzer") + .unwrap(); + assert_eq!(dialyzer["effective"]["value"], false); + assert_eq!(dialyzer["effective"]["source"], "global"); + let checks = rows + .iter() + .find(|row| row["declaration"]["id"] == "checks") + .unwrap(); + assert_eq!(checks["scope"], "global"); + assert_eq!(checks["effect"], "nextRun"); + let mix = rows + .iter() + .find(|row| row["declaration"]["id"] == "mixEnv") + .unwrap(); + assert_eq!(mix["global"]["value"], "dev"); + assert_eq!(mix["effective"]["value"], "test"); + assert_eq!(mix["effective"]["source"], "project"); +} + +#[test] +fn complete_edit_fixture_preserves_types_siblings_and_atomic_rejection() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/plugins/settings-documents.json" + ))) + .unwrap(); + for case in fixture["editCases"].as_array().unwrap() { + let result = execute( + "plugins.declarative.editSettings", + json!({ + "manifest": fixture["manifest"], "scope":case["scope"], + "document":case["document"], "edits":case["edits"] + }), + ); + assert_eq!(result["ok"], true, "case {}: {result}", case["name"]); + let outcome = &result["data"]; + if let Some(code) = case["errorCode"].as_str() { + assert_eq!( + outcome["status"], "rejected", + "case {}: {result}", + case["name"] + ); + assert_eq!( + outcome["error"]["code"], code, + "case {}: {result}", + case["name"] + ); + assert!( + outcome.get("edit").is_none(), + "rejected edits must not expose partial content" + ); + } else { + assert_eq!(outcome["status"], "ok", "case {}: {result}", case["name"]); + let text = outcome["edit"]["document"].as_str().unwrap(); + assert_eq!( + serde_json::from_str::(text).unwrap(), + case["expected"], + "case {}", + case["name"] + ); + assert_eq!( + outcome["edit"]["effects"], case["effects"], + "case {}", + case["name"] + ); + assert_eq!( + outcome["edit"]["delete"], + case["delete"].as_bool().unwrap_or(false) + ); + if case["unchanged"] == true { + assert_eq!(outcome["edit"]["changed"], false); + assert_eq!(text, case["document"].as_str().unwrap()); + } + } + } +} + +#[test] +fn missing_files_are_empty_but_empty_existing_documents_are_rejected() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/plugins/settings-documents.json" + ))) + .unwrap(); + let valid = execute( + "plugins.declarative.settingsState", + json!({"manifest": fixture["manifest"]}), + ); + assert_eq!(valid["data"]["status"], "ok", "{valid}"); + let invalid = execute( + "plugins.declarative.settingsState", + json!({"manifest": fixture["manifest"], "projectDocument":""}), + ); + assert_eq!( + invalid["data"]["error"]["code"], "plugin.settings.invalidDocument", + "{invalid}" + ); +} + +#[test] +fn invalid_stored_values_fall_back_without_hiding_raw_executable_overrides() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/plugins/settings-documents.json" + ))) + .unwrap(); + let result = execute( + "plugins.declarative.settingsState", + json!({"manifest": fixture["manifest"], + "globalDocument": "{\"dev.example.settings\":{\"flag\":\"false\",\"program\":\"relative\",\"dormant\":true}}", + "projectDocument": "{\"dev.example.settings\":{\"count\":9}}"}), + ); + let state = &result["data"]["state"]; + let rows = state["rows"].as_array().unwrap(); + let flag = rows + .iter() + .find(|row| row["declaration"]["id"] == "flag") + .unwrap(); + assert_eq!(flag["effective"]["value"], true); + assert_eq!(flag["effective"]["source"], "default"); + let count = rows + .iter() + .find(|row| row["declaration"]["id"] == "count") + .unwrap(); + assert_eq!(count["effective"]["value"], 3); + assert_eq!(state["settings"]["program"], "relative"); + assert!(state["settings"].get("dormant").is_none()); +} + +#[test] +fn document_size_and_batch_limits_reject_before_returning_replacement_text() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/plugins/settings-documents.json" + ))) + .unwrap(); + let oversized = execute( + "plugins.declarative.editSettings", + json!({"manifest":fixture["manifest"], + "scope":"global", "document":" ".repeat(1024*1024+1), "edits":[{"id":"flag","value":false}]}), + ); + assert_eq!( + oversized["data"]["error"]["code"], + "plugin.settings.invalidDocument" + ); + let edits: Vec<_> = (0..33) + .map(|_| json!({"id":"flag","value":false})) + .collect(); + let oversized = execute( + "plugins.declarative.editSettings", + json!({"manifest":fixture["manifest"], + "scope":"global", "edits":edits}), + ); + assert_eq!( + oversized["data"]["error"]["code"], + "plugin.settings.invalidEdit" + ); +} + +#[test] +fn plugins_without_setting_declarations_do_not_request_native_document_reads() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/plugins/settings-documents.json" + ))) + .unwrap(); + let mut manifest = fixture["manifest"].clone(); + manifest.as_object_mut().unwrap().remove("settings"); + manifest["requires"] + .as_object_mut() + .unwrap() + .remove("settings"); + let result = execute( + "plugins.declarative.settingsState", + json!({"manifest": manifest}), + ); + assert_eq!(result["data"]["status"], "ok", "{result}"); + assert_eq!(result["data"]["state"]["storage"]["readRequired"], false); + assert_eq!(result["data"]["state"]["rows"], json!([])); +} + +#[test] +fn editor_projection_owns_scope_reset_values_and_section_grouping() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/plugins/settings-documents.json" + ))) + .unwrap(); + let mut manifest = fixture["manifest"].clone(); + manifest["settings"][0]["section"] = json!("Server"); + manifest["settings"][1]["section"] = json!("General"); + let result = execute( + "plugins.declarative.settingsState", + json!({"manifest": manifest, + "globalDocument":"{\"dev.example.settings\":{\"flag\":false,\"count\":\"invalid\"}}", + "projectDocument":"{\"dev.example.settings\":{\"flag\":true}}"}), + ); + assert_eq!(result["data"]["status"], "ok", "{result}"); + let state = &result["data"]["state"]; + assert_eq!(state["metadata"]["displayName"], "Settings Fixture"); + assert_eq!(state["metadata"]["version"], "1.0.0"); + assert_eq!(state["sections"][0]["title"], ""); + assert_eq!(state["sections"][1]["title"], "General"); + assert_eq!(state["sections"][2]["title"], "Server"); + let rows = state["rows"].as_array().unwrap(); + let flag = rows + .iter() + .position(|row| row["declaration"]["id"] == "flag") + .unwrap(); + let count = rows + .iter() + .position(|row| row["declaration"]["id"] == "count") + .unwrap(); + assert_eq!(state["sections"][1]["rows"], json!([count])); + assert_eq!(state["sections"][2]["rows"], json!([flag])); + let project = &state["editors"]["project"]; + assert_eq!(project[flag]["value"], true); + assert_eq!(project[flag]["input"]["on"], true); + assert_eq!(project[flag]["resetInput"]["on"], false); + assert_eq!(project[flag]["resetValue"], false); + assert_eq!(project[flag]["editable"], true); + assert_eq!(project[flag]["overridden"], true); + assert_eq!(project[count]["editable"], false); + assert_eq!(project[count]["value"], 3); + assert_eq!(project[count]["input"]["text"], "3"); + assert_eq!(project[count]["invalidStored"], true); + let global = &state["editors"]["global"]; + assert_eq!(global[flag]["value"], false); + assert_eq!(global[flag]["resetValue"], true); + assert_eq!(global[flag]["source"], "global"); + assert_eq!(global[count]["invalidStored"], true); +} + +#[test] +fn setting_action_plans_match_shared_fixture_without_starting_a_process() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/plugins/settings-actions.json" + ))) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let mut declared = manifest(); + if let Some(confirmation) = case.get("confirmation") { + declared["settingActions"][0]["confirm"] = confirmation.clone(); + } + let mut input = case["input"].clone(); + input["manifest"] = declared; + let response = execute("plugins.declarative.settingsActionPlan", input); + if let Some(code) = case.get("error") { + assert_eq!( + &response["data"]["error"]["code"], code, + "{case}: {response}" + ); + } else { + assert_eq!(response["data"]["plan"], case["plan"], "{case}: {response}"); + } + } +} + +#[test] +fn settings_surfaces_shared_vectors() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/plugins/settings-surfaces.json" + ))) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let response = execute(case["command"].as_str().unwrap(), case["input"].clone()); + assert_eq!(response["ok"], true, "{}: {response}", case["name"]); + let data = &response["data"]; + assert_eq!( + data["status"], case["status"], + "{}: {response}", + case["name"] + ); + if let Some(count) = case["pages"].as_u64() { + assert_eq!( + data["pages"].as_array().unwrap().len() as u64, + count, + "{response}" + ); + } + if !case["delivery"].is_null() { + assert_eq!(data["delivery"], case["delivery"], "{response}"); + } + if !case["rowIDs"].is_null() { + assert_eq!( + json!(data["rows"] + .as_array() + .unwrap() + .iter() + .map(|row| row["id"].clone()) + .collect::>()), + case["rowIDs"], + "{response}" + ); + } + } +} + +#[test] +fn settings_navigation_reserves_capacity_for_host_rows() { + let mut items = vec![ + json!({"id":"languages","title":"Languages","parent":null,"group":false,"expanded":false}), + ]; + for index in 0..512 { + items.push(json!({"id":format!("language:{index}"),"title":format!("Language {index}"),"parent":"languages","group":false,"expanded":false})); + } + let response = execute( + "plugins.settings.navigation", + json!({"items":items,"query":""}), + ); + assert_eq!(response["data"]["status"], "ok", "{response}"); + assert_eq!(response["data"]["rows"].as_array().unwrap().len(), 513); +} diff --git a/rust/lithe-core/src/plugins/settings_documents.rs b/rust/lithe-core/src/plugins/settings_documents.rs new file mode 100644 index 00000000..e41975ac --- /dev/null +++ b/rust/lithe-core/src/plugins/settings_documents.rs @@ -0,0 +1,284 @@ +//! Bounded plugin preference documents, edit validation and source projections. +//! +//! Native adapters own storage, conflict checks and file publication. This +//! module never opens paths or grants permission to start a plugin process. + +use super::{ + resolve, SettingAction, SettingDeclaration, SettingEffect, SettingLayers, SettingScope, +}; +use crate::plugins::declarative::{is_plugin_id, PluginRejection, Validation}; +use crate::protocol::json_document::{parse_unique_json, UniqueMap}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet}; + +/// Most UTF-8 bytes one stored layer or edited document may contain. +const MAX_DOCUMENT_BYTES: usize = 1024 * 1024; + +/// One value change or unparsed editor input. Without text, null removes the +/// override and exposes inheritance. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct SettingsEdit { + /// Declaration identifier, unique within the complete edit batch. + pub(crate) id: String, + /// A declaration-validated value, or null to remove only this layer. + pub(crate) value: Value, + /// Unparsed editor text. When present, value must be null; Core converts + /// integer text before applying the declaration's ordinary validation. + #[serde(default)] + pub(crate) text: Option, +} + +type Document = UniqueMap>; + +fn invalid_document(field: &str) -> PluginRejection { + PluginRejection::at( + "plugin.settings.invalidDocument", + field, + "Expected a bounded JSON object of plugin IDs and unique setting maps.", + ) +} + +fn read_document(content: Option<&str>, field: &str) -> Validation { + let Some(content) = content else { + return Ok(Document::default()); + }; + if content.len() > MAX_DOCUMENT_BYTES { + return Err(invalid_document(field)); + } + let checked = parse_unique_json(content).map_err(|_| invalid_document(field))?; + let document: Document = + serde_json::from_value(checked).map_err(|_| invalid_document(field))?; + for (plugin, settings) in &document.0 { + if !is_plugin_id(plugin) + || settings + .0 + .keys() + .any(|id| !super::is_setting_identifier(id)) + { + return Err(invalid_document(field)); + } + } + Ok(document) +} + +/// Projects declared rows and raw launch layers from complete native documents. +/// Unknown settings remain in storage but are not sent across the UI boundary. +pub(crate) fn document_state( + plugin: &str, + declarations: &[SettingDeclaration], + actions: &[SettingAction], + global: Option<&str>, + project: Option<&str>, +) -> Validation { + let global = read_document(global, "globalDocument")?; + let project = read_document(project, "projectDocument")?; + let empty = BTreeMap::new(); + let global = global.0.get(plugin).map(|group| &group.0).unwrap_or(&empty); + let project = project + .0 + .get(plugin) + .map(|group| &group.0) + .unwrap_or(&empty); + let global_resolved = resolve( + declarations, + SettingLayers { + global, + project: &empty, + }, + ); + let effective = resolve(declarations, SettingLayers { global, project }); + let rows: Vec<_> = declarations.iter().zip(global_resolved.iter()).zip(effective.iter()).map(|((declaration, global_value), effective)| json!({ + "declaration": declaration, "scope": declaration.scope(), "effect": declaration.effect(), + "global": global_value, "effective": effective, + "globalStored": global.contains_key(&declaration.id), + "projectStored": project.contains_key(&declaration.id), + })).collect(); + let declared_values = |values: &BTreeMap| -> BTreeMap { + declarations + .iter() + .filter_map(|declaration| { + values + .get(&declaration.id) + .map(|value| (declaration.id.clone(), value.clone())) + }) + .collect() + }; + let mut sections: BTreeMap, Vec)> = BTreeMap::new(); + let mut global_editor = Vec::with_capacity(declarations.len()); + let mut project_editor = Vec::with_capacity(declarations.len()); + for (index, ((declaration, global_value), effective_value)) in declarations + .iter() + .zip(global_resolved.iter()) + .zip(effective.iter()) + .enumerate() + { + sections + .entry(declaration.section.clone().unwrap_or_default()) + .or_default() + .0 + .push(index); + let invalid_global = global + .get(&declaration.id) + .is_some_and(|value| !declaration.accepts(value)); + let project_editable = declaration.scope() == SettingScope::Project; + let invalid_project = if project_editable { + project + .get(&declaration.id) + .is_some_and(|value| !declaration.accepts(value)) + } else { + invalid_global + }; + global_editor.push(json!({"value":global_value.value, "resetValue":declaration.default, + "input":editor_input(declaration,&global_value.value), "resetInput":editor_input(declaration,&declaration.default), + "source":global_value.source, "editable":true, "overridden":global.contains_key(&declaration.id), "invalidStored":invalid_global})); + project_editor.push(json!({"value":effective_value.value, "resetValue":global_value.value, + "input":editor_input(declaration,&effective_value.value), "resetInput":editor_input(declaration,&global_value.value), + "source":effective_value.source, "editable":project_editable, + "overridden":project_editable && project.contains_key(&declaration.id), "invalidStored":invalid_project})); + } + for (index, action) in actions.iter().enumerate() { + sections + .entry(action.section.clone().unwrap_or_default()) + .or_default() + .1 + .push(index); + } + let sections: Vec<_> = sections + .into_iter() + .map(|(title, (rows, actions))| json!({"title":title, "rows":rows, "actions":actions})) + .collect(); + Ok( + json!({"pluginID":plugin, "rows":rows, "actions":actions, "sections":sections, + "pages":super::page_sections(declarations, actions), + "editors":{"global":global_editor,"project":project_editor}, + "settings":declared_values(global), "projectSettings":declared_values(project), + "storage":{"globalRelativePath":"plugin-settings.json", "projectRelativePath":".lithe/plugin-settings.json", "maximumBytes":MAX_DOCUMENT_BYTES, "readRequired":!declarations.is_empty()}}), + ) +} + +/// Widget input for an already validated resolved value or reset value. +/// Numeric text and option positions are presentation data, not host rules. +fn editor_input(declaration: &SettingDeclaration, value: &Value) -> Value { + match declaration.kind.as_str() { + super::SETTING_INTEGER => json!({"text":value.to_string()}), + super::SETTING_BOOLEAN => json!({"on":value == &Value::Bool(true)}), + super::SETTING_ENUM => { + json!({"selected":declaration.options.iter().position(|option| value.as_str() == Some(option.value.as_str())).unwrap_or_default()}) + } + super::SETTING_MULTI_SELECT => { + json!({"checks":declaration.options.iter().map(|option| value.as_array().is_some_and(|values| values.iter().any(|value| value.as_str() == Some(option.value.as_str())))).collect::>()}) + } + _ => json!({"text":value.as_str().unwrap_or_default()}), + } +} + +/// Validates the entire batch before editing one layer. Siblings and dormant +/// values survive; the host receives text to publish, never an I/O instruction. +pub(crate) fn edit_document( + plugin: &str, + declarations: &[SettingDeclaration], + scope: SettingScope, + content: Option<&str>, + edits: Vec, +) -> Validation { + let mut document = read_document(content, "document")?; + if edits.is_empty() || edits.len() > crate::plugins::declarative::MAX_SETTINGS { + return Err(PluginRejection::at( + "plugin.settings.invalidEdit", + "edits", + "Supply one bounded edit per declared setting.", + )); + } + let mut seen = BTreeSet::new(); + let mut prepared = Vec::with_capacity(edits.len()); + for (index, mut edit) in edits.into_iter().enumerate() { + let field = format!("edits[{index}]"); + let declaration = declarations.iter().find(|setting| setting.id == edit.id); + let Some(declaration) = declaration else { + return Err(PluginRejection::at( + "plugin.settings.invalidEdit", + format!("{field}.id"), + "The setting is not declared by this plugin.", + )); + }; + if !seen.insert(edit.id.clone()) + || (scope == SettingScope::Project && declaration.scope() != SettingScope::Project) + { + return Err(PluginRejection::at( + "plugin.settings.invalidEdit", + format!("{field}.id"), + "The edit is repeated or has no project layer.", + )); + } + if let Some(text) = edit.text.take() { + if !edit.value.is_null() || text.len() > MAX_DOCUMENT_BYTES { + return Err(PluginRejection::at( + "plugin.settings.invalidEdit", + format!("{field}.text"), + "Supply bounded editor text without a separate value.", + )); + } + edit.value = if declaration.kind == super::SETTING_INTEGER { + Value::from(text.trim().parse::().map_err(|_| { + PluginRejection::at( + "plugin.settings.invalidEdit", + format!("{field}.text"), + "Enter a whole number for this setting.", + ) + })?) + } else { + Value::String(text) + }; + } + if !edit.value.is_null() && !declaration.accepts(&edit.value) { + return Err(PluginRejection::at( + "plugin.settings.invalidEdit", + format!("{field}.value"), + "The value does not satisfy its setting declaration.", + )); + } + prepared.push((edit, declaration)); + } + let original = serde_json::to_value(&document).map_err(|_| invalid_document("document"))?; + let group = document.0.entry(plugin.to_owned()).or_default(); + let mut effects = BTreeSet::new(); + for (edit, declaration) in prepared { + let changed = if edit.value.is_null() { + group.0.remove(&edit.id).is_some() + } else if group.0.get(&edit.id) != Some(&edit.value) { + group.0.insert(edit.id, edit.value); + true + } else { + false + }; + if changed { + effects.insert(match declaration.effect() { + SettingEffect::Restart => "restart", + SettingEffect::Immediate => "immediate", + SettingEffect::Reload => "reload", + SettingEffect::NextRun => "nextRun", + }); + } + } + if group.0.is_empty() { + document.0.remove(plugin); + } + let changed = + original != serde_json::to_value(&document).map_err(|_| invalid_document("document"))?; + let text = if changed { + let mut text = + serde_json::to_string_pretty(&document).map_err(|_| invalid_document("document"))?; + text.push('\n'); + text + } else { + content.unwrap_or("{}\n").to_owned() + }; + if text.len() > MAX_DOCUMENT_BYTES { + return Err(invalid_document("document")); + } + Ok( + json!({"document":text, "delete":document.0.is_empty(), "changed":changed, "effects":effects}), + ) +} diff --git a/rust/lithe-core/src/plugins/settings_pages.rs b/rust/lithe-core/src/plugins/settings_pages.rs new file mode 100644 index 00000000..06e9c31d --- /dev/null +++ b/rust/lithe-core/src/plugins/settings_pages.rs @@ -0,0 +1,41 @@ +//! Placement projections retain indices into the complete owner-bound editor. + +use super::{SettingAction, SettingDeclaration, SettingPage}; +use serde_json::{json, Value}; +use std::collections::BTreeMap; + +/// Groups only explicitly placed rows and actions. Unplaced declarations remain +/// on the plugin's own page; an action alone is sufficient to create a page. +pub(crate) fn page_sections(rows: &[SettingDeclaration], actions: &[SettingAction]) -> Vec { + let mut pages: BTreeMap, Vec)>> = + BTreeMap::new(); + for (index, row) in rows.iter().enumerate() { + if let Some(page) = &row.page { + pages + .entry(page.clone()) + .or_default() + .entry(row.section.clone().unwrap_or_default()) + .or_default() + .0 + .push(index); + } + } + for (index, action) in actions.iter().enumerate() { + if let Some(page) = &action.page { + pages + .entry(page.clone()) + .or_default() + .entry(action.section.clone().unwrap_or_default()) + .or_default() + .1 + .push(index); + } + } + pages.into_iter().map(|(page, sections)| { + let (kind, id) = match &page { + SettingPage::Language(id) => ("language", id), + SettingPage::BuildSystem(id) => ("buildSystem", id), + }; + json!({"key":format!("{kind}:{id}"),"kind":kind,"id":id,"sections":sections.into_iter().map(|(title,(rows,actions))| json!({"title":title,"rows":rows,"actions":actions})).collect::>()}) + }).collect() +} diff --git a/rust/lithe-core/src/plugins/settings_surfaces.rs b/rust/lithe-core/src/plugins/settings_surfaces.rs new file mode 100644 index 00000000..d0bb37c5 --- /dev/null +++ b/rust/lithe-core/src/plugins/settings_surfaces.rs @@ -0,0 +1,223 @@ +//! Generic settings page catalog, navigation and committed-change routing. +//! Native adapters supply installed identities and directory equality facts; +//! these commands never discover files, subscribe windows or launch plugins. + +use super::{decode_payload, outcome, validate_manifest_value, SettingsOwner}; +use crate::{plugins::declarative::PluginRejection, protocol::CoreError}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet}; + +/// Limits catalog transfer and the number of native package reads per request. +const MAX_PLUGINS: usize = 64; +/// Bounds both dynamic surfaces and the complete navigation projection. +const MAX_PAGES: usize = 512; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Contribution { + manifest: Value, + owner: SettingsOwner, + enabled: bool, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CatalogRequest { + plugins: Vec, +} + +fn rejected(field: &str) -> PluginRejection { + PluginRejection::at( + "plugin.settings.invalidSurface", + field, + "Expected bounded, unique settings surface facts.", + ) +} + +/// Builds a deterministic catalog from complete validated installed manifests. +/// Disabled packages cannot contribute pages or rename another plugin's page. +pub fn settings_catalog_command(payload: Value) -> Result { + let request: CatalogRequest = decode_payload(payload, "plugins.settings.catalog")?; + Ok(outcome((|| { + if request.plugins.len() > MAX_PLUGINS { + return Err(rejected("plugins")); + } + let mut owners = BTreeSet::new(); + let mut manifests = BTreeMap::new(); + for entry in request.plugins { + let manifest = validate_manifest_value(entry.manifest.clone())?; + if manifest.id != entry.owner.plugin_id || manifest.version != entry.owner.version { + return Err(PluginRejection::at( + "plugin.settings.ownerChanged", + "owner", + "Installed package identity does not match its manifest.", + )); + } + if !owners.insert(manifest.id.clone()) { + return Err(rejected("plugins")); + } + if entry.enabled { + manifests.insert(manifest.id.clone(), (manifest, entry.manifest)); + } + } + let mut titles = BTreeMap::new(); + // Plugin ID order resolves competing display names deterministically. + for (_, raw) in manifests.values() { + for (field, kind) in [("languages", "language"), ("buildSystems", "buildSystem")] { + if let Some(entries) = raw[field].as_array() { + for entry in entries { + if let (Some(id), Some(title)) = + (entry["id"].as_str(), entry["displayName"].as_str()) + { + titles + .entry(format!("{kind}:{id}")) + .or_insert_with(|| title.to_owned()); + } + } + } + } + } + let mut pages = BTreeMap::::new(); + for (id, (manifest, _)) in manifests { + for page in crate::plugins::settings::page_sections( + &manifest.settings, + &manifest.setting_actions, + ) { + let key = page["key"].as_str().unwrap_or_default().to_owned(); + let target = pages.entry(key.clone()).or_insert_with(|| json!({ + "key":key,"kind":page["kind"],"id":page["id"], + "title":titles.get(&key).cloned().unwrap_or_else(|| page["id"].as_str().unwrap_or_default().to_owned()),"contributors":[] + })); + target["contributors"].as_array_mut().expect("catalog array").push(json!({ + "pluginID":id,"version":manifest.version,"title":manifest.display_name,"sections":page["sections"] + })); + } + } + if pages.len() > MAX_PAGES { + return Err(rejected("pages")); + } + Ok(("pages", json!(pages.into_values().collect::>()))) + })())) +} + +/// Native static page labels and stable identifiers. Hierarchy is supplied once; +/// search, expansion and ancestor retention are shared projection rules. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct NavigationItem { + id: String, + title: String, + parent: Option, + group: bool, + expanded: bool, + #[serde(default)] + keywords: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct NavigationRequest { + items: Vec, + query: String, +} + +/// Projects a bounded two-level settings tree; matched descendants retain their +/// parent and a matched parent shows its children, including dynamic surfaces. +pub fn settings_navigation_command(payload: Value) -> Result { + let request: NavigationRequest = decode_payload(payload, "plugins.settings.navigation")?; + Ok(outcome((|| { + if request.items.len() > MAX_PAGES + 64 || request.query.len() > 1024 { + return Err(rejected("navigation")); + } + let query = request.query.trim().to_lowercase(); + let mut ids = BTreeSet::new(); + for item in &request.items { + if item.id.is_empty() + || item.id.len() > 256 + || item.title.len() > 1024 + || item.keywords.len() > 16384 + || !ids.insert(&item.id) + { + return Err(rejected("items")); + } + } + for item in &request.items { + if let Some(parent) = &item.parent { + if !request + .items + .iter() + .any(|candidate| &candidate.id == parent && candidate.parent.is_none()) + { + return Err(rejected("parent")); + } + } + } + let matches = |item: &NavigationItem| { + item.title.to_lowercase().contains(&query) + || item.keywords.to_lowercase().contains(&query) + }; + let row = |item: &NavigationItem, level: usize| json!({"id":item.id,"title":item.title,"group":item.group,"expanded":!query.is_empty() || item.expanded,"level":level}); + let mut result = Vec::new(); + for parent in request.items.iter().filter(|item| item.parent.is_none()) { + let children: Vec<_> = request + .items + .iter() + .filter(|item| item.parent.as_ref() == Some(&parent.id)) + .collect(); + let own = matches(parent); + if !own && !children.iter().any(|child| matches(child)) { + continue; + } + result.push(row(parent, 0)); + if !parent.group || parent.expanded || !query.is_empty() { + result.extend( + children + .into_iter() + .filter(|child| own || matches(child)) + .map(|child| row(child, 1)), + ); + } + } + Ok(("rows", json!(result))) + })())) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct DeliveryRequest { + scope: String, + same_preferences: bool, + same_workspace: bool, + same_version: bool, + enabled: bool, + effects: Vec, +} + +/// Routes only committed changes to matching native owners. Global delivery +/// deliberately carries no values: each receiver expands its own project layer. +pub fn settings_delivery_command(payload: Value) -> Result { + let request: DeliveryRequest = decode_payload(payload, "plugins.settings.delivery")?; + Ok(outcome((|| { + if !matches!(request.scope.as_str(), "global" | "project") + || request.effects.len() > 4 + || request.effects.iter().any(|effect| { + !matches!( + effect.as_str(), + "restart" | "reload" | "immediate" | "nextRun" + ) + }) + { + return Err(rejected("delivery")); + } + let refresh = request.same_preferences + && request.same_version + && (request.scope == "global" || request.same_workspace); + let effects: BTreeSet<_> = if refresh && request.enabled { + request.effects.into_iter().collect() + } else { + BTreeSet::new() + }; + Ok(("delivery", json!({"refresh":refresh,"effects":effects}))) + })())) +} diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index 5569461b..582df306 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -158,6 +158,20 @@ pub enum CoreCommand { PluginsDeclarativeCheckRuntimeVersion, /// Merges a plugin's stored setting layers into effective values (`plugins.declarative.resolveSettings`). PluginsDeclarativeResolveSettings, + /// Projects stored documents into plugin setting rows and launch layers. + PluginsDeclarativeSettingsState, + /// Projects enabled plugin settings pages from installed manifests. + PluginsSettingsCatalog, + /// Projects searched settings navigation without platform policy. + PluginsSettingsNavigation, + /// Routes committed preference effects using native ownership facts. + PluginsSettingsDelivery, + /// Resolves an installed settings action and its native confirmation requirement. + PluginsDeclarativeSettingsActionPlan, + /// Selects numeric installed versions and reports malformed native facts. + PluginsInstalledSelect, + /// Validates an atomic plugin settings edit and preserves unrelated values. + PluginsDeclarativeEditSettings, /// Expands a language server's `workspaceConfiguration` (`plugins.declarative.workspaceConfiguration`). PluginsDeclarativeWorkspaceConfiguration, /// Validates a language's grammar file (`plugins.declarative.decodeGrammar`). @@ -182,6 +196,14 @@ pub enum CoreCommand { ToolchainsFailureReply, /// Resolves the nearest machine-local directory pin. ToolchainsResolvePins, + /// Edits a bounded batch of machine-local directory pins without native writes. + ToolchainsEditPins, + /// Plans native workspace pin source paths and byte budgets. + ToolchainsPinStoragePlan, + /// Parses a complete native pin document or its absent-source default. + ToolchainsDecodePinDocument, + /// Plans complete pin and ignore source edits without native writes. + ToolchainsEditPinSources, /// Resolves consumer defaults while keeping workspace inheritance symbolic. ToolchainsResolveConsumerPreferences, /// Edits one machine-local consumer preference and returns its effective input. @@ -462,6 +484,8 @@ pub enum CoreCommand { /// Validates workspace-edit targets and orders their commit /// (`lsp.planWorkspaceEdit`). LspPlanWorkspaceEdit, + /// Resolves a diagnostic identity inside its workspace (`lsp.documentIdentity`). + LspDocumentIdentity, /// Decides whether a language-server jump target opens, opens read-only /// or is refused (`lsp.classifyNavigationTarget`). LspClassifyNavigationTarget, @@ -525,6 +549,8 @@ pub enum CoreCommand { LspKeymapImportRequest, /// Invokes one plugin function (`lsp.functionRequest`). LspFunctionRequest, + /// Selects a registered function provider strictly inside one workspace. + LspFunctionProvider, /// Replaces the plugins and workspace contexts the cross-plugin function /// relay checks calls against (`lsp.configureFunctionRelay`). LspConfigureFunctionRelay, @@ -863,6 +889,15 @@ impl CoreCommand { Some(Self::PluginsDeclarativeCheckRuntimeVersion) } "plugins.declarative.resolveSettings" => Some(Self::PluginsDeclarativeResolveSettings), + "plugins.settings.catalog" => Some(Self::PluginsSettingsCatalog), + "plugins.settings.navigation" => Some(Self::PluginsSettingsNavigation), + "plugins.settings.delivery" => Some(Self::PluginsSettingsDelivery), + "plugins.declarative.settingsState" => Some(Self::PluginsDeclarativeSettingsState), + "plugins.declarative.settingsActionPlan" => { + Some(Self::PluginsDeclarativeSettingsActionPlan) + } + "plugins.installed.select" => Some(Self::PluginsInstalledSelect), + "plugins.declarative.editSettings" => Some(Self::PluginsDeclarativeEditSettings), "plugins.declarative.workspaceConfiguration" => { Some(Self::PluginsDeclarativeWorkspaceConfiguration) } @@ -879,6 +914,10 @@ impl CoreCommand { "toolchains.decodeReply" => Some(Self::ToolchainsDecodeReply), "toolchains.failureReply" => Some(Self::ToolchainsFailureReply), "toolchains.resolvePins" => Some(Self::ToolchainsResolvePins), + "toolchains.editPins" => Some(Self::ToolchainsEditPins), + "toolchains.pinStoragePlan" => Some(Self::ToolchainsPinStoragePlan), + "toolchains.decodePinDocument" => Some(Self::ToolchainsDecodePinDocument), + "toolchains.editPinSources" => Some(Self::ToolchainsEditPinSources), "toolchains.resolveConsumerPreferences" => { Some(Self::ToolchainsResolveConsumerPreferences) } @@ -1019,6 +1058,7 @@ impl CoreCommand { "debug.destroySession" => Some(Self::DebugDestroySession), "lsp.applyTextEdits" => Some(Self::LspApplyTextEdits), "lsp.planWorkspaceEdit" => Some(Self::LspPlanWorkspaceEdit), + "lsp.documentIdentity" => Some(Self::LspDocumentIdentity), "lsp.classifyNavigationTarget" => Some(Self::LspClassifyNavigationTarget), "editor.lineEdit" => Some(Self::EditorLineEdit), "editor.planTextEdits" => Some(Self::EditorPlanTextEdits), @@ -1047,6 +1087,7 @@ impl CoreCommand { "lsp.toolchainRequest" => Some(Self::LspToolchainRequest), "lsp.keymapImportRequest" => Some(Self::LspKeymapImportRequest), "lsp.functionRequest" => Some(Self::LspFunctionRequest), + "lsp.functionProvider" => Some(Self::LspFunctionProvider), "lsp.configureFunctionRelay" => Some(Self::LspConfigureFunctionRelay), "lsp.hostResponse" => Some(Self::LspHostResponse), "lsp.hostNotify" => Some(Self::LspHostNotify), diff --git a/rust/lithe-core/src/protocol/json_document.rs b/rust/lithe-core/src/protocol/json_document.rs new file mode 100644 index 00000000..e2c9cb52 --- /dev/null +++ b/rust/lithe-core/src/protocol/json_document.rs @@ -0,0 +1,107 @@ +//! Strict JSON values shared by native-backed configuration editors. + +use serde::{ + de::{MapAccess, Visitor}, + Deserialize, Deserializer, Serialize, +}; +use serde_json::Value; +use std::{collections::BTreeMap, fmt, marker::PhantomData}; + +/// An ordered object that rejects duplicate keys at deserialization. +#[derive(Debug, PartialEq, Serialize)] +pub(crate) struct UniqueMap(pub(crate) BTreeMap); + +impl Default for UniqueMap { + fn default() -> Self { + Self(BTreeMap::new()) + } +} + +impl<'de, T: Deserialize<'de>> Deserialize<'de> for UniqueMap { + fn deserialize>(deserializer: D) -> Result { + struct MapVisitor(PhantomData); + impl<'de, T: Deserialize<'de>> Visitor<'de> for MapVisitor { + type Value = UniqueMap; + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("an object with unique keys") + } + fn visit_map>(self, mut input: A) -> Result { + let mut entries = BTreeMap::new(); + while let Some((key, value)) = input.next_entry::()? { + if entries.insert(key, value).is_some() { + return Err(serde::de::Error::custom("duplicate preference key")); + } + } + Ok(UniqueMap(entries)) + } + } + deserializer.deserialize_map(MapVisitor(PhantomData)) + } +} + +/// A JSON value whose objects were all checked for duplicate keys by serde. +struct DuplicateCheckedValue(Value); + +impl<'de> Deserialize<'de> for DuplicateCheckedValue { + fn deserialize>(deserializer: D) -> Result { + struct ValueVisitor; + impl<'de> Visitor<'de> for ValueVisitor { + type Value = DuplicateCheckedValue; + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("JSON without duplicate object keys") + } + fn visit_bool(self, value: bool) -> Result { + Ok(DuplicateCheckedValue(Value::Bool(value))) + } + fn visit_i64(self, value: i64) -> Result { + Ok(DuplicateCheckedValue(value.into())) + } + fn visit_u64(self, value: u64) -> Result { + Ok(DuplicateCheckedValue(value.into())) + } + fn visit_f64(self, value: f64) -> Result { + serde_json::Number::from_f64(value) + .map(|number| DuplicateCheckedValue(Value::Number(number))) + .ok_or_else(|| E::custom("non-finite preference number")) + } + fn visit_str(self, value: &str) -> Result { + self.visit_string(value.to_owned()) + } + fn visit_string(self, value: String) -> Result { + Ok(DuplicateCheckedValue(Value::String(value))) + } + fn visit_unit(self) -> Result { + Ok(DuplicateCheckedValue(Value::Null)) + } + fn visit_seq>( + self, + mut input: A, + ) -> Result { + let mut values = Vec::new(); + while let Some(value) = input.next_element::()? { + values.push(value.0); + } + Ok(DuplicateCheckedValue(Value::Array(values))) + } + fn visit_map>(self, input: A) -> Result { + let entries = UniqueMap::::deserialize( + serde::de::value::MapAccessDeserializer::new(input), + )?; + Ok(DuplicateCheckedValue(Value::Object( + entries + .0 + .into_iter() + .map(|(key, value)| (key, value.0)) + .collect(), + ))) + } + } + deserializer.deserialize_any(ValueVisitor) + } +} + +/// Parses JSON without losing duplicate keys, including nested objects and +/// escaped spellings. Callers own byte budgets and schema-specific failures. +pub(crate) fn parse_unique_json(content: &str) -> Result { + serde_json::from_str::(content).map(|value| value.0) +} diff --git a/rust/lithe-core/src/protocol/mod.rs b/rust/lithe-core/src/protocol/mod.rs index d060af01..1c5186ce 100644 --- a/rust/lithe-core/src/protocol/mod.rs +++ b/rust/lithe-core/src/protocol/mod.rs @@ -5,6 +5,7 @@ mod command; mod contracts; mod error; mod event; +pub(crate) mod json_document; pub use command::*; pub use contracts::*; diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index c35f8147..e0536573 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -368,6 +368,41 @@ fn execute_parsed(mut parsed: CoreRequest) -> CoreResponse { parsed.payload, crate::plugins::declarative::resolve_settings_command, ), + CoreCommand::PluginsInstalledSelect => plugin_response( + id, + parsed.payload, + crate::plugins::installed::select_command, + ), + CoreCommand::PluginsSettingsCatalog => plugin_response( + id, + parsed.payload, + crate::plugins::declarative::settings_catalog_command, + ), + CoreCommand::PluginsSettingsNavigation => plugin_response( + id, + parsed.payload, + crate::plugins::declarative::settings_navigation_command, + ), + CoreCommand::PluginsSettingsDelivery => plugin_response( + id, + parsed.payload, + crate::plugins::declarative::settings_delivery_command, + ), + CoreCommand::PluginsDeclarativeSettingsState => plugin_response( + id, + parsed.payload, + crate::plugins::declarative::settings_state_command, + ), + CoreCommand::PluginsDeclarativeSettingsActionPlan => plugin_response( + id, + parsed.payload, + crate::plugins::declarative::settings_action_plan_command, + ), + CoreCommand::PluginsDeclarativeEditSettings => plugin_response( + id, + parsed.payload, + crate::plugins::declarative::edit_settings_command, + ), CoreCommand::PluginsDeclarativeWorkspaceConfiguration => plugin_response( id, parsed.payload, @@ -431,6 +466,24 @@ fn execute_parsed(mut parsed: CoreRequest) -> CoreResponse { CoreCommand::ToolchainsResolvePins => { plugin_response(id, parsed.payload, crate::toolchain::resolve_pins_command) } + CoreCommand::ToolchainsEditPins => { + plugin_response(id, parsed.payload, crate::toolchain::edit_pins_command) + } + CoreCommand::ToolchainsPinStoragePlan => plugin_response( + id, + parsed.payload, + crate::toolchain::pin_storage_plan_command, + ), + CoreCommand::ToolchainsDecodePinDocument => plugin_response( + id, + parsed.payload, + crate::toolchain::decode_pin_document_command, + ), + CoreCommand::ToolchainsEditPinSources => plugin_response( + id, + parsed.payload, + crate::toolchain::edit_pin_sources_command, + ), CoreCommand::ToolchainsResolveConsumerPreferences => plugin_response( id, parsed.payload, @@ -1857,6 +1910,11 @@ fn execute_parsed(mut parsed: CoreRequest) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::LspDocumentIdentity => plugin_response( + id, + parsed.payload, + crate::lsp::document_identity::resolve_command, + ), CoreCommand::LspPlanWorkspaceEdit => plugin_response( id, parsed.payload, @@ -2225,6 +2283,9 @@ fn execute_parsed(mut parsed: CoreRequest) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::LspFunctionProvider => { + plugin_response(id, parsed.payload, crate::lsp::function_provider_command) + } CoreCommand::LspFunctionRequest => { match serde_json::from_value::(parsed.payload) .map_err(|error| { diff --git a/rust/lithe-core/src/tests/toolchain_pins.rs b/rust/lithe-core/src/tests/toolchain_pins.rs index bfca2698..30728709 100644 --- a/rust/lithe-core/src/tests/toolchain_pins.rs +++ b/rust/lithe-core/src/tests/toolchain_pins.rs @@ -259,3 +259,111 @@ fn toolchain_pins_enforce_collection_and_text_limits_before_resolution() { "plugin.toolchain.limitExceeded" ); } + +/// Calls the public JSON boundary so an unregistered command cannot pass. +fn edit(payload: Value) -> Value { + serde_json::from_str(&crate::execute_json( + &json!({"id":"pin-edit","command":"toolchains.editPins","payload":payload}).to_string(), + )) + .unwrap() +} + +#[test] +fn toolchain_pin_edits_preserve_siblings_and_remove_only_the_selected_override() { + let document = local_pins(); + let changed = edit(json!({"document":document,"directory":"apps/web","edits":[ + {"tool":"java","pin":null}, + {"tool":"node","pin":pin("/opt/lithe-fixtures/node")} + ]})); + assert_eq!(changed["ok"], true, "{changed}"); + assert_eq!(changed["data"]["status"], "ok", "{changed}"); + let result = &changed["data"]["edit"]["document"]; + assert_eq!(result["toolchains"], document["toolchains"]); + assert_eq!(result["pins"]["."], document["pins"]["."]); + assert_eq!( + result["pins"][".config/tools"], + document["pins"][".config/tools"] + ); + assert!(result["pins"]["apps/web"].get("java").is_none()); + assert_eq!( + result["pins"]["apps/web"]["node"], + pin("/opt/lithe-fixtures/node") + ); + assert_eq!( + resolve(result, "apps/web/src", "java")["sourceDirectory"], + "." + ); +} + +#[test] +fn toolchain_pin_edits_reject_invalid_batches_without_a_partial_document() { + for edits in [ + json!([]), + json!([{"tool":"java","pin":pin("relative")}]), + json!([{"tool":"java","pin":null},{"tool":"java","pin":null}]), + json!([{"tool":"java"}]), + json!([{"tool":"java","pin":null,"extra":true}]), + ] { + let response = edit(json!({"document":local_pins(),"directory":".","edits":edits})); + assert_eq!(response["data"]["status"], "rejected", "{response}"); + assert!(response["data"].get("edit").is_none(), "{response}"); + } + let response = edit( + json!({"document":local_pins(),"directory":"../outside","edits":[{"tool":"java","pin":null}]}), + ); + assert_eq!(response["data"]["status"], "rejected", "{response}"); +} + +#[test] +fn toolchain_pin_edits_prune_empty_containers_and_leave_absent_removals_inert() { + let document = + json!({"version":1,"toolchains":{},"pins":{".":{"java":pin("/opt/lithe-fixtures/java")}}}); + let response = + edit(json!({"document":document,"directory":".","edits":[{"tool":"java","pin":null}]})); + assert_eq!( + response["data"]["edit"]["document"], + json!({"version":1,"toolchains":{}}) + ); + let document = json!({"version":1,"toolchains":{}}); + let response = + edit(json!({"document":document,"directory":"src","edits":[{"tool":"node","pin":null}]})); + assert_eq!(response["data"]["edit"]["document"], document); +} + +#[test] +fn toolchain_pin_edits_match_the_shared_boundary_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/toolchain-pin-edit.json" + )) + .unwrap(); + let response = edit(fixture["request"].clone()); + assert_eq!(response["data"]["edit"]["document"], fixture["document"]); +} + +#[test] +fn toolchain_pin_edits_enforce_final_collection_limits_and_validate_the_source() { + let mut request = + json!({"document":local_pins(),"directory":".","edits":[{"tool":"java","pin":null}]}); + request["document"]["unknown"] = json!(true); + assert_eq!(edit(request)["data"]["status"], "rejected"); + let edits: Vec<_> = (0..65) + .map(|index| json!({"tool":format!("tool-{index}"),"pin":null})) + .collect(); + let response = edit(json!({"document":local_pins(),"directory":".","edits":edits})); + assert_eq!( + response["data"]["error"]["code"], + "plugin.toolchain.limitExceeded" + ); + let tools: serde_json::Map = (0..64) + .map(|index| (format!("tool-{index}"), pin("/fixture/tool"))) + .collect(); + let document = json!({"version":1,"toolchains":{},"pins":{".":tools}}); + let response = edit( + json!({"document":document,"directory":".","edits":[{"tool":"extra","pin":pin("/fixture/extra")}]}), + ); + assert_eq!( + response["data"]["error"]["code"], + "plugin.toolchain.limitExceeded" + ); + assert!(response["data"].get("edit").is_none()); +} diff --git a/rust/lithe-core/src/toolchain/mod.rs b/rust/lithe-core/src/toolchain/mod.rs index 3a9b8eec..16282da9 100644 --- a/rust/lithe-core/src/toolchain/mod.rs +++ b/rust/lithe-core/src/toolchain/mod.rs @@ -5,6 +5,7 @@ pub(crate) mod definitions; pub(crate) mod fixed; pub(crate) mod paths; +mod pin_sources; pub(crate) mod pins; pub(crate) mod preferences; pub(crate) mod query; @@ -54,6 +55,26 @@ pub fn resolve_pins_command(input: Value) -> Result { command(input, pins::resolve, "resolution") } +/// Applies one atomic batch of inert directory-pin edits without native writes. +pub fn edit_pins_command(input: Value) -> Result { + command(input, pins::edit, "edit") +} + +/// Plans bounded reads of native workspace pin sources without opening them. +pub fn pin_storage_plan_command(input: Value) -> Result { + command(input, pin_sources::storage_plan, "storage") +} + +/// Decodes a complete pin source without host-side schema parsing. +pub fn decode_pin_document_command(input: Value) -> Result { + command(input, pin_sources::decode, "document") +} + +/// Plans conditional publication of the pin document and its ignore prerequisite. +pub fn edit_pin_sources_command(input: Value) -> Result { + command(input, pin_sources::edit, "edit") +} + /// Resolves inert consumer choices without materializing symbolic inheritance. pub fn resolve_consumer_preferences_command(input: Value) -> Result { command(input, preferences::resolve, "preferences") diff --git a/rust/lithe-core/src/toolchain/pin_sources.rs b/rust/lithe-core/src/toolchain/pin_sources.rs new file mode 100644 index 00000000..4dfb7e66 --- /dev/null +++ b/rust/lithe-core/src/toolchain/pin_sources.rs @@ -0,0 +1,243 @@ +//! Native-backed pin documents: bounded read plans and pure complete-source edits. +//! +//! Hosts own filesystem identity, metadata, cancellation and conditional atomic +//! publication. Reading or editing these documents never grants execution authority. + +use super::pins; +use crate::plugins::declarative::{PluginRejection, Validation}; +use crate::protocol::json_document::parse_unique_json; +use ignore::gitignore::GitignoreBuilder; +use serde::Deserialize; +use serde_json::{json, Value}; + +const DOCUMENT_PATH: &str = ".lithe/toolchains/local.json"; +const IGNORE_PATH: &str = ".lithe/.gitignore"; +const IGNORE_ENTRY: &str = "/toolchains/local.json"; +const MAX_DOCUMENT_BYTES: usize = 1024 * 1024; +const MAX_IGNORE_BYTES: usize = 64 * 1024; +const MAX_IGNORE_LINES: usize = 4096; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct DecodeRequest { + /// Null means the native source does not exist; an empty string is explicit. + document: Value, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct EditRequest { + document: Value, + ignore_document: Value, + directory: String, + /// Passed unchanged to the existing bounded pin edit validator. + edits: Value, +} + +fn invalid(field: &str) -> PluginRejection { + PluginRejection::at( + "plugin.toolchain.invalidParams", + field, + "Expected complete native source strings or null and a pin edit request.", + ) +} + +fn limit(field: &str) -> PluginRejection { + PluginRejection::at( + "plugin.toolchain.limitExceeded", + field, + "Toolchain source exceeds its byte or line budget.", + ) +} + +fn invalid_document(field: &str) -> PluginRejection { + PluginRejection::at( + "plugin.toolchain.invalidDocument", + field, + "Expected a bounded version 1 toolchain document with unique JSON keys.", + ) +} + +fn invalid_ignore() -> PluginRejection { + PluginRejection::at( + "plugin.toolchain.invalidIgnore", + "ignoreDocument", + "Expected a bounded text file of valid Git ignore patterns.", + ) +} + +fn source<'a>(value: &'a Value, field: &str, maximum: usize) -> Validation> { + match value { + Value::Null => Ok(None), + Value::String(text) if text.len() <= maximum => Ok(Some(text)), + Value::String(_) => Err(limit(field)), + _ => Err(invalid(field)), + } +} + +fn document(content: Option<&str>) -> Validation { + let value = match content { + None => json!({"version": 1, "toolchains": {}}), + Some(text) => parse_unique_json(text).map_err(|_| invalid_document("document"))?, + }; + pins::validate(&value) + .map_err(|failure| invalid_document(failure.field.as_deref().unwrap_or("document")))?; + Ok(value) +} + +/// Gives native adapters the workspace-relative sources and their read budgets. +/// The source paths are owned by this contract, not inferred from an app package. +pub(crate) fn storage_plan(input: &Value) -> Validation { + if !input.as_object().is_some_and(|object| object.is_empty()) { + return Err(invalid("input")); + } + Ok(json!({ + "documentRelativePath": DOCUMENT_PATH, + "ignoreRelativePath": IGNORE_PATH, + "maximumDocumentBytes": MAX_DOCUMENT_BYTES, + "maximumIgnoreBytes": MAX_IGNORE_BYTES, + })) +} + +/// Parses one complete source or supplies the absent-source default. This also +/// validates dormant entries when no active tool currently consumes them. +pub(crate) fn decode(input: &Value) -> Validation { + let request: DecodeRequest = + serde_json::from_value(input.clone()).map_err(|_| invalid("input"))?; + document(source(&request.document, "document", MAX_DOCUMENT_BYTES)?) +} + +/// Edits captured sources as one plan. No semantic pin change preserves exact +/// source bytes and does not create a document or ignore file for a missing source. +pub(crate) fn edit(input: &Value) -> Validation { + let request: EditRequest = + serde_json::from_value(input.clone()).map_err(|_| invalid("input"))?; + let original = source(&request.document, "document", MAX_DOCUMENT_BYTES)?; + let ignore = source(&request.ignore_document, "ignoreDocument", MAX_IGNORE_BYTES)?; + let parsed = document(original)?; + let mut edited = pins::edit(&json!({ + "document": parsed, + "directory": request.directory, + "edits": request.edits, + }))?["document"] + .clone(); + if edited == parsed { + return Ok(json!({ + "document": original, + "documentChanged": false, + "ignoreDocument": ignore, + "ignoreChanged": false, + })); + } + // Keep source output deterministic across serde_json map feature unification. + edited.sort_all_objects(); + let encoded = serde_json::to_string(&edited).map_err(|_| invalid_document("document"))?; + if encoded.len() > MAX_DOCUMENT_BYTES { + return Err(limit("document")); + } + let (ignore_document, ignore_changed) = prepare_ignore(ignore)?; + Ok(json!({ + "document": encoded, + "documentChanged": true, + "ignoreDocument": ignore_document, + "ignoreChanged": ignore_changed, + })) +} + +fn prepare_ignore(original: Option<&str>) -> Validation<(String, bool)> { + let content = original.unwrap_or(""); + if content.contains('\0') { + return Err(invalid_ignore()); + } + let mut builder = GitignoreBuilder::new(""); + for (index, line) in content.lines().enumerate() { + if index >= MAX_IGNORE_LINES { + return Err(limit("ignoreDocument")); + } + // UTF-8 BOM is an encoding marker in Git's ignore files, not a glob. + let line = if index == 0 { + line.strip_prefix('\u{feff}').unwrap_or(line) + } else { + line + }; + builder.add_line(None, line).map_err(|_| invalid_ignore())?; + } + let matcher = builder.build().map_err(|_| invalid_ignore())?; + if matcher + .matched_path_or_any_parents("toolchains/local.json", false) + .is_ignore() + { + return Ok((content.to_owned(), false)); + } + let newline = if content.contains("\r\n") { + "\r\n" + } else { + "\n" + }; + let mut updated = content.to_owned(); + if !updated.is_empty() && !updated.ends_with('\n') { + updated.push_str(newline); + } + updated.push_str(IGNORE_ENTRY); + updated.push_str(newline); + if updated.len() > MAX_IGNORE_BYTES || updated.lines().count() > MAX_IGNORE_LINES { + return Err(limit("ignoreDocument")); + } + Ok((updated, true)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn source_edits_match_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/toolchain-pin-storage.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let response: Value = serde_json::from_str(&crate::execute_json( + &json!({"command": case["command"], "payload": case["input"]}).to_string(), + )) + .unwrap(); + if let Some(code) = case.get("error") { + assert_eq!(response["data"]["status"], "rejected", "{case}: {response}"); + assert_eq!( + &response["data"]["error"]["code"], code, + "{case}: {response}" + ); + } else { + assert_eq!(response["data"], case["expected"], "{case}: {response}"); + } + } + } + + #[test] + fn ignore_growth_cannot_publish_a_document_the_next_edit_would_refuse() { + let full = "#\n".repeat(MAX_IGNORE_LINES); + assert_eq!( + prepare_ignore(Some(&full)).unwrap_err().code(), + "plugin.toolchain.limitExceeded" + ); + } + + #[test] + fn oversized_sources_are_rejected_before_schema_or_glob_parsing() { + assert_eq!( + decode(&json!({"document":" ".repeat(MAX_DOCUMENT_BYTES + 1)})) + .unwrap_err() + .code(), + "plugin.toolchain.limitExceeded" + ); + assert_eq!( + edit( + &json!({"document":null,"ignoreDocument":" ".repeat(MAX_IGNORE_BYTES + 1), + "directory":".","edits":[{"tool":"dev.example:compiler","pin":null}]}) + ) + .unwrap_err() + .code(), + "plugin.toolchain.limitExceeded" + ); + } +} diff --git a/rust/lithe-core/src/toolchain/pins.rs b/rust/lithe-core/src/toolchain/pins.rs index 17377983..e7c7d8b9 100644 --- a/rust/lithe-core/src/toolchain/pins.rs +++ b/rust/lithe-core/src/toolchain/pins.rs @@ -45,6 +45,85 @@ pub(crate) fn resolve(input: &Value) -> Validation { } } +/// Edits a bounded batch against one captured document without performing I/O. +/// A null pin removes only that override; invalid batches return no document. +pub(crate) fn edit(input: &Value) -> Validation { + let request = object(input, "input", &["document", "directory", "edits"])?; + let document = required(request, "document", "input")?; + validate(document)?; + let directory = text( + required(request, "directory", "input")?, + "directory", + MAX_DIRECTORY_BYTES, + )?; + if !valid_directory(directory) { + return Err(invalid( + "directory", + "Expected a normalized workspace-relative directory.", + )); + } + let edits = required(request, "edits", "input")? + .as_array() + .ok_or_else(|| invalid("edits", "Expected an array."))?; + count(edits.len(), MAX_TOOLS_PER_DIRECTORY, "edits")?; + if edits.is_empty() { + return Err(invalid("edits", "Expected at least one edit.")); + } + let mut seen = std::collections::BTreeSet::new(); + let mut updated = document.clone(); + for (index, edit) in edits.iter().enumerate() { + let field = format!("edits.{index}"); + let edit = object(edit, &field, &["tool", "pin"])?; + let tool = text( + required(edit, "tool", &field)?, + &format!("{field}.tool"), + 192, + )?; + if !valid_tool_id(tool) || !seen.insert(tool) { + return Err(invalid( + format!("{field}.tool"), + "Expected a unique valid tool identifier.", + )); + } + let pin = required(edit, "pin", &field)?; + if pin.is_null() { + if let Some(pins) = updated.get_mut("pins").and_then(Value::as_object_mut) { + if let Some(tools) = pins.get_mut(directory).and_then(Value::as_object_mut) { + tools.remove(tool); + if tools.is_empty() { + pins.remove(directory); + } + } + if pins.is_empty() { + updated + .as_object_mut() + .expect("validated document") + .remove("pins"); + } + } + } else { + validate_pin(pin, &format!("{field}.pin"))?; + let pins = updated + .as_object_mut() + .expect("validated document") + .entry("pins") + .or_insert_with(|| json!({})); + let tools = pins + .as_object_mut() + .expect("validated pins") + .entry(directory.to_string()) + .or_insert_with(|| json!({})); + tools + .as_object_mut() + .expect("validated tools") + .insert(tool.to_string(), pin.clone()); + } + } + // Enforce document budgets after the entire batch, allowing replacement at capacity. + validate(&updated)?; + Ok(json!({"document":updated})) +} + /// Checks the complete local document, retaining legacy entries and dormant tool IDs. /// This validation is lexical only; paths are never probed, canonicalized, or launched. pub(crate) fn validate(document: &Value) -> Validation<()> { diff --git a/scripts/rust-core/README.md b/scripts/rust-core/README.md new file mode 100644 index 00000000..9b421443 --- /dev/null +++ b/scripts/rust-core/README.md @@ -0,0 +1,15 @@ +# Verify Rust Core comments before compiling tests + +Run `scripts/rust-core/verify-rust-core-comments.sh` from any directory. It checks +module documentation, English comments, unsafe API safety sections, exported +Rustdoc, and intra-doc links in `lithe-core` and `lithe-core-rules`. + +The small `comment-lexer` helper uses pinned `rustc_lexer` tokens to distinguish +comments from ordinary, raw, and byte string literals. It does not parse source +with delimiter regular expressions or exempt Unicode test data. Run its tests +with `cargo test --locked --manifest-path scripts/rust-core/comment-lexer/Cargo.toml`. + +The gate uses `CARGO_TARGET_DIR` when supplied. Otherwise it writes only to this +checkout's `.artifacts/rust-core-checks/`. Cargo state and generated Rustdoc stay +local to the checkout, are ignored by Git, and must not be shared with another +worktree or installed with the application. diff --git a/scripts/rust-core/comment-lexer/Cargo.lock b/scripts/rust-core/comment-lexer/Cargo.lock new file mode 100644 index 00000000..fc0d2cdc --- /dev/null +++ b/scripts/rust-core/comment-lexer/Cargo.lock @@ -0,0 +1,70 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "lithe-comment-lexer" +version = "0.1.0" +dependencies = [ + "regex", + "rustc_lexer", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustc_lexer" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86aae0c77166108c01305ee1a36a1e77289d7dc6ca0a3cd91ff4992de2d16a5" +dependencies = [ + "unicode-xid", +] + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" diff --git a/scripts/rust-core/comment-lexer/Cargo.toml b/scripts/rust-core/comment-lexer/Cargo.toml new file mode 100644 index 00000000..ff569b5b --- /dev/null +++ b/scripts/rust-core/comment-lexer/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "lithe-comment-lexer" +version = "0.1.0" +edition = "2021" +publish = false +license = "Apache-2.0" + +# Independent verification tool; it must not compile the product before its +# cheap source checks have passed. +[workspace] + +[dependencies] +regex = "=1.13.1" +rustc_lexer = "=0.1.0" diff --git a/scripts/rust-core/comment-lexer/src/main.rs b/scripts/rust-core/comment-lexer/src/main.rs new file mode 100644 index 00000000..b9c22c29 --- /dev/null +++ b/scripts/rust-core/comment-lexer/src/main.rs @@ -0,0 +1,98 @@ +//! Checks actual Rust comment tokens without treating literals as comments. + +use regex::Regex; +use rustc_lexer::{tokenize, TokenKind}; +use std::{env, fs, process::ExitCode}; + +fn non_english_comments<'a>(source: &'a str, han: &Regex) -> Vec<(usize, &'a str)> { + let mut found = Vec::new(); + let mut offset = 0; + let mut line = 1; + for token in tokenize(source) { + let text = &source[offset..offset + token.len]; + if matches!( + token.kind, + TokenKind::LineComment | TokenKind::BlockComment { .. } + ) && han.is_match(text) + { + found.push((line, text)); + } + line += text.bytes().filter(|byte| *byte == b'\n').count(); + offset += token.len; + } + found +} + +fn main() -> ExitCode { + let han = Regex::new(r"\p{Han}").expect("fixed Unicode script expression"); + let paths: Vec<_> = env::args_os().skip(1).collect(); + if paths.is_empty() { + eprintln!("Rust comment verification requires at least one source file"); + return ExitCode::FAILURE; + } + let mut failed = false; + for path in paths { + match fs::read_to_string(&path) { + Ok(source) => { + for (line, comment) in non_english_comments(&source, &han) { + eprintln!( + "{}:{line}: Rust comments must be English: {comment}", + path.to_string_lossy() + ); + failed = true; + } + } + Err(error) => { + eprintln!( + "{}: could not read Rust source: {error}", + path.to_string_lossy() + ); + failed = true; + } + } + } + if failed { + ExitCode::FAILURE + } else { + ExitCode::SUCCESS + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn literals_with_comment_delimiters_do_not_hide_following_comments() { + let source = r###"let glob = "projects/*/repos/\n晚到目录/\n"; +let path = r##"中文 // /* " still a raw string"##; +let bytes = br#"/* raw byte string */"#; +// English comment. +let kept = "中文"; // 中文注释 +"###; + let han = Regex::new(r"\p{Han}").unwrap(); + assert_eq!(non_english_comments(source, &han), vec![(5, "// 中文注释")]); + } + + #[test] + fn nested_and_inline_block_comments_are_checked() { + let source = "let a = 1; /* outer /* 中文 */ still outer */\n//! 中文文档\n"; + let han = Regex::new(r"\p{Han}").unwrap(); + assert_eq!( + non_english_comments(source, &han), + vec![ + (1, "/* outer /* 中文 */ still outer */"), + (2, "//! 中文文档"), + ] + ); + } + + #[test] + fn escaped_quotes_and_character_literals_preserve_token_boundaries() { + let source = r###"let quote = '\''; let text = "\" // 中文文本"; /* English */ +fn borrowed<'a>(x: &'a str) {} // 中文注释 +"###; + let han = Regex::new(r"\p{Han}").unwrap(); + assert_eq!(non_english_comments(source, &han), vec![(2, "// 中文注释")]); + } +} diff --git a/scripts/rust-core/verify-rust-core-comments.sh b/scripts/rust-core/verify-rust-core-comments.sh new file mode 100755 index 00000000..f54cce7a --- /dev/null +++ b/scripts/rust-core/verify-rust-core-comments.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd -- "$SCRIPT_DIR/../.." && pwd)" +# Keep this helper, Rustdoc and subsequent checks in one native build-state +# directory. A desktop worktree supplies its own existing Cargo target. +export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT_DIR/.artifacts/rust-core-checks}" +# lithe-core-rules is part of Core and holds reusable editor/UI rules. +SOURCE_DIRS=("$ROOT_DIR/rust/lithe-core/src" "$ROOT_DIR/rust/lithe-core-rules/src") +cd "$ROOT_DIR" + +rust_files=() +production_files=() +while IFS= read -r -d '' file; do + rust_files+=("$file") +done < <(find "${SOURCE_DIRS[@]}" -type f -name '*.rs' -print0) +while IFS= read -r -d '' file; do + production_files+=("$file") +done < <(find "${SOURCE_DIRS[@]}" -type f -name '*.rs' \ + ! -path '*/tests/*' \ + ! -name 'tests.rs' \ + -print0) + +if (( ${#production_files[@]} == 0 )); then + printf '%s\n' "Rust Core comment verification found no production modules" >&2 + exit 1 +fi + +failures=0 + +# Module documentation is a cheap textual check and should fail before Rustdoc runs. +for file in "${production_files[@]}"; do + first_source_line="$(awk 'NF { sub(/^[[:space:]]*/, ""); print; exit }' "$file")" + if [[ "$first_source_line" != "//!"* ]]; then + relative_file="${file#"$ROOT_DIR"/}" + printf '%s\n' "$relative_file:1: production modules must start with //! documentation" >&2 + failures=$((failures + 1)) + fi +done + +# Rust lexical tokens distinguish nested comments from ordinary, raw and byte +# strings. Textual delimiter matching misclassified Unicode .gitignore fixtures +# containing "/*" and could also miss an actual comment after a string literal. +if ! cargo run --quiet --locked \ + --manifest-path "$SCRIPT_DIR/comment-lexer/Cargo.toml" -- "${rust_files[@]}"; then + failures=$((failures + 1)) +fi + +# The C ABI lives behind a private Rust module, so rustdoc's missing_docs lint +# cannot enforce its safety sections. Check public unsafe functions explicitly. +unsafe_without_safety="$(awk ' + function reset_docs() { + documented = 0 + safety = 0 + } + + FNR == 1 { + reset_docs() + in_attribute = 0 + } + + /^[[:space:]]*\/\/\/([^!]|$)/ { + documented = 1 + if ($0 ~ /#[[:space:]]*Safety/) { + safety = 1 + } + next + } + + /^[[:space:]]*#\[/ { + in_attribute = ($0 !~ /\][[:space:]]*$/) + next + } + + in_attribute { + if ($0 ~ /\][[:space:]]*$/) { + in_attribute = 0 + } + next + } + + /^[[:space:]]*$/ { + reset_docs() + next + } + + { + public_unsafe_function = $0 ~ /^[[:space:]]*pub([[:space:]]*\([^)]*\))?[[:space:]]+(async[[:space:]]+)?(const[[:space:]]+)?unsafe[[:space:]]+(extern[[:space:]]+"[^"]+"[[:space:]]+)?fn[[:space:]]+/ + if (public_unsafe_function && (!documented || !safety)) { + print FILENAME ":" FNR ": public unsafe functions require /// documentation with a # Safety section" + } + reset_docs() + } +' "${rust_files[@]}")" +if [[ -n "$unsafe_without_safety" ]]; then + printf '%s\n' "$unsafe_without_safety" >&2 + failures=$((failures + 1)) +fi + +if (( failures > 0 )); then + exit 1 +fi + +# Let Rust understand visibility instead of treating pub items in private +# modules as exported APIs. This covers public functions, types, variants, and +# fields without requiring documentation for every internal helper. +for manifest in rust/lithe-core/Cargo.toml rust/lithe-core-rules/Cargo.toml; do + cargo rustdoc --quiet \ + --manifest-path "$manifest" \ + --lib -- \ + -D missing_docs \ + -D rustdoc::broken_intra_doc_links +done + +printf '%s\n' "Rust Core comment verification passed" diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 463b5e12..3bec93fd 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -1422,6 +1422,7 @@ package manager owns the download and Lithe does not infer bytes from logs. | `lsp.activationPolicy` | Decide whether a language server may start automatically after failures, using a platform monotonic clock | | `lsp.problems.query` | Filter, order, and group Problems tool-window diagnostics and count them by severity | | `lsp.documentUri` | Convert a native absolute path into a canonical file URI | +| `lsp.documentIdentity` | Resolve a diagnostic URI or path strictly inside its workspace | | `lsp.startServer` | Start one Rust-owned process/session and begin initialization | | `lsp.jdtWorkspaceKey` | Derive the deterministic JDT LS workspace-state directory key | | `java.workspacePolicy` | Decide Java workspace activation and classify changed paths | @@ -1579,12 +1580,19 @@ package manager owns the download and Lithe does not infer bytes from logs. | `debug.adapters.startArguments` | Check one debug start against a plugin's `debugAdapters[]` entry and return its DAP launch arguments | | `debug.adapters.executablePlan` | Say where to look for the program a launch debugs: the user's setting and the adapter's candidate paths | | `plugins.declarative.resolveSettings` | Merge a plugin's stored global and project setting layers into effective values with their source | +| `plugins.installed.select` | Select numeric installed versions from native facts without reading paths | +| `plugins.declarative.settingsState` | Read complete plugin preference documents into Core-normalized rows, sources, and bounded native read plans | +| `plugins.declarative.editSettings` | Validate one atomic setting edit batch and return replacement text while preserving unrelated plugins and dormant values | | `plugins.declarative.workspaceConfiguration` | Expand a language server's `workspaceConfiguration` with the merged setting values | | `plugins.declarative.checkRuntimeVersion` | Decide whether a launch runtime's version output meets the declared minimum version | | `toolchains.registry` | Revalidate complete enabled plugin manifests, `{plugins:[{manifest}]}`, into a deterministic tool-definition/provider registry; the host supplies no origin evidence. A short tool ID defined by several enabled plugins belongs to none of them: none of those definitions is registered, the rest register as usual, and `conflicts: [{tool, pluginIDs}]` (sorted by tool, `pluginIDs` sorted) reports each contested tool. A repeated plugin ID rejects the batch. No native host capability claim | | `toolchains.decodeReply` | Validate an inert toolchain query/reply pair against bound provider identity, generation, declared tools and host-native filesystem evidence; no transport or native I/O | | `toolchains.failureReply` | Build the JSON-RPC error reply for a query that missed its deadline or found no backend, so the host passes it to `toolchains.decodeReply` like a provider's own error; cancellation has none | | `toolchains.resolvePins` | Validate a captured local document and find the nearest ancestor directory pin, retaining dormant and legacy entries without probing or writing | +| `toolchains.editPins` | Apply one bounded batch of directory pin edits to a captured local document without writing native files | +| `toolchains.pinStoragePlan` | Declare bounded workspace-native pin and ignore sources | +| `toolchains.decodePinDocument` | Validate a complete native source or supply the absent-source default | +| `toolchains.editPinSources` | Plan deterministic pin and Git ignore source edits without native I/O | | `toolchains.resolveConsumerPreferences` | Validate an inert consumer preference document and resolve one tool's explicit Run, consumer, directory or workspace choice while keeping workspace/consumer inheritance symbolic; no native files or launch authority | | `toolchains.editConsumerPreferences` | Edit or reset one exact consumer/directory tool preference; return the document and effective input without native writes | | `toolchains.resolve` | Resolve the fixed/project/global/automatic layers from one generation-bound snapshot, retaining unmet requirements, unavailable providers with their cause, conflicts and terminal fixed-value failures | @@ -3726,6 +3734,18 @@ returns deterministically sorted `expiredWorkspaceKeys` older than the fixed enumeration, revalidation, deletion, and error logging; Core performs no cache filesystem I/O. See `shared/fixtures/lsp/jdt-cache-retention-v1.json`. +`lsp.documentIdentity` accepts `{ workspaceRoot, input, platform }` with the same +`posix | windows | auto` path syntax. It returns `{ document: { path, relativePath } | null }`. +`input` may be a file URI, native absolute path, or workspace-relative path. +Core reuses URI normalization and workspace containment; `path` uses the native +separator and `relativePath` uses `/`. File URIs decode percent escapes and +`localhost`; Windows drive/UNC aliases use existing case rules. Empty identities, +non-file URIs, URI queries/fragments, drive-relative/rooted-without-drive Windows +paths, the workspace root itself and outside targets return null. Native roots +must be absolute; invalid requests return `invalid_request`. This is lexical +placement only: it performs no I/O and grants no symlink or file access permission. +See `shared/fixtures/lsp/document-identity-v1.json`. + `lsp.documentUri` accepts `{ path, platform }` and returns `{ uri }`. `platform` is `posix`, `windows`, or `auto`; `auto` recognizes drive and UNC paths and otherwise treats an absolute path as POSIX. Paths must be absolute, contain no @@ -5302,10 +5322,12 @@ Success returns `plan` (`executable`, `arguments`, `workingDirectory`, plus `plugins.declarative.languageServerLaunchPlan` accepts `{ "manifest": object, "languageID"?: string, "buildSystemID"?: string, -"request": object }` with exactly one of `languageID` (a language server) and -`buildSystemID` (a build provider); naming neither or both is an -`invalid_request` failure. The request is `{ pluginDirectory, stateDirectory, -workspaceStateDirectory, architecture, settings?, projectSettings? }`. The directories are absolute host paths used only +"vcsProviderID"?: string, "backend"?: boolean, "debugAdapterID"?: string, +"request": object }` with exactly one selector: `languageID` (a language server), +`buildSystemID` (a build provider), `vcsProviderID` (a VCS provider), +`backend: true` (the plugin backend), or `debugAdapterID` (a debug adapter). +Naming none or more than one is an `invalid_request` failure. The request is `{ pluginDirectory, stateDirectory, +workspaceStateDirectory?, logDirectory?, temporaryDirectory?, architecture, settings?, projectSettings? }`. The directories are absolute host paths used only as substitution text; the command does not read or create them. `settings` maps setting IDs to the values the user stored for every project, and `projectSettings` to the open project's overrides; a project value counts only @@ -5324,6 +5346,12 @@ architecture or an empty directory is refused with build system, and a build system without a provider with `plugin.launch.unavailable`. +A backend plan additionally returns `backend.hostServices` (and `version` +for backend capability version 2 and later). These are the declared grants; +the host must provide only services it actually implements and must verify the +package and approved programs before starting the plan. A VCS or debug-adapter +plan likewise includes its corresponding typed descriptor. + A language server declared with `launch.kind: "backend"` (`serverProcess` 3) has no command line in its manifest; the plugin's backend chooses it. The plan this command returns is then only the first step: `launch` is `{ kind: "backend", @@ -5445,6 +5473,106 @@ the expanded object, or `null` when that server declares none; a language without a server of the plugin is refused with `plugin.launch.unavailable`. Hosts never merge layers themselves. +`plugins.declarative.settingsState` accepts +`{ manifest, globalDocument?: string|null, projectDocument?: string|null, + owner?: { pluginID, version } }`. +Send each complete captured UTF-8 document; null or omission means a missing +file. An existing empty, malformed, oversized, or duplicate-key document is an +error, not an empty layer. Native adapters must reject undecodable bytes rather +than replacing them before JSON transport. Storage remains +`{ pluginID: { settingID: value } }`, including dormant values. + +For an installed package, pass its captured registration as `owner`. Core +rejects a mismatched manifest ID or version with `plugin.settings.ownerChanged` +before returning editable sources or catalog metadata. This binds native package +identity; it neither enables capabilities nor grants process execution. + +Success returns `{ status: "ok", state }`. The state contains: + +- `pluginID`, `actions`, and `rows` in validated declaration order. Each row has + `declaration`, normalized `scope` and `effect`, `global` and `effective` + `{ id, value, source }`, and `globalStored`/`projectStored` flags. `global` is + the value without project overrides; `effective` uses the existing resolver. +- `metadata`: `{ displayName, version, vendor }` from the validated manifest, + for installed-plugin presentation without reinterpreting the manifest in a host. +- `sections`: `{ title, rows: number[], actions: number[] }[]`, ordered by + section title (the empty heading first). References are zero-based indices + into `rows` and `actions`; declaration order is retained within each section. +- `editors`: `{ global, project }`, each an array in exactly `rows` order. + Every entry contains `value`, `resetValue`, `source`, `editable`, `overridden` + and `invalidStored`, plus `input`/`resetInput` native-control projections. + These use `text` for integer/string/path fields, `on` for booleans, `selected` + for enum option index, or `checks` in multi-select option order. Core supplies the scope-specific value, inheritance after + reset, edit permission and invalid stored-value indication. Global-only rows + remain visible but read-only in the project projection. Hosts select a scope + and render this projection; they do not recompute grouping or inheritance. +- `settings` and `projectSettings`: raw stored values for declared IDs only. + Pass these to launch planners. Do not replace them with effective values: + executable planning must still reject an explicitly invalid stored path. +- `storage`: `{ globalRelativePath: "plugin-settings.json", + projectRelativePath: ".lithe/plugin-settings.json", maximumBytes: 1048576, + readRequired: bool }`. `readRequired` is false when the plugin has no setting + declarations; no preference document is then needed for that plugin. + Resolve these under the native application-preference and workspace roots. + A first call with missing documents provides this bounded read plan; publish + UI state only after the actual reads and the second checked projection. + +`plugins.declarative.editSettings` accepts +`{ manifest, scope: "global"|"project", document?: string|null, + edits: [{ id, value, text?: string|null }] }`. The batch has 1–32 unique declared IDs. +Null removes only that layer's override. Other values must satisfy the current +setting declaration; a global-only setting cannot be edited in project scope. +All edits are validated before any replacement is returned. + +Native text fields may supply `text` with `value: null`. Core parses trimmed +signed decimal integer text for an integer declaration; other kinds receive a +string and still pass the ordinary declaration validator. A non-null `value` +and `text` cannot be combined. In this mode null is a transport placeholder, +not a reset; without `text`, `value: null` keeps its existing reset meaning. +Malformed integer text, overflow, range violations and unsupported shapes reject +all edits. Editor text is bounded by the document byte limit. Hosts must not +validate or clamp declaration limits in a second implementation. + +Success returns `{ status: "ok", edit: { document: string, delete: bool, +changed: bool, effects: string[] } }`. `document` is complete UTF-8 JSON text. +Unrelated plugin groups and dormant settings survive; an emptied edited group +is removed. `delete` says the owned document is empty. `changed` compares stored +values; a semantic no-op returns the captured text unchanged. `effects` lists +only actually changed settings' declared effects, unique and alphabetically +ordered. The source and result must each fit the storage byte limit. + +The native owner still confirms the scope, checks captured bytes and +cancellation, preserves native permissions, publishes or deletes the owned +file, and rereads that same source. A project switch cannot retarget a save. +Only after publication may the host apply the declared effects. Core performs +no file I/O, starts no process, and grants no plugin capability through these +commands. They do not alter the plugin/SDK protocol or justify advertising +`settings: 2` before its complete UI and effects are implemented. + +Invalid documents use `plugin.settings.invalidDocument`; invalid batches, +values, or edit scopes use `plugin.settings.invalidEdit`, with a safe `field`. +Malformed envelopes use `invalid_request`; manifest failures retain existing +rejection codes. Rejection never includes partial replacement text. Rust and +desktop consumers share `shared/fixtures/plugins/settings-documents.json`. + +`plugins.installed.select` accepts `{ entries: [{ pluginID, version, enabled }], +includeDisabled?: bool }`, with at most 4096 native records. It returns +`{ selected: number[], rejected: [{ index, code }], maximumEntries: 4096 }`. +Indices refer to the supplied native array; paths stay in the host. Core reuses +its existing three-component `PluginVersion` comparator (numeric u32 components) +and returns one newest valid version per opaque native identity, ordered by ID. +Equal numeric versions retain the first input record; an exact duplicate version +is rejected rather than replacing that record. Enablement filters only after the +newest version is chosen, so a disabled newer version cannot revive an older one. +`includeDisabled: true` is for settings/inventory, not permission to execute. +Invalid identity/version records and later exact duplicates use +`plugin.installed.invalidIdentity`, `plugin.installed.invalidVersion`, and +`plugin.installed.duplicateVersion` indexed diagnostics. Healthy selections remain +available; the native owner must report diagnostics and must not abandon a +successfully started session merely because other plugins failed discovery. +This is metadata selection, not manifest validation, approval or admission. +Shared cases are in `shared/fixtures/plugins/installed-versions.json`. + `plugins.declarative.checkRuntimeVersion` accepts `{ tool, minimumVersion, output }`, where `output` is what the runtime printed for the plan's `launch.versionArguments`, and returns @@ -7015,3 +7143,143 @@ rename) before deleting anything. The result is `{delete, keep, seed}`, the firs seven days old or of unknown age), `abandoned` and `expired` (deleted). The cache and the ledger live in the platform cache directory, never in the app bundle or install directory. The fixture is `shared/fixtures/agent/attachment-retention-v1.json`. + +### Atomic local toolchain pin editing + +`toolchains.editPins` accepts `{document,directory,edits:[{tool,pin}]}` and returns +`{status:"ok",edit:{document}}`. It uses the v1 local toolchain document and the +same normalized directory, tool ID, pin and size limits as `resolvePins`. +A batch contains 1–64 unique tool IDs. `pin:null` removes that directory's explicit +override (ancestor inheritance remains effective); a pin object replaces it. +Only emptied containers on the edited path are pruned. Legacy `toolchains`, +dormant pins and unrelated directories remain intact. Unknown fields, duplicate +IDs, malformed pins and over-budget documents reject the whole batch with no +partial document. Rejections use the existing `plugin.toolchain.invalidParams` +and `plugin.toolchain.limitExceeded` structured error envelope. + +The command performs no I/O and grants no execution authority. The host owns +explicit Save confirmation, byte-baseline conflict detection, cancellation, +ignore-file preparation, atomic native replacement, and re-reading the source. +See `shared/fixtures/toolchain-pin-edit.json` for the cross-client vector. + +### Native toolchain pin sources + +`toolchains.pinStoragePlan` accepts `{}` and returns +`{status:"ok",storage:{documentRelativePath,ignoreRelativePath,maximumDocumentBytes,maximumIgnoreBytes}}`. +Paths are workspace-relative: `.lithe/toolchains/local.json` (1 MiB) and +`.lithe/.gitignore` (64 KiB). Hosts read bounded UTF-8 sources through their +native storage adapter, without creating directories during reads. + +`toolchains.decodePinDocument` accepts `{document:string|null}` and returns +`{status:"ok",document:}`. Null means absent and supplies +the empty default. Existing empty, malformed, duplicate-key or invalid dormant +entries reject with `plugin.toolchain.invalidDocument`. + +`toolchains.editPinSources` accepts +`{document:string|null,ignoreDocument:string|null,directory,edits:[{tool,pin}]}` +and returns `{status:"ok",edit:{document,documentChanged,ignoreDocument,ignoreChanged}}`. +A semantic no-op preserves both exact sources, including absence. A changed +pin document is serialized deterministically; the ignore matcher preserves +an already effective ignore rule or appends `/toolchains/local.json` after any +negation, preserving existing text and newline convention. Ignore parsing is +bounded to 4096 lines when preparing a changed pin document; NUL and invalid patterns reject with +`plugin.toolchain.invalidIgnore`. Input and output budgets reject with +`plugin.toolchain.limitExceeded`; invalid request shapes use +`plugin.toolchain.invalidParams`. The matcher never reads files or global rules. + +These commands perform no native I/O. Hosts bind captured bytes and native root +identity, recheck both even for no-op saves, preserve metadata, and publish the +ignore prerequisite before the pin document. A document already committed +followed by cleanup/readback failure must invalidate the old draft baseline: +reload before retrying. User-selected workspace storage never changes an +installed app or plugin bundle. See `shared/fixtures/toolchain-pin-storage.json`. + +### Debug launch response ordering + +A debug adapter may emit `stopped` or `terminated` before replying to `launch` +or `attach`. The acknowledgement completes that operation; it changes state to +`running` only if the session is still `launching`. It never resumes a paused +target or resurrects a terminated one. See +`shared/fixtures/debug/launch-event-order.json`. Hosts serialize each Core update, +its outbound frames and its UI publication in the order returned by the engine. + +### Settings action planning + +`plugins.declarative.settingsActionPlan` accepts +`{manifest,owner:{pluginID,version},actionID,confirmed,workspaceAvailable,enabled}`. +All fields are required and unknown fields reject. It validates the complete +manifest and its captured installed identity, then resolves the declared action +without starting a process or reading preferences. Unknown actions reject with +`plugin.settings.actionUnavailable`; registration mismatch uses +`plugin.settings.ownerChanged`, in the ordinary declarative rejection envelope. + +The success envelope is `{status:"ok",plan:{kind,pluginID,version,actionID,title,...}}`. +A disabled plugin returns `kind:"unavailable",reason:"pluginDisabled"`. +Without an open project, `kind:"unavailable",reason:"projectRequired"` contains +no function invocation. Otherwise an unconfirmed action whose declaration has +`confirm:true` returns `kind:"confirmationRequired"`, also without a function. +Once confirmed (or when the declaration needs no confirmation), `kind:"invoke"` +adds `function` and `params:{}`. Labels and function identity always come from +the validated manifest; callers cannot supply a different target or arguments. +See `shared/fixtures/plugins/settings-actions.json`. + +`confirmed` and `workspaceAvailable` are facts from the native owner, not a +capability grant. The host must capture user confirmation, check the installed +package and project identity again before invocation, and use that plugin's +provider session for the open project. Core's existing `lsp.functionRequest` +continues to enforce runtime function registration, owner and permissions. +This internal desktop C ABI command does not add a plugin host method or change +the published plugin function protocol. Progress, cancellation and completion +use the existing session event stream; settings drafts are not saved implicitly. + +### Workspace-bound function provider discovery + +Direct and backend-resolved plugin process plans include +`owner:{pluginID,version}` from the validated manifest. Native session handles +retain that version without inferring it from a path or mutable settings draft. + +`lsp.functionProvider` accepts `{pluginID,version,function,rootUri,candidates}`. +`rootUri` is the canonical file URI produced by `lsp.documentUri` for the +requesting workspace. Each native-owned candidate is `{sessionID,version}`; +at most 64 unique IDs may be supplied. Strings and the root URI are bounded. +Malformed or duplicate facts reject with `InvalidRequest`. + +The result is `{sessionID:string|null}`. Core requires the expected native +version, the actual runtime plugin owner, the same file workspace URI and a +ready session with the function currently registered. Candidate order does not +affect selection: the first matching session ID in lexical order wins. This +query performs no I/O, starts nothing and does not consume runtime events. +There is no cross-workspace fallback. An absent match is explicit null; normal +startup/admission and later function invocation checks remain required. +The pure selection vector is `shared/fixtures/plugins/settings-function-provider.json`. +This is an internal desktop command, not a new public plugin host method. + +### Dynamic plugin settings surfaces + +Internal desktop commands reuse the validated settings v2 manifest. They do not +add plugin RPCs or admit capabilities. `plugins.settings.catalog` accepts up to +64 `{manifest, owner: {pluginID, version}, enabled}` records. It validates all +identities, rejects duplicates and returns deterministic `pages` (up to 512), +with stable `key`, `kind` (`language` or `buildSystem`), opaque `id`, display +`title`, and plugin-ID ordered `contributors` (`pluginID`, `version`, `title`, +`sections`). Sections reference indices in the complete settingsState rows and +actions. Only enabled contributors create pages; action-only pages are valid. +Names come from enabled language/build declarations, with opaque ID fallback. +`settingsState.pages` supplies the same per-plugin placement projections. + +`plugins.settings.navigation` accepts at most 576 two-level `items` (512 contributed pages plus 64 host entries) with stable +`id`, `title`, nullable `parent`, `group`, `expanded`, optional `keywords`, plus +`query` (at most 1024 bytes). It returns `rows` containing id/title/group/expanded/level. +Search retains ancestors and expands matching groups; ordering follows input. +Duplicate IDs, invalid parents and excessive input are rejected. + +`plugins.settings.delivery` accepts `scope` (global/project), native equality +facts `samePreferences`, `sameWorkspace`, `sameVersion`, current `enabled`, and +at most four declared `effects`. It returns `delivery: {refresh, effects}`. +Global changes reach matching preference stores and versions; project changes +additionally require native workspace identity equality. Disabled plugins may +refresh displayed sources but receive no runtime effects. Effects are sorted +and deduplicated. No setting values cross windows: receivers read their own +project layers. Native owners must recheck window/project lifetime on delivery. +Invalid surface input returns `plugin.settings.invalidSurface`; owner mismatch +returns `plugin.settings.ownerChanged`. Shared vectors: `shared/fixtures/plugins/settings-surfaces.json`. diff --git a/shared/fixtures/debug/launch-event-order.json b/shared/fixtures/debug/launch-event-order.json new file mode 100644 index 00000000..52200d28 --- /dev/null +++ b/shared/fixtures/debug/launch-event-order.json @@ -0,0 +1,25 @@ +{ + "cases": [ + { + "id": "late-launch-paused", + "event": { + "type": "event", + "event": "stopped", + "body": { + "reason": "breakpoint", + "threadId": 1 + } + }, + "state": "paused" + }, + { + "id": "late-launch-ended", + "event": { + "type": "event", + "event": "terminated", + "body": {} + }, + "state": "terminated" + } + ] +} diff --git a/shared/fixtures/lsp/document-identity-v1.json b/shared/fixtures/lsp/document-identity-v1.json new file mode 100644 index 00000000..7f1b3bdd --- /dev/null +++ b/shared/fixtures/lsp/document-identity-v1.json @@ -0,0 +1,258 @@ +{ + "schemaVersion": 1, + "cases": [ + { + "name": "posix-relative", + "request": { + "workspaceRoot": "/work/app", + "input": "src/A.java", + "platform": "posix" + }, + "expected": { + "document": { + "path": "/work/app/src/A.java", + "relativePath": "src/A.java" + } + } + }, + { + "name": "posix-absolute", + "request": { + "workspaceRoot": "/work/app", + "input": "/work/app/src/A.java", + "platform": "auto" + }, + "expected": { + "document": { + "path": "/work/app/src/A.java", + "relativePath": "src/A.java" + } + } + }, + { + "name": "percent-decoded", + "request": { + "workspaceRoot": "/work/app", + "input": "file:///work/app/src/A%20%E4%B8%AD.java", + "platform": "posix" + }, + "expected": { + "document": { + "path": "/work/app/src/A 中.java", + "relativePath": "src/A 中.java" + } + } + }, + { + "name": "localhost", + "request": { + "workspaceRoot": "/work/app", + "input": "file://localhost/work/app/A.java", + "platform": "posix" + }, + "expected": { + "document": { + "path": "/work/app/A.java", + "relativePath": "A.java" + } + } + }, + { + "name": "outside", + "request": { + "workspaceRoot": "/work/app", + "input": "file:///elsewhere/A.java", + "platform": "posix" + }, + "expected": { + "document": null + } + }, + { + "name": "prefix-boundary", + "request": { + "workspaceRoot": "/work/app", + "input": "/work/application/A.java", + "platform": "posix" + }, + "expected": { + "document": null + } + }, + { + "name": "escape-relative", + "request": { + "workspaceRoot": "/work/app", + "input": "../A.java", + "platform": "posix" + }, + "expected": { + "document": null + } + }, + { + "name": "root-is-not-document", + "request": { + "workspaceRoot": "/work/app", + "input": ".", + "platform": "posix" + }, + "expected": { + "document": null + } + }, + { + "name": "external-uri", + "request": { + "workspaceRoot": "/work/app", + "input": "https://example.invalid/a", + "platform": "posix" + }, + "expected": { + "document": null + } + }, + { + "name": "remote-file-posix", + "request": { + "workspaceRoot": "/server/share", + "input": "file://server/share/A.java", + "platform": "posix" + }, + "expected": { + "document": null + } + }, + { + "name": "empty", + "request": { + "workspaceRoot": "/work/app", + "input": "", + "platform": "posix" + }, + "expected": { + "document": null + } + }, + { + "name": "uri-fragment", + "request": { + "workspaceRoot": "/work/app", + "input": "file:///work/app/A.java#secret", + "platform": "posix" + }, + "expected": { + "document": null + } + }, + { + "name": "windows-uri", + "request": { + "workspaceRoot": "C:\\Work\\App", + "input": "file:///C:/Work/App/src/A%20B.java", + "platform": "auto" + }, + "expected": { + "document": { + "path": "C:\\Work\\App\\src\\A B.java", + "relativePath": "src/A B.java" + } + } + }, + { + "name": "windows-relative", + "request": { + "workspaceRoot": "C:\\Work\\App", + "input": "src/A.java", + "platform": "windows" + }, + "expected": { + "document": { + "path": "C:\\Work\\App\\src\\A.java", + "relativePath": "src/A.java" + } + } + }, + { + "name": "windows-case-alias", + "request": { + "workspaceRoot": "C:\\Work\\App", + "input": "c:\\work\\app\\A.java", + "platform": "auto" + }, + "expected": { + "document": { + "path": "c:\\work\\app\\A.java", + "relativePath": "A.java" + } + } + }, + { + "name": "windows-rooted-uri-is-not-relative", + "request": { + "workspaceRoot": "C:\\Work\\App", + "input": "file:///elsewhere/A.java", + "platform": "windows" + }, + "expected": { + "document": null + } + }, + { + "name": "windows-drive-relative-is-ambiguous", + "request": { + "workspaceRoot": "C:\\Work\\App", + "input": "C:A.java", + "platform": "windows" + }, + "expected": { + "document": null + } + }, + { + "name": "windows-rooted-without-drive", + "request": { + "workspaceRoot": "C:\\Work\\App", + "input": "\\elsewhere\\A.java", + "platform": "windows" + }, + "expected": { + "document": null + } + }, + { + "name": "windows-outside", + "request": { + "workspaceRoot": "C:\\Work\\App", + "input": "D:\\Other\\A.java", + "platform": "windows" + }, + "expected": { + "document": null + } + }, + { + "name": "unc-uri", + "request": { + "workspaceRoot": "\\\\server\\share\\app", + "input": "file://server/share/app/A%20B.java", + "platform": "windows" + }, + "expected": { + "document": { + "path": "\\\\server\\share\\app\\A B.java", + "relativePath": "A B.java" + } + } + }, + { + "name": "invalid-root", + "request": { + "workspaceRoot": "relative", + "input": "A.java", + "platform": "posix" + }, + "error": "invalid_request" + } + ] +} diff --git a/shared/fixtures/plugins/installed-versions.json b/shared/fixtures/plugins/installed-versions.json new file mode 100644 index 00000000..3f34852a --- /dev/null +++ b/shared/fixtures/plugins/installed-versions.json @@ -0,0 +1,172 @@ +{ + "schemaVersion": 1, + "cases": [ + { + "name": "numeric-minor-over-directory-order", + "entries": [ + { + "pluginID": "example", + "version": "0.10.0", + "enabled": true + }, + { + "pluginID": "example", + "version": "0.9.0", + "enabled": true + } + ], + "selected": [ + 0 + ], + "rejected": [] + }, + { + "name": "numeric-order-independent", + "entries": [ + { + "pluginID": "example", + "version": "0.9.0", + "enabled": true + }, + { + "pluginID": "example", + "version": "0.10.0", + "enabled": true + } + ], + "selected": [ + 1 + ], + "rejected": [] + }, + { + "name": "numeric-major-and-patch", + "entries": [ + { + "pluginID": "example", + "version": "2.99.99", + "enabled": true + }, + { + "pluginID": "example", + "version": "10.0.9", + "enabled": true + }, + { + "pluginID": "example", + "version": "10.0.10", + "enabled": true + } + ], + "selected": [ + 2 + ], + "rejected": [] + }, + { + "name": "disabled-latest-does-not-revive-older", + "entries": [ + { + "pluginID": "example", + "version": "1.0.0", + "enabled": true + }, + { + "pluginID": "example", + "version": "2.0.0", + "enabled": false + } + ], + "selected": [], + "rejected": [] + }, + { + "name": "settings-may-list-disabled-latest", + "includeDisabled": true, + "entries": [ + { + "pluginID": "example", + "version": "1.0.0", + "enabled": true + }, + { + "pluginID": "example", + "version": "2.0.0", + "enabled": false + } + ], + "selected": [ + 1 + ], + "rejected": [] + }, + { + "name": "invalid-fact-does-not-hide-another-plugin", + "entries": [ + { + "pluginID": "broken", + "version": "invalid", + "enabled": true + }, + { + "pluginID": "healthy", + "version": "1.0.0", + "enabled": true + } + ], + "selected": [ + 1 + ], + "rejected": [ + { + "index": 0, + "code": "plugin.installed.invalidVersion" + } + ] + }, + { + "name": "selection-orders-plugin-identities", + "entries": [ + { + "pluginID": "zeta", + "version": "1.0.0", + "enabled": true + }, + { + "pluginID": "alpha", + "version": "1.0.0", + "enabled": true + } + ], + "selected": [ + 1, + 0 + ], + "rejected": [] + }, + { + "name": "duplicate-version-cannot-overwrite-first-fact", + "entries": [ + { + "pluginID": "example", + "version": "1.0.0", + "enabled": true + }, + { + "pluginID": "example", + "version": "1.0.0", + "enabled": false + } + ], + "selected": [ + 0 + ], + "rejected": [ + { + "index": 1, + "code": "plugin.installed.duplicateVersion" + } + ] + } + ] +} diff --git a/shared/fixtures/plugins/settings-actions.json b/shared/fixtures/plugins/settings-actions.json new file mode 100644 index 00000000..e192c996 --- /dev/null +++ b/shared/fixtures/plugins/settings-actions.json @@ -0,0 +1,140 @@ +{ + "schemaVersion": 1, + "cases": [ + { + "name": "confirmation-before-invocation", + "input": { + "owner": { + "pluginID": "dev.lithe.fixture.settings-v2", + "version": "0.1.0" + }, + "actionID": "rebuildPlt", + "confirmed": false, + "workspaceAvailable": true, + "enabled": true + }, + "plan": { + "pluginID": "dev.lithe.fixture.settings-v2", + "actionID": "rebuildPlt", + "title": "Rebuild PLT", + "kind": "confirmationRequired", + "version": "0.1.0" + } + }, + { + "name": "confirmed-action-has-empty-params", + "input": { + "owner": { + "pluginID": "dev.lithe.fixture.settings-v2", + "version": "0.1.0" + }, + "actionID": "rebuildPlt", + "confirmed": true, + "workspaceAvailable": true, + "enabled": true + }, + "plan": { + "pluginID": "dev.lithe.fixture.settings-v2", + "actionID": "rebuildPlt", + "title": "Rebuild PLT", + "kind": "invoke", + "function": "dialyzer.rebuild", + "params": {}, + "version": "0.1.0" + } + }, + { + "name": "project-required", + "input": { + "owner": { + "pluginID": "dev.lithe.fixture.settings-v2", + "version": "0.1.0" + }, + "actionID": "rebuildPlt", + "confirmed": true, + "workspaceAvailable": false, + "enabled": true + }, + "plan": { + "pluginID": "dev.lithe.fixture.settings-v2", + "actionID": "rebuildPlt", + "title": "Rebuild PLT", + "kind": "unavailable", + "reason": "projectRequired", + "version": "0.1.0" + } + }, + { + "name": "unknown-action", + "input": { + "owner": { + "pluginID": "dev.lithe.fixture.settings-v2", + "version": "0.1.0" + }, + "actionID": "missing", + "confirmed": false, + "workspaceAvailable": true, + "enabled": true + }, + "error": "plugin.settings.actionUnavailable" + }, + { + "name": "wrong-installed-owner", + "input": { + "owner": { + "pluginID": "dev.lithe.fixture.settings-v2", + "version": "9.0.0" + }, + "actionID": "rebuildPlt", + "confirmed": false, + "workspaceAvailable": true, + "enabled": true + }, + "error": "plugin.settings.ownerChanged" + }, + { + "name": "action-without-confirmation", + "input": { + "owner": { + "pluginID": "dev.lithe.fixture.settings-v2", + "version": "0.1.0" + }, + "actionID": "rebuildPlt", + "confirmed": false, + "workspaceAvailable": true, + "enabled": true + }, + "confirmation": false, + "plan": { + "pluginID": "dev.lithe.fixture.settings-v2", + "actionID": "rebuildPlt", + "title": "Rebuild PLT", + "kind": "invoke", + "function": "dialyzer.rebuild", + "params": {}, + "version": "0.1.0" + } + }, + { + "name": "disabled-plugin-is-not-invoked", + "input": { + "owner": { + "pluginID": "dev.lithe.fixture.settings-v2", + "version": "0.1.0" + }, + "actionID": "rebuildPlt", + "confirmed": true, + "workspaceAvailable": true, + "enabled": false + }, + "plan": { + "kind": "unavailable", + "pluginID": "dev.lithe.fixture.settings-v2", + "actionID": "rebuildPlt", + "title": "Rebuild PLT", + "reason": "pluginDisabled", + "version": "0.1.0" + } + } + ] +} diff --git a/shared/fixtures/plugins/settings-documents.json b/shared/fixtures/plugins/settings-documents.json new file mode 100644 index 00000000..c4ee308f --- /dev/null +++ b/shared/fixtures/plugins/settings-documents.json @@ -0,0 +1,560 @@ +{ + "schemaVersion": 1, + "manifest": { + "schemaVersion": 2, + "id": "dev.example.settings", + "displayName": "Settings Fixture", + "version": "1.0.0", + "pluginAPIVersion": 3, + "requires": { + "settings": 2, + "languages": 1, + "serverProcess": 1, + "languageServers": 1 + }, + "vendor": { + "id": "dev.example", + "displayName": "Example", + "signatureRequirement": "none" + }, + "permissions": [ + "languageServer.launch" + ], + "activationEvents": [ + "onLanguage:fixture" + ], + "languages": [ + { + "id": "fixture", + "displayName": "Fixture", + "fileExtensions": [ + "fixture" + ] + } + ], + "settings": [ + { + "id": "flag", + "type": "boolean", + "title": "Flag", + "default": true, + "scope": "project" + }, + { + "id": "count", + "type": "integer", + "title": "Count", + "default": 3, + "minimum": 1, + "maximum": 10 + }, + { + "id": "format", + "type": "enum", + "title": "Format", + "default": "plain", + "options": [ + { + "value": "plain", + "title": "Plain" + }, + { + "value": "rich", + "title": "Rich" + } + ], + "scope": "project" + }, + { + "id": "checks", + "type": "multiSelect", + "title": "Checks", + "default": [], + "options": [ + { + "value": "lint", + "title": "Lint" + }, + { + "value": "test", + "title": "Test" + } + ], + "scope": "project", + "effect": "nextRun" + }, + { + "id": "label", + "type": "text", + "title": "Label", + "default": "base", + "pattern": "[a-z]+", + "maxLength": 4, + "scope": "project" + }, + { + "id": "program", + "type": "file", + "title": "Program", + "default": "", + "scope": "project" + }, + { + "id": "folder", + "type": "directory", + "title": "Folder", + "default": "", + "scope": "project" + } + ], + "languageServers": [ + { + "languageID": "fixture", + "launch": { + "kind": "systemCommand", + "commands": [ + "fixture-server" + ] + }, + "arguments": [] + } + ] + }, + "editCases": [ + { + "name": "all-types-and-sibling", + "scope": "global", + "document": "{\"dev.other\":{\"keep\":{\"nested\":[\"unchanged\"]}},\"dev.example.settings\":{\"dormant\":17}}", + "edits": [ + { + "id": "flag", + "value": false + }, + { + "id": "count", + "value": 6 + }, + { + "id": "format", + "value": "rich" + }, + { + "id": "checks", + "value": [ + "lint", + "test" + ] + }, + { + "id": "label", + "value": "fast" + }, + { + "id": "program", + "value": "/fixture/app" + }, + { + "id": "folder", + "value": "C:\\fixture tools" + } + ], + "expected": { + "dev.other": { + "keep": { + "nested": [ + "unchanged" + ] + } + }, + "dev.example.settings": { + "dormant": 17, + "flag": false, + "count": 6, + "format": "rich", + "checks": [ + "lint", + "test" + ], + "label": "fast", + "program": "/fixture/app", + "folder": "C:\\fixture tools" + } + }, + "effects": [ + "nextRun", + "restart" + ] + }, + { + "name": "remove-one-retains-sibling", + "scope": "project", + "document": "{\"dev.example.settings\":{\"flag\":false,\"label\":\"fast\"}}", + "edits": [ + { + "id": "flag", + "value": null + } + ], + "expected": { + "dev.example.settings": { + "label": "fast" + } + }, + "effects": [ + "restart" + ] + }, + { + "name": "empty-layer-delete", + "scope": "project", + "document": "{\"dev.example.settings\":{\"flag\":false}}", + "edits": [ + { + "id": "flag", + "value": null + } + ], + "expected": {}, + "delete": true, + "effects": [ + "restart" + ] + }, + { + "name": "unchanged-preserves-source-text", + "scope": "global", + "document": "{ \"dev.example.settings\": {\"flag\":false} }\n", + "edits": [ + { + "id": "flag", + "value": false + } + ], + "expected": { + "dev.example.settings": { + "flag": false + } + }, + "unchanged": true, + "effects": [] + }, + { + "name": "unknown-id", + "scope": "global", + "document": null, + "edits": [ + { + "id": "missing", + "value": true + } + ], + "errorCode": "plugin.settings.invalidEdit" + }, + { + "name": "global-setting-in-project", + "scope": "project", + "document": null, + "edits": [ + { + "id": "count", + "value": 6 + } + ], + "errorCode": "plugin.settings.invalidEdit" + }, + { + "name": "invalid-batch-is-not-partial", + "scope": "global", + "document": "{\"dev.example.settings\":{\"flag\":true}}", + "edits": [ + { + "id": "flag", + "value": false + }, + { + "id": "count", + "value": 11 + } + ], + "errorCode": "plugin.settings.invalidEdit" + }, + { + "name": "duplicate-edit", + "scope": "global", + "document": null, + "edits": [ + { + "id": "flag", + "value": false + }, + { + "id": "flag", + "value": true + } + ], + "errorCode": "plugin.settings.invalidEdit" + }, + { + "name": "bad-document", + "scope": "global", + "document": "not json", + "edits": [ + { + "id": "flag", + "value": true + } + ], + "errorCode": "plugin.settings.invalidDocument" + }, + { + "name": "duplicate-plugin-key", + "scope": "global", + "document": "{\"dev.other\":{},\"dev.other\":{}}", + "edits": [ + { + "id": "flag", + "value": true + } + ], + "errorCode": "plugin.settings.invalidDocument" + }, + { + "name": "duplicate-setting-key", + "scope": "global", + "document": "{\"dev.example.settings\":{\"flag\":false,\"flag\":true}}", + "edits": [ + { + "id": "flag", + "value": true + } + ], + "errorCode": "plugin.settings.invalidDocument" + }, + { + "name": "invalid-group", + "scope": "global", + "document": "{\"dev.other\":[]}", + "edits": [ + { + "id": "flag", + "value": true + } + ], + "errorCode": "plugin.settings.invalidDocument" + }, + { + "name": "empty-batch", + "scope": "global", + "document": null, + "edits": [], + "errorCode": "plugin.settings.invalidEdit" + }, + { + "name": "wrong-boolean", + "scope": "global", + "document": null, + "edits": [ + { + "id": "flag", + "value": "false" + } + ], + "errorCode": "plugin.settings.invalidEdit" + }, + { + "name": "fractional-integer", + "scope": "global", + "document": null, + "edits": [ + { + "id": "count", + "value": 1.5 + } + ], + "errorCode": "plugin.settings.invalidEdit" + }, + { + "name": "unknown-enum", + "scope": "global", + "document": null, + "edits": [ + { + "id": "format", + "value": "unknown" + } + ], + "errorCode": "plugin.settings.invalidEdit" + }, + { + "name": "duplicate-option", + "scope": "global", + "document": null, + "edits": [ + { + "id": "checks", + "value": [ + "lint", + "lint" + ] + } + ], + "errorCode": "plugin.settings.invalidEdit" + }, + { + "name": "unsafe-text", + "scope": "global", + "document": null, + "edits": [ + { + "id": "label", + "value": "-x" + } + ], + "errorCode": "plugin.settings.invalidEdit" + }, + { + "name": "long-text", + "scope": "global", + "document": null, + "edits": [ + { + "id": "label", + "value": "abcde" + } + ], + "errorCode": "plugin.settings.invalidEdit" + }, + { + "name": "relative-file", + "scope": "global", + "document": null, + "edits": [ + { + "id": "program", + "value": "app" + } + ], + "errorCode": "plugin.settings.invalidEdit" + }, + { + "name": "relative-directory", + "scope": "global", + "document": null, + "edits": [ + { + "id": "folder", + "value": "tools" + } + ], + "errorCode": "plugin.settings.invalidEdit" + }, + { + "name": "duplicate-nested-dormant-value", + "scope": "global", + "document": "{\"dev.other\":{\"dormant\":{\"key\":1,\"key\":2}}}", + "edits": [ + { + "id": "flag", + "value": false + } + ], + "errorCode": "plugin.settings.invalidDocument" + }, + { + "name": "integer-editor-text", + "scope": "global", + "document": null, + "edits": [ + { + "id": "count", + "value": null, + "text": " 6 " + } + ], + "expected": { + "dev.example.settings": { + "count": 6 + } + }, + "effects": [ + "restart" + ] + }, + { + "name": "file-editor-text", + "scope": "global", + "document": null, + "edits": [ + { + "id": "program", + "value": null, + "text": "/fixture tools/app" + } + ], + "expected": { + "dev.example.settings": { + "program": "/fixture tools/app" + } + }, + "effects": [ + "restart" + ] + }, + { + "name": "integer-editor-text-rejects-fraction", + "scope": "global", + "document": null, + "edits": [ + { + "id": "count", + "value": null, + "text": "3.5" + } + ], + "errorCode": "plugin.settings.invalidEdit" + }, + { + "name": "integer-editor-text-enforces-bounds", + "scope": "global", + "document": null, + "edits": [ + { + "id": "count", + "value": null, + "text": "11" + } + ], + "errorCode": "plugin.settings.invalidEdit" + }, + { + "name": "editor-text-and-value-are-exclusive", + "scope": "global", + "document": null, + "edits": [ + { + "id": "count", + "value": 4, + "text": "6" + } + ], + "errorCode": "plugin.settings.invalidEdit" + }, + { + "name": "editor-text-rejection-is-atomic", + "scope": "global", + "document": "{\"dev.other\":{\"keep\":true}}", + "edits": [ + { + "id": "flag", + "value": false + }, + { + "id": "count", + "value": null, + "text": "not a number" + } + ], + "errorCode": "plugin.settings.invalidEdit" + } + ] +} diff --git a/shared/fixtures/plugins/settings-function-provider.json b/shared/fixtures/plugins/settings-function-provider.json new file mode 100644 index 00000000..88ea108d --- /dev/null +++ b/shared/fixtures/plugins/settings-function-provider.json @@ -0,0 +1,212 @@ +{ + "schemaVersion": 1, + "cases": [ + { + "name": "stable-same-workspace-selection", + "request": { + "pluginID": "dev.example.settings", + "version": "1.0.0", + "function": "index.refresh", + "rootUri": "file:///fixture/project", + "candidates": [ + { + "sessionID": "b", + "version": "1.0.0" + }, + { + "sessionID": "a", + "version": "1.0.0" + } + ] + }, + "facts": [ + { + "sessionID": "b", + "pluginID": "dev.example.settings", + "rootUri": "file:///fixture/project", + "serves": true + }, + { + "sessionID": "a", + "pluginID": "dev.example.settings", + "rootUri": "file:///fixture/project/", + "serves": true + } + ], + "sessionID": "a" + }, + { + "name": "never-fall-back-to-other-workspace", + "request": { + "pluginID": "dev.example.settings", + "version": "1.0.0", + "function": "index.refresh", + "rootUri": "file:///fixture/project", + "candidates": [ + { + "sessionID": "b", + "version": "1.0.0" + }, + { + "sessionID": "a", + "version": "1.0.0" + } + ] + }, + "facts": [ + { + "sessionID": "b", + "pluginID": "dev.example.settings", + "rootUri": "file:///fixture/other", + "serves": true + } + ], + "sessionID": null + }, + { + "name": "wrong-plugin-is-not-selected", + "request": { + "pluginID": "dev.example.settings", + "version": "1.0.0", + "function": "index.refresh", + "rootUri": "file:///fixture/project", + "candidates": [ + { + "sessionID": "b", + "version": "1.0.0" + }, + { + "sessionID": "a", + "version": "1.0.0" + } + ] + }, + "facts": [ + { + "sessionID": "b", + "pluginID": "dev.example.other", + "rootUri": "file:///fixture/project", + "serves": true + } + ], + "sessionID": null + }, + { + "name": "unregistered-or-not-ready", + "request": { + "pluginID": "dev.example.settings", + "version": "1.0.0", + "function": "index.refresh", + "rootUri": "file:///fixture/project", + "candidates": [ + { + "sessionID": "b", + "version": "1.0.0" + }, + { + "sessionID": "a", + "version": "1.0.0" + } + ] + }, + "facts": [ + { + "sessionID": "b", + "pluginID": "dev.example.settings", + "rootUri": "file:///fixture/project", + "serves": false + } + ], + "sessionID": null + }, + { + "name": "old-installed-version", + "request": { + "pluginID": "dev.example.settings", + "version": "2.0.0", + "function": "index.refresh", + "rootUri": "file:///fixture/project", + "candidates": [ + { + "sessionID": "b", + "version": "1.0.0" + }, + { + "sessionID": "a", + "version": "1.0.0" + } + ] + }, + "facts": [ + { + "sessionID": "b", + "pluginID": "dev.example.settings", + "rootUri": "file:///fixture/project", + "serves": true + }, + { + "sessionID": "a", + "pluginID": "dev.example.settings", + "rootUri": "file:///fixture/project/", + "serves": true + } + ], + "sessionID": null + }, + { + "name": "duplicate-native-session-facts", + "request": { + "pluginID": "dev.example.settings", + "version": "1.0.0", + "function": "index.refresh", + "rootUri": "file:///fixture/project", + "candidates": [ + { + "sessionID": "b", + "version": "1.0.0" + }, + { + "sessionID": "b", + "version": "1.0.0" + } + ] + }, + "facts": [ + { + "sessionID": "b", + "pluginID": "dev.example.settings", + "rootUri": "file:///fixture/project", + "serves": true + }, + { + "sessionID": "a", + "pluginID": "dev.example.settings", + "rootUri": "file:///fixture/project/", + "serves": true + } + ], + "invalid": true + }, + { + "name": "non-file-root-is-invalid", + "request": { + "pluginID": "dev.example.settings", + "version": "1.0.0", + "function": "index.refresh", + "rootUri": "https://example.invalid/project", + "candidates": [ + { + "sessionID": "b", + "version": "1.0.0" + }, + { + "sessionID": "a", + "version": "1.0.0" + } + ] + }, + "facts": [], + "invalid": true + } + ] +} diff --git a/shared/fixtures/plugins/settings-surfaces.json b/shared/fixtures/plugins/settings-surfaces.json new file mode 100644 index 00000000..e6107262 --- /dev/null +++ b/shared/fixtures/plugins/settings-surfaces.json @@ -0,0 +1,1677 @@ +{ + "cases": [ + { + "name": "enabled_pages", + "command": "plugins.settings.catalog", + "input": { + "plugins": [ + { + "manifest": { + "schemaVersion": 2, + "id": "dev.lithe.fixture.settings-v2", + "displayName": "Settings v2 Fixture", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { + "buildProviders": 1, + "buildSystems": 1, + "languageServers": 2, + "languages": 1, + "serverProcess": 1, + "settings": 2 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.build" + ], + "activationEvents": [ + "onBuildSystem:mix", + "onLanguage:elixir" + ], + "languages": [ + { + "id": "elixir", + "displayName": "Elixir", + "fileExtensions": [ + "ex", + "exs" + ], + "projectFileNames": [ + "mix.exs" + ] + } + ], + "languageServers": [ + { + "languageID": "elixir", + "launch": { + "kind": "systemCommand", + "commands": [ + "elixir-ls" + ] + }, + "arguments": [ + "--stdio", + "--env=${setting:mixEnv}" + ], + "workspaceConfiguration": { + "elixirLS": { + "dialyzerEnabled": "${setting:dialyzer}" + } + } + } + ], + "buildSystems": [ + { + "id": "mix", + "displayName": "Mix", + "detection": { + "markers": [ + "mix.exs" + ], + "nesting": "outermost" + }, + "invocation": { + "executables": [ + { + "kind": "command", + "command": "mix" + } + ] + }, + "provider": { + "languageServer": "elixir", + "executesProjectCode": true, + "mayUseNetwork": false + }, + "model": { + "source": "provider" + }, + "tasks": [ + { + "id": "compile", + "displayName": "Compile", + "group": "build", + "scopes": [ + "workspace" + ], + "arguments": [ + "compile" + ] + }, + { + "id": "deps-get", + "displayName": "Fetch Dependencies", + "group": "other", + "scopes": [ + "workspace" + ], + "arguments": [ + "deps.get" + ] + } + ], + "dependencies": { + "source": "provider", + "scopes": [ + "workspace" + ] + }, + "sync": { + "watch": [ + "mix.exs", + "mix.lock" + ] + }, + "layout": { + "excluded": [ + "_build", + "deps" + ] + } + } + ], + "settings": [ + { + "id": "dialyzer", + "type": "boolean", + "title": "Dialyzer", + "default": true, + "scope": "project", + "effect": "immediate", + "page": { + "languageID": "elixir" + } + }, + { + "id": "mixEnv", + "type": "text", + "title": "MIX_ENV", + "default": "dev", + "pattern": "[a-z]+", + "scope": "project", + "effect": "restart", + "page": { + "languageID": "elixir" + }, + "section": "Server" + }, + { + "id": "checks", + "type": "multiSelect", + "title": "Checks", + "default": [ + "format" + ], + "options": [ + { + "value": "format", + "title": "Format" + }, + { + "value": "credo", + "title": "Credo" + } + ], + "effect": "nextRun", + "page": { + "buildSystemID": "mix" + } + } + ], + "settingActions": [ + { + "id": "rebuildPlt", + "title": "Rebuild PLT", + "function": "dialyzer.rebuild", + "page": { + "languageID": "elixir" + }, + "confirm": true + } + ] + }, + "owner": { + "pluginID": "dev.lithe.fixture.settings-v2", + "version": "0.1.0" + }, + "enabled": true + } + ] + }, + "status": "ok", + "pages": 2 + }, + { + "name": "disabled_contributes_nothing", + "command": "plugins.settings.catalog", + "input": { + "plugins": [ + { + "manifest": { + "schemaVersion": 2, + "id": "dev.lithe.fixture.settings-v2", + "displayName": "Settings v2 Fixture", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { + "buildProviders": 1, + "buildSystems": 1, + "languageServers": 2, + "languages": 1, + "serverProcess": 1, + "settings": 2 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.build" + ], + "activationEvents": [ + "onBuildSystem:mix", + "onLanguage:elixir" + ], + "languages": [ + { + "id": "elixir", + "displayName": "Elixir", + "fileExtensions": [ + "ex", + "exs" + ], + "projectFileNames": [ + "mix.exs" + ] + } + ], + "languageServers": [ + { + "languageID": "elixir", + "launch": { + "kind": "systemCommand", + "commands": [ + "elixir-ls" + ] + }, + "arguments": [ + "--stdio", + "--env=${setting:mixEnv}" + ], + "workspaceConfiguration": { + "elixirLS": { + "dialyzerEnabled": "${setting:dialyzer}" + } + } + } + ], + "buildSystems": [ + { + "id": "mix", + "displayName": "Mix", + "detection": { + "markers": [ + "mix.exs" + ], + "nesting": "outermost" + }, + "invocation": { + "executables": [ + { + "kind": "command", + "command": "mix" + } + ] + }, + "provider": { + "languageServer": "elixir", + "executesProjectCode": true, + "mayUseNetwork": false + }, + "model": { + "source": "provider" + }, + "tasks": [ + { + "id": "compile", + "displayName": "Compile", + "group": "build", + "scopes": [ + "workspace" + ], + "arguments": [ + "compile" + ] + }, + { + "id": "deps-get", + "displayName": "Fetch Dependencies", + "group": "other", + "scopes": [ + "workspace" + ], + "arguments": [ + "deps.get" + ] + } + ], + "dependencies": { + "source": "provider", + "scopes": [ + "workspace" + ] + }, + "sync": { + "watch": [ + "mix.exs", + "mix.lock" + ] + }, + "layout": { + "excluded": [ + "_build", + "deps" + ] + } + } + ], + "settings": [ + { + "id": "dialyzer", + "type": "boolean", + "title": "Dialyzer", + "default": true, + "scope": "project", + "effect": "immediate", + "page": { + "languageID": "elixir" + } + }, + { + "id": "mixEnv", + "type": "text", + "title": "MIX_ENV", + "default": "dev", + "pattern": "[a-z]+", + "scope": "project", + "effect": "restart", + "page": { + "languageID": "elixir" + }, + "section": "Server" + }, + { + "id": "checks", + "type": "multiSelect", + "title": "Checks", + "default": [ + "format" + ], + "options": [ + { + "value": "format", + "title": "Format" + }, + { + "value": "credo", + "title": "Credo" + } + ], + "effect": "nextRun", + "page": { + "buildSystemID": "mix" + } + } + ], + "settingActions": [ + { + "id": "rebuildPlt", + "title": "Rebuild PLT", + "function": "dialyzer.rebuild", + "page": { + "languageID": "elixir" + }, + "confirm": true + } + ] + }, + "owner": { + "pluginID": "dev.lithe.fixture.settings-v2", + "version": "0.1.0" + }, + "enabled": false + } + ] + }, + "status": "ok", + "pages": 0, + "pageOwners": {} + }, + { + "name": "duplicate_owner_rejected", + "command": "plugins.settings.catalog", + "input": { + "plugins": [ + { + "manifest": { + "schemaVersion": 2, + "id": "dev.lithe.fixture.settings-v2", + "displayName": "Settings v2 Fixture", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { + "buildProviders": 1, + "buildSystems": 1, + "languageServers": 2, + "languages": 1, + "serverProcess": 1, + "settings": 2 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.build" + ], + "activationEvents": [ + "onBuildSystem:mix", + "onLanguage:elixir" + ], + "languages": [ + { + "id": "elixir", + "displayName": "Elixir", + "fileExtensions": [ + "ex", + "exs" + ], + "projectFileNames": [ + "mix.exs" + ] + } + ], + "languageServers": [ + { + "languageID": "elixir", + "launch": { + "kind": "systemCommand", + "commands": [ + "elixir-ls" + ] + }, + "arguments": [ + "--stdio", + "--env=${setting:mixEnv}" + ], + "workspaceConfiguration": { + "elixirLS": { + "dialyzerEnabled": "${setting:dialyzer}" + } + } + } + ], + "buildSystems": [ + { + "id": "mix", + "displayName": "Mix", + "detection": { + "markers": [ + "mix.exs" + ], + "nesting": "outermost" + }, + "invocation": { + "executables": [ + { + "kind": "command", + "command": "mix" + } + ] + }, + "provider": { + "languageServer": "elixir", + "executesProjectCode": true, + "mayUseNetwork": false + }, + "model": { + "source": "provider" + }, + "tasks": [ + { + "id": "compile", + "displayName": "Compile", + "group": "build", + "scopes": [ + "workspace" + ], + "arguments": [ + "compile" + ] + }, + { + "id": "deps-get", + "displayName": "Fetch Dependencies", + "group": "other", + "scopes": [ + "workspace" + ], + "arguments": [ + "deps.get" + ] + } + ], + "dependencies": { + "source": "provider", + "scopes": [ + "workspace" + ] + }, + "sync": { + "watch": [ + "mix.exs", + "mix.lock" + ] + }, + "layout": { + "excluded": [ + "_build", + "deps" + ] + } + } + ], + "settings": [ + { + "id": "dialyzer", + "type": "boolean", + "title": "Dialyzer", + "default": true, + "scope": "project", + "effect": "immediate", + "page": { + "languageID": "elixir" + } + }, + { + "id": "mixEnv", + "type": "text", + "title": "MIX_ENV", + "default": "dev", + "pattern": "[a-z]+", + "scope": "project", + "effect": "restart", + "page": { + "languageID": "elixir" + }, + "section": "Server" + }, + { + "id": "checks", + "type": "multiSelect", + "title": "Checks", + "default": [ + "format" + ], + "options": [ + { + "value": "format", + "title": "Format" + }, + { + "value": "credo", + "title": "Credo" + } + ], + "effect": "nextRun", + "page": { + "buildSystemID": "mix" + } + } + ], + "settingActions": [ + { + "id": "rebuildPlt", + "title": "Rebuild PLT", + "function": "dialyzer.rebuild", + "page": { + "languageID": "elixir" + }, + "confirm": true + } + ] + }, + "owner": { + "pluginID": "dev.lithe.fixture.settings-v2", + "version": "0.1.0" + }, + "enabled": true + }, + { + "manifest": { + "schemaVersion": 2, + "id": "dev.lithe.fixture.settings-v2", + "displayName": "Settings v2 Fixture", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { + "buildProviders": 1, + "buildSystems": 1, + "languageServers": 2, + "languages": 1, + "serverProcess": 1, + "settings": 2 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.build" + ], + "activationEvents": [ + "onBuildSystem:mix", + "onLanguage:elixir" + ], + "languages": [ + { + "id": "elixir", + "displayName": "Elixir", + "fileExtensions": [ + "ex", + "exs" + ], + "projectFileNames": [ + "mix.exs" + ] + } + ], + "languageServers": [ + { + "languageID": "elixir", + "launch": { + "kind": "systemCommand", + "commands": [ + "elixir-ls" + ] + }, + "arguments": [ + "--stdio", + "--env=${setting:mixEnv}" + ], + "workspaceConfiguration": { + "elixirLS": { + "dialyzerEnabled": "${setting:dialyzer}" + } + } + } + ], + "buildSystems": [ + { + "id": "mix", + "displayName": "Mix", + "detection": { + "markers": [ + "mix.exs" + ], + "nesting": "outermost" + }, + "invocation": { + "executables": [ + { + "kind": "command", + "command": "mix" + } + ] + }, + "provider": { + "languageServer": "elixir", + "executesProjectCode": true, + "mayUseNetwork": false + }, + "model": { + "source": "provider" + }, + "tasks": [ + { + "id": "compile", + "displayName": "Compile", + "group": "build", + "scopes": [ + "workspace" + ], + "arguments": [ + "compile" + ] + }, + { + "id": "deps-get", + "displayName": "Fetch Dependencies", + "group": "other", + "scopes": [ + "workspace" + ], + "arguments": [ + "deps.get" + ] + } + ], + "dependencies": { + "source": "provider", + "scopes": [ + "workspace" + ] + }, + "sync": { + "watch": [ + "mix.exs", + "mix.lock" + ] + }, + "layout": { + "excluded": [ + "_build", + "deps" + ] + } + } + ], + "settings": [ + { + "id": "dialyzer", + "type": "boolean", + "title": "Dialyzer", + "default": true, + "scope": "project", + "effect": "immediate", + "page": { + "languageID": "elixir" + } + }, + { + "id": "mixEnv", + "type": "text", + "title": "MIX_ENV", + "default": "dev", + "pattern": "[a-z]+", + "scope": "project", + "effect": "restart", + "page": { + "languageID": "elixir" + }, + "section": "Server" + }, + { + "id": "checks", + "type": "multiSelect", + "title": "Checks", + "default": [ + "format" + ], + "options": [ + { + "value": "format", + "title": "Format" + }, + { + "value": "credo", + "title": "Credo" + } + ], + "effect": "nextRun", + "page": { + "buildSystemID": "mix" + } + } + ], + "settingActions": [ + { + "id": "rebuildPlt", + "title": "Rebuild PLT", + "function": "dialyzer.rebuild", + "page": { + "languageID": "elixir" + }, + "confirm": true + } + ] + }, + "owner": { + "pluginID": "dev.lithe.fixture.settings-v2", + "version": "0.1.0" + }, + "enabled": true + } + ] + }, + "status": "rejected" + }, + { + "name": "delivery_global_3", + "command": "plugins.settings.delivery", + "input": { + "scope": "global", + "samePreferences": true, + "sameWorkspace": false, + "sameVersion": true, + "enabled": true, + "effects": [ + "restart", + "immediate", + "restart" + ] + }, + "status": "ok", + "delivery": { + "refresh": true, + "effects": [ + "immediate", + "restart" + ] + } + }, + { + "name": "delivery_project_4", + "command": "plugins.settings.delivery", + "input": { + "scope": "project", + "samePreferences": true, + "sameWorkspace": false, + "sameVersion": true, + "enabled": true, + "effects": [ + "restart", + "immediate", + "restart" + ] + }, + "status": "ok", + "delivery": { + "refresh": false, + "effects": [] + } + }, + { + "name": "delivery_project_5", + "command": "plugins.settings.delivery", + "input": { + "scope": "project", + "samePreferences": true, + "sameWorkspace": true, + "sameVersion": true, + "enabled": true, + "effects": [ + "restart", + "immediate", + "restart" + ] + }, + "status": "ok", + "delivery": { + "refresh": true, + "effects": [ + "immediate", + "restart" + ] + } + }, + { + "name": "delivery_global_6", + "command": "plugins.settings.delivery", + "input": { + "scope": "global", + "samePreferences": false, + "sameWorkspace": true, + "sameVersion": true, + "enabled": true, + "effects": [ + "restart", + "immediate", + "restart" + ] + }, + "status": "ok", + "delivery": { + "refresh": false, + "effects": [] + } + }, + { + "name": "delivery_global_7", + "command": "plugins.settings.delivery", + "input": { + "scope": "global", + "samePreferences": true, + "sameWorkspace": true, + "sameVersion": false, + "enabled": true, + "effects": [ + "restart", + "immediate", + "restart" + ] + }, + "status": "ok", + "delivery": { + "refresh": false, + "effects": [] + } + }, + { + "name": "delivery_global_8", + "command": "plugins.settings.delivery", + "input": { + "scope": "global", + "samePreferences": true, + "sameWorkspace": true, + "sameVersion": true, + "enabled": false, + "effects": [ + "restart", + "immediate", + "restart" + ] + }, + "status": "ok", + "delivery": { + "refresh": true, + "effects": [] + } + }, + { + "name": "search_retains_parent", + "command": "plugins.settings.navigation", + "input": { + "query": "ELIXIR", + "items": [ + { + "id": "languages", + "title": "Languages", + "parent": null, + "group": false, + "expanded": false + }, + { + "id": "language:elixir", + "title": "Elixir", + "parent": "languages", + "group": false, + "expanded": false + } + ] + }, + "status": "ok", + "rowIDs": [ + "languages", + "language:elixir" + ] + }, + { + "name": "wrong_installed_owner", + "command": "plugins.settings.catalog", + "input": { + "plugins": [ + { + "manifest": { + "schemaVersion": 2, + "id": "dev.lithe.fixture.settings-v2", + "displayName": "Settings v2 Fixture", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { + "buildProviders": 1, + "buildSystems": 1, + "languageServers": 2, + "languages": 1, + "serverProcess": 1, + "settings": 2 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.build" + ], + "activationEvents": [ + "onBuildSystem:mix", + "onLanguage:elixir" + ], + "languages": [ + { + "id": "elixir", + "displayName": "Elixir", + "fileExtensions": [ + "ex", + "exs" + ], + "projectFileNames": [ + "mix.exs" + ] + } + ], + "languageServers": [ + { + "languageID": "elixir", + "launch": { + "kind": "systemCommand", + "commands": [ + "elixir-ls" + ] + }, + "arguments": [ + "--stdio", + "--env=${setting:mixEnv}" + ], + "workspaceConfiguration": { + "elixirLS": { + "dialyzerEnabled": "${setting:dialyzer}" + } + } + } + ], + "buildSystems": [ + { + "id": "mix", + "displayName": "Mix", + "detection": { + "markers": [ + "mix.exs" + ], + "nesting": "outermost" + }, + "invocation": { + "executables": [ + { + "kind": "command", + "command": "mix" + } + ] + }, + "provider": { + "languageServer": "elixir", + "executesProjectCode": true, + "mayUseNetwork": false + }, + "model": { + "source": "provider" + }, + "tasks": [ + { + "id": "compile", + "displayName": "Compile", + "group": "build", + "scopes": [ + "workspace" + ], + "arguments": [ + "compile" + ] + }, + { + "id": "deps-get", + "displayName": "Fetch Dependencies", + "group": "other", + "scopes": [ + "workspace" + ], + "arguments": [ + "deps.get" + ] + } + ], + "dependencies": { + "source": "provider", + "scopes": [ + "workspace" + ] + }, + "sync": { + "watch": [ + "mix.exs", + "mix.lock" + ] + }, + "layout": { + "excluded": [ + "_build", + "deps" + ] + } + } + ], + "settings": [ + { + "id": "dialyzer", + "type": "boolean", + "title": "Dialyzer", + "default": true, + "scope": "project", + "effect": "immediate", + "page": { + "languageID": "elixir" + } + }, + { + "id": "mixEnv", + "type": "text", + "title": "MIX_ENV", + "default": "dev", + "pattern": "[a-z]+", + "scope": "project", + "effect": "restart", + "page": { + "languageID": "elixir" + }, + "section": "Server" + }, + { + "id": "checks", + "type": "multiSelect", + "title": "Checks", + "default": [ + "format" + ], + "options": [ + { + "value": "format", + "title": "Format" + }, + { + "value": "credo", + "title": "Credo" + } + ], + "effect": "nextRun", + "page": { + "buildSystemID": "mix" + } + } + ], + "settingActions": [ + { + "id": "rebuildPlt", + "title": "Rebuild PLT", + "function": "dialyzer.rebuild", + "page": { + "languageID": "elixir" + }, + "confirm": true + } + ] + }, + "owner": { + "pluginID": "dev.lithe.fixture.settings-v2", + "version": "9.0.0" + }, + "enabled": true + } + ] + }, + "status": "rejected" + }, + { + "name": "multiple_contributors_one_page", + "command": "plugins.settings.catalog", + "input": { + "plugins": [ + { + "manifest": { + "schemaVersion": 2, + "id": "dev.lithe.fixture.other-settings", + "displayName": "Settings v2 Fixture", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { + "buildProviders": 1, + "buildSystems": 1, + "languageServers": 2, + "languages": 1, + "serverProcess": 1, + "settings": 2 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.build" + ], + "activationEvents": [ + "onBuildSystem:mix", + "onLanguage:elixir" + ], + "languages": [ + { + "id": "elixir", + "displayName": "Elixir", + "fileExtensions": [ + "ex", + "exs" + ], + "projectFileNames": [ + "mix.exs" + ] + } + ], + "languageServers": [ + { + "languageID": "elixir", + "launch": { + "kind": "systemCommand", + "commands": [ + "elixir-ls" + ] + }, + "arguments": [ + "--stdio", + "--env=${setting:mixEnv}" + ], + "workspaceConfiguration": { + "elixirLS": { + "dialyzerEnabled": "${setting:dialyzer}" + } + } + } + ], + "buildSystems": [ + { + "id": "mix", + "displayName": "Mix", + "detection": { + "markers": [ + "mix.exs" + ], + "nesting": "outermost" + }, + "invocation": { + "executables": [ + { + "kind": "command", + "command": "mix" + } + ] + }, + "provider": { + "languageServer": "elixir", + "executesProjectCode": true, + "mayUseNetwork": false + }, + "model": { + "source": "provider" + }, + "tasks": [ + { + "id": "compile", + "displayName": "Compile", + "group": "build", + "scopes": [ + "workspace" + ], + "arguments": [ + "compile" + ] + }, + { + "id": "deps-get", + "displayName": "Fetch Dependencies", + "group": "other", + "scopes": [ + "workspace" + ], + "arguments": [ + "deps.get" + ] + } + ], + "dependencies": { + "source": "provider", + "scopes": [ + "workspace" + ] + }, + "sync": { + "watch": [ + "mix.exs", + "mix.lock" + ] + }, + "layout": { + "excluded": [ + "_build", + "deps" + ] + } + } + ], + "settings": [ + { + "id": "dialyzer", + "type": "boolean", + "title": "Dialyzer", + "default": true, + "scope": "project", + "effect": "immediate", + "page": { + "languageID": "elixir" + } + }, + { + "id": "mixEnv", + "type": "text", + "title": "MIX_ENV", + "default": "dev", + "pattern": "[a-z]+", + "scope": "project", + "effect": "restart", + "page": { + "languageID": "elixir" + }, + "section": "Server" + }, + { + "id": "checks", + "type": "multiSelect", + "title": "Checks", + "default": [ + "format" + ], + "options": [ + { + "value": "format", + "title": "Format" + }, + { + "value": "credo", + "title": "Credo" + } + ], + "effect": "nextRun", + "page": { + "buildSystemID": "mix" + } + } + ], + "settingActions": [ + { + "id": "rebuildPlt", + "title": "Rebuild PLT", + "function": "dialyzer.rebuild", + "page": { + "languageID": "elixir" + }, + "confirm": true + } + ] + }, + "owner": { + "pluginID": "dev.lithe.fixture.other-settings", + "version": "0.1.0" + }, + "enabled": true + }, + { + "manifest": { + "schemaVersion": 2, + "id": "dev.lithe.fixture.settings-v2", + "displayName": "Settings v2 Fixture", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { + "buildProviders": 1, + "buildSystems": 1, + "languageServers": 2, + "languages": 1, + "serverProcess": 1, + "settings": 2 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.build" + ], + "activationEvents": [ + "onBuildSystem:mix", + "onLanguage:elixir" + ], + "languages": [ + { + "id": "elixir", + "displayName": "Elixir", + "fileExtensions": [ + "ex", + "exs" + ], + "projectFileNames": [ + "mix.exs" + ] + } + ], + "languageServers": [ + { + "languageID": "elixir", + "launch": { + "kind": "systemCommand", + "commands": [ + "elixir-ls" + ] + }, + "arguments": [ + "--stdio", + "--env=${setting:mixEnv}" + ], + "workspaceConfiguration": { + "elixirLS": { + "dialyzerEnabled": "${setting:dialyzer}" + } + } + } + ], + "buildSystems": [ + { + "id": "mix", + "displayName": "Mix", + "detection": { + "markers": [ + "mix.exs" + ], + "nesting": "outermost" + }, + "invocation": { + "executables": [ + { + "kind": "command", + "command": "mix" + } + ] + }, + "provider": { + "languageServer": "elixir", + "executesProjectCode": true, + "mayUseNetwork": false + }, + "model": { + "source": "provider" + }, + "tasks": [ + { + "id": "compile", + "displayName": "Compile", + "group": "build", + "scopes": [ + "workspace" + ], + "arguments": [ + "compile" + ] + }, + { + "id": "deps-get", + "displayName": "Fetch Dependencies", + "group": "other", + "scopes": [ + "workspace" + ], + "arguments": [ + "deps.get" + ] + } + ], + "dependencies": { + "source": "provider", + "scopes": [ + "workspace" + ] + }, + "sync": { + "watch": [ + "mix.exs", + "mix.lock" + ] + }, + "layout": { + "excluded": [ + "_build", + "deps" + ] + } + } + ], + "settings": [ + { + "id": "dialyzer", + "type": "boolean", + "title": "Dialyzer", + "default": true, + "scope": "project", + "effect": "immediate", + "page": { + "languageID": "elixir" + } + }, + { + "id": "mixEnv", + "type": "text", + "title": "MIX_ENV", + "default": "dev", + "pattern": "[a-z]+", + "scope": "project", + "effect": "restart", + "page": { + "languageID": "elixir" + }, + "section": "Server" + }, + { + "id": "checks", + "type": "multiSelect", + "title": "Checks", + "default": [ + "format" + ], + "options": [ + { + "value": "format", + "title": "Format" + }, + { + "value": "credo", + "title": "Credo" + } + ], + "effect": "nextRun", + "page": { + "buildSystemID": "mix" + } + } + ], + "settingActions": [ + { + "id": "rebuildPlt", + "title": "Rebuild PLT", + "function": "dialyzer.rebuild", + "page": { + "languageID": "elixir" + }, + "confirm": true + } + ] + }, + "owner": { + "pluginID": "dev.lithe.fixture.settings-v2", + "version": "0.1.0" + }, + "enabled": true + } + ] + }, + "status": "ok", + "pages": 2, + "pageOwners": { + "language:elixir": [ + "dev.lithe.fixture.other-settings", + "dev.lithe.fixture.settings-v2" + ] + } + }, + { + "name": "action_only_surface", + "command": "plugins.settings.catalog", + "input": { + "plugins": [ + { + "manifest": { + "schemaVersion": 2, + "id": "dev.lithe.fixture.settings-v2", + "displayName": "Settings v2 Fixture", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { + "languages": 1, + "settings": 2, + "languageServers": 1, + "serverProcess": 1 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.build" + ], + "activationEvents": [ + "onLanguage:elixir" + ], + "languages": [ + { + "id": "elixir", + "displayName": "Elixir", + "fileExtensions": [ + "ex", + "exs" + ], + "projectFileNames": [ + "mix.exs" + ] + } + ], + "settingActions": [ + { + "id": "refresh", + "title": "Refresh", + "function": "fixture.refresh", + "page": { + "languageID": "elixir" + } + } + ], + "languageServers": [ + { + "languageID": "elixir", + "launch": { + "kind": "systemCommand", + "commands": [ + "fixture-ls" + ] + }, + "arguments": [ + "--stdio" + ] + } + ] + }, + "owner": { + "pluginID": "dev.lithe.fixture.settings-v2", + "version": "0.1.0" + }, + "enabled": true + } + ] + }, + "status": "ok", + "pages": 1 + } + ] +} diff --git a/shared/fixtures/toolchain-pin-edit.json b/shared/fixtures/toolchain-pin-edit.json new file mode 100644 index 00000000..598e42e7 --- /dev/null +++ b/shared/fixtures/toolchain-pin-edit.json @@ -0,0 +1,56 @@ +{ + "request": { + "document": { + "version": 1, + "toolchains": { + "legacy": { + "home": "legacy home" + } + }, + "pins": { + ".": { + "java": { + "home": "/fixture/java", + "version": "21", + "provider": "host:manual" + } + } + } + }, + "directory": "apps/web", + "edits": [ + { + "tool": "node", + "pin": { + "home": "/fixture/node", + "version": "22", + "provider": "host:manual" + } + } + ] + }, + "document": { + "version": 1, + "toolchains": { + "legacy": { + "home": "legacy home" + } + }, + "pins": { + ".": { + "java": { + "home": "/fixture/java", + "version": "21", + "provider": "host:manual" + } + }, + "apps/web": { + "node": { + "home": "/fixture/node", + "version": "22", + "provider": "host:manual" + } + } + } + } +} diff --git a/shared/fixtures/toolchain-pin-storage.json b/shared/fixtures/toolchain-pin-storage.json new file mode 100644 index 00000000..86f9a884 --- /dev/null +++ b/shared/fixtures/toolchain-pin-storage.json @@ -0,0 +1,450 @@ +{ + "schemaVersion": 1, + "cases": [ + { + "name": "storage-plan", + "command": "toolchains.pinStoragePlan", + "input": {}, + "expected": { + "status": "ok", + "storage": { + "documentRelativePath": ".lithe/toolchains/local.json", + "ignoreRelativePath": ".lithe/.gitignore", + "maximumDocumentBytes": 1048576, + "maximumIgnoreBytes": 65536 + } + } + }, + { + "name": "storage-plan-refuses-unknown-input", + "command": "toolchains.pinStoragePlan", + "input": { + "unexpected": true + }, + "error": "plugin.toolchain.invalidParams" + }, + { + "name": "absent-source-default", + "command": "toolchains.decodePinDocument", + "input": { + "document": null + }, + "expected": { + "status": "ok", + "document": { + "version": 1, + "toolchains": {} + } + } + }, + { + "name": "empty-existing", + "command": "toolchains.decodePinDocument", + "input": { + "document": "" + }, + "error": "plugin.toolchain.invalidDocument" + }, + { + "name": "malformed", + "command": "toolchains.decodePinDocument", + "input": { + "document": "{" + }, + "error": "plugin.toolchain.invalidDocument" + }, + { + "name": "duplicate-root", + "command": "toolchains.decodePinDocument", + "input": { + "document": "{\"version\":1,\"toolchains\":{},\"version\":1}" + }, + "error": "plugin.toolchain.invalidDocument" + }, + { + "name": "duplicate-escaped-nested", + "command": "toolchains.decodePinDocument", + "input": { + "document": "{\"version\":1,\"toolchains\":{\"old\":{\"home\":\"/a\",\"h\\u006fme\":\"/b\"}}}" + }, + "error": "plugin.toolchain.invalidDocument" + }, + { + "name": "unknown-root-field", + "command": "toolchains.decodePinDocument", + "input": { + "document": "{\"version\":1,\"toolchains\":{},\"extra\":true}" + }, + "error": "plugin.toolchain.invalidDocument" + }, + { + "name": "non-string-source", + "command": "toolchains.decodePinDocument", + "input": { + "document": {} + }, + "error": "plugin.toolchain.invalidParams" + }, + { + "name": "missing-source-field", + "command": "toolchains.decodePinDocument", + "input": {}, + "error": "plugin.toolchain.invalidParams" + }, + { + "name": "complete-dormant-document", + "command": "toolchains.decodePinDocument", + "input": { + "document": "{\"pins\":{\"nested\":{\"dev.example:compiler\":{\"home\":\"/fixture/tool\",\"provider\":\"host:system\",\"version\":\"1.0\"}}},\"toolchains\":{\"legacy\":{\"home\":\"/fixture/legacy\"}},\"version\":1}" + }, + "expected": { + "status": "ok", + "document": { + "version": 1, + "toolchains": { + "legacy": { + "home": "/fixture/legacy" + } + }, + "pins": { + "nested": { + "dev.example:compiler": { + "home": "/fixture/tool", + "version": "1.0", + "provider": "host:system" + } + } + } + } + } + }, + { + "name": "absent-reset-writes-nothing", + "command": "toolchains.editPinSources", + "input": { + "document": null, + "ignoreDocument": null, + "directory": ".", + "edits": [ + { + "tool": "dev.example:compiler", + "pin": null + } + ] + }, + "expected": { + "status": "ok", + "edit": { + "document": null, + "documentChanged": false, + "ignoreDocument": null, + "ignoreChanged": false + } + } + }, + { + "name": "existing-unchanged-preserves-bytes", + "command": "toolchains.editPinSources", + "input": { + "document": "{ \"version\": 1, \"toolchains\": {} }\n", + "ignoreDocument": "# user notes\n", + "directory": ".", + "edits": [ + { + "tool": "dev.example:compiler", + "pin": null + } + ] + }, + "expected": { + "status": "ok", + "edit": { + "document": "{ \"version\": 1, \"toolchains\": {} }\n", + "documentChanged": false, + "ignoreDocument": "# user notes\n", + "ignoreChanged": false + } + } + }, + { + "name": "new-pin-and-ignore", + "command": "toolchains.editPinSources", + "input": { + "document": null, + "ignoreDocument": null, + "directory": ".", + "edits": [ + { + "tool": "dev.example:compiler", + "pin": { + "home": "/fixture/tool", + "version": "1.0", + "provider": "host:system" + } + } + ] + }, + "expected": { + "status": "ok", + "edit": { + "document": "{\"pins\":{\".\":{\"dev.example:compiler\":{\"home\":\"/fixture/tool\",\"provider\":\"host:system\",\"version\":\"1.0\"}}},\"toolchains\":{},\"version\":1}", + "documentChanged": true, + "ignoreDocument": "/toolchains/local.json\n", + "ignoreChanged": true + } + } + }, + { + "name": "comments-preserved", + "command": "toolchains.editPinSources", + "input": { + "document": "{\"toolchains\":{},\"version\":1}", + "ignoreDocument": "# user notes\n", + "directory": ".", + "edits": [ + { + "tool": "dev.example:compiler", + "pin": { + "home": "/fixture/tool", + "version": "1.0", + "provider": "host:system" + } + } + ] + }, + "expected": { + "status": "ok", + "edit": { + "document": "{\"pins\":{\".\":{\"dev.example:compiler\":{\"home\":\"/fixture/tool\",\"provider\":\"host:system\",\"version\":\"1.0\"}}},\"toolchains\":{},\"version\":1}", + "documentChanged": true, + "ignoreDocument": "# user notes\n/toolchains/local.json\n", + "ignoreChanged": true + } + } + }, + { + "name": "later-negation-needs-effective-rule", + "command": "toolchains.editPinSources", + "input": { + "document": "{\"toolchains\":{},\"version\":1}", + "ignoreDocument": "/toolchains/local.json\n!/toolchains/local.json\n", + "directory": ".", + "edits": [ + { + "tool": "dev.example:compiler", + "pin": { + "home": "/fixture/tool", + "version": "1.0", + "provider": "host:system" + } + } + ] + }, + "expected": { + "status": "ok", + "edit": { + "document": "{\"pins\":{\".\":{\"dev.example:compiler\":{\"home\":\"/fixture/tool\",\"provider\":\"host:system\",\"version\":\"1.0\"}}},\"toolchains\":{},\"version\":1}", + "documentChanged": true, + "ignoreDocument": "/toolchains/local.json\n!/toolchains/local.json\n/toolchains/local.json\n", + "ignoreChanged": true + } + } + }, + { + "name": "wildcard-already-ignores", + "command": "toolchains.editPinSources", + "input": { + "document": "{\"toolchains\":{},\"version\":1}", + "ignoreDocument": "**/local.json\n", + "directory": ".", + "edits": [ + { + "tool": "dev.example:compiler", + "pin": { + "home": "/fixture/tool", + "version": "1.0", + "provider": "host:system" + } + } + ] + }, + "expected": { + "status": "ok", + "edit": { + "document": "{\"pins\":{\".\":{\"dev.example:compiler\":{\"home\":\"/fixture/tool\",\"provider\":\"host:system\",\"version\":\"1.0\"}}},\"toolchains\":{},\"version\":1}", + "documentChanged": true, + "ignoreDocument": "**/local.json\n", + "ignoreChanged": false + } + } + }, + { + "name": "crlf-preserved", + "command": "toolchains.editPinSources", + "input": { + "document": "{\"toolchains\":{},\"version\":1}", + "ignoreDocument": "# user notes\r\n", + "directory": ".", + "edits": [ + { + "tool": "dev.example:compiler", + "pin": { + "home": "/fixture/tool", + "version": "1.0", + "provider": "host:system" + } + } + ] + }, + "expected": { + "status": "ok", + "edit": { + "document": "{\"pins\":{\".\":{\"dev.example:compiler\":{\"home\":\"/fixture/tool\",\"provider\":\"host:system\",\"version\":\"1.0\"}}},\"toolchains\":{},\"version\":1}", + "documentChanged": true, + "ignoreDocument": "# user notes\r\n/toolchains/local.json\r\n", + "ignoreChanged": true + } + } + }, + { + "name": "missing-final-newline", + "command": "toolchains.editPinSources", + "input": { + "document": "{\"toolchains\":{},\"version\":1}", + "ignoreDocument": "# user notes", + "directory": ".", + "edits": [ + { + "tool": "dev.example:compiler", + "pin": { + "home": "/fixture/tool", + "version": "1.0", + "provider": "host:system" + } + } + ] + }, + "expected": { + "status": "ok", + "edit": { + "document": "{\"pins\":{\".\":{\"dev.example:compiler\":{\"home\":\"/fixture/tool\",\"provider\":\"host:system\",\"version\":\"1.0\"}}},\"toolchains\":{},\"version\":1}", + "documentChanged": true, + "ignoreDocument": "# user notes\n/toolchains/local.json\n", + "ignoreChanged": true + } + } + }, + { + "name": "bom-rule-is-not-a-new-pattern", + "command": "toolchains.editPinSources", + "input": { + "document": "{\"toolchains\":{},\"version\":1}", + "ignoreDocument": "/toolchains/local.json\n", + "directory": ".", + "edits": [ + { + "tool": "dev.example:compiler", + "pin": { + "home": "/fixture/tool", + "version": "1.0", + "provider": "host:system" + } + } + ] + }, + "expected": { + "status": "ok", + "edit": { + "document": "{\"pins\":{\".\":{\"dev.example:compiler\":{\"home\":\"/fixture/tool\",\"provider\":\"host:system\",\"version\":\"1.0\"}}},\"toolchains\":{},\"version\":1}", + "documentChanged": true, + "ignoreDocument": "/toolchains/local.json\n", + "ignoreChanged": false + } + } + }, + { + "name": "nul-ignore", + "command": "toolchains.editPinSources", + "input": { + "document": null, + "ignoreDocument": "a\u0000b\n", + "directory": ".", + "edits": [ + { + "tool": "dev.example:compiler", + "pin": { + "home": "/fixture/tool", + "version": "1.0", + "provider": "host:system" + } + } + ] + }, + "error": "plugin.toolchain.invalidIgnore" + }, + { + "name": "invalid-ignore-glob", + "command": "toolchains.editPinSources", + "input": { + "document": null, + "ignoreDocument": "[z-a]\n", + "directory": ".", + "edits": [ + { + "tool": "dev.example:compiler", + "pin": { + "home": "/fixture/tool", + "version": "1.0", + "provider": "host:system" + } + } + ] + }, + "error": "plugin.toolchain.invalidIgnore" + }, + { + "name": "invalid-edit-batch", + "command": "toolchains.editPinSources", + "input": { + "document": null, + "ignoreDocument": null, + "directory": ".", + "edits": [ + { + "tool": "not a tool", + "pin": { + "home": "/fixture/tool", + "version": "1.0", + "provider": "host:system" + } + } + ] + }, + "error": "plugin.toolchain.invalidParams" + }, + { + "name": "reset-keeps-other-pins", + "command": "toolchains.editPinSources", + "input": { + "document": "{\"pins\":{\".\":{\"dev.example:compiler\":{\"home\":\"/fixture/tool\",\"provider\":\"host:system\",\"version\":\"1.0\"}},\"nested\":{\"dev.example:compiler\":{\"home\":\"/fixture/tool\",\"provider\":\"host:system\",\"version\":\"1.0\"}}},\"toolchains\":{\"legacy\":{\"home\":\"/fixture/legacy\"}},\"version\":1}", + "ignoreDocument": "/toolchains/local.json\n", + "directory": ".", + "edits": [ + { + "tool": "dev.example:compiler", + "pin": null + } + ] + }, + "expected": { + "status": "ok", + "edit": { + "document": "{\"pins\":{\"nested\":{\"dev.example:compiler\":{\"home\":\"/fixture/tool\",\"provider\":\"host:system\",\"version\":\"1.0\"}}},\"toolchains\":{\"legacy\":{\"home\":\"/fixture/legacy\"}},\"version\":1}", + "documentChanged": true, + "ignoreDocument": "/toolchains/local.json\n", + "ignoreChanged": false + } + } + } + ] +}