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, Deserialize, PartialEq)]
17#[serde(rename_all = "camelCase")]
18struct VsCodeTaskDefinition {
19 label: String,
20 #[serde(flatten)]
21 command: Option<Command>,
22 #[serde(flatten)]
23 other_attributes: HashMap<String, serde_json_lenient::Value>,
24 options: Option<TaskOptions>,
25}
26
27#[derive(Clone, Deserialize, PartialEq, Debug)]
28#[serde(tag = "type")]
29#[serde(rename_all = "camelCase")]
30enum Command {
31 Npm {
32 script: String,
33 },
34 Shell {
35 command: String,
36 #[serde(default)]
37 args: Vec<String>,
38 },
39 Gulp {
40 task: String,
41 },
42}
43
44impl VsCodeTaskDefinition {
45 fn into_zed_format(
46 self,
47 replacer: &EnvVariableReplacer,
48 ) -> anyhow::Result<Option<TaskTemplate>> {
49 if self.other_attributes.contains_key("dependsOn") {
50 log::warn!("Skipping deserializing of a task with the unsupported `dependsOn` key");
51 return Ok(None);
52 }
53 // `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),
54 // as that way we can provide more specific description of why deserialization failed.
55 // 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)
56 // catch-all if on value.command presence.
57 let Some(command) = self.command else {
58 bail!("Missing `type` field in task");
59 };
60
61 let (command, args) = match command {
62 Command::Npm { script } => ("npm".to_owned(), vec!["run".to_string(), script]),
63 Command::Shell { command, args } => (command, args),
64 Command::Gulp { task } => ("gulp".to_owned(), vec![task]),
65 };
66 // Per VSC docs, only `command`, `args` and `options` support variable substitution.
67 let command = replacer.replace(&command);
68 let args = args.into_iter().map(|arg| replacer.replace(&arg)).collect();
69 let mut template = TaskTemplate {
70 label: self.label,
71 command,
72 args,
73 ..TaskTemplate::default()
74 };
75 if let Some(options) = self.options {
76 template.cwd = options.cwd.map(|cwd| replacer.replace(&cwd));
77 template.env = options.env;
78 }
79 Ok(Some(template))
80 }
81}
82
83/// [`VsCodeTaskFile`] is a superset of Code's task definition format.
84#[derive(Debug, Deserialize, PartialEq)]
85pub struct VsCodeTaskFile {
86 tasks: Vec<VsCodeTaskDefinition>,
87}
88
89impl TryFrom<VsCodeTaskFile> for TaskTemplates {
90 type Error = anyhow::Error;
91
92 fn try_from(value: VsCodeTaskFile) -> Result<Self, Self::Error> {
93 let replacer = EnvVariableReplacer::new(HashMap::from_iter([
94 (
95 "workspaceFolder".to_owned(),
96 VariableName::WorktreeRoot.to_string(),
97 ),
98 ("file".to_owned(), VariableName::File.to_string()),
99 ("lineNumber".to_owned(), VariableName::Row.to_string()),
100 (
101 "selectedText".to_owned(),
102 VariableName::SelectedText.to_string(),
103 ),
104 ]));
105 let templates = value
106 .tasks
107 .into_iter()
108 .filter_map(|vscode_definition| {
109 vscode_definition
110 .into_zed_format(&replacer)
111 .log_err()
112 .flatten()
113 })
114 .collect();
115 Ok(Self(templates))
116 }
117}
118
119#[cfg(test)]
120mod tests {
121 use std::collections::HashMap;
122
123 use crate::{
124 TaskTemplate, TaskTemplates, VsCodeTaskFile,
125 vscode_format::{Command, VsCodeTaskDefinition},
126 };
127
128 use super::EnvVariableReplacer;
129
130 fn compare_without_other_attributes(lhs: VsCodeTaskDefinition, rhs: VsCodeTaskDefinition) {
131 assert_eq!(
132 VsCodeTaskDefinition {
133 other_attributes: Default::default(),
134 ..lhs
135 },
136 VsCodeTaskDefinition {
137 other_attributes: Default::default(),
138 ..rhs
139 },
140 );
141 }
142
143 #[test]
144 fn test_variable_substitution() {
145 let replacer = EnvVariableReplacer::new(Default::default());
146 assert_eq!(replacer.replace("Food"), "Food");
147 // Unknown variables are left in tact.
148 assert_eq!(
149 replacer.replace("$PATH is an environment variable"),
150 "$PATH is an environment variable"
151 );
152 assert_eq!(replacer.replace("${PATH}"), "${PATH}");
153 assert_eq!(replacer.replace("${PATH:food}"), "${PATH:food}");
154 // And now, the actual replacing
155 let replacer = EnvVariableReplacer::new(HashMap::from_iter([(
156 "PATH".to_owned(),
157 "ZED_PATH".to_owned(),
158 )]));
159 assert_eq!(replacer.replace("Food"), "Food");
160 assert_eq!(
161 replacer.replace("$PATH is an environment variable"),
162 "${ZED_PATH} is an environment variable"
163 );
164 assert_eq!(replacer.replace("${PATH}"), "${ZED_PATH}");
165 assert_eq!(replacer.replace("${PATH:food}"), "${ZED_PATH:food}");
166 }
167
168 #[test]
169 fn can_deserialize_ts_tasks() {
170 const TYPESCRIPT_TASKS: &str = include_str!("../test_data/typescript.json");
171 let vscode_definitions: VsCodeTaskFile =
172 serde_json_lenient::from_str(TYPESCRIPT_TASKS).unwrap();
173
174 let expected = vec![
175 VsCodeTaskDefinition {
176 label: "gulp: tests".to_string(),
177 command: Some(Command::Npm {
178 script: "build:tests:notypecheck".to_string(),
179 }),
180 other_attributes: Default::default(),
181 options: None,
182 },
183 VsCodeTaskDefinition {
184 label: "tsc: watch ./src".to_string(),
185 command: Some(Command::Shell {
186 command: "node".to_string(),
187 args: vec![
188 "${workspaceFolder}/node_modules/typescript/lib/tsc.js".to_string(),
189 "--build".to_string(),
190 "${workspaceFolder}/src".to_string(),
191 "--watch".to_string(),
192 ],
193 }),
194 other_attributes: Default::default(),
195 options: None,
196 },
197 VsCodeTaskDefinition {
198 label: "npm: build:compiler".to_string(),
199 command: Some(Command::Npm {
200 script: "build:compiler".to_string(),
201 }),
202 other_attributes: Default::default(),
203 options: None,
204 },
205 VsCodeTaskDefinition {
206 label: "npm: build:tests".to_string(),
207 command: Some(Command::Npm {
208 script: "build:tests:notypecheck".to_string(),
209 }),
210 other_attributes: Default::default(),
211 options: None,
212 },
213 ];
214
215 assert_eq!(vscode_definitions.tasks.len(), expected.len());
216 vscode_definitions
217 .tasks
218 .iter()
219 .zip(expected)
220 .for_each(|(lhs, rhs)| compare_without_other_attributes(lhs.clone(), rhs));
221
222 let expected = vec![
223 TaskTemplate {
224 label: "gulp: tests".to_string(),
225 command: "npm".to_string(),
226 args: vec!["run".to_string(), "build:tests:notypecheck".to_string()],
227 ..Default::default()
228 },
229 TaskTemplate {
230 label: "tsc: watch ./src".to_string(),
231 command: "node".to_string(),
232 args: vec![
233 "${ZED_WORKTREE_ROOT}/node_modules/typescript/lib/tsc.js".to_string(),
234 "--build".to_string(),
235 "${ZED_WORKTREE_ROOT}/src".to_string(),
236 "--watch".to_string(),
237 ],
238 ..Default::default()
239 },
240 TaskTemplate {
241 label: "npm: build:compiler".to_string(),
242 command: "npm".to_string(),
243 args: vec!["run".to_string(), "build:compiler".to_string()],
244 ..Default::default()
245 },
246 TaskTemplate {
247 label: "npm: build:tests".to_string(),
248 command: "npm".to_string(),
249 args: vec!["run".to_string(), "build:tests:notypecheck".to_string()],
250 ..Default::default()
251 },
252 ];
253
254 let tasks: TaskTemplates = vscode_definitions.try_into().unwrap();
255 assert_eq!(tasks.0, expected);
256 }
257
258 #[test]
259 fn can_deserialize_rust_analyzer_tasks() {
260 const RUST_ANALYZER_TASKS: &str = include_str!("../test_data/rust-analyzer.json");
261 let vscode_definitions: VsCodeTaskFile =
262 serde_json_lenient::from_str(RUST_ANALYZER_TASKS).unwrap();
263 let expected = vec![
264 VsCodeTaskDefinition {
265 label: "Build Extension in Background".to_string(),
266 command: Some(Command::Npm {
267 script: "watch".to_string(),
268 }),
269 options: None,
270 other_attributes: Default::default(),
271 },
272 VsCodeTaskDefinition {
273 label: "Build Extension".to_string(),
274 command: Some(Command::Npm {
275 script: "build".to_string(),
276 }),
277 options: None,
278 other_attributes: Default::default(),
279 },
280 VsCodeTaskDefinition {
281 label: "Build Server".to_string(),
282 command: Some(Command::Shell {
283 command: "cargo build --package rust-analyzer".to_string(),
284 args: Default::default(),
285 }),
286 options: None,
287 other_attributes: Default::default(),
288 },
289 VsCodeTaskDefinition {
290 label: "Build Server (Release)".to_string(),
291 command: Some(Command::Shell {
292 command: "cargo build --release --package rust-analyzer".to_string(),
293 args: Default::default(),
294 }),
295 options: None,
296 other_attributes: Default::default(),
297 },
298 VsCodeTaskDefinition {
299 label: "Pretest".to_string(),
300 command: Some(Command::Npm {
301 script: "pretest".to_string(),
302 }),
303 options: None,
304 other_attributes: Default::default(),
305 },
306 VsCodeTaskDefinition {
307 label: "Build Server and Extension".to_string(),
308 command: None,
309 options: None,
310 other_attributes: Default::default(),
311 },
312 VsCodeTaskDefinition {
313 label: "Build Server (Release) and Extension".to_string(),
314 command: None,
315 options: None,
316 other_attributes: Default::default(),
317 },
318 ];
319 assert_eq!(vscode_definitions.tasks.len(), expected.len());
320 vscode_definitions
321 .tasks
322 .iter()
323 .zip(expected)
324 .for_each(|(lhs, rhs)| compare_without_other_attributes(lhs.clone(), rhs));
325 let expected = vec![
326 TaskTemplate {
327 label: "Build Extension in Background".to_string(),
328 command: "npm".to_string(),
329 args: vec!["run".to_string(), "watch".to_string()],
330 ..Default::default()
331 },
332 TaskTemplate {
333 label: "Build Extension".to_string(),
334 command: "npm".to_string(),
335 args: vec!["run".to_string(), "build".to_string()],
336 ..Default::default()
337 },
338 TaskTemplate {
339 label: "Build Server".to_string(),
340 command: "cargo build --package rust-analyzer".to_string(),
341 ..Default::default()
342 },
343 TaskTemplate {
344 label: "Build Server (Release)".to_string(),
345 command: "cargo build --release --package rust-analyzer".to_string(),
346 ..Default::default()
347 },
348 TaskTemplate {
349 label: "Pretest".to_string(),
350 command: "npm".to_string(),
351 args: vec!["run".to_string(), "pretest".to_string()],
352 ..Default::default()
353 },
354 ];
355 let tasks: TaskTemplates = vscode_definitions.try_into().unwrap();
356 assert_eq!(tasks.0, expected);
357 }
358}