1use anyhow::bail;
2use collections::HashMap;
3use serde::Deserialize;
4use util::ResultExt;
5
6use crate::{EnvVariableReplacer, TaskTemplate, TaskTemplates, VariableName};
7
8#[derive(Clone, Debug, Deserialize, PartialEq)]
9#[serde(rename_all = "camelCase")]
10struct TaskOptions {
11 cwd: Option<String>,
12 #[serde(default)]
13 env: HashMap<String, String>,
14}
15
16#[derive(Clone, Debug, PartialEq)]
17struct VsCodeTaskDefinition {
18 label: String,
19 command: Option<Command>,
20 other_attributes: HashMap<String, serde_json_lenient::Value>,
21 options: Option<TaskOptions>,
22}
23
24impl<'de> serde::Deserialize<'de> for VsCodeTaskDefinition {
25 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
26 where
27 D: serde::Deserializer<'de>,
28 {
29 #[derive(Deserialize)]
30 #[serde(rename_all = "camelCase")]
31 struct TaskHelper {
32 #[serde(default)]
33 label: Option<String>,
34 #[serde(flatten)]
35 command: Option<Command>,
36 #[serde(flatten)]
37 other_attributes: HashMap<String, serde_json_lenient::Value>,
38 options: Option<TaskOptions>,
39 }
40
41 let helper = TaskHelper::deserialize(deserializer)?;
42
43 let label = helper
44 .label
45 .unwrap_or_else(|| generate_label(&helper.command));
46
47 Ok(VsCodeTaskDefinition {
48 label,
49 command: helper.command,
50 other_attributes: helper.other_attributes,
51 options: helper.options,
52 })
53 }
54}
55
56#[derive(Clone, Deserialize, PartialEq, Debug)]
57#[serde(tag = "type")]
58#[serde(rename_all = "camelCase")]
59enum Command {
60 Npm {
61 script: String,
62 },
63 Shell {
64 command: String,
65 #[serde(default)]
66 args: Vec<String>,
67 },
68 Gulp {
69 task: String,
70 },
71}
72
73fn generate_label(command: &Option<Command>) -> String {
74 match command {
75 Some(Command::Npm { script }) => format!("npm: {}", script),
76 Some(Command::Gulp { task }) => format!("gulp: {}", task),
77 Some(Command::Shell { command, .. }) => {
78 if command.trim().is_empty() {
79 "shell".to_string()
80 } else {
81 command.clone()
82 }
83 }
84 None => "Untitled Task".to_string(),
85 }
86}
87
88impl VsCodeTaskDefinition {
89 fn into_zed_format(
90 self,
91 replacer: &EnvVariableReplacer,
92 ) -> anyhow::Result<Option<TaskTemplate>> {
93 if self.other_attributes.contains_key("dependsOn") {
94 log::warn!(
95 "Skipping deserializing of a task `{}` with the unsupported `dependsOn` key",
96 self.label
97 );
98 return Ok(None);
99 }
100 // `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),
101 // as that way we can provide more specific description of why deserialization failed.
102 // 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)
103 // catch-all if on value.command presence.
104 let Some(command) = self.command else {
105 bail!("Missing `type` field in task");
106 };
107
108 let (command, args) = match command {
109 Command::Npm { script } => ("npm".to_owned(), vec!["run".to_string(), script]),
110 Command::Shell { command, args } => (command, args),
111 Command::Gulp { task } => ("gulp".to_owned(), vec![task]),
112 };
113 // Per VSC docs, only `command`, `args` and `options` support variable substitution.
114 let command = replacer.replace(&command);
115 let args = args.into_iter().map(|arg| replacer.replace(&arg)).collect();
116 let mut template = TaskTemplate {
117 label: self.label,
118 command,
119 args,
120 ..TaskTemplate::default()
121 };
122 if let Some(options) = self.options {
123 template.cwd = options.cwd.map(|cwd| replacer.replace(&cwd));
124 template.env = options.env;
125 }
126 Ok(Some(template))
127 }
128}
129
130/// [`VsCodeTaskFile`] is a superset of Code's task definition format.
131#[derive(Debug, Deserialize, PartialEq)]
132pub struct VsCodeTaskFile {
133 tasks: Vec<VsCodeTaskDefinition>,
134}
135
136impl TryFrom<VsCodeTaskFile> for TaskTemplates {
137 type Error = anyhow::Error;
138
139 fn try_from(value: VsCodeTaskFile) -> Result<Self, Self::Error> {
140 let replacer = EnvVariableReplacer::new(HashMap::from_iter([
141 (
142 "workspaceFolder".to_owned(),
143 VariableName::WorktreeRoot.to_string(),
144 ),
145 ("file".to_owned(), VariableName::File.to_string()),
146 ("lineNumber".to_owned(), VariableName::Row.to_string()),
147 (
148 "selectedText".to_owned(),
149 VariableName::SelectedText.to_string(),
150 ),
151 ]));
152 let templates = value
153 .tasks
154 .into_iter()
155 .filter_map(|vscode_definition| {
156 vscode_definition
157 .into_zed_format(&replacer)
158 .log_err()
159 .flatten()
160 })
161 .collect();
162 Ok(Self(templates))
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use std::collections::HashMap;
169
170 use crate::{
171 TaskTemplate, TaskTemplates, VsCodeTaskFile,
172 vscode_format::{Command, VsCodeTaskDefinition},
173 };
174
175 use super::{EnvVariableReplacer, generate_label};
176
177 fn compare_without_other_attributes(lhs: VsCodeTaskDefinition, rhs: VsCodeTaskDefinition) {
178 assert_eq!(
179 VsCodeTaskDefinition {
180 other_attributes: Default::default(),
181 ..lhs
182 },
183 VsCodeTaskDefinition {
184 other_attributes: Default::default(),
185 ..rhs
186 },
187 );
188 }
189
190 #[test]
191 fn test_variable_substitution() {
192 let replacer = EnvVariableReplacer::new(Default::default());
193 assert_eq!(replacer.replace("Food"), "Food");
194 // Unknown variables are left in tact.
195 assert_eq!(
196 replacer.replace("$PATH is an environment variable"),
197 "$PATH is an environment variable"
198 );
199 assert_eq!(replacer.replace("${PATH}"), "${PATH}");
200 assert_eq!(replacer.replace("${PATH:food}"), "${PATH:food}");
201 // And now, the actual replacing
202 let replacer = EnvVariableReplacer::new(HashMap::from_iter([(
203 "PATH".to_owned(),
204 "ZED_PATH".to_owned(),
205 )]));
206 assert_eq!(replacer.replace("Food"), "Food");
207 assert_eq!(
208 replacer.replace("$PATH is an environment variable"),
209 "${ZED_PATH} is an environment variable"
210 );
211 assert_eq!(replacer.replace("${PATH}"), "${ZED_PATH}");
212 assert_eq!(replacer.replace("${PATH:food}"), "${ZED_PATH:food}");
213 }
214
215 #[test]
216 fn can_deserialize_ts_tasks() {
217 const TYPESCRIPT_TASKS: &str = include_str!("../test_data/typescript.json");
218 let vscode_definitions: VsCodeTaskFile =
219 serde_json_lenient::from_str(TYPESCRIPT_TASKS).unwrap();
220
221 let expected = vec![
222 VsCodeTaskDefinition {
223 label: "gulp: tests".to_string(),
224 command: Some(Command::Npm {
225 script: "build:tests:notypecheck".to_string(),
226 }),
227 other_attributes: Default::default(),
228 options: None,
229 },
230 VsCodeTaskDefinition {
231 label: "tsc: watch ./src".to_string(),
232 command: Some(Command::Shell {
233 command: "node".to_string(),
234 args: vec![
235 "${workspaceFolder}/node_modules/typescript/lib/tsc.js".to_string(),
236 "--build".to_string(),
237 "${workspaceFolder}/src".to_string(),
238 "--watch".to_string(),
239 ],
240 }),
241 other_attributes: Default::default(),
242 options: None,
243 },
244 VsCodeTaskDefinition {
245 label: "npm: build:compiler".to_string(),
246 command: Some(Command::Npm {
247 script: "build:compiler".to_string(),
248 }),
249 other_attributes: Default::default(),
250 options: None,
251 },
252 VsCodeTaskDefinition {
253 label: "npm: build:tests".to_string(),
254 command: Some(Command::Npm {
255 script: "build:tests:notypecheck".to_string(),
256 }),
257 other_attributes: Default::default(),
258 options: None,
259 },
260 ];
261
262 assert_eq!(vscode_definitions.tasks.len(), expected.len());
263 vscode_definitions
264 .tasks
265 .iter()
266 .zip(expected)
267 .for_each(|(lhs, rhs)| compare_without_other_attributes(lhs.clone(), rhs));
268
269 let expected = vec![
270 TaskTemplate {
271 label: "gulp: tests".to_string(),
272 command: "npm".to_string(),
273 args: vec!["run".to_string(), "build:tests:notypecheck".to_string()],
274 ..Default::default()
275 },
276 TaskTemplate {
277 label: "tsc: watch ./src".to_string(),
278 command: "node".to_string(),
279 args: vec![
280 "${ZED_WORKTREE_ROOT}/node_modules/typescript/lib/tsc.js".to_string(),
281 "--build".to_string(),
282 "${ZED_WORKTREE_ROOT}/src".to_string(),
283 "--watch".to_string(),
284 ],
285 ..Default::default()
286 },
287 TaskTemplate {
288 label: "npm: build:compiler".to_string(),
289 command: "npm".to_string(),
290 args: vec!["run".to_string(), "build:compiler".to_string()],
291 ..Default::default()
292 },
293 TaskTemplate {
294 label: "npm: build:tests".to_string(),
295 command: "npm".to_string(),
296 args: vec!["run".to_string(), "build:tests:notypecheck".to_string()],
297 ..Default::default()
298 },
299 ];
300
301 let tasks: TaskTemplates = vscode_definitions.try_into().unwrap();
302 assert_eq!(tasks.0, expected);
303 }
304
305 #[test]
306 fn can_deserialize_rust_analyzer_tasks() {
307 const RUST_ANALYZER_TASKS: &str = include_str!("../test_data/rust-analyzer.json");
308 let vscode_definitions: VsCodeTaskFile =
309 serde_json_lenient::from_str(RUST_ANALYZER_TASKS).unwrap();
310 let expected = vec![
311 VsCodeTaskDefinition {
312 label: "Build Extension in Background".to_string(),
313 command: Some(Command::Npm {
314 script: "watch".to_string(),
315 }),
316 options: None,
317 other_attributes: Default::default(),
318 },
319 VsCodeTaskDefinition {
320 label: "Build Extension".to_string(),
321 command: Some(Command::Npm {
322 script: "build".to_string(),
323 }),
324 options: None,
325 other_attributes: Default::default(),
326 },
327 VsCodeTaskDefinition {
328 label: "Build Server".to_string(),
329 command: Some(Command::Shell {
330 command: "cargo build --package rust-analyzer".to_string(),
331 args: Default::default(),
332 }),
333 options: None,
334 other_attributes: Default::default(),
335 },
336 VsCodeTaskDefinition {
337 label: "Build Server (Release)".to_string(),
338 command: Some(Command::Shell {
339 command: "cargo build --release --package rust-analyzer".to_string(),
340 args: Default::default(),
341 }),
342 options: None,
343 other_attributes: Default::default(),
344 },
345 VsCodeTaskDefinition {
346 label: "Pretest".to_string(),
347 command: Some(Command::Npm {
348 script: "pretest".to_string(),
349 }),
350 options: None,
351 other_attributes: Default::default(),
352 },
353 VsCodeTaskDefinition {
354 label: "Build Server and Extension".to_string(),
355 command: None,
356 options: None,
357 other_attributes: Default::default(),
358 },
359 VsCodeTaskDefinition {
360 label: "Build Server (Release) and Extension".to_string(),
361 command: None,
362 options: None,
363 other_attributes: Default::default(),
364 },
365 ];
366 assert_eq!(vscode_definitions.tasks.len(), expected.len());
367 vscode_definitions
368 .tasks
369 .iter()
370 .zip(expected)
371 .for_each(|(lhs, rhs)| compare_without_other_attributes(lhs.clone(), rhs));
372 let expected = vec![
373 TaskTemplate {
374 label: "Build Extension in Background".to_string(),
375 command: "npm".to_string(),
376 args: vec!["run".to_string(), "watch".to_string()],
377 ..Default::default()
378 },
379 TaskTemplate {
380 label: "Build Extension".to_string(),
381 command: "npm".to_string(),
382 args: vec!["run".to_string(), "build".to_string()],
383 ..Default::default()
384 },
385 TaskTemplate {
386 label: "Build Server".to_string(),
387 command: "cargo build --package rust-analyzer".to_string(),
388 ..Default::default()
389 },
390 TaskTemplate {
391 label: "Build Server (Release)".to_string(),
392 command: "cargo build --release --package rust-analyzer".to_string(),
393 ..Default::default()
394 },
395 TaskTemplate {
396 label: "Pretest".to_string(),
397 command: "npm".to_string(),
398 args: vec!["run".to_string(), "pretest".to_string()],
399 ..Default::default()
400 },
401 ];
402 let tasks: TaskTemplates = vscode_definitions.try_into().unwrap();
403 assert_eq!(tasks.0, expected);
404 }
405
406 #[test]
407 fn can_deserialize_tasks_without_labels() {
408 const TASKS_WITHOUT_LABELS: &str = include_str!("../test_data/tasks-without-labels.json");
409 let vscode_definitions: VsCodeTaskFile =
410 serde_json_lenient::from_str(TASKS_WITHOUT_LABELS).unwrap();
411
412 assert_eq!(vscode_definitions.tasks.len(), 4);
413 assert_eq!(vscode_definitions.tasks[0].label, "npm: start");
414 assert_eq!(vscode_definitions.tasks[1].label, "Explicit Label");
415 assert_eq!(vscode_definitions.tasks[2].label, "gulp: build");
416 assert_eq!(vscode_definitions.tasks[3].label, "echo hello");
417 }
418
419 #[test]
420 fn test_generate_label() {
421 assert_eq!(
422 generate_label(&Some(Command::Npm {
423 script: "start".to_string()
424 })),
425 "npm: start"
426 );
427 assert_eq!(
428 generate_label(&Some(Command::Gulp {
429 task: "build".to_string()
430 })),
431 "gulp: build"
432 );
433 assert_eq!(
434 generate_label(&Some(Command::Shell {
435 command: "echo hello".to_string(),
436 args: vec![]
437 })),
438 "echo hello"
439 );
440 assert_eq!(
441 generate_label(&Some(Command::Shell {
442 command: "cargo build --release".to_string(),
443 args: vec![]
444 })),
445 "cargo build --release"
446 );
447 assert_eq!(
448 generate_label(&Some(Command::Shell {
449 command: " ".to_string(),
450 args: vec![]
451 })),
452 "shell"
453 );
454 assert_eq!(
455 generate_label(&Some(Command::Shell {
456 command: "".to_string(),
457 args: vec![]
458 })),
459 "shell"
460 );
461 assert_eq!(generate_label(&None), "Untitled Task");
462 }
463}