vscode_format.rs

  1use anyhow::bail;
  2use collections::HashMap;
  3use serde::Deserialize;
  4use util::ResultExt;
  5
  6use crate::static_source::{Definition, DefinitionProvider};
  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_str("}");
 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 to_zed_format(self, replacer: &EnvVariableReplacer) -> anyhow::Result<Definition> {
 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 = Definition {
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 DefinitionProvider {
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            ("workspaceFolder".to_owned(), "ZED_WORKTREE_ROOT".to_owned()),
133            ("file".to_owned(), "ZED_FILE".to_owned()),
134            ("lineNumber".to_owned(), "ZED_ROW".to_owned()),
135            ("selectedText".to_owned(), "ZED_SELECTED_TEXT".to_owned()),
136        ]));
137        let definitions = value
138            .tasks
139            .into_iter()
140            .filter_map(|vscode_definition| vscode_definition.to_zed_format(&replacer).log_err())
141            .collect();
142        Ok(Self(definitions))
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use std::collections::HashMap;
149
150    use crate::{
151        static_source::{Definition, DefinitionProvider},
152        vscode_format::{Command, VsCodeTaskDefinition},
153        VsCodeTaskFile,
154    };
155
156    use super::EnvVariableReplacer;
157
158    fn compare_without_other_attributes(lhs: VsCodeTaskDefinition, rhs: VsCodeTaskDefinition) {
159        assert_eq!(
160            VsCodeTaskDefinition {
161                other_attributes: Default::default(),
162                ..lhs
163            },
164            VsCodeTaskDefinition {
165                other_attributes: Default::default(),
166                ..rhs
167            },
168        );
169    }
170
171    #[test]
172    fn test_variable_substitution() {
173        let replacer = EnvVariableReplacer::new(Default::default());
174        assert_eq!(replacer.replace("Food"), "Food");
175        // Unknown variables are left in tact.
176        assert_eq!(
177            replacer.replace("$PATH is an environment variable"),
178            "$PATH is an environment variable"
179        );
180        assert_eq!(replacer.replace("${PATH}"), "${PATH}");
181        assert_eq!(replacer.replace("${PATH:food}"), "${PATH:food}");
182        // And now, the actual replacing
183        let replacer = EnvVariableReplacer::new(HashMap::from_iter([(
184            "PATH".to_owned(),
185            "ZED_PATH".to_owned(),
186        )]));
187        assert_eq!(replacer.replace("Food"), "Food");
188        assert_eq!(
189            replacer.replace("$PATH is an environment variable"),
190            "${ZED_PATH} is an environment variable"
191        );
192        assert_eq!(replacer.replace("${PATH}"), "${ZED_PATH}");
193        assert_eq!(replacer.replace("${PATH:food}"), "${ZED_PATH:food}");
194    }
195
196    #[test]
197    fn can_deserialize_ts_tasks() {
198        static TYPESCRIPT_TASKS: &'static str = include_str!("../test_data/typescript.json");
199        let vscode_definitions: VsCodeTaskFile =
200            serde_json_lenient::from_str(&TYPESCRIPT_TASKS).unwrap();
201
202        let expected = vec![
203            VsCodeTaskDefinition {
204                label: "gulp: tests".to_string(),
205                command: Some(Command::Npm {
206                    script: "build:tests:notypecheck".to_string(),
207                }),
208                other_attributes: Default::default(),
209                options: None,
210            },
211            VsCodeTaskDefinition {
212                label: "tsc: watch ./src".to_string(),
213                command: Some(Command::Shell {
214                    command: "node".to_string(),
215                    args: vec![
216                        "${workspaceFolder}/node_modules/typescript/lib/tsc.js".to_string(),
217                        "--build".to_string(),
218                        "${workspaceFolder}/src".to_string(),
219                        "--watch".to_string(),
220                    ],
221                }),
222                other_attributes: Default::default(),
223                options: None,
224            },
225            VsCodeTaskDefinition {
226                label: "npm: build:compiler".to_string(),
227                command: Some(Command::Npm {
228                    script: "build:compiler".to_string(),
229                }),
230                other_attributes: Default::default(),
231                options: None,
232            },
233            VsCodeTaskDefinition {
234                label: "npm: build:tests".to_string(),
235                command: Some(Command::Npm {
236                    script: "build:tests:notypecheck".to_string(),
237                }),
238                other_attributes: Default::default(),
239                options: None,
240            },
241        ];
242
243        assert_eq!(vscode_definitions.tasks.len(), expected.len());
244        vscode_definitions
245            .tasks
246            .iter()
247            .zip(expected)
248            .for_each(|(lhs, rhs)| compare_without_other_attributes(lhs.clone(), rhs));
249
250        let expected = vec![
251            Definition {
252                label: "gulp: tests".to_string(),
253                command: "npm".to_string(),
254                args: vec!["run".to_string(), "build:tests:notypecheck".to_string()],
255                ..Default::default()
256            },
257            Definition {
258                label: "tsc: watch ./src".to_string(),
259                command: "node".to_string(),
260                args: vec![
261                    "${ZED_WORKTREE_ROOT}/node_modules/typescript/lib/tsc.js".to_string(),
262                    "--build".to_string(),
263                    "${ZED_WORKTREE_ROOT}/src".to_string(),
264                    "--watch".to_string(),
265                ],
266                ..Default::default()
267            },
268            Definition {
269                label: "npm: build:compiler".to_string(),
270                command: "npm".to_string(),
271                args: vec!["run".to_string(), "build:compiler".to_string()],
272                ..Default::default()
273            },
274            Definition {
275                label: "npm: build:tests".to_string(),
276                command: "npm".to_string(),
277                args: vec!["run".to_string(), "build:tests:notypecheck".to_string()],
278                ..Default::default()
279            },
280        ];
281
282        let tasks: DefinitionProvider = vscode_definitions.try_into().unwrap();
283        assert_eq!(tasks.0, expected);
284    }
285
286    #[test]
287    fn can_deserialize_rust_analyzer_tasks() {
288        static RUST_ANALYZER_TASKS: &'static str = include_str!("../test_data/rust-analyzer.json");
289        let vscode_definitions: VsCodeTaskFile =
290            serde_json_lenient::from_str(&RUST_ANALYZER_TASKS).unwrap();
291        let expected = vec![
292            VsCodeTaskDefinition {
293                label: "Build Extension in Background".to_string(),
294                command: Some(Command::Npm {
295                    script: "watch".to_string(),
296                }),
297                options: None,
298                other_attributes: Default::default(),
299            },
300            VsCodeTaskDefinition {
301                label: "Build Extension".to_string(),
302                command: Some(Command::Npm {
303                    script: "build".to_string(),
304                }),
305                options: None,
306                other_attributes: Default::default(),
307            },
308            VsCodeTaskDefinition {
309                label: "Build Server".to_string(),
310                command: Some(Command::Shell {
311                    command: "cargo build --package rust-analyzer".to_string(),
312                    args: Default::default(),
313                }),
314                options: None,
315                other_attributes: Default::default(),
316            },
317            VsCodeTaskDefinition {
318                label: "Build Server (Release)".to_string(),
319                command: Some(Command::Shell {
320                    command: "cargo build --release --package rust-analyzer".to_string(),
321                    args: Default::default(),
322                }),
323                options: None,
324                other_attributes: Default::default(),
325            },
326            VsCodeTaskDefinition {
327                label: "Pretest".to_string(),
328                command: Some(Command::Npm {
329                    script: "pretest".to_string(),
330                }),
331                options: None,
332                other_attributes: Default::default(),
333            },
334            VsCodeTaskDefinition {
335                label: "Build Server and Extension".to_string(),
336                command: None,
337                options: None,
338                other_attributes: Default::default(),
339            },
340            VsCodeTaskDefinition {
341                label: "Build Server (Release) and Extension".to_string(),
342                command: None,
343                options: None,
344                other_attributes: Default::default(),
345            },
346        ];
347        assert_eq!(vscode_definitions.tasks.len(), expected.len());
348        vscode_definitions
349            .tasks
350            .iter()
351            .zip(expected)
352            .for_each(|(lhs, rhs)| compare_without_other_attributes(lhs.clone(), rhs));
353        let expected = vec![
354            Definition {
355                label: "Build Extension in Background".to_string(),
356                command: "npm".to_string(),
357                args: vec!["run".to_string(), "watch".to_string()],
358                ..Default::default()
359            },
360            Definition {
361                label: "Build Extension".to_string(),
362                command: "npm".to_string(),
363                args: vec!["run".to_string(), "build".to_string()],
364                ..Default::default()
365            },
366            Definition {
367                label: "Build Server".to_string(),
368                command: "cargo build --package rust-analyzer".to_string(),
369                ..Default::default()
370            },
371            Definition {
372                label: "Build Server (Release)".to_string(),
373                command: "cargo build --release --package rust-analyzer".to_string(),
374                ..Default::default()
375            },
376            Definition {
377                label: "Pretest".to_string(),
378                command: "npm".to_string(),
379                args: vec!["run".to_string(), "pretest".to_string()],
380                ..Default::default()
381            },
382        ];
383        let tasks: DefinitionProvider = vscode_definitions.try_into().unwrap();
384        assert_eq!(tasks.0, expected);
385    }
386}