logiguard fork v3: full patch set on verified 8c74db0 tree

Includes prior-session patches (carry forward so the app compiles):
  - crates/gpui/build.rs: cross-compile manifest fix
  - crates/gpui/src/platform.rs: PlatformWindow::activate_with_token trait method
  - crates/gpui/src/window.rs: Window::activate_with_token public API
  - crates/gpui_linux/src/linux/wayland/window.rs: WaylandWindow::activate_with_token + activate() keyboard-serial fix

Plus the focus-serial fix:
  - serial.rs: SerialKind::KeyboardEnter
  - client.rs: store wl_keyboard.enter serial; latest_serial_of()

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mohamad Khani
2026-07-14 01:52:12 +03:30
commit b9819977a5
3984 changed files with 1487015 additions and 0 deletions

41
crates/task/Cargo.toml Normal file
View File

@@ -0,0 +1,41 @@
[package]
name = "task"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
license = "GPL-3.0-or-later"
[features]
test-support = [
"gpui/test-support",
"util/test-support"
]
[lib]
path = "src/task.rs"
doctest = false
[lints]
workspace = true
[dependencies]
anyhow.workspace = true
collections.workspace = true
futures.workspace = true
gpui.workspace = true
hex.workspace = true
log.workspace = true
parking_lot.workspace = true
proto.workspace = true
schemars.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_json_lenient.workspace = true
sha2.workspace = true
shellexpand.workspace = true
util.workspace = true
zed_actions.workspace = true
[dev-dependencies]
gpui = { workspace = true, features = ["test-support"] }
pretty_assertions.workspace = true

1
crates/task/LICENSE-GPL Symbolic link
View File

@@ -0,0 +1 @@
../../LICENSE-GPL

View File

@@ -0,0 +1,16 @@
use gpui::SharedString;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// JSON schema for a specific adapter
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
pub struct AdapterSchema {
/// The adapter name identifier
pub adapter: SharedString,
/// The JSON schema for this adapter's configuration
pub schema: serde_json::Value,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(transparent)]
pub struct AdapterSchemas(pub Vec<AdapterSchema>);

View File

@@ -0,0 +1,534 @@
use anyhow::{Context as _, Result};
use collections::FxHashMap;
use gpui::SharedString;
use log as _;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::net::IpAddr;
use std::path::PathBuf;
use util::{debug_panic, schemars::add_new_subschema};
use crate::{TaskTemplate, adapter_schema::AdapterSchemas};
/// Represents the host information of the debug adapter
#[derive(Default, Deserialize, Serialize, PartialEq, Eq, JsonSchema, Clone, Debug)]
pub struct TcpArgumentsTemplate {
/// The port that the debug adapter is listening on
///
/// Default: We will try to find an open port
pub port: Option<u16>,
/// The host that the debug adapter is listening too
///
/// Default: 127.0.0.1
pub host: Option<IpAddr>,
/// The max amount of time in milliseconds to connect to a tcp DAP before returning an error
///
/// Default: 2000ms
pub timeout: Option<u64>,
}
impl TcpArgumentsTemplate {
/// Get the host or fallback to the default host
pub fn host(&self) -> IpAddr {
self.host
.unwrap_or(IpAddr::V4(std::net::Ipv4Addr::LOCALHOST))
}
pub fn from_proto(proto: proto::TcpHost) -> Result<Self> {
Ok(Self {
port: proto.port.map(|p| p.try_into()).transpose()?,
host: proto.host.map(|h| h.parse()).transpose()?,
timeout: proto.timeout,
})
}
pub fn to_proto(&self) -> proto::TcpHost {
proto::TcpHost {
port: self.port.map(|p| p.into()),
host: self.host.map(|h| h.to_string()),
timeout: self.timeout,
}
}
}
/// Represents the attach request information of the debug adapter
#[derive(Default, Serialize, PartialEq, Eq, JsonSchema, Clone, Debug)]
pub struct AttachRequest {
/// The processId to attach to, if left empty we will show a process picker
pub process_id: Option<u32>,
}
impl<'de> Deserialize<'de> for AttachRequest {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
struct Helper {
process_id: Option<u32>,
}
let helper = Helper::deserialize(deserializer)?;
// Skip creating an AttachRequest if process_id is None
if helper.process_id.is_none() {
return Err(serde::de::Error::custom("process_id is required"));
}
Ok(AttachRequest {
process_id: helper.process_id,
})
}
}
/// Represents the launch request information of the debug adapter
#[derive(Deserialize, Serialize, Default, PartialEq, Eq, JsonSchema, Clone, Debug)]
pub struct LaunchRequest {
/// The program that you trying to debug
pub program: String,
/// The current working directory of your project
#[serde(default)]
pub cwd: Option<PathBuf>,
/// Arguments to pass to a debuggee
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: FxHashMap<String, String>,
}
impl LaunchRequest {
pub fn env_json(&self) -> serde_json::Value {
serde_json::Value::Object(
self.env
.iter()
.map(|(k, v)| (k.clone(), v.to_owned().into()))
.collect::<serde_json::Map<String, serde_json::Value>>(),
)
}
}
/// Represents the type that will determine which request to call on the debug adapter
#[derive(Deserialize, Serialize, PartialEq, Eq, JsonSchema, Clone, Debug)]
#[serde(rename_all = "lowercase", tag = "request")]
pub enum DebugRequest {
/// Call the `launch` request on the debug adapter
Launch(LaunchRequest),
/// Call the `attach` request on the debug adapter
Attach(AttachRequest),
}
impl DebugRequest {
pub fn to_proto(&self) -> proto::DebugRequest {
match self {
DebugRequest::Launch(launch_request) => proto::DebugRequest {
request: Some(proto::debug_request::Request::DebugLaunchRequest(
proto::DebugLaunchRequest {
program: launch_request.program.clone(),
cwd: launch_request
.cwd
.as_ref()
.map(|cwd| cwd.to_string_lossy().into_owned()),
args: launch_request.args.clone(),
env: launch_request
.env
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
},
)),
},
DebugRequest::Attach(attach_request) => proto::DebugRequest {
request: Some(proto::debug_request::Request::DebugAttachRequest(
proto::DebugAttachRequest {
process_id: attach_request
.process_id
.expect("The process ID to be already filled out."),
},
)),
},
}
}
pub fn from_proto(val: proto::DebugRequest) -> Result<DebugRequest> {
let request = val.request.context("Missing debug request")?;
match request {
proto::debug_request::Request::DebugLaunchRequest(proto::DebugLaunchRequest {
program,
cwd,
args,
env,
}) => Ok(DebugRequest::Launch(LaunchRequest {
program,
cwd: cwd.map(From::from),
args,
env: env.into_iter().collect(),
})),
proto::debug_request::Request::DebugAttachRequest(proto::DebugAttachRequest {
process_id,
}) => Ok(DebugRequest::Attach(AttachRequest {
process_id: Some(process_id),
})),
}
}
}
impl From<LaunchRequest> for DebugRequest {
fn from(launch_config: LaunchRequest) -> Self {
DebugRequest::Launch(launch_config)
}
}
impl From<AttachRequest> for DebugRequest {
fn from(attach_config: AttachRequest) -> Self {
DebugRequest::Attach(attach_config)
}
}
#[derive(Serialize, PartialEq, Eq, JsonSchema, Clone, Debug)]
#[serde(untagged)]
pub enum BuildTaskDefinition {
ByName(SharedString),
Template {
#[serde(flatten)]
task_template: TaskTemplate,
#[serde(skip)]
locator_name: Option<SharedString>,
},
}
impl<'de> Deserialize<'de> for BuildTaskDefinition {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
struct TemplateHelper {
#[serde(default)]
label: Option<String>,
#[serde(flatten)]
rest: serde_json::Value,
}
let value = serde_json::Value::deserialize(deserializer)?;
if let Ok(name) = serde_json::from_value::<SharedString>(value.clone()) {
return Ok(BuildTaskDefinition::ByName(name));
}
let helper: TemplateHelper =
serde_json::from_value(value).map_err(serde::de::Error::custom)?;
let mut template_value = helper.rest;
if let serde_json::Value::Object(ref mut map) = template_value {
map.insert(
"label".to_string(),
serde_json::to_value(helper.label.unwrap_or_else(|| "debug-build".to_owned()))
.map_err(serde::de::Error::custom)?,
);
}
let task_template: TaskTemplate =
serde_json::from_value(template_value).map_err(serde::de::Error::custom)?;
Ok(BuildTaskDefinition::Template {
task_template,
locator_name: None,
})
}
}
#[derive(Deserialize, Serialize, PartialEq, Eq, Clone, Debug, JsonSchema)]
pub enum Request {
Launch,
Attach,
}
/// This struct represent a user created debug task from the new process modal
#[derive(Deserialize, Serialize, PartialEq, Eq, Clone, Debug, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub struct ZedDebugConfig {
/// Name of the debug task
pub label: SharedString,
/// The debug adapter to use
pub adapter: SharedString,
#[serde(flatten)]
pub request: DebugRequest,
/// Whether to tell the debug adapter to stop on entry
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stop_on_entry: Option<bool>,
}
/// This struct represent a user created debug task
#[derive(Deserialize, Serialize, PartialEq, Eq, Clone, Debug, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub struct DebugScenario {
pub adapter: SharedString,
/// Name of the debug task
pub label: SharedString,
/// A task to run prior to spawning the debuggee.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub build: Option<BuildTaskDefinition>,
/// The main arguments to be sent to the debug adapter
#[serde(default, flatten)]
pub config: serde_json::Value,
/// Optional TCP connection information
///
/// If provided, this will be used to connect to the debug adapter instead of
/// spawning a new process. This is useful for connecting to a debug adapter
/// that is already running or is started by another process.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tcp_connection: Option<TcpArgumentsTemplate>,
}
/// A group of Debug Tasks defined in a JSON file.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(transparent)]
pub struct DebugTaskFile(pub Vec<DebugScenario>);
impl DebugTaskFile {
pub fn generate_json_schema(schemas: &AdapterSchemas) -> serde_json::Value {
let mut generator = schemars::generate::SchemaSettings::draft2019_09().into_generator();
let mut build_task_value = BuildTaskDefinition::json_schema(&mut generator).to_value();
if let Some(template_object) = build_task_value
.get_mut("anyOf")
.and_then(|array| array.as_array_mut())
.and_then(|array| array.get_mut(1))
{
if let Some(properties) = template_object
.get_mut("properties")
.and_then(|value| value.as_object_mut())
&& properties.remove("label").is_none()
{
debug_panic!(
"Generated TaskTemplate json schema did not have expected 'label' field. \
Schema of 2nd alternative is: {template_object:?}"
);
}
if let Some(arr) = template_object
.get_mut("required")
.and_then(|array| array.as_array_mut())
{
arr.retain(|v| v.as_str() != Some("label"));
}
} else {
debug_panic!(
"Generated TaskTemplate json schema did not match expectations. \
Schema is: {build_task_value:?}"
);
}
let adapter_conditions = schemas
.0
.iter()
.map(|adapter_schema| {
let adapter_name = adapter_schema.adapter.to_string();
add_new_subschema(
&mut generator,
&format!("{adapter_name}DebugSettings"),
serde_json::json!({
"if": {
"properties": {
"adapter": { "const": adapter_name }
}
},
"then": adapter_schema.schema
}),
)
})
.collect::<Vec<_>>();
let build_task_definition_ref = add_new_subschema(
&mut generator,
BuildTaskDefinition::schema_name().as_ref(),
build_task_value,
);
let meta_schema = generator
.settings()
.meta_schema
.as_ref()
.expect("meta_schema should be present in schemars settings")
.to_string();
serde_json::json!({
"$schema": meta_schema,
"title": "Debug Configurations",
"description": "Configuration for debug scenarios",
"allowTrailingCommas": true,
"type": "array",
"items": {
"type": "object",
"required": ["adapter", "label"],
// TODO: Uncommenting this will cause json-language-server to provide warnings for
// unrecognized properties. It should be enabled if/when there's an adapter JSON
// schema that's comprehensive. In order to not get warnings for the other schemas,
// `additionalProperties` or `unevaluatedProperties` (to handle "allOf" etc style
// schema combinations) could be set to `true` for that schema.
//
// "unevaluatedProperties": false,
"properties": {
"adapter": {
"type": "string",
"description": "The name of the debug adapter"
},
"label": {
"type": "string",
"description": "The name of the debug configuration"
},
"build": build_task_definition_ref,
"tcp_connection": {
"type": "object",
"description": "Optional TCP connection information for connecting to an already running debug adapter",
"properties": {
"port": {
"type": "integer",
"description": "The port that the debug adapter is listening on (default: auto-find open port)"
},
"host": {
"type": "string",
"description": "The host that the debug adapter is listening to, as an IPv4 or IPv6 address (default: 127.0.0.1)"
},
"timeout": {
"type": "integer",
"description": "The max amount of time in milliseconds to connect to a tcp DAP before returning an error (default: 2000ms)"
}
}
}
},
"allOf": adapter_conditions
},
"$defs": generator.take_definitions(true),
})
}
}
#[cfg(test)]
mod tests {
use crate::DebugScenario;
use serde_json::json;
#[test]
fn test_just_build_args() {
let json = r#"{
"label": "Build & debug rust",
"adapter": "CodeLLDB",
"build": {
"command": "rust",
"args": ["build"]
}
}"#;
let deserialized: DebugScenario = serde_json::from_str(json).unwrap();
assert!(deserialized.build.is_some());
match deserialized.build.as_ref().unwrap() {
crate::BuildTaskDefinition::Template { task_template, .. } => {
assert_eq!("debug-build", task_template.label);
assert_eq!("rust", task_template.command);
assert_eq!(vec!["build"], task_template.args);
}
_ => panic!("Expected Template variant"),
}
assert_eq!(json!({}), deserialized.config);
assert_eq!("CodeLLDB", deserialized.adapter.as_ref());
assert_eq!("Build & debug rust", deserialized.label.as_ref());
}
#[test]
fn test_empty_scenario_has_none_request() {
let json = r#"{
"label": "Build & debug rust",
"build": "rust",
"adapter": "CodeLLDB"
}"#;
let deserialized: DebugScenario = serde_json::from_str(json).unwrap();
assert_eq!(json!({}), deserialized.config);
assert_eq!("CodeLLDB", deserialized.adapter.as_ref());
assert_eq!("Build & debug rust", deserialized.label.as_ref());
}
#[test]
fn test_launch_scenario_deserialization() {
let json = r#"{
"label": "Launch program",
"adapter": "CodeLLDB",
"request": "launch",
"program": "target/debug/myapp",
"args": ["--test"]
}"#;
let deserialized: DebugScenario = serde_json::from_str(json).unwrap();
assert_eq!(
json!({ "request": "launch", "program": "target/debug/myapp", "args": ["--test"] }),
deserialized.config
);
assert_eq!("CodeLLDB", deserialized.adapter.as_ref());
assert_eq!("Launch program", deserialized.label.as_ref());
}
#[test]
fn test_attach_scenario_deserialization() {
let json = r#"{
"label": "Attach to process",
"adapter": "CodeLLDB",
"process_id": 1234,
"request": "attach"
}"#;
let deserialized: DebugScenario = serde_json::from_str(json).unwrap();
assert_eq!(
json!({ "request": "attach", "process_id": 1234 }),
deserialized.config
);
assert_eq!("CodeLLDB", deserialized.adapter.as_ref());
assert_eq!("Attach to process", deserialized.label.as_ref());
}
#[test]
fn test_build_task_definition_without_label() {
use crate::BuildTaskDefinition;
let json = r#""my_build_task""#;
let deserialized: BuildTaskDefinition = serde_json::from_str(json).unwrap();
match deserialized {
BuildTaskDefinition::ByName(name) => assert_eq!("my_build_task", name.as_ref()),
_ => panic!("Expected ByName variant"),
}
let json = r#"{
"command": "cargo",
"args": ["build", "--release"]
}"#;
let deserialized: BuildTaskDefinition = serde_json::from_str(json).unwrap();
match deserialized {
BuildTaskDefinition::Template { task_template, .. } => {
assert_eq!("debug-build", task_template.label);
assert_eq!("cargo", task_template.command);
assert_eq!(vec!["build", "--release"], task_template.args);
}
_ => panic!("Expected Template variant"),
}
let json = r#"{
"label": "Build Release",
"command": "cargo",
"args": ["build", "--release"]
}"#;
let deserialized: BuildTaskDefinition = serde_json::from_str(json).unwrap();
match deserialized {
BuildTaskDefinition::Template { task_template, .. } => {
assert_eq!("Build Release", task_template.label);
assert_eq!("cargo", task_template.command);
assert_eq!(vec!["build", "--release"], task_template.args);
}
_ => panic!("Expected Template variant"),
}
}
}

View File

@@ -0,0 +1,37 @@
use serde::de::{self, Deserializer, Visitor};
use std::fmt;
/// Deserializes a non-empty string array.
pub fn non_empty_string_vec<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: Deserializer<'de>,
{
struct NonEmptyStringVecVisitor;
impl<'de> Visitor<'de> for NonEmptyStringVecVisitor {
type Value = Vec<String>;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a list of non-empty strings")
}
fn visit_seq<V>(self, mut seq: V) -> Result<Vec<String>, V::Error>
where
V: de::SeqAccess<'de>,
{
let mut vec = Vec::new();
while let Some(value) = seq.next_element::<String>()? {
if value.is_empty() {
return Err(de::Error::invalid_value(
de::Unexpected::Str(&value),
&"a non-empty string",
));
}
vec.push(value);
}
Ok(vec)
}
}
deserializer.deserialize_seq(NonEmptyStringVecVisitor)
}

View File

@@ -0,0 +1,123 @@
//! A source of tasks, based on a static configuration, deserialized from the tasks config file, and related infrastructure for tracking changes to the file.
use std::sync::Arc;
use futures::{StreamExt, channel::mpsc::UnboundedSender};
use gpui::{App, AppContext, TaskExt};
use parking_lot::RwLock;
use serde::Deserialize;
use util::ResultExt;
use crate::TaskTemplates;
use futures::channel::mpsc::UnboundedReceiver;
/// The source of tasks defined in a tasks config file.
pub struct StaticSource {
tasks: TrackedFile<TaskTemplates>,
}
/// A Wrapper around deserializable T that keeps track of its contents
/// via a provided channel.
pub struct TrackedFile<T> {
parsed_contents: Arc<RwLock<T>>,
}
impl<T: PartialEq + 'static + Sync> TrackedFile<T> {
/// Initializes new [`TrackedFile`] with a type that's deserializable.
pub fn new(
mut tracker: UnboundedReceiver<String>,
notification_outlet: UnboundedSender<()>,
cx: &App,
) -> Self
where
T: for<'a> Deserialize<'a> + Default + Send,
{
let parsed_contents: Arc<RwLock<T>> = Arc::default();
cx.background_spawn({
let parsed_contents = parsed_contents.clone();
async move {
while let Some(new_contents) = tracker.next().await {
if Arc::strong_count(&parsed_contents) == 1 {
// We're no longer being observed. Stop polling.
break;
}
if !new_contents.trim().is_empty() {
let Some(new_contents) =
serde_json_lenient::from_str::<T>(&new_contents).log_err()
else {
continue;
};
let mut contents = parsed_contents.write();
if *contents != new_contents {
*contents = new_contents;
if notification_outlet.unbounded_send(()).is_err() {
// Whoever cared about contents is not around anymore.
break;
}
}
}
}
anyhow::Ok(())
}
})
.detach_and_log_err(cx);
Self { parsed_contents }
}
/// Initializes new [`TrackedFile`] with a type that's convertible from another deserializable type.
pub fn new_convertible<U: for<'a> Deserialize<'a> + TryInto<T, Error = anyhow::Error>>(
mut tracker: UnboundedReceiver<String>,
notification_outlet: UnboundedSender<()>,
cx: &App,
) -> Self
where
T: Default + Send,
{
let parsed_contents: Arc<RwLock<T>> = Arc::default();
cx.background_spawn({
async move {
while let Some(new_contents) = tracker.next().await {
if Arc::strong_count(&parsed_contents) == 1 {
// We're no longer being observed. Stop polling.
break;
}
if !new_contents.trim().is_empty() {
let Some(new_contents) =
serde_json_lenient::from_str::<U>(&new_contents).log_err()
else {
continue;
};
let Some(new_contents) = new_contents.try_into().log_err() else {
continue;
};
let mut contents = parsed_contents.write();
if *contents != new_contents {
*contents = new_contents;
if notification_outlet.unbounded_send(()).is_err() {
// Whoever cared about contents is not around anymore.
break;
}
}
}
}
anyhow::Ok(())
}
})
.detach_and_log_err(cx);
Self {
parsed_contents: Default::default(),
}
}
}
impl StaticSource {
/// Initializes the static source, reacting on tasks config changes.
pub fn new(tasks: TrackedFile<TaskTemplates>) -> Self {
Self { tasks }
}
/// Returns current list of tasks
pub fn tasks_to_schedule(&self) -> TaskTemplates {
self.tasks.parsed_contents.read().clone()
}
}

478
crates/task/src/task.rs Normal file
View File

@@ -0,0 +1,478 @@
//! Baseline interface of Tasks in Zed: all tasks in Zed are intended to use those for implementing their own logic.
mod adapter_schema;
mod debug_format;
mod serde_helpers;
pub mod static_source;
mod task_template;
mod vscode_debug_format;
mod vscode_format;
use anyhow::Context as _;
use collections::{HashMap, HashSet, hash_map};
use gpui::SharedString;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use std::path::PathBuf;
use std::str::FromStr;
use std::sync::Arc;
pub use adapter_schema::{AdapterSchema, AdapterSchemas};
pub use debug_format::{
AttachRequest, BuildTaskDefinition, DebugRequest, DebugScenario, DebugTaskFile, LaunchRequest,
Request, TcpArgumentsTemplate, ZedDebugConfig,
};
pub use task_template::{
DebugArgsRequest, HideStrategy, RevealStrategy, SaveStrategy, TaskHook, TaskTemplate,
TaskTemplates, substitute_variables_in_map, substitute_variables_in_str,
};
pub use util::shell::{Shell, ShellKind};
pub use util::shell_builder::ShellBuilder;
pub use vscode_debug_format::VsCodeDebugTaskFile;
pub use vscode_format::VsCodeTaskFile;
pub use zed_actions::RevealTarget;
/// Task identifier, unique within the application.
/// Based on it, task reruns and terminal tabs are managed.
#[derive(Default, Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Deserialize)]
pub struct TaskId(pub String);
/// Contains all information needed by Zed to spawn a new terminal tab for the given task.
#[derive(Default, Debug, Clone, PartialEq, Eq)]
pub struct SpawnInTerminal {
/// Id of the task to use when determining task tab affinity.
pub id: TaskId,
/// Full unshortened form of `label` field.
pub full_label: String,
/// Human readable name of the terminal tab.
pub label: String,
/// Executable command to spawn.
pub command: Option<String>,
/// Arguments to the command, potentially unsubstituted,
/// to let the shell that spawns the command to do the substitution, if needed.
pub args: Vec<String>,
/// A human-readable label, containing command and all of its arguments, joined and substituted.
pub command_label: String,
/// Current working directory to spawn the command into.
pub cwd: Option<PathBuf>,
/// Env overrides for the command, will be appended to the terminal's environment from the settings.
pub env: HashMap<String, String>,
/// Whether to use a new terminal tab or reuse the existing one to spawn the process.
pub use_new_terminal: bool,
/// Whether to allow multiple instances of the same task to be run, or rather wait for the existing ones to finish.
pub allow_concurrent_runs: bool,
/// What to do with the terminal pane and tab, after the command was started.
pub reveal: RevealStrategy,
/// Where to show tasks' terminal output.
pub reveal_target: RevealTarget,
/// What to do with the terminal pane and tab, after the command had finished.
pub hide: HideStrategy,
/// Which shell to use when spawning the task.
pub shell: Shell,
/// Whether to show the task summary line in the task output (sucess/failure).
pub show_summary: bool,
/// Whether to show the command line in the task output.
pub show_command: bool,
/// Whether to show the rerun button in the terminal tab.
pub show_rerun: bool,
/// Which edited buffers to save before running the task.
pub save: SaveStrategy,
}
impl SpawnInTerminal {
pub fn to_proto(&self) -> proto::SpawnInTerminal {
proto::SpawnInTerminal {
label: self.label.clone(),
command: self.command.clone(),
args: self.args.clone(),
env: self
.env
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
cwd: self
.cwd
.clone()
.map(|cwd| cwd.to_string_lossy().into_owned()),
}
}
pub fn from_proto(proto: proto::SpawnInTerminal) -> Self {
Self {
label: proto.label.clone(),
command: proto.command.clone(),
args: proto.args.clone(),
env: proto.env.into_iter().collect(),
cwd: proto.cwd.map(PathBuf::from),
..Default::default()
}
}
}
/// A final form of the [`TaskTemplate`], that got resolved with a particular [`TaskContext`] and now is ready to spawn the actual task.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResolvedTask {
/// A way to distinguish tasks produced by the same template, but different contexts.
/// NOTE: Resolved tasks may have the same labels, commands and do the same things,
/// but still may have different ids if the context was different during the resolution.
/// Since the template has `env` field, for a generic task that may be a bash command,
/// so it's impossible to determine the id equality without more context in a generic case.
pub id: TaskId,
/// A template the task got resolved from.
original_task: TaskTemplate,
/// Full, unshortened label of the task after all resolutions are made.
pub resolved_label: String,
/// Variables that were substituted during the task template resolution.
substituted_variables: HashSet<VariableName>,
/// Further actions that need to take place after the resolved task is spawned,
/// with all task variables resolved.
pub resolved: SpawnInTerminal,
}
impl ResolvedTask {
/// A task template before the resolution.
pub fn original_task(&self) -> &TaskTemplate {
&self.original_task
}
/// Variables that were substituted during the task template resolution.
pub fn substituted_variables(&self) -> &HashSet<VariableName> {
&self.substituted_variables
}
/// A human-readable label to display in the UI.
pub fn display_label(&self) -> &str {
self.resolved.label.as_str()
}
}
/// Variables, available for use in [`TaskContext`] when a Zed's [`TaskTemplate`] gets resolved into a [`ResolvedTask`].
/// Name of the variable must be a valid shell variable identifier, which generally means that it is
/// a word consisting only of alphanumeric characters and underscores,
/// and beginning with an alphabetic character or an underscore.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
pub enum VariableName {
/// An absolute path of the currently opened file.
File,
/// A path of the currently opened file (relative to worktree root).
RelativeFile,
/// A path of the currently opened file's directory (relative to worktree root).
RelativeDir,
/// The currently opened filename.
Filename,
/// The path to a parent directory of a currently opened file.
Dirname,
/// Stem (filename without extension) of the currently opened file.
Stem,
/// An absolute path of the currently opened worktree, that contains the file.
WorktreeRoot,
/// A symbol text, that contains latest cursor/selection position.
Symbol,
/// A row with the latest cursor/selection position.
Row,
/// A column with the latest cursor/selection position.
Column,
/// Text from the latest selection.
SelectedText,
/// The language of the currently opened buffer (e.g., "Rust", "Python").
Language,
/// The symbol selected by the symbol tagging system, specifically the @run capture in a runnables.scm
RunnableSymbol,
/// Open a Picker to select a process ID to use in place
/// Can only be used to debug configurations
PickProcessId,
/// An absolute path of the main (original) git worktree for the current repository.
/// For normal checkouts, this equals the worktree root. For linked worktrees,
/// this is the original repo's working directory.
MainGitWorktree,
/// Full SHA for the Git commit associated with the task context.
GitSha,
/// Short SHA for the Git commit associated with the task context.
GitShaShort,
/// Name of the Git repository associated with the task context.
GitRepositoryName,
/// Absolute path of the Git repository associated with the task context.
GitRepositoryPath,
/// Custom variable, provided by the plugin or other external source.
/// Will be printed with `CUSTOM_` prefix to avoid potential conflicts with other variables.
Custom(Cow<'static, str>),
}
impl VariableName {
/// Generates a `$VARIABLE`-like string value to be used in templates.
pub fn template_value(&self) -> String {
format!("${self}")
}
/// Generates a `"$VARIABLE"`-like string, to be used instead of `Self::template_value` when expanded value could contain spaces or special characters.
pub fn template_value_with_whitespace(&self) -> String {
format!("\"${self}\"")
}
}
impl FromStr for VariableName {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
let without_prefix = s.strip_prefix(ZED_VARIABLE_NAME_PREFIX).ok_or(())?;
let value = match without_prefix {
"FILE" => Self::File,
"FILENAME" => Self::Filename,
"RELATIVE_FILE" => Self::RelativeFile,
"RELATIVE_DIR" => Self::RelativeDir,
"DIRNAME" => Self::Dirname,
"STEM" => Self::Stem,
"WORKTREE_ROOT" => Self::WorktreeRoot,
"SYMBOL" => Self::Symbol,
"RUNNABLE_SYMBOL" => Self::RunnableSymbol,
"SELECTED_TEXT" => Self::SelectedText,
"LANGUAGE" => Self::Language,
"ROW" => Self::Row,
"COLUMN" => Self::Column,
"MAIN_GIT_WORKTREE" => Self::MainGitWorktree,
"GIT_SHA" => Self::GitSha,
"GIT_SHA_SHORT" => Self::GitShaShort,
"GIT_REPOSITORY_NAME" => Self::GitRepositoryName,
"GIT_REPOSITORY_PATH" => Self::GitRepositoryPath,
_ => {
if let Some(custom_name) =
without_prefix.strip_prefix(ZED_CUSTOM_VARIABLE_NAME_PREFIX)
{
Self::Custom(Cow::Owned(custom_name.to_owned()))
} else {
return Err(());
}
}
};
Ok(value)
}
}
/// A prefix that all [`VariableName`] variants are prefixed with when used in environment variables and similar template contexts.
pub const ZED_VARIABLE_NAME_PREFIX: &str = "ZED_";
const ZED_CUSTOM_VARIABLE_NAME_PREFIX: &str = "CUSTOM_";
impl std::fmt::Display for VariableName {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
Self::File => write!(f, "{ZED_VARIABLE_NAME_PREFIX}FILE"),
Self::Filename => write!(f, "{ZED_VARIABLE_NAME_PREFIX}FILENAME"),
Self::RelativeFile => write!(f, "{ZED_VARIABLE_NAME_PREFIX}RELATIVE_FILE"),
Self::RelativeDir => write!(f, "{ZED_VARIABLE_NAME_PREFIX}RELATIVE_DIR"),
Self::Dirname => write!(f, "{ZED_VARIABLE_NAME_PREFIX}DIRNAME"),
Self::Stem => write!(f, "{ZED_VARIABLE_NAME_PREFIX}STEM"),
Self::WorktreeRoot => write!(f, "{ZED_VARIABLE_NAME_PREFIX}WORKTREE_ROOT"),
Self::Symbol => write!(f, "{ZED_VARIABLE_NAME_PREFIX}SYMBOL"),
Self::Row => write!(f, "{ZED_VARIABLE_NAME_PREFIX}ROW"),
Self::Column => write!(f, "{ZED_VARIABLE_NAME_PREFIX}COLUMN"),
Self::SelectedText => write!(f, "{ZED_VARIABLE_NAME_PREFIX}SELECTED_TEXT"),
Self::Language => write!(f, "{ZED_VARIABLE_NAME_PREFIX}LANGUAGE"),
Self::RunnableSymbol => write!(f, "{ZED_VARIABLE_NAME_PREFIX}RUNNABLE_SYMBOL"),
Self::PickProcessId => write!(f, "{ZED_VARIABLE_NAME_PREFIX}PICK_PID"),
Self::MainGitWorktree => write!(f, "{ZED_VARIABLE_NAME_PREFIX}MAIN_GIT_WORKTREE"),
Self::GitSha => write!(f, "{ZED_VARIABLE_NAME_PREFIX}GIT_SHA"),
Self::GitShaShort => write!(f, "{ZED_VARIABLE_NAME_PREFIX}GIT_SHA_SHORT"),
Self::GitRepositoryName => write!(f, "{ZED_VARIABLE_NAME_PREFIX}GIT_REPOSITORY_NAME"),
Self::GitRepositoryPath => write!(f, "{ZED_VARIABLE_NAME_PREFIX}GIT_REPOSITORY_PATH"),
Self::Custom(s) => write!(
f,
"{ZED_VARIABLE_NAME_PREFIX}{ZED_CUSTOM_VARIABLE_NAME_PREFIX}{s}"
),
}
}
}
/// Container for predefined environment variables that describe state of Zed at the time the task was spawned.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct TaskVariables(HashMap<VariableName, String>);
impl TaskVariables {
/// Inserts another variable into the container, overwriting the existing one if it already exists — in this case, the old value is returned.
pub fn insert(&mut self, variable: VariableName, value: String) -> Option<String> {
self.0.insert(variable, value)
}
/// Extends the container with another one, overwriting the existing variables on collision.
pub fn extend(&mut self, other: Self) {
self.0.extend(other.0);
}
/// Get the value associated with given variable name, if there is one.
pub fn get(&self, key: &VariableName) -> Option<&str> {
self.0.get(key).map(|s| s.as_str())
}
/// Clear out variables obtained from tree-sitter queries, which are prefixed with '_' character
pub fn sweep(&mut self) {
self.0.retain(|name, _| {
if let VariableName::Custom(name) = name {
!name.starts_with('_')
} else {
true
}
})
}
pub fn iter(&self) -> impl Iterator<Item = (&VariableName, &String)> {
self.0.iter()
}
}
impl FromIterator<(VariableName, String)> for TaskVariables {
fn from_iter<T: IntoIterator<Item = (VariableName, String)>>(iter: T) -> Self {
Self(HashMap::from_iter(iter))
}
}
impl IntoIterator for TaskVariables {
type Item = (VariableName, String);
type IntoIter = hash_map::IntoIter<VariableName, String>;
fn into_iter(self) -> Self::IntoIter {
self.0.into_iter()
}
}
/// Keeps track of the file associated with a task and context of tasks execution (i.e. current file or current function).
/// Keeps all Zed-related state inside, used to produce a resolved task out of its template.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct TaskContext {
/// A path to a directory in which the task should be executed.
pub cwd: Option<PathBuf>,
/// Additional environment variables associated with a given task.
pub task_variables: TaskVariables,
/// Environment variables obtained when loading the project into Zed.
/// This is the environment one would get when `cd`ing in a terminal
/// into the project's root directory.
pub project_env: HashMap<String, String>,
}
/// A shared reference to a [`TaskContext`], used to avoid cloning the context multiple times.
#[derive(Clone, Debug, Default)]
pub struct SharedTaskContext(Arc<TaskContext>);
impl std::ops::Deref for SharedTaskContext {
type Target = TaskContext;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl From<TaskContext> for SharedTaskContext {
fn from(context: TaskContext) -> Self {
Self(Arc::new(context))
}
}
/// This is a new type representing a 'tag' on a 'runnable symbol', typically a test of main() function, found via treesitter.
#[derive(Clone, Debug)]
pub struct RunnableTag(pub SharedString);
pub fn shell_from_proto(proto: proto::Shell) -> anyhow::Result<Shell> {
let shell_type = proto.shell_type.context("invalid shell type")?;
let shell = match shell_type {
proto::shell::ShellType::System(_) => Shell::System,
proto::shell::ShellType::Program(program) => Shell::Program(program),
proto::shell::ShellType::WithArguments(program) => Shell::WithArguments {
program: program.program,
args: program.args,
title_override: None,
},
};
Ok(shell)
}
pub fn shell_to_proto(shell: Shell) -> proto::Shell {
let shell_type = match shell {
Shell::System => proto::shell::ShellType::System(proto::System {}),
Shell::Program(program) => proto::shell::ShellType::Program(program),
Shell::WithArguments {
program,
args,
title_override: _,
} => proto::shell::ShellType::WithArguments(proto::shell::WithArguments { program, args }),
};
proto::Shell {
shell_type: Some(shell_type),
}
}
type VsCodeEnvVariable = String;
type VsCodeCommand = String;
type ZedEnvVariable = String;
struct EnvVariableReplacer {
variables: HashMap<VsCodeEnvVariable, ZedEnvVariable>,
commands: HashMap<VsCodeCommand, ZedEnvVariable>,
}
impl EnvVariableReplacer {
fn new(variables: HashMap<VsCodeEnvVariable, ZedEnvVariable>) -> Self {
Self {
variables,
commands: HashMap::default(),
}
}
fn with_commands(
mut self,
commands: impl IntoIterator<Item = (VsCodeCommand, ZedEnvVariable)>,
) -> Self {
self.commands = commands.into_iter().collect();
self
}
fn replace_value(&self, input: serde_json::Value) -> serde_json::Value {
match input {
serde_json::Value::String(s) => serde_json::Value::String(self.replace(&s)),
serde_json::Value::Array(arr) => {
serde_json::Value::Array(arr.into_iter().map(|v| self.replace_value(v)).collect())
}
serde_json::Value::Object(obj) => serde_json::Value::Object(
obj.into_iter()
.map(|(k, v)| (self.replace(&k), self.replace_value(v)))
.collect(),
),
_ => input,
}
}
// 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}}}"));
} else if left == "command" && !right.is_empty() {
let command_name = &right[1..];
if let Some(replacement_command) = self.commands.get(command_name) {
return Some(format!("${{{replacement_command}}}"));
}
}
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()
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,194 @@
use collections::HashMap;
use serde::Deserialize;
use util::ResultExt as _;
use crate::{
DebugScenario, DebugTaskFile, EnvVariableReplacer, TcpArgumentsTemplate, VariableName,
};
// TODO support preLaunchTask linkage with other tasks
#[derive(Clone, Debug, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
struct VsCodeDebugTaskDefinition {
r#type: String,
name: String,
#[serde(default)]
port: Option<u16>,
#[serde(flatten)]
other_attributes: serde_json::Value,
}
impl VsCodeDebugTaskDefinition {
fn try_to_zed(mut self, replacer: &EnvVariableReplacer) -> anyhow::Result<DebugScenario> {
let label = replacer.replace(&self.name);
let mut config = replacer.replace_value(self.other_attributes);
let adapter = task_type_to_adapter_name(&self.r#type);
if let Some(config) = config.as_object_mut()
&& adapter == "JavaScript"
{
config.insert("type".to_owned(), self.r#type.clone().into());
if let Some(port) = self.port.take() {
config.insert("port".to_owned(), port.into());
}
}
let definition = DebugScenario {
label: label.into(),
build: None,
adapter: adapter.into(),
tcp_connection: self.port.map(|port| TcpArgumentsTemplate {
port: Some(port),
host: None,
timeout: None,
}),
config,
};
Ok(definition)
}
}
#[derive(Clone, Debug, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct VsCodeDebugTaskFile {
#[serde(default)]
version: Option<String>,
configurations: Vec<VsCodeDebugTaskDefinition>,
}
impl TryFrom<VsCodeDebugTaskFile> for DebugTaskFile {
type Error = anyhow::Error;
fn try_from(file: VsCodeDebugTaskFile) -> Result<Self, Self::Error> {
let replacer = EnvVariableReplacer::new(HashMap::from_iter([
(
"workspaceFolder".to_owned(),
VariableName::WorktreeRoot.to_string(),
),
(
"relativeFile".to_owned(),
VariableName::RelativeFile.to_string(),
),
("file".to_owned(), VariableName::File.to_string()),
]))
.with_commands([(
"pickMyProcess".to_owned(),
VariableName::PickProcessId.to_string(),
)]);
let templates = file
.configurations
.into_iter()
.filter_map(|config| config.try_to_zed(&replacer).log_err())
.collect::<Vec<_>>();
Ok(DebugTaskFile(templates))
}
}
fn task_type_to_adapter_name(task_type: &str) -> String {
match task_type {
"pwa-node" | "node" | "node-terminal" | "chrome" | "pwa-chrome" | "edge" | "pwa-edge"
| "msedge" | "pwa-msedge" => "JavaScript",
"go" => "Delve",
"php" => "Xdebug",
"cppdbg" | "lldb" => "CodeLLDB",
"debugpy" => "Debugpy",
"rdbg" => "rdbg",
_ => task_type,
}
.to_owned()
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::{DebugScenario, DebugTaskFile, VariableName};
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![DebugScenario {
label: "Debug my JS app".into(),
adapter: "JavaScript".into(),
config: json!({
"request": "launch",
"program": "${ZED_WORKTREE_ROOT}/xyz.js",
"showDevDebugOutput": false,
"stopOnEntry": true,
"args": [
"--foo",
"${ZED_WORKTREE_ROOT}/thing",
],
"cwd": "${ZED_WORKTREE_ROOT}/${FOO}/sub",
"env": {
"X": "Y",
},
"type": "node",
"port": 17,
}),
tcp_connection: None,
build: None
}])
);
}
#[test]
fn test_command_pickmyprocess_replacement() {
let raw = r#"
{
"version": "0.2.0",
"configurations": [
{
"name": "Attach to Process",
"request": "attach",
"type": "cppdbg",
"processId": "${command:pickMyProcess}"
}
]
}
"#;
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");
let expected_placeholder = format!("${{{}}}", VariableName::PickProcessId);
pretty_assertions::assert_eq!(
zed,
DebugTaskFile(vec![DebugScenario {
label: "Attach to Process".into(),
adapter: "CodeLLDB".into(),
config: json!({
"request": "attach",
"processId": expected_placeholder,
}),
tcp_connection: None,
build: None
}])
);
}
}

View File

@@ -0,0 +1,463 @@
use anyhow::bail;
use collections::HashMap;
use serde::Deserialize;
use util::ResultExt;
use crate::{EnvVariableReplacer, TaskTemplate, TaskTemplates, VariableName};
#[derive(Clone, Debug, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
struct TaskOptions {
cwd: Option<String>,
#[serde(default)]
env: HashMap<String, String>,
}
#[derive(Clone, Debug, PartialEq)]
struct VsCodeTaskDefinition {
label: String,
command: Option<Command>,
other_attributes: HashMap<String, serde_json_lenient::Value>,
options: Option<TaskOptions>,
}
impl<'de> serde::Deserialize<'de> for VsCodeTaskDefinition {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct TaskHelper {
#[serde(default)]
label: Option<String>,
#[serde(flatten)]
command: Option<Command>,
#[serde(flatten)]
other_attributes: HashMap<String, serde_json_lenient::Value>,
options: Option<TaskOptions>,
}
let helper = TaskHelper::deserialize(deserializer)?;
let label = helper
.label
.unwrap_or_else(|| generate_label(&helper.command));
Ok(VsCodeTaskDefinition {
label,
command: helper.command,
other_attributes: helper.other_attributes,
options: helper.options,
})
}
}
#[derive(Clone, Deserialize, PartialEq, Debug)]
#[serde(tag = "type")]
#[serde(rename_all = "camelCase")]
enum Command {
Npm {
script: String,
},
Shell {
command: String,
#[serde(default)]
args: Vec<String>,
},
Gulp {
task: String,
},
}
fn generate_label(command: &Option<Command>) -> String {
match command {
Some(Command::Npm { script }) => format!("npm: {}", script),
Some(Command::Gulp { task }) => format!("gulp: {}", task),
Some(Command::Shell { command, .. }) => {
if command.trim().is_empty() {
"shell".to_string()
} else {
command.clone()
}
}
None => "Untitled Task".to_string(),
}
}
impl VsCodeTaskDefinition {
fn into_zed_format(
self,
replacer: &EnvVariableReplacer,
) -> anyhow::Result<Option<TaskTemplate>> {
if self.other_attributes.contains_key("dependsOn") {
log::warn!(
"Skipping deserializing of a task `{}` with the unsupported `dependsOn` key",
self.label
);
return Ok(None);
}
// `type` might not be set in e.g. tasks that use `dependsOn`; we still want to deserialize the whole object though (hence command is an Option),
// as that way we can provide more specific description of why deserialization failed.
// E.g. if the command is missing due to `dependsOn` presence, we can check other_attributes first before doing this (and provide nice error message)
// catch-all if on value.command presence.
let Some(command) = self.command else {
bail!("Missing `type` field in task");
};
let (command, args) = match command {
Command::Npm { script } => ("npm".to_owned(), vec!["run".to_string(), script]),
Command::Shell { command, args } => (command, args),
Command::Gulp { task } => ("gulp".to_owned(), vec![task]),
};
// Per VSC docs, only `command`, `args` and `options` support variable substitution.
let command = replacer.replace(&command);
let args = args.into_iter().map(|arg| replacer.replace(&arg)).collect();
let mut template = TaskTemplate {
label: self.label,
command,
args,
..TaskTemplate::default()
};
if let Some(options) = self.options {
template.cwd = options.cwd.map(|cwd| replacer.replace(&cwd));
template.env = options.env;
}
Ok(Some(template))
}
}
/// [`VsCodeTaskFile`] is a superset of Code's task definition format.
#[derive(Debug, Deserialize, PartialEq)]
pub struct VsCodeTaskFile {
tasks: Vec<VsCodeTaskDefinition>,
}
impl TryFrom<VsCodeTaskFile> for TaskTemplates {
type Error = anyhow::Error;
fn try_from(value: VsCodeTaskFile) -> Result<Self, Self::Error> {
let replacer = EnvVariableReplacer::new(HashMap::from_iter([
(
"workspaceFolder".to_owned(),
VariableName::WorktreeRoot.to_string(),
),
("file".to_owned(), VariableName::File.to_string()),
("lineNumber".to_owned(), VariableName::Row.to_string()),
(
"selectedText".to_owned(),
VariableName::SelectedText.to_string(),
),
]));
let templates = value
.tasks
.into_iter()
.filter_map(|vscode_definition| {
vscode_definition
.into_zed_format(&replacer)
.log_err()
.flatten()
})
.collect();
Ok(Self(templates))
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use crate::{
TaskTemplate, TaskTemplates, VsCodeTaskFile,
vscode_format::{Command, VsCodeTaskDefinition},
};
use super::{EnvVariableReplacer, generate_label};
fn compare_without_other_attributes(lhs: VsCodeTaskDefinition, rhs: VsCodeTaskDefinition) {
assert_eq!(
VsCodeTaskDefinition {
other_attributes: Default::default(),
..lhs
},
VsCodeTaskDefinition {
other_attributes: Default::default(),
..rhs
},
);
}
#[test]
fn test_variable_substitution() {
let replacer = EnvVariableReplacer::new(Default::default());
assert_eq!(replacer.replace("Food"), "Food");
// Unknown variables are left in tact.
assert_eq!(
replacer.replace("$PATH is an environment variable"),
"$PATH is an environment variable"
);
assert_eq!(replacer.replace("${PATH}"), "${PATH}");
assert_eq!(replacer.replace("${PATH:food}"), "${PATH:food}");
// And now, the actual replacing
let replacer = EnvVariableReplacer::new(HashMap::from_iter([(
"PATH".to_owned(),
"ZED_PATH".to_owned(),
)]));
assert_eq!(replacer.replace("Food"), "Food");
assert_eq!(
replacer.replace("$PATH is an environment variable"),
"${ZED_PATH} is an environment variable"
);
assert_eq!(replacer.replace("${PATH}"), "${ZED_PATH}");
assert_eq!(replacer.replace("${PATH:food}"), "${ZED_PATH:food}");
}
#[test]
fn can_deserialize_ts_tasks() {
const TYPESCRIPT_TASKS: &str = include_str!("../test_data/typescript.json");
let vscode_definitions: VsCodeTaskFile =
serde_json_lenient::from_str(TYPESCRIPT_TASKS).unwrap();
let expected = vec![
VsCodeTaskDefinition {
label: "gulp: tests".to_string(),
command: Some(Command::Npm {
script: "build:tests:notypecheck".to_string(),
}),
other_attributes: Default::default(),
options: None,
},
VsCodeTaskDefinition {
label: "tsc: watch ./src".to_string(),
command: Some(Command::Shell {
command: "node".to_string(),
args: vec![
"${workspaceFolder}/node_modules/typescript/lib/tsc.js".to_string(),
"--build".to_string(),
"${workspaceFolder}/src".to_string(),
"--watch".to_string(),
],
}),
other_attributes: Default::default(),
options: None,
},
VsCodeTaskDefinition {
label: "npm: build:compiler".to_string(),
command: Some(Command::Npm {
script: "build:compiler".to_string(),
}),
other_attributes: Default::default(),
options: None,
},
VsCodeTaskDefinition {
label: "npm: build:tests".to_string(),
command: Some(Command::Npm {
script: "build:tests:notypecheck".to_string(),
}),
other_attributes: Default::default(),
options: None,
},
];
assert_eq!(vscode_definitions.tasks.len(), expected.len());
vscode_definitions
.tasks
.iter()
.zip(expected)
.for_each(|(lhs, rhs)| compare_without_other_attributes(lhs.clone(), rhs));
let expected = vec![
TaskTemplate {
label: "gulp: tests".to_string(),
command: "npm".to_string(),
args: vec!["run".to_string(), "build:tests:notypecheck".to_string()],
..Default::default()
},
TaskTemplate {
label: "tsc: watch ./src".to_string(),
command: "node".to_string(),
args: vec![
"${ZED_WORKTREE_ROOT}/node_modules/typescript/lib/tsc.js".to_string(),
"--build".to_string(),
"${ZED_WORKTREE_ROOT}/src".to_string(),
"--watch".to_string(),
],
..Default::default()
},
TaskTemplate {
label: "npm: build:compiler".to_string(),
command: "npm".to_string(),
args: vec!["run".to_string(), "build:compiler".to_string()],
..Default::default()
},
TaskTemplate {
label: "npm: build:tests".to_string(),
command: "npm".to_string(),
args: vec!["run".to_string(), "build:tests:notypecheck".to_string()],
..Default::default()
},
];
let tasks: TaskTemplates = vscode_definitions.try_into().unwrap();
assert_eq!(tasks.0, expected);
}
#[test]
fn can_deserialize_rust_analyzer_tasks() {
const RUST_ANALYZER_TASKS: &str = include_str!("../test_data/rust-analyzer.json");
let vscode_definitions: VsCodeTaskFile =
serde_json_lenient::from_str(RUST_ANALYZER_TASKS).unwrap();
let expected = vec![
VsCodeTaskDefinition {
label: "Build Extension in Background".to_string(),
command: Some(Command::Npm {
script: "watch".to_string(),
}),
options: None,
other_attributes: Default::default(),
},
VsCodeTaskDefinition {
label: "Build Extension".to_string(),
command: Some(Command::Npm {
script: "build".to_string(),
}),
options: None,
other_attributes: Default::default(),
},
VsCodeTaskDefinition {
label: "Build Server".to_string(),
command: Some(Command::Shell {
command: "cargo build --package rust-analyzer".to_string(),
args: Default::default(),
}),
options: None,
other_attributes: Default::default(),
},
VsCodeTaskDefinition {
label: "Build Server (Release)".to_string(),
command: Some(Command::Shell {
command: "cargo build --release --package rust-analyzer".to_string(),
args: Default::default(),
}),
options: None,
other_attributes: Default::default(),
},
VsCodeTaskDefinition {
label: "Pretest".to_string(),
command: Some(Command::Npm {
script: "pretest".to_string(),
}),
options: None,
other_attributes: Default::default(),
},
VsCodeTaskDefinition {
label: "Build Server and Extension".to_string(),
command: None,
options: None,
other_attributes: Default::default(),
},
VsCodeTaskDefinition {
label: "Build Server (Release) and Extension".to_string(),
command: None,
options: None,
other_attributes: Default::default(),
},
];
assert_eq!(vscode_definitions.tasks.len(), expected.len());
vscode_definitions
.tasks
.iter()
.zip(expected)
.for_each(|(lhs, rhs)| compare_without_other_attributes(lhs.clone(), rhs));
let expected = vec![
TaskTemplate {
label: "Build Extension in Background".to_string(),
command: "npm".to_string(),
args: vec!["run".to_string(), "watch".to_string()],
..Default::default()
},
TaskTemplate {
label: "Build Extension".to_string(),
command: "npm".to_string(),
args: vec!["run".to_string(), "build".to_string()],
..Default::default()
},
TaskTemplate {
label: "Build Server".to_string(),
command: "cargo build --package rust-analyzer".to_string(),
..Default::default()
},
TaskTemplate {
label: "Build Server (Release)".to_string(),
command: "cargo build --release --package rust-analyzer".to_string(),
..Default::default()
},
TaskTemplate {
label: "Pretest".to_string(),
command: "npm".to_string(),
args: vec!["run".to_string(), "pretest".to_string()],
..Default::default()
},
];
let tasks: TaskTemplates = vscode_definitions.try_into().unwrap();
assert_eq!(tasks.0, expected);
}
#[test]
fn can_deserialize_tasks_without_labels() {
const TASKS_WITHOUT_LABELS: &str = include_str!("../test_data/tasks-without-labels.json");
let vscode_definitions: VsCodeTaskFile =
serde_json_lenient::from_str(TASKS_WITHOUT_LABELS).unwrap();
assert_eq!(vscode_definitions.tasks.len(), 4);
assert_eq!(vscode_definitions.tasks[0].label, "npm: start");
assert_eq!(vscode_definitions.tasks[1].label, "Explicit Label");
assert_eq!(vscode_definitions.tasks[2].label, "gulp: build");
assert_eq!(vscode_definitions.tasks[3].label, "echo hello");
}
#[test]
fn test_generate_label() {
assert_eq!(
generate_label(&Some(Command::Npm {
script: "start".to_string()
})),
"npm: start"
);
assert_eq!(
generate_label(&Some(Command::Gulp {
task: "build".to_string()
})),
"gulp: build"
);
assert_eq!(
generate_label(&Some(Command::Shell {
command: "echo hello".to_string(),
args: vec![]
})),
"echo hello"
);
assert_eq!(
generate_label(&Some(Command::Shell {
command: "cargo build --release".to_string(),
args: vec![]
})),
"cargo build --release"
);
assert_eq!(
generate_label(&Some(Command::Shell {
command: " ".to_string(),
args: vec![]
})),
"shell"
);
assert_eq!(
generate_label(&Some(Command::Shell {
command: "".to_string(),
args: vec![]
})),
"shell"
);
assert_eq!(generate_label(&None), "Untitled Task");
}
}

View File

@@ -0,0 +1,67 @@
// See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format
{
"version": "2.0.0",
"tasks": [
{
"label": "Build Extension in Background",
"group": "build",
"type": "npm",
"script": "watch",
"path": "editors/code/",
"problemMatcher": {
"base": "$tsc-watch",
"fileLocation": ["relative", "${workspaceFolder}/editors/code/"]
},
"isBackground": true
},
{
"label": "Build Extension",
"group": "build",
"type": "npm",
"script": "build",
"path": "editors/code/",
"problemMatcher": {
"base": "$tsc",
"fileLocation": ["relative", "${workspaceFolder}/editors/code/"]
}
},
{
"label": "Build Server",
"group": "build",
"type": "shell",
"command": "cargo build --package rust-analyzer",
"problemMatcher": "$rustc"
},
{
"label": "Build Server (Release)",
"group": "build",
"type": "shell",
"command": "cargo build --release --package rust-analyzer",
"problemMatcher": "$rustc"
},
{
"label": "Pretest",
"group": "build",
"isBackground": false,
"type": "npm",
"script": "pretest",
"path": "editors/code/",
"problemMatcher": {
"base": "$tsc",
"fileLocation": ["relative", "${workspaceFolder}/editors/code/"]
}
},
{
"label": "Build Server and Extension",
"dependsOn": ["Build Server", "Build Extension"],
"problemMatcher": "$rustc"
},
{
"label": "Build Server (Release) and Extension",
"dependsOn": ["Build Server (Release)", "Build Extension"],
"problemMatcher": "$rustc"
}
]
}

View File

@@ -0,0 +1,22 @@
{
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start"
},
{
"label": "Explicit Label",
"type": "npm",
"script": "test"
},
{
"type": "gulp",
"task": "build"
},
{
"type": "shell",
"command": "echo hello"
}
]
}

View File

@@ -0,0 +1,51 @@
{
// See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format
"version": "2.0.0",
"tasks": [
{
// Kept for backwards compat for old launch.json files so it's
// less annoying if moving up to the new build or going back to
// the old build.
//
// This is first because the actual "npm: build:tests" task
// below has the same script value, and VS Code ignores labels
// and deduplicates them.
// https://github.com/microsoft/vscode/issues/93001
"label": "gulp: tests",
"type": "npm",
"script": "build:tests:notypecheck",
"group": "build",
"hide": true,
"problemMatcher": ["$tsc"]
},
{
"label": "tsc: watch ./src",
"type": "shell",
"command": "node",
"args": [
"${workspaceFolder}/node_modules/typescript/lib/tsc.js",
"--build",
"${workspaceFolder}/src",
"--watch"
],
"group": "build",
"isBackground": true,
"problemMatcher": ["$tsc-watch"]
},
{
"label": "npm: build:compiler",
"type": "npm",
"script": "build:compiler",
"group": "build",
"problemMatcher": ["$tsc"]
},
{
"label": "npm: build:tests",
"type": "npm",
"script": "build:tests:notypecheck",
"group": "build",
"problemMatcher": ["$tsc"]
}
]
}