diff --git a/Cargo.lock b/Cargo.lock index 18b68edf1a..cf8833968b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14228,6 +14228,7 @@ dependencies = [ "gpui", "hex", "parking_lot", + "pretty_assertions", "proto", "schemars", "serde", diff --git a/crates/paths/src/paths.rs b/crates/paths/src/paths.rs index 66ed81b101..b0e5f509c3 100644 --- a/crates/paths/src/paths.rs +++ b/crates/paths/src/paths.rs @@ -401,7 +401,7 @@ pub fn task_file_name() -> &'static str { "tasks.json" } -/// Returns the relative path to a `launch.json` file within a project. +/// Returns the relative path to a `debug.json` file within a project. pub fn local_debug_file_relative_path() -> &'static Path { Path::new(".zed/debug.json") } diff --git a/crates/project/src/project_settings.rs b/crates/project/src/project_settings.rs index 5a98e163bd..b9cb13a948 100644 --- a/crates/project/src/project_settings.rs +++ b/crates/project/src/project_settings.rs @@ -7,7 +7,8 @@ use gpui::{App, AsyncApp, BorrowAppContext, Context, Entity, EventEmitter, Task} use lsp::LanguageServerName; use paths::{ EDITORCONFIG_NAME, local_debug_file_relative_path, local_settings_file_relative_path, - local_tasks_file_relative_path, local_vscode_tasks_file_relative_path, + local_tasks_file_relative_path, local_vscode_launch_file_relative_path, + local_vscode_tasks_file_relative_path, }; use rpc::{ AnyProtoClient, TypedEnvelope, @@ -24,7 +25,7 @@ use std::{ sync::Arc, time::Duration, }; -use task::{TaskTemplates, VsCodeTaskFile}; +use task::{DebugTaskFile, TaskTemplates, VsCodeDebugTaskFile, VsCodeTaskFile}; use util::{ResultExt, serde::default_true}; use worktree::{PathChange, UpdatedEntriesSet, Worktree, WorktreeId}; @@ -573,6 +574,18 @@ impl SettingsObserver { .unwrap(), ); (settings_dir, LocalSettingsKind::Tasks(TaskKind::Debug)) + } else if path.ends_with(local_vscode_launch_file_relative_path()) { + let settings_dir = Arc::::from( + path.ancestors() + .nth( + local_vscode_tasks_file_relative_path() + .components() + .count() + .saturating_sub(1), + ) + .unwrap(), + ); + (settings_dir, LocalSettingsKind::Tasks(TaskKind::Debug)) } else if path.ends_with(EDITORCONFIG_NAME) { let Some(settings_dir) = path.parent().map(Arc::from) else { continue; @@ -618,6 +631,23 @@ impl SettingsObserver { "serializing Zed tasks into JSON, file {abs_path:?}" ) }) + } else if abs_path.ends_with(local_vscode_launch_file_relative_path()) { + let vscode_tasks = + parse_json_with_comments::(&content) + .with_context(|| { + format!("parsing VSCode debug tasks, file {abs_path:?}") + })?; + let zed_tasks = DebugTaskFile::try_from(vscode_tasks) + .with_context(|| { + format!( + "converting VSCode debug tasks into Zed ones, file {abs_path:?}" + ) + })?; + serde_json::to_string(&zed_tasks).with_context(|| { + format!( + "serializing Zed tasks into JSON, file {abs_path:?}" + ) + }) } else { Ok(content) } diff --git a/crates/task/Cargo.toml b/crates/task/Cargo.toml index f39b462a13..ab07524e08 100644 --- a/crates/task/Cargo.toml +++ b/crates/task/Cargo.toml @@ -34,3 +34,4 @@ workspace-hack.workspace = true [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } +pretty_assertions.workspace = true diff --git a/crates/task/src/lib.rs b/crates/task/src/lib.rs index 0b2fb63d90..a55e89fdaf 100644 --- a/crates/task/src/lib.rs +++ b/crates/task/src/lib.rs @@ -4,6 +4,7 @@ mod debug_format; mod serde_helpers; pub mod static_source; mod task_template; +mod vscode_debug_format; mod vscode_format; use collections::{HashMap, HashSet, hash_map}; @@ -22,6 +23,7 @@ pub use task_template::{ DebugArgs, DebugArgsRequest, HideStrategy, RevealStrategy, TaskModal, TaskTemplate, TaskTemplates, TaskType, }; +pub use vscode_debug_format::VsCodeDebugTaskFile; pub use vscode_format::VsCodeTaskFile; pub use zed_actions::RevealTarget; @@ -522,3 +524,50 @@ impl ShellBuilder { } } } + +type VsCodeEnvVariable = String; +type ZedEnvVariable = String; + +struct EnvVariableReplacer { + variables: HashMap, +} + +impl EnvVariableReplacer { + fn new(variables: HashMap) -> Self { + Self { variables } + } + // Replaces occurrences of VsCode-specific environment variables with Zed equivalents. + fn replace(&self, input: &str) -> String { + shellexpand::env_with_context_no_errors(&input, |var: &str| { + // Colons denote a default value in case the variable is not set. We want to preserve that default, as otherwise shellexpand will substitute it for us. + let colon_position = var.find(':').unwrap_or(var.len()); + let (left, right) = var.split_at(colon_position); + if left == "env" && !right.is_empty() { + let variable_name = &right[1..]; + return Some(format!("${{{variable_name}}}")); + } + let (variable_name, default) = (left, right); + let append_previous_default = |ret: &mut String| { + if !default.is_empty() { + ret.push_str(default); + } + }; + if let Some(substitution) = self.variables.get(variable_name) { + // Got a VSCode->Zed hit, perform a substitution + let mut name = format!("${{{substitution}"); + append_previous_default(&mut name); + name.push('}'); + return Some(name); + } + // This is an unknown variable. + // We should not error out, as they may come from user environment (e.g. $PATH). That means that the variable substitution might not be perfect. + // If there's a default, we need to return the string verbatim as otherwise shellexpand will apply that default for us. + if !default.is_empty() { + return Some(format!("${{{var}}}")); + } + // Else we can just return None and that variable will be left as is. + None + }) + .into_owned() + } +} diff --git a/crates/task/src/vscode_debug_format.rs b/crates/task/src/vscode_debug_format.rs new file mode 100644 index 0000000000..cecb8629a5 --- /dev/null +++ b/crates/task/src/vscode_debug_format.rs @@ -0,0 +1,184 @@ +use std::path::PathBuf; + +use anyhow::anyhow; +use collections::HashMap; +use serde::Deserialize; +use util::ResultExt as _; + +use crate::{ + AttachRequest, DebugRequest, DebugTaskDefinition, DebugTaskFile, DebugTaskTemplate, + EnvVariableReplacer, LaunchRequest, TcpArgumentsTemplate, VariableName, +}; + +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +enum Request { + Launch, + Attach, +} + +// TODO support preLaunchTask linkage with other tasks +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +struct VsCodeDebugTaskDefinition { + r#type: String, + name: String, + request: Request, + + #[serde(default)] + program: Option, + #[serde(default)] + args: Vec, + #[serde(default)] + env: HashMap>, + // TODO envFile? + #[serde(default)] + cwd: Option, + #[serde(default)] + port: Option, + #[serde(default)] + stop_on_entry: Option, + #[serde(flatten)] + other_attributes: HashMap, +} + +impl VsCodeDebugTaskDefinition { + fn try_to_zed(self, replacer: &EnvVariableReplacer) -> anyhow::Result { + let label = replacer.replace(&self.name); + // TODO based on grep.app results it seems that vscode supports whitespace-splitting this field (ugh) + let definition = DebugTaskDefinition { + label, + request: match self.request { + Request::Launch => { + let cwd = self.cwd.map(|cwd| PathBuf::from(replacer.replace(&cwd))); + let program = self.program.ok_or_else(|| { + anyhow!("vscode debug launch configuration does not define a program") + })?; + let program = replacer.replace(&program); + let args = self + .args + .into_iter() + .map(|arg| replacer.replace(&arg)) + .collect(); + DebugRequest::Launch(LaunchRequest { program, cwd, args }) + } + Request::Attach => DebugRequest::Attach(AttachRequest { process_id: None }), + }, + adapter: task_type_to_adapter_name(self.r#type), + // TODO host? + tcp_connection: self.port.map(|port| TcpArgumentsTemplate { + port: Some(port), + host: None, + timeout: None, + }), + stop_on_entry: self.stop_on_entry, + // TODO + initialize_args: None, + }; + let template = DebugTaskTemplate { + locator: None, + definition, + }; + Ok(template) + } +} + +/// blah +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct VsCodeDebugTaskFile { + version: String, + configurations: Vec, +} + +impl TryFrom for DebugTaskFile { + type Error = anyhow::Error; + + fn try_from(file: VsCodeDebugTaskFile) -> Result { + let replacer = EnvVariableReplacer::new(HashMap::from_iter([ + ( + "workspaceFolder".to_owned(), + VariableName::WorktreeRoot.to_string(), + ), + // TODO other interesting variables? + ])); + let templates = file + .configurations + .into_iter() + .filter_map(|config| config.try_to_zed(&replacer).log_err()) + .collect::>(); + Ok(DebugTaskFile(templates)) + } +} + +// TODO figure out how to make JsDebugAdapter::ADAPTER_NAME et al available here +fn task_type_to_adapter_name(task_type: String) -> String { + match task_type.as_str() { + "node" => "JavaScript".to_owned(), + "go" => "Delve".to_owned(), + "php" => "PHP".to_owned(), + "cppdbg" | "lldb" => "CodeLLDB".to_owned(), + "debugpy" => "Debugpy".to_owned(), + _ => task_type, + } +} + +#[cfg(test)] +mod tests { + use crate::{ + DebugRequest, DebugTaskDefinition, DebugTaskFile, DebugTaskTemplate, LaunchRequest, + TcpArgumentsTemplate, + }; + + use super::VsCodeDebugTaskFile; + + #[test] + fn test_parsing_vscode_launch_json() { + let raw = r#" + { + "version": "0.2.0", + "configurations": [ + { + "name": "Debug my JS app", + "request": "launch", + "type": "node", + "program": "${workspaceFolder}/xyz.js", + "showDevDebugOutput": false, + "stopOnEntry": true, + "args": ["--foo", "${workspaceFolder}/thing"], + "cwd": "${workspaceFolder}/${env:FOO}/sub", + "env": { + "X": "Y" + }, + "port": 17 + }, + ] + } + "#; + let parsed: VsCodeDebugTaskFile = + serde_json_lenient::from_str(&raw).expect("deserializing launch.json"); + let zed = DebugTaskFile::try_from(parsed).expect("converting to Zed debug templates"); + pretty_assertions::assert_eq!( + zed, + DebugTaskFile(vec![DebugTaskTemplate { + locator: None, + definition: DebugTaskDefinition { + label: "Debug my JS app".into(), + adapter: "JavaScript".into(), + stop_on_entry: Some(true), + initialize_args: None, + tcp_connection: Some(TcpArgumentsTemplate { + port: Some(17), + host: None, + timeout: None, + }), + request: DebugRequest::Launch(LaunchRequest { + program: "${ZED_WORKTREE_ROOT}/xyz.js".into(), + args: vec!["--foo".into(), "${ZED_WORKTREE_ROOT}/thing".into()], + cwd: Some("${ZED_WORKTREE_ROOT}/${FOO}/sub".into()), + }), + } + }]) + ); + } +} diff --git a/crates/task/src/vscode_format.rs b/crates/task/src/vscode_format.rs index 3e82d6abba..71471e649d 100644 --- a/crates/task/src/vscode_format.rs +++ b/crates/task/src/vscode_format.rs @@ -3,7 +3,7 @@ use collections::HashMap; use serde::Deserialize; use util::ResultExt; -use crate::{TaskTemplate, TaskTemplates, VariableName}; +use crate::{EnvVariableReplacer, TaskTemplate, TaskTemplates, VariableName}; #[derive(Clone, Debug, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] @@ -41,48 +41,6 @@ enum Command { }, } -type VsCodeEnvVariable = String; -type ZedEnvVariable = String; - -struct EnvVariableReplacer { - variables: HashMap, -} - -impl EnvVariableReplacer { - fn new(variables: HashMap) -> Self { - Self { variables } - } - // Replaces occurrences of VsCode-specific environment variables with Zed equivalents. - fn replace(&self, input: &str) -> String { - shellexpand::env_with_context_no_errors(&input, |var: &str| { - // Colons denote a default value in case the variable is not set. We want to preserve that default, as otherwise shellexpand will substitute it for us. - let colon_position = var.find(':').unwrap_or(var.len()); - let (variable_name, default) = var.split_at(colon_position); - let append_previous_default = |ret: &mut String| { - if !default.is_empty() { - ret.push_str(default); - } - }; - if let Some(substitution) = self.variables.get(variable_name) { - // Got a VSCode->Zed hit, perform a substitution - let mut name = format!("${{{substitution}"); - append_previous_default(&mut name); - name.push('}'); - return Some(name); - } - // This is an unknown variable. - // We should not error out, as they may come from user environment (e.g. $PATH). That means that the variable substitution might not be perfect. - // If there's a default, we need to return the string verbatim as otherwise shellexpand will apply that default for us. - if !default.is_empty() { - return Some(format!("${{{var}}}")); - } - // Else we can just return None and that variable will be left as is. - None - }) - .into_owned() - } -} - impl VsCodeTaskDefinition { fn into_zed_format(self, replacer: &EnvVariableReplacer) -> anyhow::Result { if self.other_attributes.contains_key("dependsOn") {