1use std::path::PathBuf;
2
3use anyhow::{bail, Context};
4use collections::{HashMap, HashSet};
5use schemars::{gen::SchemaSettings, JsonSchema};
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8use util::{truncate_and_remove_front, ResultExt};
9
10use crate::{
11 ResolvedTask, SpawnInTerminal, TaskContext, TaskId, VariableName, ZED_VARIABLE_NAME_PREFIX,
12};
13
14/// A template definition of a Zed task to run.
15/// May use the [`VariableName`] to get the corresponding substitutions into its fields.
16///
17/// Template itself is not ready to spawn a task, it needs to be resolved with a [`TaskContext`] first, that
18/// contains all relevant Zed state in task variables.
19/// A single template may produce different tasks (or none) for different contexts.
20#[derive(Clone, Default, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
21#[serde(rename_all = "snake_case")]
22pub struct TaskTemplate {
23 /// Human readable name of the task to display in the UI.
24 pub label: String,
25 /// Executable command to spawn.
26 pub command: String,
27 /// Arguments to the command.
28 #[serde(default)]
29 pub args: Vec<String>,
30 /// Env overrides for the command, will be appended to the terminal's environment from the settings.
31 #[serde(default)]
32 pub env: HashMap<String, String>,
33 /// Current working directory to spawn the command into, defaults to current project root.
34 #[serde(default)]
35 pub cwd: Option<String>,
36 /// Whether to use a new terminal tab or reuse the existing one to spawn the process.
37 #[serde(default)]
38 pub use_new_terminal: bool,
39 /// Whether to allow multiple instances of the same task to be run, or rather wait for the existing ones to finish.
40 #[serde(default)]
41 pub allow_concurrent_runs: bool,
42 /// What to do with the terminal pane and tab, after the command was started:
43 /// * `always` — always show the terminal pane, add and focus the corresponding task's tab in it (default)
44 /// * `never` — avoid changing current terminal pane focus, but still add/reuse the task's tab there
45 #[serde(default)]
46 pub reveal: RevealStrategy,
47
48 /// Represents the tags which this template attaches to. Adding this removes this task from other UI.
49 #[serde(default)]
50 pub tags: Vec<String>,
51}
52
53/// What to do with the terminal pane and tab, after the command was started.
54#[derive(Default, Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
55#[serde(rename_all = "snake_case")]
56pub enum RevealStrategy {
57 /// Always show the terminal pane, add and focus the corresponding task's tab in it.
58 #[default]
59 Always,
60 /// Do not change terminal pane focus, but still add/reuse the task's tab there.
61 Never,
62}
63
64/// A group of Tasks defined in a JSON file.
65#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
66pub struct TaskTemplates(pub Vec<TaskTemplate>);
67
68impl TaskTemplates {
69 /// Generates JSON schema of Tasks JSON template format.
70 pub fn generate_json_schema() -> serde_json_lenient::Value {
71 let schema = SchemaSettings::draft07()
72 .with(|settings| settings.option_add_null_type = false)
73 .into_generator()
74 .into_root_schema_for::<Self>();
75
76 serde_json_lenient::to_value(schema).unwrap()
77 }
78}
79
80impl TaskTemplate {
81 /// Replaces all `VariableName` task variables in the task template string fields.
82 /// If any replacement fails or the new string substitutions still have [`ZED_VARIABLE_NAME_PREFIX`],
83 /// `None` is returned.
84 ///
85 /// Every [`ResolvedTask`] gets a [`TaskId`], based on the `id_base` (to avoid collision with various task sources),
86 /// and hashes of its template and [`TaskContext`], see [`ResolvedTask`] fields' documentation for more details.
87 pub fn resolve_task(&self, id_base: &str, cx: &TaskContext) -> Option<ResolvedTask> {
88 if self.label.trim().is_empty() || self.command.trim().is_empty() {
89 return None;
90 }
91
92 let mut variable_names = HashMap::default();
93 let mut substituted_variables = HashSet::default();
94 let task_variables = cx
95 .task_variables
96 .0
97 .iter()
98 .map(|(key, value)| {
99 let key_string = key.to_string();
100 if !variable_names.contains_key(&key_string) {
101 variable_names.insert(key_string.clone(), key.clone());
102 }
103 (key_string, value.as_str())
104 })
105 .collect::<HashMap<_, _>>();
106 let truncated_variables = truncate_variables(&task_variables);
107 let cwd = match self.cwd.as_deref() {
108 Some(cwd) => {
109 let substitured_cwd = substitute_all_template_variables_in_str(
110 cwd,
111 &task_variables,
112 &variable_names,
113 &mut substituted_variables,
114 )?;
115 Some(substitured_cwd)
116 }
117 None => None,
118 }
119 .map(PathBuf::from)
120 .or(cx.cwd.clone());
121 let human_readable_label = substitute_all_template_variables_in_str(
122 &self.label,
123 &truncated_variables,
124 &variable_names,
125 &mut substituted_variables,
126 )?
127 .lines()
128 .fold(String::new(), |mut string, line| {
129 if string.is_empty() {
130 string.push_str(line);
131 } else {
132 string.push_str("\\n");
133 string.push_str(line);
134 }
135 string
136 });
137 let full_label = substitute_all_template_variables_in_str(
138 &self.label,
139 &task_variables,
140 &variable_names,
141 &mut substituted_variables,
142 )?;
143 let command = substitute_all_template_variables_in_str(
144 &self.command,
145 &task_variables,
146 &variable_names,
147 &mut substituted_variables,
148 )?;
149 let args_with_substitutions = substitute_all_template_variables_in_vec(
150 &self.args,
151 &task_variables,
152 &variable_names,
153 &mut substituted_variables,
154 )?;
155
156 let task_hash = to_hex_hash(&self)
157 .context("hashing task template")
158 .log_err()?;
159 let variables_hash = to_hex_hash(&task_variables)
160 .context("hashing task variables")
161 .log_err()?;
162 let id = TaskId(format!("{id_base}_{task_hash}_{variables_hash}"));
163 let mut env = substitute_all_template_variables_in_map(
164 &self.env,
165 &task_variables,
166 &variable_names,
167 &mut substituted_variables,
168 )?;
169 env.extend(task_variables.into_iter().map(|(k, v)| (k, v.to_owned())));
170 Some(ResolvedTask {
171 id: id.clone(),
172 substituted_variables,
173 original_task: self.clone(),
174 resolved_label: full_label.clone(),
175 resolved: Some(SpawnInTerminal {
176 id,
177 cwd,
178 full_label,
179 label: human_readable_label,
180 command_label: args_with_substitutions.iter().fold(
181 command.clone(),
182 |mut command_label, arg| {
183 command_label.push(' ');
184 command_label.push_str(arg);
185 command_label
186 },
187 ),
188 command,
189 args: self.args.clone(),
190 env,
191 use_new_terminal: self.use_new_terminal,
192 allow_concurrent_runs: self.allow_concurrent_runs,
193 reveal: self.reveal,
194 }),
195 })
196 }
197}
198
199const MAX_DISPLAY_VARIABLE_LENGTH: usize = 15;
200
201fn truncate_variables(task_variables: &HashMap<String, &str>) -> HashMap<String, String> {
202 task_variables
203 .iter()
204 .map(|(key, value)| {
205 (
206 key.clone(),
207 truncate_and_remove_front(value, MAX_DISPLAY_VARIABLE_LENGTH),
208 )
209 })
210 .collect()
211}
212
213fn to_hex_hash(object: impl Serialize) -> anyhow::Result<String> {
214 let json = serde_json_lenient::to_string(&object).context("serializing the object")?;
215 let mut hasher = Sha256::new();
216 hasher.update(json.as_bytes());
217 Ok(hex::encode(hasher.finalize()))
218}
219
220fn substitute_all_template_variables_in_str<A: AsRef<str>>(
221 template_str: &str,
222 task_variables: &HashMap<String, A>,
223 variable_names: &HashMap<String, VariableName>,
224 substituted_variables: &mut HashSet<VariableName>,
225) -> Option<String> {
226 let substituted_string = shellexpand::env_with_context(template_str, |var| {
227 // 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.
228 let colon_position = var.find(':').unwrap_or(var.len());
229 let (variable_name, default) = var.split_at(colon_position);
230 if let Some(name) = task_variables.get(variable_name) {
231 if let Some(substituted_variable) = variable_names.get(variable_name) {
232 substituted_variables.insert(substituted_variable.clone());
233 }
234
235 let mut name = name.as_ref().to_owned();
236 // Got a task variable hit
237 if !default.is_empty() {
238 name.push_str(default);
239 }
240 return Ok(Some(name));
241 } else if variable_name.starts_with(ZED_VARIABLE_NAME_PREFIX) {
242 bail!("Unknown variable name: {variable_name}");
243 }
244 // This is an unknown variable.
245 // 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.
246 // If there's a default, we need to return the string verbatim as otherwise shellexpand will apply that default for us.
247 if !default.is_empty() {
248 return Ok(Some(format!("${{{var}}}")));
249 }
250 // Else we can just return None and that variable will be left as is.
251 Ok(None)
252 })
253 .ok()?;
254 Some(substituted_string.into_owned())
255}
256
257fn substitute_all_template_variables_in_vec(
258 template_strs: &[String],
259 task_variables: &HashMap<String, &str>,
260 variable_names: &HashMap<String, VariableName>,
261 substituted_variables: &mut HashSet<VariableName>,
262) -> Option<Vec<String>> {
263 let mut expanded = Vec::with_capacity(template_strs.len());
264 for variable in template_strs {
265 let new_value = substitute_all_template_variables_in_str(
266 variable,
267 task_variables,
268 variable_names,
269 substituted_variables,
270 )?;
271 expanded.push(new_value);
272 }
273 Some(expanded)
274}
275
276fn substitute_all_template_variables_in_map(
277 keys_and_values: &HashMap<String, String>,
278 task_variables: &HashMap<String, &str>,
279 variable_names: &HashMap<String, VariableName>,
280 substituted_variables: &mut HashSet<VariableName>,
281) -> Option<HashMap<String, String>> {
282 let mut new_map: HashMap<String, String> = Default::default();
283 for (key, value) in keys_and_values {
284 let new_value = substitute_all_template_variables_in_str(
285 &value,
286 task_variables,
287 variable_names,
288 substituted_variables,
289 )?;
290 let new_key = substitute_all_template_variables_in_str(
291 &key,
292 task_variables,
293 variable_names,
294 substituted_variables,
295 )?;
296 new_map.insert(new_key, new_value);
297 }
298 Some(new_map)
299}
300
301#[cfg(test)]
302mod tests {
303 use std::{borrow::Cow, path::Path};
304
305 use crate::{TaskVariables, VariableName};
306
307 use super::*;
308
309 const TEST_ID_BASE: &str = "test_base";
310
311 #[test]
312 fn test_resolving_templates_with_blank_command_and_label() {
313 let task_with_all_properties = TaskTemplate {
314 label: "test_label".to_string(),
315 command: "test_command".to_string(),
316 args: vec!["test_arg".to_string()],
317 env: HashMap::from_iter([("test_env_key".to_string(), "test_env_var".to_string())]),
318 ..TaskTemplate::default()
319 };
320
321 for task_with_blank_property in &[
322 TaskTemplate {
323 label: "".to_string(),
324 ..task_with_all_properties.clone()
325 },
326 TaskTemplate {
327 command: "".to_string(),
328 ..task_with_all_properties.clone()
329 },
330 TaskTemplate {
331 label: "".to_string(),
332 command: "".to_string(),
333 ..task_with_all_properties.clone()
334 },
335 ] {
336 assert_eq!(
337 task_with_blank_property.resolve_task(TEST_ID_BASE, &TaskContext::default()),
338 None,
339 "should not resolve task with blank label and/or command: {task_with_blank_property:?}"
340 );
341 }
342 }
343
344 #[test]
345 fn test_template_cwd_resolution() {
346 let task_without_cwd = TaskTemplate {
347 cwd: None,
348 label: "test task".to_string(),
349 command: "echo 4".to_string(),
350 ..TaskTemplate::default()
351 };
352
353 let resolved_task = |task_template: &TaskTemplate, task_cx| {
354 let resolved_task = task_template
355 .resolve_task(TEST_ID_BASE, task_cx)
356 .unwrap_or_else(|| panic!("failed to resolve task {task_without_cwd:?}"));
357 assert_substituted_variables(&resolved_task, Vec::new());
358 resolved_task
359 .resolved
360 .clone()
361 .unwrap_or_else(|| {
362 panic!("failed to get resolve data for resolved task. Template: {task_without_cwd:?} Resolved: {resolved_task:?}")
363 })
364 };
365
366 let cx = TaskContext {
367 cwd: None,
368 task_variables: TaskVariables::default(),
369 };
370 assert_eq!(
371 resolved_task(&task_without_cwd, &cx).cwd,
372 None,
373 "When neither task nor task context have cwd, it should be None"
374 );
375
376 let context_cwd = Path::new("a").join("b").join("c");
377 let cx = TaskContext {
378 cwd: Some(context_cwd.clone()),
379 task_variables: TaskVariables::default(),
380 };
381 assert_eq!(
382 resolved_task(&task_without_cwd, &cx).cwd.as_deref(),
383 Some(context_cwd.as_path()),
384 "TaskContext's cwd should be taken on resolve if task's cwd is None"
385 );
386
387 let task_cwd = Path::new("d").join("e").join("f");
388 let mut task_with_cwd = task_without_cwd.clone();
389 task_with_cwd.cwd = Some(task_cwd.display().to_string());
390 let task_with_cwd = task_with_cwd;
391
392 let cx = TaskContext {
393 cwd: None,
394 task_variables: TaskVariables::default(),
395 };
396 assert_eq!(
397 resolved_task(&task_with_cwd, &cx).cwd.as_deref(),
398 Some(task_cwd.as_path()),
399 "TaskTemplate's cwd should be taken on resolve if TaskContext's cwd is None"
400 );
401
402 let cx = TaskContext {
403 cwd: Some(context_cwd.clone()),
404 task_variables: TaskVariables::default(),
405 };
406 assert_eq!(
407 resolved_task(&task_with_cwd, &cx).cwd.as_deref(),
408 Some(task_cwd.as_path()),
409 "TaskTemplate's cwd should be taken on resolve if TaskContext's cwd is not None"
410 );
411 }
412
413 #[test]
414 fn test_template_variables_resolution() {
415 let custom_variable_1 = VariableName::Custom(Cow::Borrowed("custom_variable_1"));
416 let custom_variable_2 = VariableName::Custom(Cow::Borrowed("custom_variable_2"));
417 let long_value = "01".repeat(MAX_DISPLAY_VARIABLE_LENGTH * 2);
418 let all_variables = [
419 (VariableName::Row, "1234".to_string()),
420 (VariableName::Column, "5678".to_string()),
421 (VariableName::File, "test_file".to_string()),
422 (VariableName::SelectedText, "test_selected_text".to_string()),
423 (VariableName::Symbol, long_value.clone()),
424 (VariableName::WorktreeRoot, "/test_root/".to_string()),
425 (
426 custom_variable_1.clone(),
427 "test_custom_variable_1".to_string(),
428 ),
429 (
430 custom_variable_2.clone(),
431 "test_custom_variable_2".to_string(),
432 ),
433 ];
434
435 let task_with_all_variables = TaskTemplate {
436 label: format!(
437 "test label for {} and {}",
438 VariableName::Row.template_value(),
439 VariableName::Symbol.template_value(),
440 ),
441 command: format!(
442 "echo {} {}",
443 VariableName::File.template_value(),
444 VariableName::Symbol.template_value(),
445 ),
446 args: vec![
447 format!("arg1 {}", VariableName::SelectedText.template_value()),
448 format!("arg2 {}", VariableName::Column.template_value()),
449 format!("arg3 {}", VariableName::Symbol.template_value()),
450 ],
451 env: HashMap::from_iter([
452 ("test_env_key".to_string(), "test_env_var".to_string()),
453 (
454 "env_key_1".to_string(),
455 VariableName::WorktreeRoot.template_value(),
456 ),
457 (
458 "env_key_2".to_string(),
459 format!(
460 "env_var_2_{}_{}",
461 custom_variable_1.template_value(),
462 custom_variable_2.template_value()
463 ),
464 ),
465 (
466 "env_key_3".to_string(),
467 format!("env_var_3_{}", VariableName::Symbol.template_value()),
468 ),
469 ]),
470 ..TaskTemplate::default()
471 };
472
473 let mut first_resolved_id = None;
474 for i in 0..15 {
475 let resolved_task = task_with_all_variables.resolve_task(
476 TEST_ID_BASE,
477 &TaskContext {
478 cwd: None,
479 task_variables: TaskVariables::from_iter(all_variables.clone()),
480 },
481 ).unwrap_or_else(|| panic!("Should successfully resolve task {task_with_all_variables:?} with variables {all_variables:?}"));
482
483 match &first_resolved_id {
484 None => first_resolved_id = Some(resolved_task.id.clone()),
485 Some(first_id) => assert_eq!(
486 &resolved_task.id, first_id,
487 "Step {i}, for the same task template and context, there should be the same resolved task id"
488 ),
489 }
490
491 assert_eq!(
492 resolved_task.original_task, task_with_all_variables,
493 "Resolved task should store its template without changes"
494 );
495 assert_eq!(
496 resolved_task.resolved_label,
497 format!("test label for 1234 and {long_value}"),
498 "Resolved task label should be substituted with variables and those should not be shortened"
499 );
500 assert_substituted_variables(
501 &resolved_task,
502 all_variables.iter().map(|(name, _)| name.clone()).collect(),
503 );
504
505 let spawn_in_terminal = resolved_task
506 .resolved
507 .as_ref()
508 .expect("should have resolved a spawn in terminal task");
509 assert_eq!(
510 spawn_in_terminal.label,
511 format!(
512 "test label for 1234 and …{}",
513 &long_value[..=MAX_DISPLAY_VARIABLE_LENGTH]
514 ),
515 "Human-readable label should have long substitutions trimmed"
516 );
517 assert_eq!(
518 spawn_in_terminal.command,
519 format!("echo test_file {long_value}"),
520 "Command should be substituted with variables and those should not be shortened"
521 );
522 assert_eq!(
523 spawn_in_terminal.args,
524 &[
525 "arg1 $ZED_SELECTED_TEXT",
526 "arg2 $ZED_COLUMN",
527 "arg3 $ZED_SYMBOL",
528 ],
529 "Args should not be substituted with variables"
530 );
531 assert_eq!(
532 spawn_in_terminal.command_label,
533 format!("{} arg1 test_selected_text arg2 5678 arg3 {long_value}", spawn_in_terminal.command),
534 "Command label args should be substituted with variables and those should not be shortened"
535 );
536
537 assert_eq!(
538 spawn_in_terminal
539 .env
540 .get("test_env_key")
541 .map(|s| s.as_str()),
542 Some("test_env_var")
543 );
544 assert_eq!(
545 spawn_in_terminal.env.get("env_key_1").map(|s| s.as_str()),
546 Some("/test_root/")
547 );
548 assert_eq!(
549 spawn_in_terminal.env.get("env_key_2").map(|s| s.as_str()),
550 Some("env_var_2_test_custom_variable_1_test_custom_variable_2")
551 );
552 assert_eq!(
553 spawn_in_terminal.env.get("env_key_3"),
554 Some(&format!("env_var_3_{long_value}")),
555 "Env vars should be substituted with variables and those should not be shortened"
556 );
557 }
558
559 for i in 0..all_variables.len() {
560 let mut not_all_variables = all_variables.to_vec();
561 let removed_variable = not_all_variables.remove(i);
562 let resolved_task_attempt = task_with_all_variables.resolve_task(
563 TEST_ID_BASE,
564 &TaskContext {
565 cwd: None,
566 task_variables: TaskVariables::from_iter(not_all_variables),
567 },
568 );
569 assert_eq!(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})");
570 }
571 }
572
573 #[test]
574 fn test_can_resolve_free_variables() {
575 let task = TaskTemplate {
576 label: "My task".into(),
577 command: "echo".into(),
578 args: vec!["$PATH".into()],
579 ..Default::default()
580 };
581 let resolved_task = task
582 .resolve_task(TEST_ID_BASE, &TaskContext::default())
583 .unwrap();
584 assert_substituted_variables(&resolved_task, Vec::new());
585 let resolved = resolved_task.resolved.unwrap();
586 assert_eq!(resolved.label, task.label);
587 assert_eq!(resolved.command, task.command);
588 assert_eq!(resolved.args, task.args);
589 }
590
591 #[test]
592 fn test_errors_on_missing_zed_variable() {
593 let task = TaskTemplate {
594 label: "My task".into(),
595 command: "echo".into(),
596 args: vec!["$ZED_VARIABLE".into()],
597 ..Default::default()
598 };
599 assert!(task
600 .resolve_task(TEST_ID_BASE, &TaskContext::default())
601 .is_none());
602 }
603
604 #[test]
605 fn test_symbol_dependent_tasks() {
606 let task_with_all_properties = TaskTemplate {
607 label: "test_label".to_string(),
608 command: "test_command".to_string(),
609 args: vec!["test_arg".to_string()],
610 env: HashMap::from_iter([("test_env_key".to_string(), "test_env_var".to_string())]),
611 ..TaskTemplate::default()
612 };
613 let cx = TaskContext {
614 cwd: None,
615 task_variables: TaskVariables::from_iter(Some((
616 VariableName::Symbol,
617 "test_symbol".to_string(),
618 ))),
619 };
620
621 for (i, symbol_dependent_task) in [
622 TaskTemplate {
623 label: format!("test_label_{}", VariableName::Symbol.template_value()),
624 ..task_with_all_properties.clone()
625 },
626 TaskTemplate {
627 command: format!("test_command_{}", VariableName::Symbol.template_value()),
628 ..task_with_all_properties.clone()
629 },
630 TaskTemplate {
631 args: vec![format!(
632 "test_arg_{}",
633 VariableName::Symbol.template_value()
634 )],
635 ..task_with_all_properties.clone()
636 },
637 TaskTemplate {
638 env: HashMap::from_iter([(
639 "test_env_key".to_string(),
640 format!("test_env_var_{}", VariableName::Symbol.template_value()),
641 )]),
642 ..task_with_all_properties.clone()
643 },
644 ]
645 .into_iter()
646 .enumerate()
647 {
648 let resolved = symbol_dependent_task
649 .resolve_task(TEST_ID_BASE, &cx)
650 .unwrap_or_else(|| panic!("Failed to resolve task {symbol_dependent_task:?}"));
651 assert_eq!(
652 resolved.substituted_variables,
653 HashSet::from_iter(Some(VariableName::Symbol)),
654 "(index {i}) Expected the task to depend on symbol task variable: {resolved:?}"
655 )
656 }
657 }
658
659 #[track_caller]
660 fn assert_substituted_variables(resolved_task: &ResolvedTask, mut expected: Vec<VariableName>) {
661 let mut resolved_variables = resolved_task
662 .substituted_variables
663 .iter()
664 .cloned()
665 .collect::<Vec<_>>();
666 resolved_variables.sort_by_key(|var| var.to_string());
667 expected.sort_by_key(|var| var.to_string());
668 assert_eq!(resolved_variables, expected)
669 }
670}