This PR adds support to create custom git task that are triggered from
the git graph context menu. Both Sublime Merge and Fork have custom
command support, and I use custom commands I've built out frequently to
speed up some of my tasks at Zed. Thus, I'd like to have support in
Zed's git graph so I can use Zed even more!
It offers initial support for the following env variables:
- `ZED_GIT_SHA`
- `ZED_GIT_SHA_SHORT`
- `ZED_GIT_REPOSITORY_NAME`
- `ZED_GIT_REPOSITORY_PATH`
These are only populated in the git graph context.
This PR also introduces a the `git-command` tag, which is needed so the
git graph can filter down to these custom git commands, and so other
tasks aren't polluting the context menu.
An example would be (in the global `tasks.json`):
```json
{
"label": "Branches containing commit: $ZED_GIT_SHA_SHORT",
"command": "git",
"args": ["branch", "-a", "--contains", "$ZED_GIT_SHA"],
"tags": ["git-command"],
},
```
And then in the context menu:
<img width="646" height="296" alt="SCR-20260511-mnfa"
src="https://github.com/user-attachments/assets/0e7b811b-f47d-4a2f-9270-99e392c38663"
/>
And the output in the terminal:
<img width="585" height="184" alt="SCR-20260511-mnks"
src="https://github.com/user-attachments/assets/54d7d205-6212-4eff-8dbb-c8e908996747"
/>
The awesome thing about using tasks is we get all the task
infrastructure for free, such as history!
<img width="591" height="292" alt="SCR-20260511-mnud"
src="https://github.com/user-attachments/assets/6315be8f-dd33-470f-bfcd-aa56d7fbfdce"
/>
<img width="602" height="173" alt="SCR-20260511-moch"
src="https://github.com/user-attachments/assets/b528422c-efcc-4a7d-9783-73d945e9665b"
/>
And we have all the task configuration options too out of the box.
---
Right now, this only works with global tasks. It isn't clear how to shoe
in support for worktree-specific tasks (`.zed/tasks.json`) in a clear
way, as not all invocations of the git graph are in an area that has a
clear worktree id, and so we sort of have to guess or fallback to
something else. That can be a followup once it's more clear how we
should cover that. Or maybe someone else has a better solution.
I chose to only ship with these 4 git-specific variables for now. The
git graph also currently ONLY resolve those variables. We can adjust /
add in more in follow up PRs.
Self-Review Checklist:
- [X] I've reviewed my own diff for quality, security, and reliability
- [X] Unsafe blocks (if any) have justifying comments
- [X] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [X] Tests cover the new/changed behavior
- [X] Performance impact has been considered and is acceptable
Release Notes:
- Added support for running global custom Git command tasks from the Git
Graph commit context menu.
1155 lines
42 KiB
Rust
1155 lines
42 KiB
Rust
use anyhow::{Context as _, bail};
|
|
use collections::{HashMap, HashSet};
|
|
use schemars::JsonSchema;
|
|
use serde::{Deserialize, Serialize};
|
|
use sha2::{Digest, Sha256};
|
|
use std::path::PathBuf;
|
|
use util::schemars::{AllowTrailingCommas, DefaultDenyUnknownFields};
|
|
use util::serde::default_true;
|
|
use util::{ResultExt, truncate_and_remove_front};
|
|
|
|
use crate::{
|
|
AttachRequest, ResolvedTask, RevealTarget, Shell, SpawnInTerminal, TaskContext, TaskId,
|
|
VariableName, ZED_VARIABLE_NAME_PREFIX, serde_helpers::non_empty_string_vec,
|
|
};
|
|
|
|
/// A template definition of a Zed task to run.
|
|
/// May use the [`VariableName`] to get the corresponding substitutions into its fields.
|
|
///
|
|
/// Template itself is not ready to spawn a task, it needs to be resolved with a [`TaskContext`] first, that
|
|
/// contains all relevant Zed state in task variables.
|
|
/// A single template may produce different tasks (or none) for different contexts.
|
|
#[derive(Clone, Default, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub struct TaskTemplate {
|
|
/// Human readable name of the task to display in the UI.
|
|
pub label: String,
|
|
/// Executable command to spawn.
|
|
pub command: String,
|
|
/// Arguments to the command.
|
|
#[serde(default)]
|
|
pub args: Vec<String>,
|
|
/// Env overrides for the command, will be appended to the terminal's environment from the settings.
|
|
#[serde(default)]
|
|
pub env: HashMap<String, String>,
|
|
/// Current working directory to spawn the command into, defaults to current project root.
|
|
#[serde(default)]
|
|
pub cwd: Option<String>,
|
|
/// Whether to use a new terminal tab or reuse the existing one to spawn the process.
|
|
#[serde(default)]
|
|
pub use_new_terminal: bool,
|
|
/// Whether to allow multiple instances of the same task to be run, or rather wait for the existing ones to finish.
|
|
#[serde(default)]
|
|
pub allow_concurrent_runs: bool,
|
|
/// What to do with the terminal pane and tab, after the command was started:
|
|
/// * `always` — always show the task's pane, and focus the corresponding tab in it (default)
|
|
// * `no_focus` — always show the task's pane, add the task's tab in it, but don't focus it
|
|
// * `never` — do not alter focus, but still add/reuse the task's tab in its pane
|
|
#[serde(default)]
|
|
pub reveal: RevealStrategy,
|
|
/// Where to place the task's terminal item after starting the task.
|
|
/// * `dock` — in the terminal dock, "regular" terminal items' place (default).
|
|
/// * `center` — in the central pane group, "main" editor area.
|
|
#[serde(default)]
|
|
pub reveal_target: RevealTarget,
|
|
/// What to do with the terminal pane and tab, after the command had finished:
|
|
/// * `never` — do nothing when the command finishes (default)
|
|
/// * `always` — always hide the terminal tab, hide the pane also if it was the last tab in it
|
|
/// * `on_success` — hide the terminal tab on task success only, otherwise behaves similar to `always`.
|
|
#[serde(default)]
|
|
pub hide: HideStrategy,
|
|
/// Represents the tags which this template attaches to.
|
|
/// Adding this removes this task from other UI and gives you ability to run it by tag.
|
|
#[serde(default, deserialize_with = "non_empty_string_vec")]
|
|
#[schemars(length(min = 1))]
|
|
pub tags: Vec<String>,
|
|
/// Which shell to use when spawning the task.
|
|
#[serde(default)]
|
|
pub shell: Shell,
|
|
/// Whether to show the task line in the task output.
|
|
#[serde(default = "default_true")]
|
|
pub show_summary: bool,
|
|
/// Whether to show the command line in the task output.
|
|
#[serde(default = "default_true")]
|
|
pub show_command: bool,
|
|
/// Which edited buffers to save before running the task.
|
|
#[serde(default)]
|
|
pub save: SaveStrategy,
|
|
/// Hooks that this task runs when emitted.
|
|
#[serde(default)]
|
|
pub hooks: HashSet<TaskHook>,
|
|
}
|
|
|
|
#[derive(Deserialize, Eq, PartialEq, Clone, Debug)]
|
|
/// Use to represent debug request type
|
|
pub enum DebugArgsRequest {
|
|
/// launch (program, cwd) are stored in TaskTemplate as (command, cwd)
|
|
Launch,
|
|
/// Attach
|
|
Attach(AttachRequest),
|
|
}
|
|
|
|
/// What to do with the terminal pane and tab, after the command was started.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Hash, Eq, Serialize, Deserialize, JsonSchema)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum TaskHook {
|
|
#[serde(alias = "create_git_worktree")]
|
|
CreateWorktree,
|
|
}
|
|
|
|
/// What to do with the terminal pane and tab, after the command was started.
|
|
#[derive(Default, Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum RevealStrategy {
|
|
/// Always show the task's pane, and focus the corresponding tab in it.
|
|
#[default]
|
|
Always,
|
|
/// Always show the task's pane, add the task's tab in it, but don't focus it.
|
|
NoFocus,
|
|
/// Do not alter focus, but still add/reuse the task's tab in its pane.
|
|
Never,
|
|
}
|
|
|
|
/// What to do with the terminal pane and tab, after the command has finished.
|
|
#[derive(Default, Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum HideStrategy {
|
|
/// Do nothing when the command finishes.
|
|
#[default]
|
|
Never,
|
|
/// Always hide the terminal tab, hide the pane also if it was the last tab in it.
|
|
Always,
|
|
/// Hide the terminal tab on task success only, otherwise behaves similar to `Always`.
|
|
OnSuccess,
|
|
}
|
|
|
|
/// Which edited buffers to save before running a task.
|
|
#[derive(Default, Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum SaveStrategy {
|
|
/// Save all edited buffers.
|
|
All,
|
|
/// Save the current buffer.
|
|
Current,
|
|
#[default]
|
|
/// Don't save any buffers.
|
|
None,
|
|
}
|
|
|
|
/// A group of Tasks defined in a JSON file.
|
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
|
pub struct TaskTemplates(pub Vec<TaskTemplate>);
|
|
|
|
impl TaskTemplates {
|
|
pub const FILE_NAME: &str = "tasks.json";
|
|
/// Generates JSON schema of Tasks JSON template format.
|
|
pub fn generate_json_schema() -> serde_json::Value {
|
|
let schema = schemars::generate::SchemaSettings::draft2019_09()
|
|
.with_transform(DefaultDenyUnknownFields)
|
|
.with_transform(AllowTrailingCommas)
|
|
.into_generator()
|
|
.root_schema_for::<Self>();
|
|
|
|
serde_json::to_value(schema).unwrap()
|
|
}
|
|
}
|
|
|
|
impl TaskTemplate {
|
|
/// Replaces all `VariableName` task variables in the task template string fields.
|
|
///
|
|
/// Every [`ResolvedTask`] gets a [`TaskId`], based on the `id_base` (to avoid collision with various task sources),
|
|
/// and hashes of its template and [`TaskContext`], see [`ResolvedTask`] fields' documentation for more details.
|
|
pub fn resolve_task(&self, id_base: &str, cx: &TaskContext) -> Option<ResolvedTask> {
|
|
if self.label.trim().is_empty() || self.command.trim().is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let mut variable_names = HashMap::default();
|
|
let mut substituted_variables = HashSet::default();
|
|
let task_variables = cx
|
|
.task_variables
|
|
.0
|
|
.iter()
|
|
.map(|(key, value)| {
|
|
let key_string = key.to_string();
|
|
if !variable_names.contains_key(&key_string) {
|
|
variable_names.insert(key_string.clone(), key.clone());
|
|
}
|
|
(key_string, value.as_str())
|
|
})
|
|
.collect::<HashMap<_, _>>();
|
|
let truncated_variables = truncate_variables(&task_variables);
|
|
let cwd = match self.cwd.as_deref() {
|
|
Some(cwd) => {
|
|
let substituted_cwd = substitute_all_template_variables_in_str(
|
|
cwd,
|
|
&task_variables,
|
|
&variable_names,
|
|
&mut substituted_variables,
|
|
)?;
|
|
Some(PathBuf::from(substituted_cwd))
|
|
}
|
|
None => None,
|
|
}
|
|
.or(cx.cwd.clone());
|
|
let full_label = substitute_all_template_variables_in_str(
|
|
&self.label,
|
|
&task_variables,
|
|
&variable_names,
|
|
&mut substituted_variables,
|
|
)?;
|
|
|
|
// Arbitrarily picked threshold below which we don't truncate any variables.
|
|
const TRUNCATION_THRESHOLD: usize = 64;
|
|
|
|
let human_readable_label = if full_label.len() > TRUNCATION_THRESHOLD {
|
|
substitute_all_template_variables_in_str(
|
|
&self.label,
|
|
&truncated_variables,
|
|
&variable_names,
|
|
&mut substituted_variables,
|
|
)?
|
|
} else {
|
|
#[allow(
|
|
clippy::redundant_clone,
|
|
reason = "We want to clone the full_label to avoid borrowing it in the fold closure"
|
|
)]
|
|
full_label.clone()
|
|
}
|
|
.lines()
|
|
.fold(String::new(), |mut string, line| {
|
|
if string.is_empty() {
|
|
string.push_str(line);
|
|
} else {
|
|
string.push_str("\\n");
|
|
string.push_str(line);
|
|
}
|
|
string
|
|
});
|
|
|
|
let command = substitute_all_template_variables_in_str(
|
|
&self.command,
|
|
&task_variables,
|
|
&variable_names,
|
|
&mut substituted_variables,
|
|
)?;
|
|
let args_with_substitutions = substitute_all_template_variables_in_vec(
|
|
&self.args,
|
|
&task_variables,
|
|
&variable_names,
|
|
&mut substituted_variables,
|
|
)?;
|
|
|
|
let task_hash = to_hex_hash(self)
|
|
.context("hashing task template")
|
|
.log_err()?;
|
|
let variables_hash = to_hex_hash(&task_variables)
|
|
.context("hashing task variables")
|
|
.log_err()?;
|
|
let id = TaskId(format!("{id_base}_{task_hash}_{variables_hash}"));
|
|
|
|
let env = {
|
|
// Start with the project environment as the base.
|
|
let mut env = cx.project_env.clone();
|
|
|
|
// Extend that environment with what's defined in the TaskTemplate
|
|
env.extend(self.env.clone());
|
|
|
|
// Then we replace all task variables that could be set in environment variables
|
|
let mut env = substitute_all_template_variables_in_map(
|
|
&env,
|
|
&task_variables,
|
|
&variable_names,
|
|
&mut substituted_variables,
|
|
)?;
|
|
|
|
// Last step: set the task variables as environment variables too
|
|
env.extend(task_variables.into_iter().map(|(k, v)| (k, v.to_owned())));
|
|
env
|
|
};
|
|
|
|
Some(ResolvedTask {
|
|
id: id.clone(),
|
|
substituted_variables,
|
|
original_task: self.clone(),
|
|
resolved_label: full_label.clone(),
|
|
resolved: SpawnInTerminal {
|
|
id,
|
|
cwd,
|
|
full_label,
|
|
label: human_readable_label,
|
|
command_label: args_with_substitutions.iter().fold(
|
|
command.clone(),
|
|
|mut command_label, arg| {
|
|
command_label.push(' ');
|
|
command_label.push_str(arg);
|
|
command_label
|
|
},
|
|
),
|
|
command: Some(command),
|
|
args: args_with_substitutions,
|
|
env,
|
|
use_new_terminal: self.use_new_terminal,
|
|
allow_concurrent_runs: self.allow_concurrent_runs,
|
|
reveal: self.reveal,
|
|
reveal_target: self.reveal_target,
|
|
hide: self.hide,
|
|
shell: self.shell.clone(),
|
|
show_summary: self.show_summary,
|
|
show_command: self.show_command,
|
|
show_rerun: true,
|
|
save: self.save,
|
|
},
|
|
})
|
|
}
|
|
|
|
/// Validates that all `$ZED_*` variables used in this template are known
|
|
/// variable names, returning a vector with all of the unique unknown
|
|
/// variables.
|
|
///
|
|
/// Note that `$ZED_CUSTOM_*` variables are never considered to be invalid
|
|
/// since those are provided dynamically by extensions.
|
|
pub fn unknown_variables(&self) -> Vec<String> {
|
|
let mut variables = HashSet::default();
|
|
|
|
Self::collect_unknown_variables(&self.label, &mut variables);
|
|
Self::collect_unknown_variables(&self.command, &mut variables);
|
|
|
|
self.args
|
|
.iter()
|
|
.for_each(|arg| Self::collect_unknown_variables(arg, &mut variables));
|
|
|
|
self.env
|
|
.values()
|
|
.for_each(|value| Self::collect_unknown_variables(value, &mut variables));
|
|
|
|
if let Some(cwd) = &self.cwd {
|
|
Self::collect_unknown_variables(cwd, &mut variables);
|
|
}
|
|
|
|
variables.into_iter().collect()
|
|
}
|
|
|
|
fn collect_unknown_variables(template: &str, unknown: &mut HashSet<String>) {
|
|
shellexpand::env_with_context_no_errors(template, |variable| {
|
|
// It's possible that the variable has a default defined, which is
|
|
// separated by a `:`, for example, `${ZED_FILE:default_value} so we
|
|
// ensure that we're only looking at the variable name itself.
|
|
let colon_position = variable.find(':').unwrap_or(variable.len());
|
|
let variable_name = &variable[..colon_position];
|
|
|
|
if variable_name.starts_with(ZED_VARIABLE_NAME_PREFIX)
|
|
&& let without_prefix = &variable_name[ZED_VARIABLE_NAME_PREFIX.len()..]
|
|
&& !without_prefix.starts_with("CUSTOM_")
|
|
&& variable_name.parse::<VariableName>().is_err()
|
|
{
|
|
unknown.insert(variable_name.to_string());
|
|
}
|
|
|
|
None::<&str>
|
|
});
|
|
}
|
|
}
|
|
|
|
const MAX_DISPLAY_VARIABLE_LENGTH: usize = 15;
|
|
|
|
fn truncate_variables(task_variables: &HashMap<String, &str>) -> HashMap<String, String> {
|
|
task_variables
|
|
.iter()
|
|
.map(|(key, value)| {
|
|
(
|
|
key.clone(),
|
|
truncate_and_remove_front(value, MAX_DISPLAY_VARIABLE_LENGTH),
|
|
)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn to_hex_hash(object: impl Serialize) -> anyhow::Result<String> {
|
|
let json = serde_json_lenient::to_string(&object).context("serializing the object")?;
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(json.as_bytes());
|
|
Ok(hex::encode(hasher.finalize()))
|
|
}
|
|
|
|
pub fn substitute_variables_in_str(template_str: &str, context: &TaskContext) -> Option<String> {
|
|
let mut variable_names = HashMap::default();
|
|
let mut substituted_variables = HashSet::default();
|
|
let task_variables = context
|
|
.task_variables
|
|
.0
|
|
.iter()
|
|
.map(|(key, value)| {
|
|
let key_string = key.to_string();
|
|
if !variable_names.contains_key(&key_string) {
|
|
variable_names.insert(key_string.clone(), key.clone());
|
|
}
|
|
(key_string, value.as_str())
|
|
})
|
|
.collect::<HashMap<_, _>>();
|
|
substitute_all_template_variables_in_str(
|
|
template_str,
|
|
&task_variables,
|
|
&variable_names,
|
|
&mut substituted_variables,
|
|
)
|
|
}
|
|
fn substitute_all_template_variables_in_str<A: AsRef<str>>(
|
|
template_str: &str,
|
|
task_variables: &HashMap<String, A>,
|
|
variable_names: &HashMap<String, VariableName>,
|
|
substituted_variables: &mut HashSet<VariableName>,
|
|
) -> Option<String> {
|
|
let substituted_string = shellexpand::env_with_context(template_str, |var| {
|
|
// 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.
|
|
let colon_position = var.find(':').unwrap_or(var.len());
|
|
let (variable_name, default) = var.split_at(colon_position);
|
|
if let Some(name) = task_variables.get(variable_name) {
|
|
if let Some(substituted_variable) = variable_names.get(variable_name) {
|
|
substituted_variables.insert(substituted_variable.clone());
|
|
}
|
|
// Got a task variable hit - use the variable value, ignore default
|
|
return Ok(Some(name.as_ref().to_owned()));
|
|
} else if variable_name.starts_with(ZED_VARIABLE_NAME_PREFIX) {
|
|
// Unknown ZED variable - use default if available
|
|
if !default.is_empty() {
|
|
// Strip the colon and return the default value
|
|
return Ok(Some(default[1..].to_owned()));
|
|
} else {
|
|
bail!("Unknown variable name: {variable_name}");
|
|
}
|
|
}
|
|
// This is an unknown variable.
|
|
// 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. If there's a default, we need to return the string verbatim
|
|
// as otherwise shellexpand will apply that default for us.
|
|
if !default.is_empty() {
|
|
return Ok(Some(format!("${{{var}}}")));
|
|
}
|
|
|
|
// Else we can just return None and that variable will be left as is.
|
|
Ok(None)
|
|
})
|
|
.ok()?;
|
|
|
|
Some(substituted_string.into_owned())
|
|
}
|
|
|
|
fn substitute_all_template_variables_in_vec(
|
|
template_strs: &[String],
|
|
task_variables: &HashMap<String, &str>,
|
|
variable_names: &HashMap<String, VariableName>,
|
|
substituted_variables: &mut HashSet<VariableName>,
|
|
) -> Option<Vec<String>> {
|
|
let mut expanded = Vec::with_capacity(template_strs.len());
|
|
for variable in template_strs {
|
|
let new_value = substitute_all_template_variables_in_str(
|
|
variable,
|
|
task_variables,
|
|
variable_names,
|
|
substituted_variables,
|
|
)?;
|
|
expanded.push(new_value);
|
|
}
|
|
|
|
Some(expanded)
|
|
}
|
|
|
|
pub fn substitute_variables_in_map(
|
|
keys_and_values: &HashMap<String, String>,
|
|
context: &TaskContext,
|
|
) -> Option<HashMap<String, String>> {
|
|
let mut variable_names = HashMap::default();
|
|
let mut substituted_variables = HashSet::default();
|
|
let task_variables = context
|
|
.task_variables
|
|
.0
|
|
.iter()
|
|
.map(|(key, value)| {
|
|
let key_string = key.to_string();
|
|
if !variable_names.contains_key(&key_string) {
|
|
variable_names.insert(key_string.clone(), key.clone());
|
|
}
|
|
(key_string, value.as_str())
|
|
})
|
|
.collect::<HashMap<_, _>>();
|
|
substitute_all_template_variables_in_map(
|
|
keys_and_values,
|
|
&task_variables,
|
|
&variable_names,
|
|
&mut substituted_variables,
|
|
)
|
|
}
|
|
fn substitute_all_template_variables_in_map(
|
|
keys_and_values: &HashMap<String, String>,
|
|
task_variables: &HashMap<String, &str>,
|
|
variable_names: &HashMap<String, VariableName>,
|
|
substituted_variables: &mut HashSet<VariableName>,
|
|
) -> Option<HashMap<String, String>> {
|
|
let mut new_map: HashMap<String, String> = Default::default();
|
|
for (key, value) in keys_and_values {
|
|
let new_value = substitute_all_template_variables_in_str(
|
|
value,
|
|
task_variables,
|
|
variable_names,
|
|
substituted_variables,
|
|
)?;
|
|
let new_key = substitute_all_template_variables_in_str(
|
|
key,
|
|
task_variables,
|
|
variable_names,
|
|
substituted_variables,
|
|
)?;
|
|
new_map.insert(new_key, new_value);
|
|
}
|
|
|
|
Some(new_map)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::{
|
|
borrow::Cow,
|
|
path::{Path, PathBuf},
|
|
};
|
|
|
|
use crate::{TaskVariables, VariableName};
|
|
|
|
use super::*;
|
|
|
|
const TEST_ID_BASE: &str = "test_base";
|
|
|
|
#[test]
|
|
fn test_resolving_templates_with_blank_command_and_label() {
|
|
let task_with_all_properties = TaskTemplate {
|
|
label: "test_label".to_string(),
|
|
command: "test_command".to_string(),
|
|
args: vec!["test_arg".to_string()],
|
|
env: HashMap::from_iter([("test_env_key".to_string(), "test_env_var".to_string())]),
|
|
..TaskTemplate::default()
|
|
};
|
|
|
|
for task_with_blank_property in &[
|
|
TaskTemplate {
|
|
label: "".to_string(),
|
|
..task_with_all_properties.clone()
|
|
},
|
|
TaskTemplate {
|
|
command: "".to_string(),
|
|
..task_with_all_properties.clone()
|
|
},
|
|
TaskTemplate {
|
|
label: "".to_string(),
|
|
command: "".to_string(),
|
|
..task_with_all_properties
|
|
},
|
|
] {
|
|
assert_eq!(
|
|
task_with_blank_property.resolve_task(TEST_ID_BASE, &TaskContext::default()),
|
|
None,
|
|
"should not resolve task with blank label and/or command: {task_with_blank_property:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_template_cwd_resolution() {
|
|
let task_without_cwd = TaskTemplate {
|
|
cwd: None,
|
|
label: "test task".to_string(),
|
|
command: "echo 4".to_string(),
|
|
..TaskTemplate::default()
|
|
};
|
|
|
|
let resolved_task = |task_template: &TaskTemplate, task_cx| {
|
|
let resolved_task = task_template
|
|
.resolve_task(TEST_ID_BASE, task_cx)
|
|
.unwrap_or_else(|| panic!("failed to resolve task {task_without_cwd:?}"));
|
|
assert_substituted_variables(&resolved_task, Vec::new());
|
|
resolved_task.resolved
|
|
};
|
|
|
|
let cx = TaskContext {
|
|
cwd: None,
|
|
task_variables: TaskVariables::default(),
|
|
project_env: HashMap::default(),
|
|
};
|
|
assert_eq!(
|
|
resolved_task(&task_without_cwd, &cx).cwd,
|
|
None,
|
|
"When neither task nor task context have cwd, it should be None"
|
|
);
|
|
|
|
let context_cwd = Path::new("a").join("b").join("c");
|
|
let cx = TaskContext {
|
|
cwd: Some(context_cwd.clone()),
|
|
task_variables: TaskVariables::default(),
|
|
project_env: HashMap::default(),
|
|
};
|
|
assert_eq!(
|
|
resolved_task(&task_without_cwd, &cx).cwd,
|
|
Some(context_cwd.clone()),
|
|
"TaskContext's cwd should be taken on resolve if task's cwd is None"
|
|
);
|
|
|
|
let task_cwd = Path::new("d").join("e").join("f");
|
|
let mut task_with_cwd = task_without_cwd.clone();
|
|
task_with_cwd.cwd = Some(task_cwd.display().to_string());
|
|
let task_with_cwd = task_with_cwd;
|
|
|
|
let cx = TaskContext {
|
|
cwd: None,
|
|
task_variables: TaskVariables::default(),
|
|
project_env: HashMap::default(),
|
|
};
|
|
assert_eq!(
|
|
resolved_task(&task_with_cwd, &cx).cwd,
|
|
Some(task_cwd.clone()),
|
|
"TaskTemplate's cwd should be taken on resolve if TaskContext's cwd is None"
|
|
);
|
|
|
|
let cx = TaskContext {
|
|
cwd: Some(context_cwd),
|
|
task_variables: TaskVariables::default(),
|
|
project_env: HashMap::default(),
|
|
};
|
|
assert_eq!(
|
|
resolved_task(&task_with_cwd, &cx).cwd,
|
|
Some(task_cwd),
|
|
"TaskTemplate's cwd should be taken on resolve if TaskContext's cwd is not None"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_template_variables_resolution() {
|
|
let custom_variable_1 = VariableName::Custom(Cow::Borrowed("custom_variable_1"));
|
|
let custom_variable_2 = VariableName::Custom(Cow::Borrowed("custom_variable_2"));
|
|
let long_value = "01".repeat(MAX_DISPLAY_VARIABLE_LENGTH * 2);
|
|
let all_variables = [
|
|
(VariableName::Row, "1234".to_string()),
|
|
(VariableName::Column, "5678".to_string()),
|
|
(VariableName::File, "test_file".to_string()),
|
|
(VariableName::SelectedText, "test_selected_text".to_string()),
|
|
(VariableName::Symbol, long_value.clone()),
|
|
(VariableName::WorktreeRoot, "/test_root/".to_string()),
|
|
(
|
|
custom_variable_1.clone(),
|
|
"test_custom_variable_1".to_string(),
|
|
),
|
|
(
|
|
custom_variable_2.clone(),
|
|
"test_custom_variable_2".to_string(),
|
|
),
|
|
];
|
|
|
|
let task_with_all_variables = TaskTemplate {
|
|
label: format!(
|
|
"test label for {} and {}",
|
|
VariableName::Row.template_value(),
|
|
VariableName::Symbol.template_value(),
|
|
),
|
|
command: format!(
|
|
"echo {} {}",
|
|
VariableName::File.template_value(),
|
|
VariableName::Symbol.template_value(),
|
|
),
|
|
args: vec![
|
|
format!("arg1 {}", VariableName::SelectedText.template_value()),
|
|
format!("arg2 {}", VariableName::Column.template_value()),
|
|
format!("arg3 {}", VariableName::Symbol.template_value()),
|
|
],
|
|
env: HashMap::from_iter([
|
|
("test_env_key".to_string(), "test_env_var".to_string()),
|
|
(
|
|
"env_key_1".to_string(),
|
|
VariableName::WorktreeRoot.template_value(),
|
|
),
|
|
(
|
|
"env_key_2".to_string(),
|
|
format!(
|
|
"env_var_2 {} {}",
|
|
custom_variable_1.template_value(),
|
|
custom_variable_2.template_value()
|
|
),
|
|
),
|
|
(
|
|
"env_key_3".to_string(),
|
|
format!("env_var_3 {}", VariableName::Symbol.template_value()),
|
|
),
|
|
]),
|
|
..TaskTemplate::default()
|
|
};
|
|
|
|
let mut first_resolved_id = None;
|
|
for i in 0..15 {
|
|
let resolved_task = task_with_all_variables.resolve_task(
|
|
TEST_ID_BASE,
|
|
&TaskContext {
|
|
cwd: None,
|
|
task_variables: TaskVariables::from_iter(all_variables.clone()),
|
|
project_env: HashMap::default(),
|
|
},
|
|
).unwrap_or_else(|| panic!("Should successfully resolve task {task_with_all_variables:?} with variables {all_variables:?}"));
|
|
|
|
match &first_resolved_id {
|
|
None => first_resolved_id = Some(resolved_task.id.clone()),
|
|
Some(first_id) => assert_eq!(
|
|
&resolved_task.id, first_id,
|
|
"Step {i}, for the same task template and context, there should be the same resolved task id"
|
|
),
|
|
}
|
|
|
|
assert_eq!(
|
|
resolved_task.original_task, task_with_all_variables,
|
|
"Resolved task should store its template without changes"
|
|
);
|
|
assert_eq!(
|
|
resolved_task.resolved_label,
|
|
format!("test label for 1234 and {long_value}"),
|
|
"Resolved task label should be substituted with variables and those should not be shortened"
|
|
);
|
|
assert_substituted_variables(
|
|
&resolved_task,
|
|
all_variables.iter().map(|(name, _)| name.clone()).collect(),
|
|
);
|
|
|
|
let spawn_in_terminal = &resolved_task.resolved;
|
|
assert_eq!(
|
|
spawn_in_terminal.label,
|
|
format!(
|
|
"test label for 1234 and …{}",
|
|
&long_value[long_value.len() - MAX_DISPLAY_VARIABLE_LENGTH..]
|
|
),
|
|
"Human-readable label should have long substitutions trimmed"
|
|
);
|
|
assert_eq!(
|
|
spawn_in_terminal.command.clone().unwrap(),
|
|
format!("echo test_file {long_value}"),
|
|
"Command should be substituted with variables and those should not be shortened"
|
|
);
|
|
assert_eq!(
|
|
spawn_in_terminal.args,
|
|
&[
|
|
"arg1 test_selected_text",
|
|
"arg2 5678",
|
|
"arg3 010101010101010101010101010101010101010101010101010101010101",
|
|
],
|
|
"Args should be substituted with variables"
|
|
);
|
|
assert_eq!(
|
|
spawn_in_terminal.command_label,
|
|
format!(
|
|
"{} arg1 test_selected_text arg2 5678 arg3 {long_value}",
|
|
spawn_in_terminal.command.clone().unwrap()
|
|
),
|
|
"Command label args should be substituted with variables and those should not be shortened"
|
|
);
|
|
|
|
assert_eq!(
|
|
spawn_in_terminal
|
|
.env
|
|
.get("test_env_key")
|
|
.map(|s| s.as_str()),
|
|
Some("test_env_var")
|
|
);
|
|
assert_eq!(
|
|
spawn_in_terminal.env.get("env_key_1").map(|s| s.as_str()),
|
|
Some("/test_root/")
|
|
);
|
|
assert_eq!(
|
|
spawn_in_terminal.env.get("env_key_2").map(|s| s.as_str()),
|
|
Some("env_var_2 test_custom_variable_1 test_custom_variable_2")
|
|
);
|
|
assert_eq!(
|
|
spawn_in_terminal.env.get("env_key_3"),
|
|
Some(&format!("env_var_3 {long_value}")),
|
|
"Env vars should be substituted with variables and those should not be shortened"
|
|
);
|
|
}
|
|
|
|
for i in 0..all_variables.len() {
|
|
let mut not_all_variables = all_variables.to_vec();
|
|
let removed_variable = not_all_variables.remove(i);
|
|
let resolved_task_attempt = task_with_all_variables.resolve_task(
|
|
TEST_ID_BASE,
|
|
&TaskContext {
|
|
cwd: None,
|
|
task_variables: TaskVariables::from_iter(not_all_variables),
|
|
project_env: HashMap::default(),
|
|
},
|
|
);
|
|
assert!(
|
|
matches!(resolved_task_attempt, None),
|
|
"If any of the Zed task variables is not substituted, the task should not be resolved, but got some resolution without the variable {removed_variable:?} (index {i})"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_can_resolve_free_variables() {
|
|
let task = TaskTemplate {
|
|
label: "My task".into(),
|
|
command: "echo".into(),
|
|
args: vec!["$PATH".into()],
|
|
..TaskTemplate::default()
|
|
};
|
|
let resolved_task = task
|
|
.resolve_task(TEST_ID_BASE, &TaskContext::default())
|
|
.unwrap();
|
|
assert_substituted_variables(&resolved_task, Vec::new());
|
|
let resolved = resolved_task.resolved;
|
|
assert_eq!(resolved.label, task.label);
|
|
assert_eq!(resolved.command, Some(task.command));
|
|
assert_eq!(resolved.args, task.args);
|
|
}
|
|
|
|
#[test]
|
|
fn test_errors_on_missing_zed_variable() {
|
|
let task = TaskTemplate {
|
|
label: "My task".into(),
|
|
command: "echo".into(),
|
|
args: vec!["$ZED_VARIABLE".into()],
|
|
..TaskTemplate::default()
|
|
};
|
|
assert!(
|
|
task.resolve_task(TEST_ID_BASE, &TaskContext::default())
|
|
.is_none()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_symbol_dependent_tasks() {
|
|
let task_with_all_properties = TaskTemplate {
|
|
label: "test_label".to_string(),
|
|
command: "test_command".to_string(),
|
|
args: vec!["test_arg".to_string()],
|
|
env: HashMap::from_iter([("test_env_key".to_string(), "test_env_var".to_string())]),
|
|
..TaskTemplate::default()
|
|
};
|
|
let cx = TaskContext {
|
|
cwd: None,
|
|
task_variables: TaskVariables::from_iter(Some((
|
|
VariableName::Symbol,
|
|
"test_symbol".to_string(),
|
|
))),
|
|
project_env: HashMap::default(),
|
|
};
|
|
|
|
for (i, symbol_dependent_task) in [
|
|
TaskTemplate {
|
|
label: format!("test_label_{}", VariableName::Symbol.template_value()),
|
|
..task_with_all_properties.clone()
|
|
},
|
|
TaskTemplate {
|
|
command: format!("test_command_{}", VariableName::Symbol.template_value()),
|
|
..task_with_all_properties.clone()
|
|
},
|
|
TaskTemplate {
|
|
args: vec![format!(
|
|
"test_arg_{}",
|
|
VariableName::Symbol.template_value()
|
|
)],
|
|
..task_with_all_properties.clone()
|
|
},
|
|
TaskTemplate {
|
|
env: HashMap::from_iter([(
|
|
"test_env_key".to_string(),
|
|
format!("test_env_var_{}", VariableName::Symbol.template_value()),
|
|
)]),
|
|
..task_with_all_properties
|
|
},
|
|
]
|
|
.into_iter()
|
|
.enumerate()
|
|
{
|
|
let resolved = symbol_dependent_task
|
|
.resolve_task(TEST_ID_BASE, &cx)
|
|
.unwrap_or_else(|| panic!("Failed to resolve task {symbol_dependent_task:?}"));
|
|
assert_eq!(
|
|
resolved.substituted_variables,
|
|
HashSet::from_iter(Some(VariableName::Symbol)),
|
|
"(index {i}) Expected the task to depend on symbol task variable: {resolved:?}"
|
|
)
|
|
}
|
|
}
|
|
|
|
#[track_caller]
|
|
fn assert_substituted_variables(resolved_task: &ResolvedTask, mut expected: Vec<VariableName>) {
|
|
let mut resolved_variables = resolved_task
|
|
.substituted_variables
|
|
.iter()
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
resolved_variables.sort_by_key(|var| var.to_string());
|
|
expected.sort_by_key(|var| var.to_string());
|
|
assert_eq!(resolved_variables, expected)
|
|
}
|
|
|
|
#[test]
|
|
fn substitute_funky_labels() {
|
|
let faulty_go_test = TaskTemplate {
|
|
label: format!(
|
|
"go test {}/{}",
|
|
VariableName::Symbol.template_value(),
|
|
VariableName::Symbol.template_value(),
|
|
),
|
|
command: "go".into(),
|
|
args: vec![format!(
|
|
"^{}$/^{}$",
|
|
VariableName::Symbol.template_value(),
|
|
VariableName::Symbol.template_value()
|
|
)],
|
|
..TaskTemplate::default()
|
|
};
|
|
let mut context = TaskContext::default();
|
|
context
|
|
.task_variables
|
|
.insert(VariableName::Symbol, "my-symbol".to_string());
|
|
assert!(faulty_go_test.resolve_task("base", &context).is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_project_env() {
|
|
let all_variables = [
|
|
(VariableName::Row, "1234".to_string()),
|
|
(VariableName::Column, "5678".to_string()),
|
|
(VariableName::File, "test_file".to_string()),
|
|
(VariableName::Symbol, "my symbol".to_string()),
|
|
];
|
|
|
|
let template = TaskTemplate {
|
|
label: "my task".to_string(),
|
|
command: format!(
|
|
"echo {} {}",
|
|
VariableName::File.template_value(),
|
|
VariableName::Symbol.template_value(),
|
|
),
|
|
args: vec![],
|
|
env: HashMap::from_iter([
|
|
(
|
|
"TASK_ENV_VAR1".to_string(),
|
|
"TASK_ENV_VAR1_VALUE".to_string(),
|
|
),
|
|
(
|
|
"TASK_ENV_VAR2".to_string(),
|
|
format!(
|
|
"env_var_2 {} {}",
|
|
VariableName::Row.template_value(),
|
|
VariableName::Column.template_value()
|
|
),
|
|
),
|
|
(
|
|
"PROJECT_ENV_WILL_BE_OVERWRITTEN".to_string(),
|
|
"overwritten".to_string(),
|
|
),
|
|
]),
|
|
..TaskTemplate::default()
|
|
};
|
|
|
|
let project_env = HashMap::from_iter([
|
|
(
|
|
"PROJECT_ENV_VAR1".to_string(),
|
|
"PROJECT_ENV_VAR1_VALUE".to_string(),
|
|
),
|
|
(
|
|
"PROJECT_ENV_WILL_BE_OVERWRITTEN".to_string(),
|
|
"PROJECT_ENV_WILL_BE_OVERWRITTEN_VALUE".to_string(),
|
|
),
|
|
]);
|
|
|
|
let context = TaskContext {
|
|
cwd: None,
|
|
task_variables: TaskVariables::from_iter(all_variables),
|
|
project_env,
|
|
};
|
|
|
|
let resolved = template
|
|
.resolve_task(TEST_ID_BASE, &context)
|
|
.unwrap()
|
|
.resolved;
|
|
|
|
assert_eq!(resolved.env["TASK_ENV_VAR1"], "TASK_ENV_VAR1_VALUE");
|
|
assert_eq!(resolved.env["TASK_ENV_VAR2"], "env_var_2 1234 5678");
|
|
assert_eq!(resolved.env["PROJECT_ENV_VAR1"], "PROJECT_ENV_VAR1_VALUE");
|
|
assert_eq!(
|
|
resolved.env["PROJECT_ENV_WILL_BE_OVERWRITTEN"],
|
|
"overwritten"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_variable_default_values() {
|
|
let task_with_defaults = TaskTemplate {
|
|
label: "test with defaults".to_string(),
|
|
command: format!(
|
|
"echo ${{{}}}",
|
|
VariableName::File.to_string() + ":fallback.txt"
|
|
),
|
|
args: vec![
|
|
"${ZED_MISSING_VAR:default_value}".to_string(),
|
|
format!("${{{}}}", VariableName::Row.to_string() + ":42"),
|
|
],
|
|
..TaskTemplate::default()
|
|
};
|
|
|
|
// Test 1: When ZED_FILE exists, should use actual value and ignore default
|
|
let context_with_file = TaskContext {
|
|
cwd: None,
|
|
task_variables: TaskVariables::from_iter(vec![
|
|
(VariableName::File, "actual_file.rs".to_string()),
|
|
(VariableName::Row, "123".to_string()),
|
|
]),
|
|
project_env: HashMap::default(),
|
|
};
|
|
|
|
let resolved = task_with_defaults
|
|
.resolve_task(TEST_ID_BASE, &context_with_file)
|
|
.expect("Should resolve task with existing variables");
|
|
|
|
assert_eq!(
|
|
resolved.resolved.command.unwrap(),
|
|
"echo actual_file.rs",
|
|
"Should use actual ZED_FILE value, not default"
|
|
);
|
|
assert_eq!(
|
|
resolved.resolved.args,
|
|
vec!["default_value", "123"],
|
|
"Should use default for missing var, actual value for existing var"
|
|
);
|
|
|
|
// Test 2: When ZED_FILE doesn't exist, should use default value
|
|
let context_without_file = TaskContext {
|
|
cwd: None,
|
|
task_variables: TaskVariables::from_iter(vec![(VariableName::Row, "456".to_string())]),
|
|
project_env: HashMap::default(),
|
|
};
|
|
|
|
let resolved = task_with_defaults
|
|
.resolve_task(TEST_ID_BASE, &context_without_file)
|
|
.expect("Should resolve task using default values");
|
|
|
|
assert_eq!(
|
|
resolved.resolved.command.unwrap(),
|
|
"echo fallback.txt",
|
|
"Should use default value when ZED_FILE is missing"
|
|
);
|
|
assert_eq!(
|
|
resolved.resolved.args,
|
|
vec!["default_value", "456"],
|
|
"Should use defaults for missing vars"
|
|
);
|
|
|
|
// Test 3: Missing ZED variable without default should fail
|
|
let task_no_default = TaskTemplate {
|
|
label: "test no default".to_string(),
|
|
command: "${ZED_MISSING_NO_DEFAULT}".to_string(),
|
|
..TaskTemplate::default()
|
|
};
|
|
|
|
assert!(
|
|
task_no_default
|
|
.resolve_task(TEST_ID_BASE, &TaskContext::default())
|
|
.is_none(),
|
|
"Should fail when ZED variable has no default and doesn't exist"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_unknown_variables() {
|
|
// Variable names starting with `ZED_` that are not valid should be
|
|
// reported.
|
|
let label = "test unknown variables".to_string();
|
|
let command = "$ZED_UNKNOWN".to_string();
|
|
let task = TaskTemplate {
|
|
label,
|
|
command,
|
|
..TaskTemplate::default()
|
|
};
|
|
|
|
assert_eq!(task.unknown_variables(), vec!["ZED_UNKNOWN".to_string()]);
|
|
|
|
// Variable names starting with `ZED_CUSTOM_` should never be reported,
|
|
// as those are dynamically provided by extensions.
|
|
let label = "test custom variables".to_string();
|
|
let command = "$ZED_CUSTOM_UNKNOWN".to_string();
|
|
let task = TaskTemplate {
|
|
label,
|
|
command,
|
|
..TaskTemplate::default()
|
|
};
|
|
|
|
assert!(task.unknown_variables().is_empty());
|
|
|
|
// Unknown variable names with defaults should still be reported,
|
|
// otherwise the default would always be silently used.
|
|
let label = "test custom variables".to_string();
|
|
let command = "${ZED_UNKNOWN:default_value}".to_string();
|
|
let task = TaskTemplate {
|
|
label,
|
|
command,
|
|
..TaskTemplate::default()
|
|
};
|
|
|
|
assert_eq!(task.unknown_variables(), vec!["ZED_UNKNOWN".to_string()]);
|
|
|
|
// Valid variable names are not reported.
|
|
let label = "test custom variables".to_string();
|
|
let command = "$ZED_FILE".to_string();
|
|
let task = TaskTemplate {
|
|
label,
|
|
command,
|
|
..TaskTemplate::default()
|
|
};
|
|
assert!(task.unknown_variables().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_git_variables_resolution() {
|
|
let task = TaskTemplate {
|
|
label: "Show $ZED_GIT_SHA_SHORT in $ZED_GIT_REPOSITORY_NAME".to_string(),
|
|
command: "git".to_string(),
|
|
args: vec!["show".to_string(), "$ZED_GIT_SHA".to_string()],
|
|
cwd: Some("$ZED_GIT_REPOSITORY_PATH".to_string()),
|
|
env: HashMap::from_iter([("COMMIT".to_string(), "$ZED_GIT_SHA".to_string())]),
|
|
..TaskTemplate::default()
|
|
};
|
|
let sha = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string();
|
|
let sha_short = "0123456".to_string();
|
|
let repo_name = "zed".to_string();
|
|
let repo_path = format!("/Users/example/{repo_name}");
|
|
|
|
let context = TaskContext {
|
|
task_variables: TaskVariables::from_iter([
|
|
(VariableName::GitSha, sha.clone()),
|
|
(VariableName::GitShaShort, sha_short.clone()),
|
|
(VariableName::GitRepositoryName, repo_name.clone()),
|
|
(VariableName::GitRepositoryPath, repo_path.clone()),
|
|
]),
|
|
..TaskContext::default()
|
|
};
|
|
|
|
let task = task.resolve_task(TEST_ID_BASE, &context).unwrap();
|
|
assert_eq!(
|
|
task.resolved_label,
|
|
format!("Show {sha_short} in {repo_name}")
|
|
);
|
|
assert_eq!(task.resolved.command, Some("git".to_string()));
|
|
assert_eq!(task.resolved.args, vec!["show".to_string(), sha.clone()]);
|
|
assert_eq!(task.resolved.cwd, Some(PathBuf::from(repo_path)));
|
|
assert_eq!(task.resolved.env.get("COMMIT"), Some(&sha));
|
|
|
|
assert_substituted_variables(
|
|
&task,
|
|
vec![
|
|
VariableName::GitSha,
|
|
VariableName::GitShaShort,
|
|
VariableName::GitRepositoryName,
|
|
VariableName::GitRepositoryPath,
|
|
],
|
|
);
|
|
}
|
|
}
|