vscode_format.rs

  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}