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