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