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