vscode_format.rs

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