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