463 lines · 16.0 KB · Rust
RawDownload
use anyhow::bail;use collections::HashMap;use serde::Deserialize;use util::ResultExt; use crate::{EnvVariableReplacer, TaskTemplate, TaskTemplates, VariableName}; #[derive(Clone, Debug, Deserialize, PartialEq)]#[serde(rename_all = "camelCase")]struct TaskOptions {    cwd: Option<String>,    #[serde(default)]    env: HashMap<String, String>,} #[derive(Clone, Debug, PartialEq)]struct VsCodeTaskDefinition {    label: String,    command: Option<Command>,    other_attributes: HashMap<String, serde_json_lenient::Value>,    options: Option<TaskOptions>,} impl<'de> serde::Deserialize<'de> for VsCodeTaskDefinition {    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>    where        D: serde::Deserializer<'de>,    {        #[derive(Deserialize)]        #[serde(rename_all = "camelCase")]        struct TaskHelper {            #[serde(default)]            label: Option<String>,            #[serde(flatten)]            command: Option<Command>,            #[serde(flatten)]            other_attributes: HashMap<String, serde_json_lenient::Value>,            options: Option<TaskOptions>,        }         let helper = TaskHelper::deserialize(deserializer)?;         let label = helper            .label            .unwrap_or_else(|| generate_label(&helper.command));         Ok(VsCodeTaskDefinition {            label,            command: helper.command,            other_attributes: helper.other_attributes,            options: helper.options,        })    }} #[derive(Clone, Deserialize, PartialEq, Debug)]#[serde(tag = "type")]#[serde(rename_all = "camelCase")]enum Command {    Npm {        script: String,    },    Shell {        command: String,        #[serde(default)]        args: Vec<String>,    },    Gulp {        task: String,    },} fn generate_label(command: &Option<Command>) -> String {    match command {        Some(Command::Npm { script }) => format!("npm: {}", script),        Some(Command::Gulp { task }) => format!("gulp: {}", task),        Some(Command::Shell { command, .. }) => {            if command.trim().is_empty() {                "shell".to_string()            } else {                command.clone()            }        }        None => "Untitled Task".to_string(),    }} impl VsCodeTaskDefinition {    fn into_zed_format(        self,        replacer: &EnvVariableReplacer,    ) -> anyhow::Result<Option<TaskTemplate>> {        if self.other_attributes.contains_key("dependsOn") {            log::warn!(                "Skipping deserializing of a task `{}` with the unsupported `dependsOn` key",                self.label            );            return Ok(None);        }        // `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),        // as that way we can provide more specific description of why deserialization failed.        // 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)        // catch-all if on value.command presence.        let Some(command) = self.command else {            bail!("Missing `type` field in task");        };         let (command, args) = match command {            Command::Npm { script } => ("npm".to_owned(), vec!["run".to_string(), script]),            Command::Shell { command, args } => (command, args),            Command::Gulp { task } => ("gulp".to_owned(), vec![task]),        };        // Per VSC docs, only `command`, `args` and `options` support variable substitution.        let command = replacer.replace(&command);        let args = args.into_iter().map(|arg| replacer.replace(&arg)).collect();        let mut template = TaskTemplate {            label: self.label,            command,            args,            ..TaskTemplate::default()        };        if let Some(options) = self.options {            template.cwd = options.cwd.map(|cwd| replacer.replace(&cwd));            template.env = options.env;        }        Ok(Some(template))    }} /// [`VsCodeTaskFile`] is a superset of Code's task definition format.#[derive(Debug, Deserialize, PartialEq)]pub struct VsCodeTaskFile {    tasks: Vec<VsCodeTaskDefinition>,} impl TryFrom<VsCodeTaskFile> for TaskTemplates {    type Error = anyhow::Error;     fn try_from(value: VsCodeTaskFile) -> Result<Self, Self::Error> {        let replacer = EnvVariableReplacer::new(HashMap::from_iter([            (                "workspaceFolder".to_owned(),                VariableName::WorktreeRoot.to_string(),            ),            ("file".to_owned(), VariableName::File.to_string()),            ("lineNumber".to_owned(), VariableName::Row.to_string()),            (                "selectedText".to_owned(),                VariableName::SelectedText.to_string(),            ),        ]));        let templates = value            .tasks            .into_iter()            .filter_map(|vscode_definition| {                vscode_definition                    .into_zed_format(&replacer)                    .log_err()                    .flatten()            })            .collect();        Ok(Self(templates))    }} #[cfg(test)]mod tests {    use std::collections::HashMap;     use crate::{        TaskTemplate, TaskTemplates, VsCodeTaskFile,        vscode_format::{Command, VsCodeTaskDefinition},    };     use super::{EnvVariableReplacer, generate_label};     fn compare_without_other_attributes(lhs: VsCodeTaskDefinition, rhs: VsCodeTaskDefinition) {        assert_eq!(            VsCodeTaskDefinition {                other_attributes: Default::default(),                ..lhs            },            VsCodeTaskDefinition {                other_attributes: Default::default(),                ..rhs            },        );    }     #[test]    fn test_variable_substitution() {        let replacer = EnvVariableReplacer::new(Default::default());        assert_eq!(replacer.replace("Food"), "Food");        // Unknown variables are left in tact.        assert_eq!(            replacer.replace("$PATH is an environment variable"),            "$PATH is an environment variable"        );        assert_eq!(replacer.replace("${PATH}"), "${PATH}");        assert_eq!(replacer.replace("${PATH:food}"), "${PATH:food}");        // And now, the actual replacing        let replacer = EnvVariableReplacer::new(HashMap::from_iter([(            "PATH".to_owned(),            "ZED_PATH".to_owned(),        )]));        assert_eq!(replacer.replace("Food"), "Food");        assert_eq!(            replacer.replace("$PATH is an environment variable"),            "${ZED_PATH} is an environment variable"        );        assert_eq!(replacer.replace("${PATH}"), "${ZED_PATH}");        assert_eq!(replacer.replace("${PATH:food}"), "${ZED_PATH:food}");    }     #[test]    fn can_deserialize_ts_tasks() {        const TYPESCRIPT_TASKS: &str = include_str!("../test_data/typescript.json");        let vscode_definitions: VsCodeTaskFile =            serde_json_lenient::from_str(TYPESCRIPT_TASKS).unwrap();         let expected = vec![            VsCodeTaskDefinition {                label: "gulp: tests".to_string(),                command: Some(Command::Npm {                    script: "build:tests:notypecheck".to_string(),                }),                other_attributes: Default::default(),                options: None,            },            VsCodeTaskDefinition {                label: "tsc: watch ./src".to_string(),                command: Some(Command::Shell {                    command: "node".to_string(),                    args: vec![                        "${workspaceFolder}/node_modules/typescript/lib/tsc.js".to_string(),                        "--build".to_string(),                        "${workspaceFolder}/src".to_string(),                        "--watch".to_string(),                    ],                }),                other_attributes: Default::default(),                options: None,            },            VsCodeTaskDefinition {                label: "npm: build:compiler".to_string(),                command: Some(Command::Npm {                    script: "build:compiler".to_string(),                }),                other_attributes: Default::default(),                options: None,            },            VsCodeTaskDefinition {                label: "npm: build:tests".to_string(),                command: Some(Command::Npm {                    script: "build:tests:notypecheck".to_string(),                }),                other_attributes: Default::default(),                options: None,            },        ];         assert_eq!(vscode_definitions.tasks.len(), expected.len());        vscode_definitions            .tasks            .iter()            .zip(expected)            .for_each(|(lhs, rhs)| compare_without_other_attributes(lhs.clone(), rhs));         let expected = vec![            TaskTemplate {                label: "gulp: tests".to_string(),                command: "npm".to_string(),                args: vec!["run".to_string(), "build:tests:notypecheck".to_string()],                ..Default::default()            },            TaskTemplate {                label: "tsc: watch ./src".to_string(),                command: "node".to_string(),                args: vec![                    "${ZED_WORKTREE_ROOT}/node_modules/typescript/lib/tsc.js".to_string(),                    "--build".to_string(),                    "${ZED_WORKTREE_ROOT}/src".to_string(),                    "--watch".to_string(),                ],                ..Default::default()            },            TaskTemplate {                label: "npm: build:compiler".to_string(),                command: "npm".to_string(),                args: vec!["run".to_string(), "build:compiler".to_string()],                ..Default::default()            },            TaskTemplate {                label: "npm: build:tests".to_string(),                command: "npm".to_string(),                args: vec!["run".to_string(), "build:tests:notypecheck".to_string()],                ..Default::default()            },        ];         let tasks: TaskTemplates = vscode_definitions.try_into().unwrap();        assert_eq!(tasks.0, expected);    }     #[test]    fn can_deserialize_rust_analyzer_tasks() {        const RUST_ANALYZER_TASKS: &str = include_str!("../test_data/rust-analyzer.json");        let vscode_definitions: VsCodeTaskFile =            serde_json_lenient::from_str(RUST_ANALYZER_TASKS).unwrap();        let expected = vec![            VsCodeTaskDefinition {                label: "Build Extension in Background".to_string(),                command: Some(Command::Npm {                    script: "watch".to_string(),                }),                options: None,                other_attributes: Default::default(),            },            VsCodeTaskDefinition {                label: "Build Extension".to_string(),                command: Some(Command::Npm {                    script: "build".to_string(),                }),                options: None,                other_attributes: Default::default(),            },            VsCodeTaskDefinition {                label: "Build Server".to_string(),                command: Some(Command::Shell {                    command: "cargo build --package rust-analyzer".to_string(),                    args: Default::default(),                }),                options: None,                other_attributes: Default::default(),            },            VsCodeTaskDefinition {                label: "Build Server (Release)".to_string(),                command: Some(Command::Shell {                    command: "cargo build --release --package rust-analyzer".to_string(),                    args: Default::default(),                }),                options: None,                other_attributes: Default::default(),            },            VsCodeTaskDefinition {                label: "Pretest".to_string(),                command: Some(Command::Npm {                    script: "pretest".to_string(),                }),                options: None,                other_attributes: Default::default(),            },            VsCodeTaskDefinition {                label: "Build Server and Extension".to_string(),                command: None,                options: None,                other_attributes: Default::default(),            },            VsCodeTaskDefinition {                label: "Build Server (Release) and Extension".to_string(),                command: None,                options: None,                other_attributes: Default::default(),            },        ];        assert_eq!(vscode_definitions.tasks.len(), expected.len());        vscode_definitions            .tasks            .iter()            .zip(expected)            .for_each(|(lhs, rhs)| compare_without_other_attributes(lhs.clone(), rhs));        let expected = vec![            TaskTemplate {                label: "Build Extension in Background".to_string(),                command: "npm".to_string(),                args: vec!["run".to_string(), "watch".to_string()],                ..Default::default()            },            TaskTemplate {                label: "Build Extension".to_string(),                command: "npm".to_string(),                args: vec!["run".to_string(), "build".to_string()],                ..Default::default()            },            TaskTemplate {                label: "Build Server".to_string(),                command: "cargo build --package rust-analyzer".to_string(),                ..Default::default()            },            TaskTemplate {                label: "Build Server (Release)".to_string(),                command: "cargo build --release --package rust-analyzer".to_string(),                ..Default::default()            },            TaskTemplate {                label: "Pretest".to_string(),                command: "npm".to_string(),                args: vec!["run".to_string(), "pretest".to_string()],                ..Default::default()            },        ];        let tasks: TaskTemplates = vscode_definitions.try_into().unwrap();        assert_eq!(tasks.0, expected);    }     #[test]    fn can_deserialize_tasks_without_labels() {        const TASKS_WITHOUT_LABELS: &str = include_str!("../test_data/tasks-without-labels.json");        let vscode_definitions: VsCodeTaskFile =            serde_json_lenient::from_str(TASKS_WITHOUT_LABELS).unwrap();         assert_eq!(vscode_definitions.tasks.len(), 4);        assert_eq!(vscode_definitions.tasks[0].label, "npm: start");        assert_eq!(vscode_definitions.tasks[1].label, "Explicit Label");        assert_eq!(vscode_definitions.tasks[2].label, "gulp: build");        assert_eq!(vscode_definitions.tasks[3].label, "echo hello");    }     #[test]    fn test_generate_label() {        assert_eq!(            generate_label(&Some(Command::Npm {                script: "start".to_string()            })),            "npm: start"        );        assert_eq!(            generate_label(&Some(Command::Gulp {                task: "build".to_string()            })),            "gulp: build"        );        assert_eq!(            generate_label(&Some(Command::Shell {                command: "echo hello".to_string(),                args: vec![]            })),            "echo hello"        );        assert_eq!(            generate_label(&Some(Command::Shell {                command: "cargo build --release".to_string(),                args: vec![]            })),            "cargo build --release"        );        assert_eq!(            generate_label(&Some(Command::Shell {                command: "  ".to_string(),                args: vec![]            })),            "shell"        );        assert_eq!(            generate_label(&Some(Command::Shell {                command: "".to_string(),                args: vec![]            })),            "shell"        );        assert_eq!(generate_label(&None), "Untitled Task");    }}