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