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