From 4fca6f06c4cac421e6792143c8aa6e06b2fe2eb6 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Wed, 4 Nov 2026 22:14:15 +0800 Subject: [PATCH 01/20] feat(core): let plugin backends start runs and debug sessions Adds the Core side of the plugin-initiated Run and debug API (Refs #1495): - serverProcess 4: launches, runtime dependencies and runSelectedRuntime name tool definitions by ID, listed in the manifest's requiresTools; toolchains.instantiateLaunch expands a definition's launch template. Versions 1 and 2 keep their closed runtime lists. - run.decodeStart and debug.decodeBackendConfiguration validate the params of lithe/host/run/start and lithe/host/debug/start; run/stop and the run/didExit notification join the plugin-function host protocol with the run.start and debug.start permissions and plugin.host.toolUnresolved. - debugAdapters 3: adapter features (hot code replace, step filters) and defaults drive the debug engine instead of recognising an adapter by ID. - outputLinkMatchers 1: outputLinks.match and outputLinks.decodeResolution find console links from patterns the plugin declares. Fixtures cover each new request, rejection and manifest. --- rust/lithe-core/src/build/manifest.rs | 3 + rust/lithe-core/src/build/mod.rs | 2 + rust/lithe-core/src/build/run_preparation.rs | 6 +- rust/lithe-core/src/build/run_start.rs | 370 +++++++++++ rust/lithe-core/src/debug/adapters.rs | 51 +- rust/lithe-core/src/debug/backend_start.rs | 135 ++++ rust/lithe-core/src/debug/engine.rs | 385 ++++++++++- rust/lithe-core/src/debug/mod.rs | 2 + rust/lithe-core/src/debug/types.rs | 168 +++++ rust/lithe-core/src/lsp/interface/mod.rs | 2 + .../src/lsp/interface/plugin_function.rs | 81 ++- rust/lithe-core/src/plugins/backend.rs | 3 + rust/lithe-core/src/plugins/capabilities.rs | 13 +- rust/lithe-core/src/plugins/declarative.rs | 249 +++++++- rust/lithe-core/src/plugins/mod.rs | 2 + rust/lithe-core/src/plugins/output_links.rs | 599 ++++++++++++++++++ rust/lithe-core/src/protocol/command.rs | 28 + rust/lithe-core/src/runtime/dispatcher.rs | 23 + .../src/tests/declarative_plugins.rs | 7 + rust/lithe-core/src/tests/mod.rs | 1 + rust/lithe-core/src/tests/plugin_run_debug.rs | 508 +++++++++++++++ rust/lithe-core/src/toolchain/mod.rs | 5 + rust/lithe-core/src/toolchain/resolution.rs | 105 +++ rust/lithe-core/src/vcs/manifest.rs | 3 + .../debug-adapter-features.plugin.json | 68 ++ .../plugins/v1/fixtures/debug-start.json | 117 ++++ .../v1/fixtures/function-protocol.json | 21 +- .../v1/fixtures/invalid-manifests.json | 13 +- .../v1/fixtures/output-link-matchers.json | 192 ++++++ .../plugins/v1/fixtures/run-start.json | 270 ++++++++ .../tool-reference-launch.plugin.json | 105 +++ 31 files changed, 3485 insertions(+), 52 deletions(-) create mode 100644 rust/lithe-core/src/build/run_start.rs create mode 100644 rust/lithe-core/src/debug/backend_start.rs create mode 100644 rust/lithe-core/src/plugins/output_links.rs create mode 100644 rust/lithe-core/src/tests/plugin_run_debug.rs create mode 100644 shared/contracts/plugins/v1/fixtures/debug-adapter-features.plugin.json create mode 100644 shared/contracts/plugins/v1/fixtures/debug-start.json create mode 100644 shared/contracts/plugins/v1/fixtures/output-link-matchers.json create mode 100644 shared/contracts/plugins/v1/fixtures/run-start.json create mode 100644 shared/contracts/plugins/v1/fixtures/tool-reference-launch.plugin.json diff --git a/rust/lithe-core/src/build/manifest.rs b/rust/lithe-core/src/build/manifest.rs index e0a5e12724..842d0b6856 100644 --- a/rust/lithe-core/src/build/manifest.rs +++ b/rust/lithe-core/src/build/manifest.rs @@ -1017,6 +1017,8 @@ pub(crate) struct DeclarationContext<'a> { pub(crate) declared_settings: &'a crate::plugins::settings::DeclaredSettings, /// Language IDs the plugin declares a language server for. pub(crate) language_servers: &'a BTreeSet, + /// Whether tool names are host runtimes or tool-definition IDs. + pub(crate) runtime_tools: crate::plugins::declarative::RuntimeTools<'a>, } pub(crate) fn validate_declarations( @@ -1532,6 +1534,7 @@ impl BuildSystemDeclaration { context.plugin_api_version, context.declared_settings, ServerRole::BuildProvider, + context.runtime_tools, ), } } diff --git a/rust/lithe-core/src/build/mod.rs b/rust/lithe-core/src/build/mod.rs index 511051affc..77829b7cc6 100644 --- a/rust/lithe-core/src/build/mod.rs +++ b/rust/lithe-core/src/build/mod.rs @@ -31,6 +31,7 @@ mod outputs; mod plans; mod project_options; mod run_preparation; +mod run_start; mod select; mod stored_options; mod template; @@ -43,4 +44,5 @@ pub(crate) use manifest::{ ProviderProcess, }; pub(crate) use project_options::project_options_command; +pub(crate) use run_start::run_decode_start_command; pub(crate) use stored_options::resolve_stored_options_command; diff --git a/rust/lithe-core/src/build/run_preparation.rs b/rust/lithe-core/src/build/run_preparation.rs index d11af23741..ef6d5ae819 100644 --- a/rust/lithe-core/src/build/run_preparation.rs +++ b/rust/lithe-core/src/build/run_preparation.rs @@ -10,9 +10,9 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::BTreeMap; -const MAX_ARGUMENTS: usize = 1024; -const MAX_ARGUMENT_BYTES: usize = 65536; -const MAX_ENVIRONMENT: usize = 64; +pub(super) const MAX_ARGUMENTS: usize = 1024; +pub(super) const MAX_ARGUMENT_BYTES: usize = 65536; +pub(super) const MAX_ENVIRONMENT: usize = 64; /// Resolved user choices; only the owning provider interprets runtime semantics. #[derive(Debug, Deserialize, Serialize)] diff --git a/rust/lithe-core/src/build/run_start.rs b/rust/lithe-core/src/build/run_start.rs new file mode 100644 index 0000000000..ed925d1982 --- /dev/null +++ b/rust/lithe-core/src/build/run_start.rs @@ -0,0 +1,370 @@ +//! `lithe/host/run/start`: the parameters a plugin backend hands the host to start a Run session. +//! +//! The process has the shape a build provider's run preparation returns, so both go through +//! the same bounded-argument, environment and workspace-directory checks. The executable is +//! either a tool the plugin required by definition ID or a file packaged with the plugin; the +//! host resolves it and owns the process. Nothing here starts a process or writes a file. + +use super::decode; +use super::run_preparation::{ + process_fields, valid_configuration_type, DecodeRunPreparationRequest, MAX_ARGUMENT_BYTES, +}; +use crate::lsp::interface::plugin_function::is_function_id; +use crate::plugins::declarative::{ + decode_payload, outcome, validate_manifest_value, PluginRejection, Validation, +}; +use crate::protocol::CoreError; +use serde::Deserialize; +use serde_json::{json, Map, Value}; +use std::collections::{BTreeMap, BTreeSet}; + +const MAX_LABEL_CHARS: usize = 256; +const MAX_TEMPORARY_FILES: usize = 4; +const MAX_TEMPORARY_FILE_BYTES: usize = 4 * 1024 * 1024; +const MAX_TEMPORARY_NAME_BYTES: usize = 64; +const MAX_PACKAGED_PATH_BYTES: usize = 4096; +const MAX_RERUN_DATA_BYTES: usize = 64 * 1024; +/// How a process argument or environment value names a temporary file. +const TEMPORARY_FILE_REFERENCE: &str = "${temporaryFile:"; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct DecodeStartPayload { + /// The plugin's manifest; its `requiresTools` limits the tools a run may start. + manifest: Value, + /// Absolute root of the workspace the run belongs to. + workspace_root: String, + /// Absolute directory the host created for this run's temporary files; required when the + /// run has any. + #[serde(default)] + temporary_directory: Option, + params: Value, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct StartParams { + /// A reference the host resolved before the service saw the request. + #[serde(default, rename = "context")] + _context: Option, + label: String, + process: Map, + #[serde(default)] + temporary_files: Vec, + #[serde(default)] + debug: Option, + #[serde(default)] + rerun: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct TemporaryFile { + name: String, + contents: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct StartDebug { + configuration_type: String, + target: Map, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Rerun { + function: String, + data: Value, +} + +fn invalid(field: &str, message: impl Into) -> PluginRejection { + PluginRejection::at("plugin.run.invalidStart", field, message) +} + +/// `run.decodeStart`: the normalized start with its executable reference, process, the files +/// to write with their final paths, and the debug and rerun choices. +pub(crate) fn run_decode_start_command(payload: Value) -> Result { + let payload: DecodeStartPayload = decode_payload(payload, "run.decodeStart")?; + Ok(outcome( + validate_manifest_value(payload.manifest.clone()) + .and_then(|manifest| decode_start(&payload, manifest.required_tools())) + .map(|start| ("start", start)), + )) +} + +fn decode_start(payload: &DecodeStartPayload, required_tools: &[String]) -> Validation { + let params: StartParams = serde_json::from_value(payload.params.clone()) + .map_err(|_| invalid("params", "The run start parameters are malformed."))?; + check_label(¶ms.label)?; + let executable = executable(¶ms.process, required_tools)?; + let files = temporary_files( + ¶ms.temporary_files, + payload.temporary_directory.as_deref(), + )?; + for key in params.process.keys() { + if !["executable", "arguments", "workingDirectory", "environment"].contains(&key.as_str()) { + return Err(invalid( + &format!("process.{key}"), + "The process has no such field.", + )); + } + } + let fields = process_fields(&DecodeRunPreparationRequest { + workspace_root: payload.workspace_root.clone(), + root: ".".to_string(), + result: Value::Object(params.process.clone()), + launch_options: None, + }) + .map_err(|_| { + invalid( + "process", + "The process arguments, directory or environment are invalid.", + ) + })?; + let paths: BTreeMap<&str, &str> = files + .iter() + .map(|file| (file.name.as_str(), file.path.as_str())) + .collect(); + let arguments = fields["arguments"] + .as_array() + .into_iter() + .flatten() + .enumerate() + .map(|(index, argument)| { + substitute( + argument.as_str().unwrap_or_default(), + &paths, + &format!("process.arguments[{index}]"), + ) + }) + .collect::>>()?; + let mut environment = Map::new(); + for (key, value) in fields["environment"].as_object().into_iter().flatten() { + environment.insert( + key.clone(), + Value::String(substitute( + value.as_str().unwrap_or_default(), + &paths, + &format!("process.environment.{key}"), + )?), + ); + } + let mut start = json!({ + "label": params.label, + "executable": executable, + "arguments": arguments, + "workingDirectory": fields["workingDirectory"], + "environment": environment, + "temporaryFiles": files + .iter() + .map(|file| json!({"path": file.path, "contents": file.contents})) + .collect::>(), + }); + if let Some(debug) = ¶ms.debug { + start["debug"] = start_debug(debug)?; + } + if let Some(rerun) = ¶ms.rerun { + start["rerun"] = rerun_target(rerun)?; + } + Ok(start) +} + +fn check_label(label: &str) -> Validation<()> { + let characters = label.chars().count(); + if label.trim().is_empty() + || characters > MAX_LABEL_CHARS + || label.chars().any(char::is_control) + { + return Err(invalid( + "label", + "A label is 1 to 256 characters without control characters.", + )); + } + Ok(()) +} + +/// A tool the manifest requires, or a relative file inside the plugin's package. +fn executable(process: &Map, required_tools: &[String]) -> Validation { + let executable = process + .get("executable") + .and_then(Value::as_object) + .ok_or_else(|| invalid("process.executable", "The process names no executable."))?; + let text = |key: &str| executable.get(key).and_then(Value::as_str); + match (text("kind"), executable.len()) { + (Some("tool"), 2) => { + let tool = text("tool").unwrap_or_default(); + if !required_tools.iter().any(|required| required == tool) { + return Err(invalid( + "process.executable.tool", + "The tool is not listed in the manifest's requiresTools.", + )); + } + Ok(json!({"kind": "toolchain", "value": tool})) + } + (Some("packaged"), 2) => { + let path = text("path").unwrap_or_default(); + if path.is_empty() + || path.len() > MAX_PACKAGED_PATH_BYTES + || decode::request_path(path, "executable").map_or(true, |clean| clean != path) + { + return Err(invalid( + "process.executable.path", + "A packaged executable is a normalized relative path inside the plugin.", + )); + } + Ok(json!({"kind": "packaged", "value": path})) + } + _ => Err(invalid( + "process.executable", + "An executable is {kind:\"tool\",tool} or {kind:\"packaged\",path}.", + )), + } +} + +/// A temporary file the host writes before the process starts. +struct PlannedFile { + name: String, + path: String, + contents: String, +} + +fn temporary_files( + files: &[TemporaryFile], + directory: Option<&str>, +) -> Validation> { + if files.len() > MAX_TEMPORARY_FILES { + return Err(invalid( + "temporaryFiles", + "A run has at most 4 temporary files.", + )); + } + if files.is_empty() { + return Ok(Vec::new()); + } + let directory = directory + .filter(|directory| { + !directory.is_empty() + && directory.len() <= MAX_PACKAGED_PATH_BYTES + && !directory.contains('\0') + && std::path::Path::new(directory).is_absolute() + }) + .ok_or_else(|| { + invalid( + "temporaryDirectory", + "The host supplies an absolute temporary directory for temporary files.", + ) + })?; + let directory = directory.trim_end_matches(['/', '\\']); + let mut names = BTreeSet::new(); + let mut planned = Vec::new(); + for (index, file) in files.iter().enumerate() { + if !valid_temporary_name(&file.name) || !names.insert(file.name.as_str()) { + return Err(invalid( + &format!("temporaryFiles[{index}].name"), + "A temporary file name is unique, 1 to 64 letters, digits, '.', '_' or '-', and starts with a letter or digit.", + )); + } + if file.contents.len() > MAX_TEMPORARY_FILE_BYTES || file.contents.contains('\0') { + return Err(invalid( + &format!("temporaryFiles[{index}].contents"), + "A temporary file holds at most 4 MiB of text.", + )); + } + planned.push(PlannedFile { + name: file.name.clone(), + path: format!("{directory}{}{}", std::path::MAIN_SEPARATOR, file.name), + contents: file.contents.clone(), + }); + } + Ok(planned) +} + +fn valid_temporary_name(name: &str) -> bool { + name.len() <= MAX_TEMPORARY_NAME_BYTES + && name + .bytes() + .next() + .is_some_and(|first| first.is_ascii_alphanumeric()) + && name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) +} + +/// Replaces every `${temporaryFile:}` with the file's path. Any other `${...}` is data. +fn substitute(text: &str, paths: &BTreeMap<&str, &str>, field: &str) -> Validation { + let mut output = String::with_capacity(text.len()); + let mut rest = text; + while let Some(start) = rest.find(TEMPORARY_FILE_REFERENCE) { + output.push_str(&rest[..start]); + let after = &rest[start + TEMPORARY_FILE_REFERENCE.len()..]; + let (name, tail) = after + .split_once('}') + .ok_or_else(|| invalid(field, "A temporary file reference is not closed with '}'."))?; + let path = paths.get(name).ok_or_else(|| { + invalid( + field, + "A temporary file reference names no declared temporary file.", + ) + })?; + output.push_str(path); + rest = tail; + } + output.push_str(rest); + if output.len() > MAX_ARGUMENT_BYTES { + return Err(invalid( + field, + "The value is too long once temporary file paths are inserted.", + )); + } + Ok(output) +} + +fn start_debug(debug: &StartDebug) -> Validation { + if !valid_configuration_type(&debug.configuration_type) { + return Err(invalid( + "debug.configurationType", + "The configuration type is bounded text without control characters.", + )); + } + let kind = debug.target.get("kind").and_then(Value::as_str); + let target = match (kind, debug.target.len()) { + (Some("launch"), 1) => json!({"kind": "launch"}), + (Some("connection"), 2) => { + let port = debug + .target + .get("port") + .and_then(Value::as_u64) + .filter(|port| (1..=65535).contains(port)) + .ok_or_else(|| { + invalid( + "debug.target.port", + "A connection target names a port from 1 to 65535.", + ) + })?; + json!({"kind": "connection", "port": port}) + } + _ => { + return Err(invalid( + "debug.target", + "A debug target is {kind:\"launch\"} or {kind:\"connection\",port}.", + )) + } + }; + Ok(json!({"configurationType": debug.configuration_type, "target": target})) +} + +fn rerun_target(rerun: &Rerun) -> Validation { + if !is_function_id(&rerun.function) { + return Err(invalid( + "rerun.function", + "A rerun names one of the plugin's registered function IDs.", + )); + } + if serde_json::to_vec(&rerun.data).map_or(usize::MAX, |bytes| bytes.len()) + > MAX_RERUN_DATA_BYTES + { + return Err(invalid("rerun.data", "Rerun data is at most 64 KiB.")); + } + Ok(json!({"function": rerun.function, "data": rerun.data})) +} diff --git a/rust/lithe-core/src/debug/adapters.rs b/rust/lithe-core/src/debug/adapters.rs index 058633a166..20129e6c0b 100644 --- a/rust/lithe-core/src/debug/adapters.rs +++ b/rust/lithe-core/src/debug/adapters.rs @@ -17,6 +17,7 @@ //! //! Note: the decision is recorded in .agents/notes/proposed/architecture/2026-10-25-unified-debug-engine-and-adapter-registry.md +use super::types::{DebugAdapterDefaults, DebugAdapterFeatures, DeclaredAdapterBehavior}; use crate::plugins::declarative::{ invalid, is_safe_relative_path, validate_process_arguments, PluginRejection, ServerProcess, ServerRole, Validation, @@ -165,6 +166,12 @@ pub(crate) struct DebugAdapterDeclaration { /// What the Debug Console offers while typing a debugger command. #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) console: Option, + /// DAP extensions the adapter implements; `debugAdapters` 3. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) features: Option, + /// Values the adapter starts with when the user set none; `debugAdapters` 3. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) defaults: Option, pub(crate) launch: DebugLaunch, } @@ -625,16 +632,16 @@ impl DebugProcess { context.plugin_api_version, context.declared_settings, ServerRole::DebugAdapter, + context.runtime_tools, ), Self::SelectedRuntime(process) => { let tool = process.launch.tool.as_str(); - if !SELECTED_RUNTIME_TOOLS.contains(&tool) { - return Err(PluginRejection::at( - "plugin.runtime.unsupported", - format!("{field}.launch.tool"), - format!("The host cannot start a debug adapter on {tool}."), - )); - } + context.runtime_tools.check( + tool, + SELECTED_RUNTIME_TOOLS, + format!("{field}.launch.tool"), + format!("The host cannot start a debug adapter on {tool}."), + )?; validate_process_arguments( &process.arguments, &process.validation_arguments, @@ -660,6 +667,8 @@ pub(crate) struct DeclarationContext<'a> { pub(crate) declared_settings: &'a DeclaredSettings, /// Language IDs of the plugin's `languageServers[]`. pub(crate) language_servers: &'a BTreeSet, + /// Whether tool names are host runtimes or tool-definition IDs. + pub(crate) runtime_tools: crate::plugins::declarative::RuntimeTools<'a>, } /// Validates every declaration and sorts them by ID. @@ -704,6 +713,14 @@ pub(crate) fn uses_selected_runtime(adapters: &[DebugAdapterDeclaration]) -> boo }) } +/// Whether any adapter declares `features` or `defaults`, which need +/// `debugAdapters` 3. +pub(crate) fn uses_declared_features(adapters: &[DebugAdapterDeclaration]) -> bool { + adapters + .iter() + .any(|adapter| adapter.features.is_some() || adapter.defaults.is_some()) +} + /// Whether any adapter serves `attach`, which needs its own permission. pub(crate) fn serves_attach(adapters: &[DebugAdapterDeclaration]) -> bool { adapters @@ -780,6 +797,13 @@ impl DebugAdapterDeclaration { if let Some(console) = &mut self.console { console.validate(&format!("{field}.console"))?; } + let declared = DeclaredAdapterBehavior { + features: self.features.clone(), + defaults: self.defaults.clone(), + }; + declared.check().map_err(|(declared_field, message)| { + invalid(format!("{field}.{declared_field}"), message) + })?; self.launch.validate(&format!("{field}.launch"), context) } @@ -1867,7 +1891,7 @@ impl DebugAdapterDeclaration { /// What the host needs besides the process: how to reach the adapter and /// what to show. The process itself is the launch plan's own part. pub(crate) fn plan_descriptor(&self, plugin_id: &str) -> Value { - json!({ + let mut descriptor = json!({ "id": self.id, "providerID": format!("{plugin_id}/{}", self.id), "displayName": self.display_name, @@ -1885,7 +1909,16 @@ impl DebugAdapterDeclaration { "pathMappings": binding.path_mappings.is_some(), "maxPathMappings": binding.path_mappings.as_ref().map_or(0, DebugPathMappingBinding::limit), })), - }) + }); + // The host hands these to the debug engine with the session, which then follows the + // declaration instead of recognizing the adapter by its ID. + if let Some(features) = &self.features { + descriptor["features"] = json!(features); + } + if let Some(defaults) = &self.defaults { + descriptor["defaults"] = json!(defaults); + } + descriptor } } diff --git a/rust/lithe-core/src/debug/backend_start.rs b/rust/lithe-core/src/debug/backend_start.rs new file mode 100644 index 0000000000..0ca8b76de7 --- /dev/null +++ b/rust/lithe-core/src/debug/backend_start.rs @@ -0,0 +1,135 @@ +//! `lithe/host/debug/start`: a plugin backend starts a debug session on an adapter it declares. +//! +//! The backend supplies the adapter's DAP launch or attach arguments. Core checks that the +//! adapter belongs to the plugin and serves the request, that the arguments fit the size +//! budget, and that they do not name keys the host owns. Starting the adapter, the transport +//! and the session are the generic debug engine's job; nothing here is specific to a language. + +use super::adapters::DebugRequest; +use crate::plugins::declarative::{ + decode_payload, is_identifier, outcome, validate_manifest_value, DeclarativePluginManifest, + PluginRejection, Validation, +}; +use crate::protocol::CoreError; +use serde::Deserialize; +use serde_json::{json, Map, Value}; + +/// Serialized size of `configuration`, in bytes. +const MAX_CONFIGURATION_BYTES: usize = 256 * 1024; +const MAX_LABEL_CHARS: usize = 256; +const MAX_ADAPTER_ID_BYTES: usize = 64; +/// DAP launch argument the host sets from the session mode (Run versus Debug). +const NO_DEBUG_KEY: &str = "noDebug"; +/// Keys with this prefix carry the host's own session bookkeeping across DAP. +const HOST_KEY_PREFIX: &str = "__"; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct DecodePayload { + manifest: Value, + params: Value, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct StartParams { + /// A reference the host resolved before the service saw the request. + #[serde(default, rename = "context")] + _context: Option, + label: String, + adapter: String, + request: DebugRequest, + configuration: Map, +} + +fn invalid(field: &str, message: impl Into) -> PluginRejection { + PluginRejection::at("plugin.debug.invalidStart", field, message) +} + +/// `debug.decodeBackendConfiguration`: the qualified adapter, request, label and checked +/// arguments of one backend-initiated debug start. +pub(crate) fn decode_backend_configuration_command(payload: Value) -> Result { + let payload: DecodePayload = decode_payload(payload, "debug.decodeBackendConfiguration")?; + Ok(outcome( + validate_manifest_value(payload.manifest) + .and_then(|manifest| decode(&manifest, payload.params)) + .map(|start| ("start", start)), + )) +} + +fn decode(manifest: &DeclarativePluginManifest, params: Value) -> Validation { + let params: StartParams = serde_json::from_value(params) + .map_err(|_| invalid("params", "The debug start parameters are malformed."))?; + let label = ¶ms.label; + if label.trim().is_empty() + || label.chars().count() > MAX_LABEL_CHARS + || label.chars().any(char::is_control) + { + return Err(invalid( + "label", + "A label is 1 to 256 characters without control characters.", + )); + } + let local = own_adapter(manifest, ¶ms.adapter)?; + let adapter = manifest + .debug_adapters() + .iter() + .find(|adapter| adapter.id == local) + .ok_or_else(|| { + PluginRejection::at( + "plugin.debug.unknownAdapter", + "adapter", + "The plugin declares no such debug adapter.", + ) + })?; + if !adapter.requests.contains(¶ms.request) { + return Err(invalid( + "request", + "The adapter does not serve this request.", + )); + } + if serde_json::to_vec(¶ms.configuration).map_or(usize::MAX, |bytes| bytes.len()) + > MAX_CONFIGURATION_BYTES + { + return Err(PluginRejection::at( + "plugin.debug.limitExceeded", + "configuration", + "The configuration is larger than 256 KiB.", + )); + } + if let Some(key) = params + .configuration + .keys() + .find(|key| key.as_str() == NO_DEBUG_KEY || key.starts_with(HOST_KEY_PREFIX)) + { + return Err(invalid( + &format!("configuration.{key}"), + "The key belongs to the host and cannot be set by a plugin.", + )); + } + Ok(json!({ + "adapterID": format!("{}/{}", manifest.id(), adapter.id), + "request": params.request, + "label": params.label, + "configuration": params.configuration, + })) +} + +/// The plugin-local adapter ID of `adapter`, which is either local or qualified by this plugin. +fn own_adapter<'a>(manifest: &DeclarativePluginManifest, adapter: &'a str) -> Validation<&'a str> { + let local = match adapter.split_once('/') { + Some((plugin, _)) if plugin != manifest.id() => { + return Err(PluginRejection::at( + "plugin.debug.foreignAdapter", + "adapter", + "A plugin starts only the debug adapters it declares itself.", + )); + } + Some((_, local)) => local, + None => adapter, + }; + if !is_identifier(local) || local.len() > MAX_ADAPTER_ID_BYTES { + return Err(invalid("adapter", "The adapter ID is malformed.")); + } + Ok(local) +} diff --git a/rust/lithe-core/src/debug/engine.rs b/rust/lithe-core/src/debug/engine.rs index 2792077ed7..0103ed748b 100644 --- a/rust/lithe-core/src/debug/engine.rs +++ b/rust/lithe-core/src/debug/engine.rs @@ -63,6 +63,13 @@ struct DebugSession { capability_states: BTreeMap, capability_revision: u64, stepping_filters: DebugSteppingFilters, + /// DAP extensions the adapter declared; empty for an undeclared adapter. + features: DebugAdapterFeatures, + /// Declared filters used when a launch names none. + default_stepping_filters: Option, + /// Neither features nor defaults were declared, so the engine keeps + /// recognizing adapters it knew before `debugAdapters` 3 by their ID. + undeclared: bool, debug_request_kind: Option, pending_launch: Option<(String, DebugLaunchConfiguration)>, outbound_frames: Vec>, @@ -122,6 +129,12 @@ pub(crate) fn create_session( validate_identifier(&request.adapter_id, "adapterId")?; validate_path(&request.root_path, "rootPath")?; let disabled_capabilities = validate_disabled(&request.disabled_capabilities)?; + request + .declared + .check() + .map_err(|(field, message)| invalid_request(message).with_details(field.to_string()))?; + let undeclared = request.declared.is_empty(); + let DeclaredAdapterBehavior { features, defaults } = request.declared; let mut sessions = sessions_lock()?; if sessions.contains_key(&request.session_id) { return Err(invalid_request( @@ -179,6 +192,11 @@ pub(crate) fn create_session( capability_states: BTreeMap::new(), capability_revision: 0, stepping_filters: DebugSteppingFilters::unfiltered(), + features: features.unwrap_or_default(), + default_stepping_filters: defaults + .and_then(|defaults| defaults.step_filters) + .map(|filters| DebugSteppingFilters::from(&filters)), + undeclared, debug_request_kind: None, pending_launch: None, outbound_frames: Vec::new(), @@ -776,9 +794,12 @@ pub(crate) fn inspect(request: InspectRequest) -> Result Result feature.request.clone(), + (kind, _) => kind.command().to_string(), + }; session.send_request( - request.kind.command(), + &command, Value::Object(arguments), PendingRequest::Inspect { operation_id: request.operation_id, @@ -1021,15 +1046,30 @@ impl DebugSession { ) -> Result<(), CoreError> { let request_kind = configuration.request; let mut arguments = configuration.arguments; + let legacy_java = self.undeclared && self.adapter_id == "java"; let filters = normalize_stepping_filters( configuration .stepping_filters - .unwrap_or_else(|| DebugSteppingFilters::defaults_for_adapter(&self.adapter_id)), + .or_else(|| self.default_stepping_filters.clone()) + .unwrap_or_else(|| { + if self.undeclared { + DebugSteppingFilters::defaults_for_adapter(&self.adapter_id) + } else { + DebugSteppingFilters::unfiltered() + } + }), )?; - if self.adapter_id == "java" && !arguments.contains_key("stepFilters") { - arguments.insert("stepFilters".to_string(), java_step_filters(&filters)); + let filter_argument = match &self.features.step_filters { + Some(feature) => Some(feature.argument.clone()), + None if legacy_java => Some("stepFilters".to_string()), + None => None, + }; + if let Some(name) = filter_argument { + if !arguments.contains_key(&name) { + arguments.insert(name, step_filter_argument(&filters)); + } } - if self.adapter_id == "java" && request_kind == DebugRequestKind::Launch { + if legacy_java && request_kind == DebugRequestKind::Launch { // A DevTools classloader restart would discard the debugger's hot-replacement target. let option = super::JAVA_DEBUG_DISABLE_DEVTOOLS_RESTART; let vm_args = arguments.entry("vmArgs").or_insert_with(|| json!("")); @@ -1461,8 +1501,12 @@ impl DebugSession { frame_id, } => { self.emit_inspection_projection(kind, thread_id, frame_id, &body); - let result = - normalize_inspection(kind, &body, &self.stepping_filters, &self.root_path); + let result = match (kind, &self.features.hot_code_replace) { + (DebugInspectKind::RedefineClasses, Some(feature)) => { + declared_replacement(feature, &body) + } + _ => normalize_inspection(kind, &body, &self.stepping_filters, &self.root_path), + }; // HCR rejection and unreadable memory are operation failures, not a broken // transport/session. if matches!( @@ -1602,6 +1646,26 @@ impl DebugSession { self.emit(DebugEventBody::Breakpoint { breakpoint }); } } + declared + if self + .features + .hot_code_replace + .as_ref() + .and_then(|feature| feature.event.as_deref()) + == Some(declared) => + { + // The declared progress event shows its message in the Debug Console. + if let Some(message) = body + .get("message") + .and_then(Value::as_str) + .filter(|message| !message.is_empty()) + { + self.emit(DebugEventBody::Output { + category: Some("console".to_string()), + output: format!("{message}\n"), + }); + } + } _ => {} } Ok(()) @@ -2485,7 +2549,46 @@ fn normalize_stepping_filters( Ok(filters) } -fn java_step_filters(filters: &DebugSteppingFilters) -> Value { +/// Reads a declared hot code replacement reply through the fields the +/// adapter named for its failure message and its replaced units. +fn declared_replacement( + feature: &HotCodeReplaceFeature, + body: &Value, +) -> Result { + if let Some(message) = feature + .error_field + .as_deref() + .and_then(|field| body.get(field)) + .and_then(Value::as_str) + .filter(|message| !message.is_empty()) + { + return Err(invalid_request(message)); + } + let mut changed = match feature.changed_field.as_deref() { + None => Vec::new(), + Some(field) => body + .get(field) + .and_then(Value::as_array) + .ok_or_else(|| invalid_request("The hot code replacement reply has no change list."))? + .iter() + .map(|value| { + value + .as_str() + .map(str::to_owned) + .ok_or_else(|| invalid_request("Invalid replaced unit name.")) + }) + .collect::, _>>()?, + }; + changed.sort(); + changed.dedup(); + Ok(DebugOperationResult::RedefineClasses { + changed_classes: changed, + }) +} + +/// The stepping filters in the launch-argument shape adapters that take +/// them as one argument read. +fn step_filter_argument(filters: &DebugSteppingFilters) -> Value { json!({ "skipClasses": filters.class_name_filters, "skipSynthetics": filters.skip_synthetics, @@ -3252,6 +3355,7 @@ mod tests { supports_start_debugging_request: false, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); // The guard always removes the process-free session, including on assertion failure. @@ -3416,6 +3520,7 @@ mod tests { supports_start_debugging_request: false, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); @@ -3606,6 +3711,7 @@ mod tests { supports_start_debugging_request: false, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); launch(LaunchRequest { @@ -3750,6 +3856,7 @@ mod tests { supports_start_debugging_request: false, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); assert_eq!(created.state, DebugSessionState::Initializing); @@ -4041,6 +4148,7 @@ mod tests { supports_start_debugging_request: false, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); // This reducer owns no processes; always destroy its registry entry below. @@ -4134,6 +4242,7 @@ mod tests { supports_start_debugging_request: true, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); // This reducer owns no processes; always destroy its registry entry below. @@ -4204,6 +4313,7 @@ mod tests { supports_start_debugging_request: supported, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); let update = receive_messages(session_id, vec![fixture["adapterRequest"].clone()]); @@ -4256,6 +4366,7 @@ mod tests { supports_start_debugging_request: true, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); receive_messages(session_id, vec![fixture["adapterRequest"].clone()]); @@ -4289,6 +4400,7 @@ mod tests { supports_start_debugging_request: false, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); // This reducer owns no processes; always destroy its registry entry below. @@ -4405,6 +4517,7 @@ mod tests { supports_start_debugging_request: false, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); set_data_breakpoints(SetDataBreakpointsRequest { @@ -4529,6 +4642,7 @@ mod tests { supports_start_debugging_request: false, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); launch(LaunchRequest { @@ -4660,6 +4774,7 @@ mod tests { supports_start_debugging_request: false, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); launch(LaunchRequest { @@ -4721,6 +4836,7 @@ mod tests { supports_start_debugging_request: false, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); launch(LaunchRequest { @@ -4852,6 +4968,7 @@ mod tests { supports_start_debugging_request: false, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); launch(LaunchRequest { @@ -4955,6 +5072,7 @@ mod tests { supports_start_debugging_request: false, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); launch(LaunchRequest { @@ -5230,6 +5348,7 @@ mod tests { supports_start_debugging_request: false, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); launch(LaunchRequest { @@ -5342,6 +5461,7 @@ mod tests { supports_start_debugging_request: false, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); launch(LaunchRequest { @@ -5441,6 +5561,7 @@ mod tests { supports_start_debugging_request: false, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); launch(LaunchRequest { @@ -5514,6 +5635,7 @@ mod tests { supports_start_debugging_request: false, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); assert_eq!( @@ -5597,6 +5719,7 @@ mod tests { supports_start_debugging_request: false, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); @@ -5633,6 +5756,7 @@ mod tests { supports_start_debugging_request: false, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); @@ -5676,6 +5800,7 @@ mod tests { supports_start_debugging_request: false, disabled_capabilities: vec!["supportsRestartRequest".to_string()], parent_session_id: None, + declared: Default::default(), }) .unwrap(); launch(LaunchRequest { @@ -5734,6 +5859,7 @@ mod tests { supports_start_debugging_request: false, disabled_capabilities: Vec::new(), parent_session_id: parent.map(str::to_string), + declared: Default::default(), } } @@ -5885,6 +6011,7 @@ mod tests { supports_start_debugging_request: false, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); launch(LaunchRequest { @@ -6414,6 +6541,7 @@ mod tests { supports_start_debugging_request: false, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); launch(LaunchRequest { @@ -6743,4 +6871,241 @@ mod tests { ))); assert_eq!(session_state(session_id), DebugSessionState::Paused); } + + /// A scripting-language adapter that declares the DAP extensions it implements. + fn scripting_adapter() -> DeclaredAdapterBehavior { + serde_json::from_value(json!({ + "features": { + "hotCodeReplace": { + "request": "script.reload", + "event": "script.reloaded", + "changedField": "modules", + "errorField": "error" + }, + "stepFilters": {"argument": "skipFilters"} + }, + "defaults": { + "stepFilters": { + "classNameFilters": ["site-packages/*"], + "skipSynthetics": true + } + } + })) + .unwrap() + } + + /// Creates a session, answers `initialize`, and launches with `arguments`. + /// Returns the launch request the adapter received. + fn launch_session( + session_id: &str, + adapter_id: &str, + declared: DeclaredAdapterBehavior, + arguments: Value, + ) -> Value { + create_session(CreateSessionRequest { + session_id: session_id.into(), + adapter_id: adapter_id.into(), + root_path: "/workspace".into(), + supports_run_in_terminal_request: false, + supports_start_debugging_request: false, + disabled_capabilities: Vec::new(), + parent_session_id: None, + declared, + }) + .unwrap(); + receive_messages( + session_id, + vec![response_message(1, "initialize", json!({}))], + ); + let launched = launch(LaunchRequest { + session_id: session_id.into(), + operation_id: "launch".into(), + configuration: DebugLaunchConfiguration { + name: "script".into(), + request: DebugRequestKind::Launch, + arguments: serde_json::from_value(arguments).unwrap(), + stepping_filters: None, + }, + }) + .unwrap(); + decode_frame(&launched.outbound_frames[0]) + } + + /// Answers the launch request and stops the debuggee at a breakpoint. + fn pause_session(session_id: &str, launch_frame: &Value) { + receive_messages( + session_id, + vec![ + response_message(launch_frame["seq"].as_i64().unwrap(), "launch", json!({})), + json!({"type": "event", "event": "stopped", + "body": {"reason": "breakpoint", "threadId": 1}}), + ], + ); + } + + fn redefine_request(session_id: &str, operation_id: &str) -> InspectRequest { + serde_json::from_value(json!({ + "sessionId": session_id, "operationId": operation_id, "kind": "redefineClasses" + })) + .unwrap() + } + + #[test] + fn declared_hot_code_replace_sends_the_declared_request_and_reads_its_fields() { + let session_id = "debug-declared-hot-replace"; + let _guard = SessionGuard(session_id); + let launched = launch_session(session_id, "debugpy", scripting_adapter(), json!({})); + pause_session(session_id, &launched); + + let update = inspect(redefine_request(session_id, "apply")).unwrap(); + let frame = decode_frame(&update.outbound_frames[0]); + assert_eq!(frame["command"], "script.reload"); + let replaced = receive_messages( + session_id, + vec![response_message( + frame["seq"].as_i64().unwrap(), + "script.reload", + json!({"modules": ["pkg.util", "pkg.app", "pkg.util"]}), + )], + ); + assert_eq!(replaced.state, DebugSessionState::Paused); + assert!(replaced.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::OperationCompleted { + operation_id, + result: DebugOperationResult::RedefineClasses { changed_classes }, + } if operation_id == "apply" + && changed_classes == &["pkg.app".to_string(), "pkg.util".to_string()] + ))); + + // A failure the adapter reports inside a successful response fails the + // operation without breaking the session. + let update = inspect(redefine_request(session_id, "rejected")).unwrap(); + let rejected = receive_messages( + session_id, + vec![response_message( + request_sequence(&update), + "script.reload", + json!({"modules": [], "error": "Syntax error in pkg.app"}), + )], + ); + assert_eq!(rejected.state, DebugSessionState::Paused); + assert!(rejected.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::OperationFailed { operation_id, message, .. } + if operation_id == "rejected" && message == "Syntax error in pkg.app" + ))); + } + + #[test] + fn declared_hot_code_replace_event_message_reaches_the_debug_console() { + let session_id = "debug-declared-hot-replace-event"; + let _guard = SessionGuard(session_id); + let launched = launch_session(session_id, "debugpy", scripting_adapter(), json!({})); + pause_session(session_id, &launched); + + let update = receive_messages( + session_id, + vec![json!({"type": "event", "event": "script.reloaded", + "body": {"message": "Reloaded 2 modules"}})], + ); + assert!(update.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::Output { category: Some(category), output } + if category == "console" && output == "Reloaded 2 modules\n" + ))); + } + + #[test] + fn an_adapter_without_the_declaration_has_no_hot_code_replace() { + let session_id = "debug-undeclared-hot-replace"; + let _guard = SessionGuard(session_id); + let launched = launch_session( + session_id, + "debugpy", + DeclaredAdapterBehavior::default(), + json!({}), + ); + pause_session(session_id, &launched); + + assert!(inspect(redefine_request(session_id, "apply")).is_err()); + } + + #[test] + fn declared_step_filters_use_the_declared_argument_and_defaults() { + let session_id = "debug-declared-step-filters"; + let _guard = SessionGuard(session_id); + let launched = launch_session(session_id, "debugpy", scripting_adapter(), json!({})); + + let filters = &launched["arguments"]["skipFilters"]; + assert_eq!(filters["skipClasses"], json!(["site-packages/*"])); + assert_eq!(filters["skipSynthetics"], true); + assert_eq!(filters["skipConstructors"], false); + // The declaration names the argument; no other argument is invented. + assert!(launched["arguments"].get("stepFilters").is_none()); + assert!(launched["arguments"].get("vmArgs").is_none()); + } + + #[test] + fn declared_step_filters_keep_what_the_launch_configuration_sets() { + let session_id = "debug-declared-step-filters-explicit"; + let _guard = SessionGuard(session_id); + let launched = launch_session( + session_id, + "debugpy", + scripting_adapter(), + json!({"skipFilters": {"skipClasses": ["mine/*"]}}), + ); + + assert_eq!( + launched["arguments"]["skipFilters"], + json!({"skipClasses": ["mine/*"]}) + ); + } + + #[test] + fn a_declared_adapter_is_not_recognized_by_its_id() { + // Declaring behavior replaces the ID-based special cases kept for + // plugins that predate `debugAdapters` 3. + let session_id = "debug-declared-not-by-id"; + let _guard = SessionGuard(session_id); + let declared = serde_json::from_value(json!({ + "features": {"stepFilters": {"argument": "skipFilters"}} + })) + .unwrap(); + let launched = launch_session(session_id, "java", declared, json!({})); + + assert!(launched["arguments"].get("stepFilters").is_none()); + assert!(launched["arguments"].get("vmArgs").is_none()); + assert_eq!( + launched["arguments"]["skipFilters"]["skipClasses"], + json!([]) + ); + pause_session(session_id, &launched); + assert!(inspect(redefine_request(session_id, "apply")).is_err()); + } + + #[test] + fn invalid_declarations_are_refused_when_the_session_is_created() { + let declared = serde_json::from_value(json!({ + "features": {"hotCodeReplace": {"request": "not a command"}} + })) + .unwrap(); + let error = create_session(CreateSessionRequest { + session_id: "debug-invalid-declaration".into(), + adapter_id: "debugpy".into(), + root_path: "/workspace".into(), + supports_run_in_terminal_request: false, + supports_start_debugging_request: false, + disabled_capabilities: Vec::new(), + parent_session_id: None, + declared, + }) + .unwrap_err(); + assert!(matches!(error.code, ErrorCode::InvalidRequest)); + assert_eq!( + error.details.as_deref(), + Some("features.hotCodeReplace.request") + ); + } } diff --git a/rust/lithe-core/src/debug/mod.rs b/rust/lithe-core/src/debug/mod.rs index f8c774d61e..8748320896 100644 --- a/rust/lithe-core/src/debug/mod.rs +++ b/rust/lithe-core/src/debug/mod.rs @@ -1,6 +1,7 @@ //! Transport-neutral Debug Adapter Protocol state and normalized debugger models. pub(crate) mod adapters; +mod backend_start; mod breakpoint_relocation; mod capabilities; mod endpoint; @@ -12,6 +13,7 @@ mod shortcut; mod transport_lifecycle; mod types; +pub(crate) use backend_start::decode_backend_configuration_command; pub(crate) use breakpoint_relocation::*; pub(crate) use endpoint::*; pub(crate) use engine::*; diff --git a/rust/lithe-core/src/debug/types.rs b/rust/lithe-core/src/debug/types.rs index cb901c4786..09d1bd1399 100644 --- a/rust/lithe-core/src/debug/types.rs +++ b/rust/lithe-core/src/debug/types.rs @@ -38,6 +38,174 @@ pub struct CreateSessionRequest { /// The session whose adapter asked for this one through `startDebugging`. #[serde(default)] pub parent_session_id: Option, + /// What the plugin declared for its adapter, passed through by the host. + #[serde(flatten)] + pub declared: DeclaredAdapterBehavior, +} + +/// The `features` and `defaults` of a `debugAdapters` 3 declaration. The +/// engine follows them instead of recognizing an adapter by its ID. +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DeclaredAdapterBehavior { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub features: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub defaults: Option, +} + +/// Adapter-specific DAP extensions the adapter declares it implements. +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DebugAdapterFeatures { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hot_code_replace: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub step_filters: Option, +} + +/// A custom request that applies changed code to the running debuggee. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct HotCodeReplaceFeature { + /// DAP command the engine sends for a `redefineClasses` inspection. + pub request: String, + /// DAP event whose `message` the Debug Console shows as progress. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub event: Option, + /// Response body field listing the replaced units; none are reported + /// when absent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub changed_field: Option, + /// Response body field with a failure message the adapter reports in a + /// successful response. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error_field: Option, +} + +/// The launch argument through which the adapter receives stepping filters, +/// shaped `{skipClasses, skipSynthetics, skipStaticInitializers, skipConstructors}`. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct StepFiltersFeature { + pub argument: String, +} + +/// Values the adapter starts with when the user configured none. +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DebugAdapterDefaults { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub step_filters: Option, +} + +/// Declared default stepping filters; the same fields as +/// `DebugSteppingFilters`, closed to unknown keys. +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DeclaredStepFilters { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub class_name_filters: Vec, + #[serde(default)] + pub skip_synthetics: bool, + #[serde(default)] + pub skip_static_initializers: bool, + #[serde(default)] + pub skip_constructors: bool, + #[serde(default)] + pub hide_filtered_stack_frames: bool, +} + +/// Longest DAP command, event, field, or argument name an adapter declares. +const MAX_DECLARED_NAME: usize = 64; +/// Most class-name patterns declared default filters carry. +const MAX_DECLARED_FILTERS: usize = 256; +/// Longest declared class-name pattern, in characters. +const MAX_DECLARED_FILTER: usize = 256; + +fn is_declared_name(value: &str) -> bool { + let mut chars = value.chars(); + value.len() <= MAX_DECLARED_NAME + && chars + .next() + .is_some_and(|first| first.is_ascii_alphabetic()) + && chars.all(|next| next.is_ascii_alphanumeric() || matches!(next, '_' | '.' | '-')) +} + +impl DeclaredAdapterBehavior { + /// Whether the adapter declared nothing, so the engine keeps the + /// behaviour it had before `debugAdapters` 3. + pub(crate) fn is_empty(&self) -> bool { + self.features.is_none() && self.defaults.is_none() + } + + /// Checks names and filters; an error names the declaration field and + /// what is wrong with it. + pub(crate) fn check(&self) -> Result<(), (&'static str, &'static str)> { + const NAME: &str = + "A DAP name is a letter followed by up to 63 letters, digits, dots, underscores, or hyphens."; + let features = self.features.as_ref(); + if let Some(feature) = features.and_then(|features| features.hot_code_replace.as_ref()) { + if !is_declared_name(&feature.request) { + return Err(("features.hotCodeReplace.request", NAME)); + } + for (field, value) in [ + ("features.hotCodeReplace.event", &feature.event), + ( + "features.hotCodeReplace.changedField", + &feature.changed_field, + ), + ("features.hotCodeReplace.errorField", &feature.error_field), + ] { + if value + .as_deref() + .is_some_and(|value| !is_declared_name(value)) + { + return Err((field, NAME)); + } + } + } + if let Some(feature) = features.and_then(|features| features.step_filters.as_ref()) { + if !is_declared_name(&feature.argument) { + return Err(("features.stepFilters.argument", NAME)); + } + } + if let Some(filters) = self + .defaults + .as_ref() + .and_then(|defaults| defaults.step_filters.as_ref()) + { + if filters.class_name_filters.len() > MAX_DECLARED_FILTERS { + return Err(( + "defaults.stepFilters.classNameFilters", + "Default stepping filters list at most 256 class-name patterns.", + )); + } + if filters.class_name_filters.iter().any(|filter| { + filter.trim().is_empty() + || filter.chars().count() > MAX_DECLARED_FILTER + || filter.chars().any(char::is_control) + }) { + return Err(( + "defaults.stepFilters.classNameFilters", + "A class-name pattern is one non-empty line of at most 256 characters.", + )); + } + } + Ok(()) + } +} + +impl From<&DeclaredStepFilters> for DebugSteppingFilters { + fn from(declared: &DeclaredStepFilters) -> Self { + Self { + class_name_filters: declared.class_name_filters.clone(), + skip_synthetics: declared.skip_synthetics, + skip_static_initializers: declared.skip_static_initializers, + skip_constructors: declared.skip_constructors, + hide_filtered_stack_frames: declared.hide_filtered_stack_frames, + } + } } #[derive(Debug, Clone, Deserialize)] diff --git a/rust/lithe-core/src/lsp/interface/mod.rs b/rust/lithe-core/src/lsp/interface/mod.rs index 4d46c9077d..965a51679f 100644 --- a/rust/lithe-core/src/lsp/interface/mod.rs +++ b/rust/lithe-core/src/lsp/interface/mod.rs @@ -2,6 +2,8 @@ mod build_provider; mod client; +/// Decodes the path of a `file:` URI an extension reply names. +pub(crate) use client::percent_decode; mod engine; mod engine_logging; mod keymap_provider; diff --git a/rust/lithe-core/src/lsp/interface/plugin_function.rs b/rust/lithe-core/src/lsp/interface/plugin_function.rs index 50c68da235..c87af6acad 100644 --- a/rust/lithe-core/src/lsp/interface/plugin_function.rs +++ b/rust/lithe-core/src/lsp/interface/plugin_function.rs @@ -107,6 +107,8 @@ pub(crate) const HOST_PERMISSIONS: &[&str] = &[ "window.messages", "terminal.create", "keymaps.readSelectedSources", + "run.start", + "debug.start", ]; const CREDENTIALS_GET: &str = "lithe/host/credentials/get"; @@ -201,6 +203,9 @@ pub(crate) mod codes { /// The plugin made too many requests of this kind recently; the same /// request may succeed after a while. pub const HOST_RATE_LIMITED: &str = "plugin.host.rateLimited"; + /// A tool definition the request references resolves to no executable + /// for this workspace; `details` carries `{tool, reason}`. + pub const HOST_TOOL_UNRESOLVED: &str = "plugin.host.toolUnresolved"; /// A page's call or a host operation ended because the backend process /// exited. Reported by hosts to pages, never sent to a backend. pub const BACKEND_EXITED: &str = "plugin.backend.exited"; @@ -506,6 +511,27 @@ pub(crate) struct HostMethod { /// service adds its rows here, its grant to `GRANTABLE_HOST_SERVICES` and /// the manifest permission, and its handler to the host's dispatcher. pub(crate) const HOST_METHODS: &[HostMethod] = &[ + HostMethod { + method: RUN_START, + service: HOST_SERVICE_RUN, + permission: Some("run.start"), + since: CONTEXT_VERSION, + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/run/stop", + service: HOST_SERVICE_RUN, + permission: Some("run.start"), + since: CONTEXT_VERSION, + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/debug/start", + service: "debug", + permission: Some("debug.start"), + since: CONTEXT_VERSION, + params: HostParams::Object, + }, HostMethod { method: "lithe/host/keymapImport/listInputs", service: "keymapImport", @@ -866,7 +892,7 @@ pub(crate) fn capabilities_answer(version: u64, granted: &BTreeSet) -> V } for entry in HOST_NOTIFICATIONS .iter() - .filter(|entry| version >= entry.since && admitted(entry.service)) + .filter(|entry| version >= entry.since && entry.admitted(granted)) { add(entry.service, "notifications", entry.method); } @@ -900,6 +926,9 @@ pub(crate) enum NotificationParams { /// `{ commandID, context? }`: one of the plugin's own commands and the /// workspace context of the window it ran in. Command, + /// `{ runID, exitCode?, signal? }`: a Run session the backend started + /// ended with an exit status, a signal, or neither when the host lost it. + RunExit, } /// One row of the host-notification table. @@ -907,13 +936,33 @@ pub(crate) struct HostNotification { pub method: &'static str, /// Service whose grant admits sending it; implicit services need none. pub service: &'static str, + /// Host method whose individual grant admits it instead of `service`, + /// for a notification that reports on a method granted per permission. + pub grant: Option<&'static str>, pub since: u64, pub params: NotificationParams, } +impl HostNotification { + /// Whether the session's grants admit sending this notification. + fn admitted(&self, granted: &BTreeSet) -> bool { + match self.grant { + Some(method) => granted.contains(method), + None => is_implicit_service(self.service) || granted.contains(self.service), + } + } +} + const WORKSPACE_DID_CHANGE_CONTEXTS: &str = "lithe/host/workspace/didChangeContexts"; const SUBSCRIPTIONS_NOTIFY: &str = "lithe/host/subscriptions/notify"; const COMMANDS_DID_RUN: &str = "lithe/host/commands/didRun"; +/// Starts a host-owned Run session; its grant also admits `RUN_DID_EXIT`. +const RUN_START: &str = "lithe/host/run/start"; +const RUN_DID_EXIT: &str = "lithe/host/run/didExit"; +/// Host-owned Run sessions a backend starts and stops. +const HOST_SERVICE_RUN: &str = "run"; +/// Longest opaque run ID a host may hand out. +const MAX_RUN_ID_BYTES: usize = 128; /// The plugin's own UI commands reaching its backend. Every plugin-owned /// session of version 3 has it; a command names only the plugin's own IDs. const HOST_SERVICE_COMMANDS: &str = "commands"; @@ -926,21 +975,31 @@ pub(crate) const HOST_NOTIFICATIONS: &[HostNotification] = &[ HostNotification { method: WORKSPACE_DID_CHANGE_CONTEXTS, service: HOST_SERVICE_WORKSPACE, + grant: None, since: CONTEXT_VERSION, params: NotificationParams::Revision, }, HostNotification { method: SUBSCRIPTIONS_NOTIFY, service: HOST_SERVICE_SUBSCRIPTIONS, + grant: None, since: CONTEXT_VERSION, params: NotificationParams::SubscriptionEvent, }, HostNotification { method: COMMANDS_DID_RUN, service: HOST_SERVICE_COMMANDS, + grant: None, since: CONTEXT_VERSION, params: NotificationParams::Command, }, + HostNotification { + method: RUN_DID_EXIT, + service: HOST_SERVICE_RUN, + grant: Some(RUN_START), + since: CONTEXT_VERSION, + params: NotificationParams::RunExit, + }, ]; /// Whether a host notification reaches this session. `Ok(false)` means the @@ -970,7 +1029,7 @@ pub(crate) fn host_notification( codes::HOST_UNAVAILABLE, )); }; - if !is_implicit_service(entry.service) && !granted.contains(entry.service) { + if !entry.admitted(granted) { return Err(refusal( ErrorCode::InvalidRequest, "The session was not granted the service of this notification.", @@ -1011,6 +1070,22 @@ pub(crate) fn host_notification( .is_some_and(is_command_id) && object.get("context").map_or(true, is_context_reference) } + NotificationParams::RunExit => { + only(&["runID", "exitCode", "signal"]) + && object + .get("runID") + .and_then(Value::as_str) + .is_some_and(|id| id.len() <= MAX_RUN_ID_BYTES && is_credential_key(id)) + && object.get("exitCode").map_or(true, |code| { + code.as_i64() + .is_some_and(|code| i32::try_from(code).is_ok()) + }) + && object.get("signal").map_or(true, |signal| { + signal + .as_u64() + .is_some_and(|signal| (1..=127).contains(&signal)) + }) + } }; if !well_formed { return Err(invalid( @@ -1231,6 +1306,7 @@ pub(crate) fn host_rpc_code(code: &str) -> i64 { codes::HOST_PRECONDITION_FAILED => -32019, codes::HOST_BUSY => -32020, codes::HOST_RATE_LIMITED => -32021, + codes::HOST_TOOL_UNRESOLVED => -32025, _ => -32603, } } @@ -1270,6 +1346,7 @@ pub(crate) fn host_failure(code: Option<&str>) -> (&'static str, i64) { Some(codes::HOST_PRECONDITION_FAILED) => codes::HOST_PRECONDITION_FAILED, Some(codes::HOST_BUSY) => codes::HOST_BUSY, Some(codes::HOST_RATE_LIMITED) => codes::HOST_RATE_LIMITED, + Some(codes::HOST_TOOL_UNRESOLVED) => codes::HOST_TOOL_UNRESOLVED, // A backend exit is reported to pages, never to the backend itself. _ => codes::HOST_FAILED, }; diff --git a/rust/lithe-core/src/plugins/backend.rs b/rust/lithe-core/src/plugins/backend.rs index 1d768b7bd6..144b6b8996 100644 --- a/rust/lithe-core/src/plugins/backend.rs +++ b/rust/lithe-core/src/plugins/backend.rs @@ -68,6 +68,8 @@ pub(crate) struct DeclarationContext<'a> { pub(crate) activates_on_workspace_open: bool, /// A provider capability may start this backend for a host-owned query. pub(crate) provider_query_trigger: bool, + /// Whether tool names are host runtimes or tool-definition IDs. + pub(crate) runtime_tools: crate::plugins::declarative::RuntimeTools<'a>, } impl BackendDeclaration { @@ -103,6 +105,7 @@ impl BackendDeclaration { context.plugin_api_version, context.declared_settings, ServerRole::Backend, + context.runtime_tools, )?; // One process serves every workspace, so a launch argument cannot // name one of them; version 2 receives the directory per context. diff --git a/rust/lithe-core/src/plugins/capabilities.rs b/rust/lithe-core/src/plugins/capabilities.rs index 47f366d4d1..5ab5358952 100644 --- a/rust/lithe-core/src/plugins/capabilities.rs +++ b/rust/lithe-core/src/plugins/capabilities.rs @@ -29,6 +29,8 @@ pub(crate) const LANGUAGES: &str = "languages"; /// `workspaceConfiguration`. pub(crate) const LANGUAGE_SERVERS: &str = "languageServers"; /// The launch fields language servers and own build providers share. +/// Version 4 names runtimes by tool-definition ID (`requiresTools`) instead +/// of the host's closed runtime list. pub(crate) const SERVER_PROCESS: &str = "serverProcess"; /// `vendor.bundledSignatureRequirement`: origin-scoped host-seal trust for a /// package whose independently installed versions require a publisher signature. @@ -61,7 +63,8 @@ pub(crate) const BACKEND: &str = "backend"; pub(crate) const PROJECT_GENERATORS: &str = "projectGenerators"; /// `debugAdapters[]`: debug adapters implemented by a plugin process; see -/// `crate::debug::adapters`. Plugin API level 4. +/// `crate::debug::adapters`. Plugin API level 4. Version 3 adds declared +/// adapter `features` and `defaults`. pub(crate) const DEBUG_ADAPTERS: &str = "debugAdapters"; /// First plugin API level that may require `debugAdapters` or `serverProcess` 2. @@ -75,6 +78,9 @@ pub(crate) const KEYMAP_IMPORT_PROVIDERS: &str = "keymapImportProviders"; pub(crate) const TOOL_DEFINITIONS: &str = "toolDefinitions"; /// Backend facts that can influence the executable selected for a defined tool. pub(crate) const TOOLCHAIN_PROVIDERS: &str = "toolchainProviders"; +/// `outputLinkMatchers[]`: patterns that turn console output into links; see +/// `crate::plugins::output_links`. +pub(crate) const OUTPUT_LINK_MATCHERS: &str = "outputLinkMatchers"; /// Capability versions this Core build validates and plans. Version 1 of a /// capability keeps the meaning its section had at API level 2. @@ -83,15 +89,16 @@ pub const SUPPORTED_CAPABILITIES: &[(&str, &[u32])] = &[ (BUILD_MODEL_INPUTS, &[1]), (BUILD_PROVIDERS, &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]), (BUILD_SYSTEMS, &[1, 2, 3, 4]), - (DEBUG_ADAPTERS, &[1, 2]), + (DEBUG_ADAPTERS, &[1, 2, 3]), (EXECUTION_PROVIDERS, &[1]), (EXECUTABLE_RESOLUTION, &[1, 2]), (KEYMAP_IMPORT_PROVIDERS, &[1]), (LANGUAGE_SERVERS, &[1, 2]), (LANGUAGES, &[1, 2]), + (OUTPUT_LINK_MATCHERS, &[1]), (PACKAGE_TRUST, &[1]), (PROJECT_GENERATORS, &[1]), - (SERVER_PROCESS, &[1, 2]), + (SERVER_PROCESS, &[1, 2, 3, 4]), (SETTINGS, &[1, 2]), (TOOL_DEFINITIONS, &[1]), (TOOLCHAIN_PROVIDERS, &[1]), diff --git a/rust/lithe-core/src/plugins/declarative.rs b/rust/lithe-core/src/plugins/declarative.rs index 97f5331933..6da0692b21 100644 --- a/rust/lithe-core/src/plugins/declarative.rs +++ b/rust/lithe-core/src/plugins/declarative.rs @@ -68,6 +68,9 @@ const PERMISSION_WORKSPACE_METADATA: &str = "workspace.metadata"; const PERMISSION_DEBUG_LAUNCH: &str = "debug.launch"; /// Attaching the plugin's adapter to a running process. `debugAdapters` 1. const PERMISSION_DEBUG_ATTACH: &str = "debug.attach"; +/// Letting the backend start a debug session on one of its own adapters +/// (`lithe/host/debug/start`). A host-method permission of `backend` 2. +const PERMISSION_DEBUG_START: &str = "debug.start"; const KNOWN_PERMISSIONS: &[&str] = &[ PERMISSION_BACKEND, PERMISSION_BUILD, @@ -95,6 +98,11 @@ pub(crate) const KNOWN_RUNTIME_TOOLS_V2: &[&str] = &["go", "java", "node"]; /// Runtimes that can start a packaged entry file themselves (`java -jar`, /// `node