vscode_format.rs

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