1use anyhow::{Context as _, bail};
2use collections::{HashMap, HashSet};
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6use std::path::PathBuf;
7use util::schemars::{AllowTrailingCommas, DefaultDenyUnknownFields};
8use util::serde::default_true;
9use util::{ResultExt, truncate_and_remove_front};
10
11use crate::{
12 AttachRequest, ResolvedTask, RevealTarget, Shell, SpawnInTerminal, TaskContext, TaskId,
13 VariableName, ZED_VARIABLE_NAME_PREFIX, serde_helpers::non_empty_string_vec,
14};
15
16/// A template definition of a Zed task to run.
17/// May use the [`VariableName`] to get the corresponding substitutions into its fields.
18///
19/// Template itself is not ready to spawn a task, it needs to be resolved with a [`TaskContext`] first, that
20/// contains all relevant Zed state in task variables.
21/// A single template may produce different tasks (or none) for different contexts.
22#[derive(Clone, Default, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
23#[serde(rename_all = "snake_case")]
24pub struct TaskTemplate {
25 /// Human readable name of the task to display in the UI.
26 pub label: String,
27 /// Executable command to spawn.
28 pub command: String,
29 /// Arguments to the command.
30 #[serde(default)]
31 pub args: Vec<String>,
32 /// Env overrides for the command, will be appended to the terminal's environment from the settings.
33 #[serde(default)]
34 pub env: HashMap<String, String>,
35 /// Current working directory to spawn the command into, defaults to current project root.
36 #[serde(default)]
37 pub cwd: Option<String>,
38 /// Whether to use a new terminal tab or reuse the existing one to spawn the process.
39 #[serde(default)]
40 pub use_new_terminal: bool,
41 /// Whether to allow multiple instances of the same task to be run, or rather wait for the existing ones to finish.
42 #[serde(default)]
43 pub allow_concurrent_runs: bool,
44 /// What to do with the terminal pane and tab, after the command was started:
45 /// * `always` — always show the task's pane, and focus the corresponding tab in it (default)
46 // * `no_focus` — always show the task's pane, add the task's tab in it, but don't focus it
47 // * `never` — do not alter focus, but still add/reuse the task's tab in its pane
48 #[serde(default)]
49 pub reveal: RevealStrategy,
50 /// Where to place the task's terminal item after starting the task.
51 /// * `dock` — in the terminal dock, "regular" terminal items' place (default).
52 /// * `center` — in the central pane group, "main" editor area.
53 #[serde(default)]
54 pub reveal_target: RevealTarget,
55 /// What to do with the terminal pane and tab, after the command had finished:
56 /// * `never` — do nothing when the command finishes (default)
57 /// * `always` — always hide the terminal tab, hide the pane also if it was the last tab in it
58 /// * `on_success` — hide the terminal tab on task success only, otherwise behaves similar to `always`.
59 #[serde(default)]
60 pub hide: HideStrategy,
61 /// Represents the tags which this template attaches to.
62 /// Adding this removes this task from other UI and gives you ability to run it by tag.
63 #[serde(default, deserialize_with = "non_empty_string_vec")]
64 #[schemars(length(min = 1))]
65 pub tags: Vec<String>,
66 /// Which shell to use when spawning the task.
67 #[serde(default)]
68 pub shell: Shell,
69 /// Whether to show the task line in the task output.
70 #[serde(default = "default_true")]
71 pub show_summary: bool,
72 /// Whether to show the command line in the task output.
73 #[serde(default = "default_true")]
74 pub show_command: bool,
75 /// Which edited buffers to save before running the task.
76 #[serde(default)]
77 pub save: SaveStrategy,
78 /// Hooks that this task runs when emitted.
79 #[serde(default)]
80 pub hooks: HashSet<TaskHook>,
81}
82
83#[derive(Deserialize, Eq, PartialEq, Clone, Debug)]
84/// Use to represent debug request type
85pub enum DebugArgsRequest {
86 /// launch (program, cwd) are stored in TaskTemplate as (command, cwd)
87 Launch,
88 /// Attach
89 Attach(AttachRequest),
90}
91
92/// What to do with the terminal pane and tab, after the command was started.
93#[derive(Clone, Copy, Debug, PartialEq, Hash, Eq, Serialize, Deserialize, JsonSchema)]
94#[serde(rename_all = "snake_case")]
95pub enum TaskHook {
96 #[serde(alias = "create_git_worktree")]
97 CreateWorktree,
98}
99
100/// What to do with the terminal pane and tab, after the command was started.
101#[derive(Default, Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
102#[serde(rename_all = "snake_case")]
103pub enum RevealStrategy {
104 /// Always show the task's pane, and focus the corresponding tab in it.
105 #[default]
106 Always,
107 /// Always show the task's pane, add the task's tab in it, but don't focus it.
108 NoFocus,
109 /// Do not alter focus, but still add/reuse the task's tab in its pane.
110 Never,
111}
112
113/// What to do with the terminal pane and tab, after the command has finished.
114#[derive(Default, Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
115#[serde(rename_all = "snake_case")]
116pub enum HideStrategy {
117 /// Do nothing when the command finishes.
118 #[default]
119 Never,
120 /// Always hide the terminal tab, hide the pane also if it was the last tab in it.
121 Always,
122 /// Hide the terminal tab on task success only, otherwise behaves similar to `Always`.
123 OnSuccess,
124}
125
126/// Which edited buffers to save before running a task.
127#[derive(Default, Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
128#[serde(rename_all = "snake_case")]
129pub enum SaveStrategy {
130 /// Save all edited buffers.
131 All,
132 /// Save the current buffer.
133 Current,
134 #[default]
135 /// Don't save any buffers.
136 None,
137}
138
139/// A group of Tasks defined in a JSON file.
140#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
141pub struct TaskTemplates(pub Vec<TaskTemplate>);
142
143impl TaskTemplates {
144 pub const FILE_NAME: &str = "tasks.json";
145 /// Generates JSON schema of Tasks JSON template format.
146 pub fn generate_json_schema() -> serde_json::Value {
147 let schema = schemars::generate::SchemaSettings::draft2019_09()
148 .with_transform(DefaultDenyUnknownFields)
149 .with_transform(AllowTrailingCommas)
150 .into_generator()
151 .root_schema_for::<Self>();
152
153 serde_json::to_value(schema).unwrap()
154 }
155}
156
157impl TaskTemplate {
158 /// Replaces all `VariableName` task variables in the task template string fields.
159 ///
160 /// Every [`ResolvedTask`] gets a [`TaskId`], based on the `id_base` (to avoid collision with various task sources),
161 /// and hashes of its template and [`TaskContext`], see [`ResolvedTask`] fields' documentation for more details.
162 pub fn resolve_task(&self, id_base: &str, cx: &TaskContext) -> Option<ResolvedTask> {
163 if self.label.trim().is_empty() || self.command.trim().is_empty() {
164 return None;
165 }
166
167 let mut variable_names = HashMap::default();
168 let mut substituted_variables = HashSet::default();
169 let task_variables = cx
170 .task_variables
171 .0
172 .iter()
173 .map(|(key, value)| {
174 let key_string = key.to_string();
175 if !variable_names.contains_key(&key_string) {
176 variable_names.insert(key_string.clone(), key.clone());
177 }
178 (key_string, value.as_str())
179 })
180 .collect::<HashMap<_, _>>();
181 let truncated_variables = truncate_variables(&task_variables);
182 let cwd = match self.cwd.as_deref() {
183 Some(cwd) => {
184 let substituted_cwd = substitute_all_template_variables_in_str(
185 cwd,
186 &task_variables,
187 &variable_names,
188 &mut substituted_variables,
189 )?;
190 Some(PathBuf::from(substituted_cwd))
191 }
192 None => None,
193 }
194 .or(cx.cwd.clone());
195 let full_label = substitute_all_template_variables_in_str(
196 &self.label,
197 &task_variables,
198 &variable_names,
199 &mut substituted_variables,
200 )?;
201
202 // Arbitrarily picked threshold below which we don't truncate any variables.
203 const TRUNCATION_THRESHOLD: usize = 64;
204
205 let human_readable_label = if full_label.len() > TRUNCATION_THRESHOLD {
206 substitute_all_template_variables_in_str(
207 &self.label,
208 &truncated_variables,
209 &variable_names,
210 &mut substituted_variables,
211 )?
212 } else {
213 #[allow(
214 clippy::redundant_clone,
215 reason = "We want to clone the full_label to avoid borrowing it in the fold closure"
216 )]
217 full_label.clone()
218 }
219 .lines()
220 .fold(String::new(), |mut string, line| {
221 if string.is_empty() {
222 string.push_str(line);
223 } else {
224 string.push_str("\\n");
225 string.push_str(line);
226 }
227 string
228 });
229
230 let command = substitute_all_template_variables_in_str(
231 &self.command,
232 &task_variables,
233 &variable_names,
234 &mut substituted_variables,
235 )?;
236 let args_with_substitutions = substitute_all_template_variables_in_vec(
237 &self.args,
238 &task_variables,
239 &variable_names,
240 &mut substituted_variables,
241 )?;
242
243 let task_hash = to_hex_hash(self)
244 .context("hashing task template")
245 .log_err()?;
246 let variables_hash = to_hex_hash(&task_variables)
247 .context("hashing task variables")
248 .log_err()?;
249 let id = TaskId(format!("{id_base}_{task_hash}_{variables_hash}"));
250
251 let env = {
252 // Start with the project environment as the base.
253 let mut env = cx.project_env.clone();
254
255 // Extend that environment with what's defined in the TaskTemplate
256 env.extend(self.env.clone());
257
258 // Then we replace all task variables that could be set in environment variables
259 let mut env = substitute_all_template_variables_in_map(
260 &env,
261 &task_variables,
262 &variable_names,
263 &mut substituted_variables,
264 )?;
265
266 // Last step: set the task variables as environment variables too
267 env.extend(task_variables.into_iter().map(|(k, v)| (k, v.to_owned())));
268 env
269 };
270
271 Some(ResolvedTask {
272 id: id.clone(),
273 substituted_variables,
274 original_task: self.clone(),
275 resolved_label: full_label.clone(),
276 resolved: SpawnInTerminal {
277 id,
278 cwd,
279 full_label,
280 label: human_readable_label,
281 command_label: args_with_substitutions.iter().fold(
282 command.clone(),
283 |mut command_label, arg| {
284 command_label.push(' ');
285 command_label.push_str(arg);
286 command_label
287 },
288 ),
289 command: Some(command),
290 args: args_with_substitutions,
291 env,
292 use_new_terminal: self.use_new_terminal,
293 allow_concurrent_runs: self.allow_concurrent_runs,
294 reveal: self.reveal,
295 reveal_target: self.reveal_target,
296 hide: self.hide,
297 shell: self.shell.clone(),
298 show_summary: self.show_summary,
299 show_command: self.show_command,
300 show_rerun: true,
301 save: self.save,
302 },
303 })
304 }
305
306 /// Validates that all `$ZED_*` variables used in this template are known
307 /// variable names, returning a vector with all of the unique unknown
308 /// variables.
309 ///
310 /// Note that `$ZED_CUSTOM_*` variables are never considered to be invalid
311 /// since those are provided dynamically by extensions.
312 pub fn unknown_variables(&self) -> Vec<String> {
313 let mut variables = HashSet::default();
314
315 Self::collect_unknown_variables(&self.label, &mut variables);
316 Self::collect_unknown_variables(&self.command, &mut variables);
317
318 self.args
319 .iter()
320 .for_each(|arg| Self::collect_unknown_variables(arg, &mut variables));
321
322 self.env
323 .values()
324 .for_each(|value| Self::collect_unknown_variables(value, &mut variables));
325
326 if let Some(cwd) = &self.cwd {
327 Self::collect_unknown_variables(cwd, &mut variables);
328 }
329
330 variables.into_iter().collect()
331 }
332
333 fn collect_unknown_variables(template: &str, unknown: &mut HashSet<String>) {
334 shellexpand::env_with_context_no_errors(template, |variable| {
335 // It's possible that the variable has a default defined, which is
336 // separated by a `:`, for example, `${ZED_FILE:default_value} so we
337 // ensure that we're only looking at the variable name itself.
338 let colon_position = variable.find(':').unwrap_or(variable.len());
339 let variable_name = &variable[..colon_position];
340
341 if variable_name.starts_with(ZED_VARIABLE_NAME_PREFIX)
342 && let without_prefix = &variable_name[ZED_VARIABLE_NAME_PREFIX.len()..]
343 && !without_prefix.starts_with("CUSTOM_")
344 && variable_name.parse::<VariableName>().is_err()
345 {
346 unknown.insert(variable_name.to_string());
347 }
348
349 None::<&str>
350 });
351 }
352}
353
354const MAX_DISPLAY_VARIABLE_LENGTH: usize = 15;
355
356fn truncate_variables(task_variables: &HashMap<String, &str>) -> HashMap<String, String> {
357 task_variables
358 .iter()
359 .map(|(key, value)| {
360 (
361 key.clone(),
362 truncate_and_remove_front(value, MAX_DISPLAY_VARIABLE_LENGTH),
363 )
364 })
365 .collect()
366}
367
368fn to_hex_hash(object: impl Serialize) -> anyhow::Result<String> {
369 let json = serde_json_lenient::to_string(&object).context("serializing the object")?;
370 let mut hasher = Sha256::new();
371 hasher.update(json.as_bytes());
372 Ok(hex::encode(hasher.finalize()))
373}
374
375pub fn substitute_variables_in_str(template_str: &str, context: &TaskContext) -> Option<String> {
376 let mut variable_names = HashMap::default();
377 let mut substituted_variables = HashSet::default();
378 let task_variables = context
379 .task_variables
380 .0
381 .iter()
382 .map(|(key, value)| {
383 let key_string = key.to_string();
384 if !variable_names.contains_key(&key_string) {
385 variable_names.insert(key_string.clone(), key.clone());
386 }
387 (key_string, value.as_str())
388 })
389 .collect::<HashMap<_, _>>();
390 substitute_all_template_variables_in_str(
391 template_str,
392 &task_variables,
393 &variable_names,
394 &mut substituted_variables,
395 )
396}
397fn substitute_all_template_variables_in_str<A: AsRef<str>>(
398 template_str: &str,
399 task_variables: &HashMap<String, A>,
400 variable_names: &HashMap<String, VariableName>,
401 substituted_variables: &mut HashSet<VariableName>,
402) -> Option<String> {
403 let substituted_string = shellexpand::env_with_context(template_str, |var| {
404 // Colons denote a default value in case the variable is not set. We
405 // want to preserve that default, as otherwise shellexpand will
406 // substitute it for us.
407 let colon_position = var.find(':').unwrap_or(var.len());
408 let (variable_name, default) = var.split_at(colon_position);
409 if let Some(name) = task_variables.get(variable_name) {
410 if let Some(substituted_variable) = variable_names.get(variable_name) {
411 substituted_variables.insert(substituted_variable.clone());
412 }
413 // Got a task variable hit - use the variable value, ignore default
414 return Ok(Some(name.as_ref().to_owned()));
415 } else if variable_name.starts_with(ZED_VARIABLE_NAME_PREFIX) {
416 // Unknown ZED variable - use default if available
417 if !default.is_empty() {
418 // Strip the colon and return the default value
419 return Ok(Some(default[1..].to_owned()));
420 } else {
421 bail!("Unknown variable name: {variable_name}");
422 }
423 }
424 // This is an unknown variable.
425 // We should not error out, as they may come from user environment (e.g.
426 // $PATH). That means that the variable substitution might not be
427 // perfect. If there's a default, we need to return the string verbatim
428 // as otherwise shellexpand will apply that default for us.
429 if !default.is_empty() {
430 return Ok(Some(format!("${{{var}}}")));
431 }
432
433 // Else we can just return None and that variable will be left as is.
434 Ok(None)
435 })
436 .ok()?;
437
438 Some(substituted_string.into_owned())
439}
440
441fn substitute_all_template_variables_in_vec(
442 template_strs: &[String],
443 task_variables: &HashMap<String, &str>,
444 variable_names: &HashMap<String, VariableName>,
445 substituted_variables: &mut HashSet<VariableName>,
446) -> Option<Vec<String>> {
447 let mut expanded = Vec::with_capacity(template_strs.len());
448 for variable in template_strs {
449 let new_value = substitute_all_template_variables_in_str(
450 variable,
451 task_variables,
452 variable_names,
453 substituted_variables,
454 )?;
455 expanded.push(new_value);
456 }
457
458 Some(expanded)
459}
460
461pub fn substitute_variables_in_map(
462 keys_and_values: &HashMap<String, String>,
463 context: &TaskContext,
464) -> Option<HashMap<String, String>> {
465 let mut variable_names = HashMap::default();
466 let mut substituted_variables = HashSet::default();
467 let task_variables = context
468 .task_variables
469 .0
470 .iter()
471 .map(|(key, value)| {
472 let key_string = key.to_string();
473 if !variable_names.contains_key(&key_string) {
474 variable_names.insert(key_string.clone(), key.clone());
475 }
476 (key_string, value.as_str())
477 })
478 .collect::<HashMap<_, _>>();
479 substitute_all_template_variables_in_map(
480 keys_and_values,
481 &task_variables,
482 &variable_names,
483 &mut substituted_variables,
484 )
485}
486fn substitute_all_template_variables_in_map(
487 keys_and_values: &HashMap<String, String>,
488 task_variables: &HashMap<String, &str>,
489 variable_names: &HashMap<String, VariableName>,
490 substituted_variables: &mut HashSet<VariableName>,
491) -> Option<HashMap<String, String>> {
492 let mut new_map: HashMap<String, String> = Default::default();
493 for (key, value) in keys_and_values {
494 let new_value = substitute_all_template_variables_in_str(
495 value,
496 task_variables,
497 variable_names,
498 substituted_variables,
499 )?;
500 let new_key = substitute_all_template_variables_in_str(
501 key,
502 task_variables,
503 variable_names,
504 substituted_variables,
505 )?;
506 new_map.insert(new_key, new_value);
507 }
508
509 Some(new_map)
510}
511
512#[cfg(test)]
513mod tests {
514 use std::{borrow::Cow, path::Path};
515
516 use crate::{TaskVariables, VariableName};
517
518 use super::*;
519
520 const TEST_ID_BASE: &str = "test_base";
521
522 #[test]
523 fn test_resolving_templates_with_blank_command_and_label() {
524 let task_with_all_properties = TaskTemplate {
525 label: "test_label".to_string(),
526 command: "test_command".to_string(),
527 args: vec!["test_arg".to_string()],
528 env: HashMap::from_iter([("test_env_key".to_string(), "test_env_var".to_string())]),
529 ..TaskTemplate::default()
530 };
531
532 for task_with_blank_property in &[
533 TaskTemplate {
534 label: "".to_string(),
535 ..task_with_all_properties.clone()
536 },
537 TaskTemplate {
538 command: "".to_string(),
539 ..task_with_all_properties.clone()
540 },
541 TaskTemplate {
542 label: "".to_string(),
543 command: "".to_string(),
544 ..task_with_all_properties
545 },
546 ] {
547 assert_eq!(
548 task_with_blank_property.resolve_task(TEST_ID_BASE, &TaskContext::default()),
549 None,
550 "should not resolve task with blank label and/or command: {task_with_blank_property:?}"
551 );
552 }
553 }
554
555 #[test]
556 fn test_template_cwd_resolution() {
557 let task_without_cwd = TaskTemplate {
558 cwd: None,
559 label: "test task".to_string(),
560 command: "echo 4".to_string(),
561 ..TaskTemplate::default()
562 };
563
564 let resolved_task = |task_template: &TaskTemplate, task_cx| {
565 let resolved_task = task_template
566 .resolve_task(TEST_ID_BASE, task_cx)
567 .unwrap_or_else(|| panic!("failed to resolve task {task_without_cwd:?}"));
568 assert_substituted_variables(&resolved_task, Vec::new());
569 resolved_task.resolved
570 };
571
572 let cx = TaskContext {
573 cwd: None,
574 task_variables: TaskVariables::default(),
575 project_env: HashMap::default(),
576 };
577 assert_eq!(
578 resolved_task(&task_without_cwd, &cx).cwd,
579 None,
580 "When neither task nor task context have cwd, it should be None"
581 );
582
583 let context_cwd = Path::new("a").join("b").join("c");
584 let cx = TaskContext {
585 cwd: Some(context_cwd.clone()),
586 task_variables: TaskVariables::default(),
587 project_env: HashMap::default(),
588 };
589 assert_eq!(
590 resolved_task(&task_without_cwd, &cx).cwd,
591 Some(context_cwd.clone()),
592 "TaskContext's cwd should be taken on resolve if task's cwd is None"
593 );
594
595 let task_cwd = Path::new("d").join("e").join("f");
596 let mut task_with_cwd = task_without_cwd.clone();
597 task_with_cwd.cwd = Some(task_cwd.display().to_string());
598 let task_with_cwd = task_with_cwd;
599
600 let cx = TaskContext {
601 cwd: None,
602 task_variables: TaskVariables::default(),
603 project_env: HashMap::default(),
604 };
605 assert_eq!(
606 resolved_task(&task_with_cwd, &cx).cwd,
607 Some(task_cwd.clone()),
608 "TaskTemplate's cwd should be taken on resolve if TaskContext's cwd is None"
609 );
610
611 let cx = TaskContext {
612 cwd: Some(context_cwd),
613 task_variables: TaskVariables::default(),
614 project_env: HashMap::default(),
615 };
616 assert_eq!(
617 resolved_task(&task_with_cwd, &cx).cwd,
618 Some(task_cwd),
619 "TaskTemplate's cwd should be taken on resolve if TaskContext's cwd is not None"
620 );
621 }
622
623 #[test]
624 fn test_template_variables_resolution() {
625 let custom_variable_1 = VariableName::Custom(Cow::Borrowed("custom_variable_1"));
626 let custom_variable_2 = VariableName::Custom(Cow::Borrowed("custom_variable_2"));
627 let long_value = "01".repeat(MAX_DISPLAY_VARIABLE_LENGTH * 2);
628 let all_variables = [
629 (VariableName::Row, "1234".to_string()),
630 (VariableName::Column, "5678".to_string()),
631 (VariableName::File, "test_file".to_string()),
632 (VariableName::SelectedText, "test_selected_text".to_string()),
633 (VariableName::Symbol, long_value.clone()),
634 (VariableName::WorktreeRoot, "/test_root/".to_string()),
635 (
636 custom_variable_1.clone(),
637 "test_custom_variable_1".to_string(),
638 ),
639 (
640 custom_variable_2.clone(),
641 "test_custom_variable_2".to_string(),
642 ),
643 ];
644
645 let task_with_all_variables = TaskTemplate {
646 label: format!(
647 "test label for {} and {}",
648 VariableName::Row.template_value(),
649 VariableName::Symbol.template_value(),
650 ),
651 command: format!(
652 "echo {} {}",
653 VariableName::File.template_value(),
654 VariableName::Symbol.template_value(),
655 ),
656 args: vec![
657 format!("arg1 {}", VariableName::SelectedText.template_value()),
658 format!("arg2 {}", VariableName::Column.template_value()),
659 format!("arg3 {}", VariableName::Symbol.template_value()),
660 ],
661 env: HashMap::from_iter([
662 ("test_env_key".to_string(), "test_env_var".to_string()),
663 (
664 "env_key_1".to_string(),
665 VariableName::WorktreeRoot.template_value(),
666 ),
667 (
668 "env_key_2".to_string(),
669 format!(
670 "env_var_2 {} {}",
671 custom_variable_1.template_value(),
672 custom_variable_2.template_value()
673 ),
674 ),
675 (
676 "env_key_3".to_string(),
677 format!("env_var_3 {}", VariableName::Symbol.template_value()),
678 ),
679 ]),
680 ..TaskTemplate::default()
681 };
682
683 let mut first_resolved_id = None;
684 for i in 0..15 {
685 let resolved_task = task_with_all_variables.resolve_task(
686 TEST_ID_BASE,
687 &TaskContext {
688 cwd: None,
689 task_variables: TaskVariables::from_iter(all_variables.clone()),
690 project_env: HashMap::default(),
691 },
692 ).unwrap_or_else(|| panic!("Should successfully resolve task {task_with_all_variables:?} with variables {all_variables:?}"));
693
694 match &first_resolved_id {
695 None => first_resolved_id = Some(resolved_task.id.clone()),
696 Some(first_id) => assert_eq!(
697 &resolved_task.id, first_id,
698 "Step {i}, for the same task template and context, there should be the same resolved task id"
699 ),
700 }
701
702 assert_eq!(
703 resolved_task.original_task, task_with_all_variables,
704 "Resolved task should store its template without changes"
705 );
706 assert_eq!(
707 resolved_task.resolved_label,
708 format!("test label for 1234 and {long_value}"),
709 "Resolved task label should be substituted with variables and those should not be shortened"
710 );
711 assert_substituted_variables(
712 &resolved_task,
713 all_variables.iter().map(|(name, _)| name.clone()).collect(),
714 );
715
716 let spawn_in_terminal = &resolved_task.resolved;
717 assert_eq!(
718 spawn_in_terminal.label,
719 format!(
720 "test label for 1234 and …{}",
721 &long_value[long_value.len() - MAX_DISPLAY_VARIABLE_LENGTH..]
722 ),
723 "Human-readable label should have long substitutions trimmed"
724 );
725 assert_eq!(
726 spawn_in_terminal.command.clone().unwrap(),
727 format!("echo test_file {long_value}"),
728 "Command should be substituted with variables and those should not be shortened"
729 );
730 assert_eq!(
731 spawn_in_terminal.args,
732 &[
733 "arg1 test_selected_text",
734 "arg2 5678",
735 "arg3 010101010101010101010101010101010101010101010101010101010101",
736 ],
737 "Args should be substituted with variables"
738 );
739 assert_eq!(
740 spawn_in_terminal.command_label,
741 format!(
742 "{} arg1 test_selected_text arg2 5678 arg3 {long_value}",
743 spawn_in_terminal.command.clone().unwrap()
744 ),
745 "Command label args should be substituted with variables and those should not be shortened"
746 );
747
748 assert_eq!(
749 spawn_in_terminal
750 .env
751 .get("test_env_key")
752 .map(|s| s.as_str()),
753 Some("test_env_var")
754 );
755 assert_eq!(
756 spawn_in_terminal.env.get("env_key_1").map(|s| s.as_str()),
757 Some("/test_root/")
758 );
759 assert_eq!(
760 spawn_in_terminal.env.get("env_key_2").map(|s| s.as_str()),
761 Some("env_var_2 test_custom_variable_1 test_custom_variable_2")
762 );
763 assert_eq!(
764 spawn_in_terminal.env.get("env_key_3"),
765 Some(&format!("env_var_3 {long_value}")),
766 "Env vars should be substituted with variables and those should not be shortened"
767 );
768 }
769
770 for i in 0..all_variables.len() {
771 let mut not_all_variables = all_variables.to_vec();
772 let removed_variable = not_all_variables.remove(i);
773 let resolved_task_attempt = task_with_all_variables.resolve_task(
774 TEST_ID_BASE,
775 &TaskContext {
776 cwd: None,
777 task_variables: TaskVariables::from_iter(not_all_variables),
778 project_env: HashMap::default(),
779 },
780 );
781 assert!(
782 matches!(resolved_task_attempt, None),
783 "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})"
784 );
785 }
786 }
787
788 #[test]
789 fn test_can_resolve_free_variables() {
790 let task = TaskTemplate {
791 label: "My task".into(),
792 command: "echo".into(),
793 args: vec!["$PATH".into()],
794 ..TaskTemplate::default()
795 };
796 let resolved_task = task
797 .resolve_task(TEST_ID_BASE, &TaskContext::default())
798 .unwrap();
799 assert_substituted_variables(&resolved_task, Vec::new());
800 let resolved = resolved_task.resolved;
801 assert_eq!(resolved.label, task.label);
802 assert_eq!(resolved.command, Some(task.command));
803 assert_eq!(resolved.args, task.args);
804 }
805
806 #[test]
807 fn test_errors_on_missing_zed_variable() {
808 let task = TaskTemplate {
809 label: "My task".into(),
810 command: "echo".into(),
811 args: vec!["$ZED_VARIABLE".into()],
812 ..TaskTemplate::default()
813 };
814 assert!(
815 task.resolve_task(TEST_ID_BASE, &TaskContext::default())
816 .is_none()
817 );
818 }
819
820 #[test]
821 fn test_symbol_dependent_tasks() {
822 let task_with_all_properties = TaskTemplate {
823 label: "test_label".to_string(),
824 command: "test_command".to_string(),
825 args: vec!["test_arg".to_string()],
826 env: HashMap::from_iter([("test_env_key".to_string(), "test_env_var".to_string())]),
827 ..TaskTemplate::default()
828 };
829 let cx = TaskContext {
830 cwd: None,
831 task_variables: TaskVariables::from_iter(Some((
832 VariableName::Symbol,
833 "test_symbol".to_string(),
834 ))),
835 project_env: HashMap::default(),
836 };
837
838 for (i, symbol_dependent_task) in [
839 TaskTemplate {
840 label: format!("test_label_{}", VariableName::Symbol.template_value()),
841 ..task_with_all_properties.clone()
842 },
843 TaskTemplate {
844 command: format!("test_command_{}", VariableName::Symbol.template_value()),
845 ..task_with_all_properties.clone()
846 },
847 TaskTemplate {
848 args: vec![format!(
849 "test_arg_{}",
850 VariableName::Symbol.template_value()
851 )],
852 ..task_with_all_properties.clone()
853 },
854 TaskTemplate {
855 env: HashMap::from_iter([(
856 "test_env_key".to_string(),
857 format!("test_env_var_{}", VariableName::Symbol.template_value()),
858 )]),
859 ..task_with_all_properties
860 },
861 ]
862 .into_iter()
863 .enumerate()
864 {
865 let resolved = symbol_dependent_task
866 .resolve_task(TEST_ID_BASE, &cx)
867 .unwrap_or_else(|| panic!("Failed to resolve task {symbol_dependent_task:?}"));
868 assert_eq!(
869 resolved.substituted_variables,
870 HashSet::from_iter(Some(VariableName::Symbol)),
871 "(index {i}) Expected the task to depend on symbol task variable: {resolved:?}"
872 )
873 }
874 }
875
876 #[track_caller]
877 fn assert_substituted_variables(resolved_task: &ResolvedTask, mut expected: Vec<VariableName>) {
878 let mut resolved_variables = resolved_task
879 .substituted_variables
880 .iter()
881 .cloned()
882 .collect::<Vec<_>>();
883 resolved_variables.sort_by_key(|var| var.to_string());
884 expected.sort_by_key(|var| var.to_string());
885 assert_eq!(resolved_variables, expected)
886 }
887
888 #[test]
889 fn substitute_funky_labels() {
890 let faulty_go_test = TaskTemplate {
891 label: format!(
892 "go test {}/{}",
893 VariableName::Symbol.template_value(),
894 VariableName::Symbol.template_value(),
895 ),
896 command: "go".into(),
897 args: vec![format!(
898 "^{}$/^{}$",
899 VariableName::Symbol.template_value(),
900 VariableName::Symbol.template_value()
901 )],
902 ..TaskTemplate::default()
903 };
904 let mut context = TaskContext::default();
905 context
906 .task_variables
907 .insert(VariableName::Symbol, "my-symbol".to_string());
908 assert!(faulty_go_test.resolve_task("base", &context).is_some());
909 }
910
911 #[test]
912 fn test_project_env() {
913 let all_variables = [
914 (VariableName::Row, "1234".to_string()),
915 (VariableName::Column, "5678".to_string()),
916 (VariableName::File, "test_file".to_string()),
917 (VariableName::Symbol, "my symbol".to_string()),
918 ];
919
920 let template = TaskTemplate {
921 label: "my task".to_string(),
922 command: format!(
923 "echo {} {}",
924 VariableName::File.template_value(),
925 VariableName::Symbol.template_value(),
926 ),
927 args: vec![],
928 env: HashMap::from_iter([
929 (
930 "TASK_ENV_VAR1".to_string(),
931 "TASK_ENV_VAR1_VALUE".to_string(),
932 ),
933 (
934 "TASK_ENV_VAR2".to_string(),
935 format!(
936 "env_var_2 {} {}",
937 VariableName::Row.template_value(),
938 VariableName::Column.template_value()
939 ),
940 ),
941 (
942 "PROJECT_ENV_WILL_BE_OVERWRITTEN".to_string(),
943 "overwritten".to_string(),
944 ),
945 ]),
946 ..TaskTemplate::default()
947 };
948
949 let project_env = HashMap::from_iter([
950 (
951 "PROJECT_ENV_VAR1".to_string(),
952 "PROJECT_ENV_VAR1_VALUE".to_string(),
953 ),
954 (
955 "PROJECT_ENV_WILL_BE_OVERWRITTEN".to_string(),
956 "PROJECT_ENV_WILL_BE_OVERWRITTEN_VALUE".to_string(),
957 ),
958 ]);
959
960 let context = TaskContext {
961 cwd: None,
962 task_variables: TaskVariables::from_iter(all_variables),
963 project_env,
964 };
965
966 let resolved = template
967 .resolve_task(TEST_ID_BASE, &context)
968 .unwrap()
969 .resolved;
970
971 assert_eq!(resolved.env["TASK_ENV_VAR1"], "TASK_ENV_VAR1_VALUE");
972 assert_eq!(resolved.env["TASK_ENV_VAR2"], "env_var_2 1234 5678");
973 assert_eq!(resolved.env["PROJECT_ENV_VAR1"], "PROJECT_ENV_VAR1_VALUE");
974 assert_eq!(
975 resolved.env["PROJECT_ENV_WILL_BE_OVERWRITTEN"],
976 "overwritten"
977 );
978 }
979
980 #[test]
981 fn test_variable_default_values() {
982 let task_with_defaults = TaskTemplate {
983 label: "test with defaults".to_string(),
984 command: format!(
985 "echo ${{{}}}",
986 VariableName::File.to_string() + ":fallback.txt"
987 ),
988 args: vec![
989 "${ZED_MISSING_VAR:default_value}".to_string(),
990 format!("${{{}}}", VariableName::Row.to_string() + ":42"),
991 ],
992 ..TaskTemplate::default()
993 };
994
995 // Test 1: When ZED_FILE exists, should use actual value and ignore default
996 let context_with_file = TaskContext {
997 cwd: None,
998 task_variables: TaskVariables::from_iter(vec![
999 (VariableName::File, "actual_file.rs".to_string()),
1000 (VariableName::Row, "123".to_string()),
1001 ]),
1002 project_env: HashMap::default(),
1003 };
1004
1005 let resolved = task_with_defaults
1006 .resolve_task(TEST_ID_BASE, &context_with_file)
1007 .expect("Should resolve task with existing variables");
1008
1009 assert_eq!(
1010 resolved.resolved.command.unwrap(),
1011 "echo actual_file.rs",
1012 "Should use actual ZED_FILE value, not default"
1013 );
1014 assert_eq!(
1015 resolved.resolved.args,
1016 vec!["default_value", "123"],
1017 "Should use default for missing var, actual value for existing var"
1018 );
1019
1020 // Test 2: When ZED_FILE doesn't exist, should use default value
1021 let context_without_file = TaskContext {
1022 cwd: None,
1023 task_variables: TaskVariables::from_iter(vec![(VariableName::Row, "456".to_string())]),
1024 project_env: HashMap::default(),
1025 };
1026
1027 let resolved = task_with_defaults
1028 .resolve_task(TEST_ID_BASE, &context_without_file)
1029 .expect("Should resolve task using default values");
1030
1031 assert_eq!(
1032 resolved.resolved.command.unwrap(),
1033 "echo fallback.txt",
1034 "Should use default value when ZED_FILE is missing"
1035 );
1036 assert_eq!(
1037 resolved.resolved.args,
1038 vec!["default_value", "456"],
1039 "Should use defaults for missing vars"
1040 );
1041
1042 // Test 3: Missing ZED variable without default should fail
1043 let task_no_default = TaskTemplate {
1044 label: "test no default".to_string(),
1045 command: "${ZED_MISSING_NO_DEFAULT}".to_string(),
1046 ..TaskTemplate::default()
1047 };
1048
1049 assert!(
1050 task_no_default
1051 .resolve_task(TEST_ID_BASE, &TaskContext::default())
1052 .is_none(),
1053 "Should fail when ZED variable has no default and doesn't exist"
1054 );
1055 }
1056
1057 #[test]
1058 fn test_unknown_variables() {
1059 // Variable names starting with `ZED_` that are not valid should be
1060 // reported.
1061 let label = "test unknown variables".to_string();
1062 let command = "$ZED_UNKNOWN".to_string();
1063 let task = TaskTemplate {
1064 label,
1065 command,
1066 ..TaskTemplate::default()
1067 };
1068
1069 assert_eq!(task.unknown_variables(), vec!["ZED_UNKNOWN".to_string()]);
1070
1071 // Variable names starting with `ZED_CUSTOM_` should never be reported,
1072 // as those are dynamically provided by extensions.
1073 let label = "test custom variables".to_string();
1074 let command = "$ZED_CUSTOM_UNKNOWN".to_string();
1075 let task = TaskTemplate {
1076 label,
1077 command,
1078 ..TaskTemplate::default()
1079 };
1080
1081 assert!(task.unknown_variables().is_empty());
1082
1083 // Unknown variable names with defaults should still be reported,
1084 // otherwise the default would always be silently used.
1085 let label = "test custom variables".to_string();
1086 let command = "${ZED_UNKNOWN:default_value}".to_string();
1087 let task = TaskTemplate {
1088 label,
1089 command,
1090 ..TaskTemplate::default()
1091 };
1092
1093 assert_eq!(task.unknown_variables(), vec!["ZED_UNKNOWN".to_string()]);
1094
1095 // Valid variable names are not reported.
1096 let label = "test custom variables".to_string();
1097 let command = "$ZED_FILE".to_string();
1098 let task = TaskTemplate {
1099 label,
1100 command,
1101 ..TaskTemplate::default()
1102 };
1103 assert!(task.unknown_variables().is_empty());
1104 }
1105}