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, 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}