1use std::collections::{BTreeMap, BTreeSet};
4
5use anyhow::{Context, Result, bail};
6use qk_config::{Target, Workspace};
7use qk_graph::ProjectGraph;
8use serde::Serialize;
9use serde_json::{Map, Value};
10
11#[derive(Clone, Debug)]
12pub struct Request {
13 pub project: String,
14 pub target: String,
15 pub configuration: Option<String>,
16 pub requested_configuration: Option<String>,
18 pub args: Vec<String>,
19}
20
21impl Request {
22 pub fn parse(value: &str) -> Result<Self> {
23 let parts: Vec<_> = value.split(':').collect();
24 if !(2..=3).contains(&parts.len()) || parts.iter().any(|part| part.is_empty()) {
25 bail!("expected project:target[:configuration], got {value:?}");
26 }
27 Ok(Self {
28 project: parts[0].into(),
29 target: parts[1].into(),
30 configuration: parts.get(2).map(|part| (*part).into()),
31 requested_configuration: parts.get(2).map(|part| (*part).into()),
32 args: Vec::new(),
33 })
34 }
35
36 pub fn parse_in(workspace: &Workspace, value: &str) -> Result<Self> {
38 let Some((project, identifier)) = value.split_once(':') else {
39 return Self::parse(value);
40 };
41 if let Some(project_config) = workspace.projects.get(project) {
42 if project_config.targets.contains_key(identifier) {
43 return Ok(Self {
44 project: project.into(),
45 target: identifier.into(),
46 configuration: None,
47 requested_configuration: None,
48 args: Vec::new(),
49 });
50 }
51 if let Some((target, configuration)) = identifier.rsplit_once(':')
52 && project_config.targets.contains_key(target)
53 && !configuration.is_empty()
54 {
55 return Ok(Self {
56 project: project.into(),
57 target: target.into(),
58 configuration: Some(configuration.into()),
59 requested_configuration: Some(configuration.into()),
60 args: Vec::new(),
61 });
62 }
63 }
64 Self::parse(value)
65 }
66}
67
68#[derive(Clone, Debug, Serialize)]
69pub struct Task {
70 pub id: String,
71 pub project: String,
72 pub target: String,
73 pub configuration: Option<String>,
74 pub args: Vec<String>,
75 pub definition: Target,
76 pub dependencies: BTreeSet<String>,
77 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
81 pub edges: BTreeMap<String, Edge>,
82}
83
84impl Task {
85 pub fn edge(&self, dependency: &str) -> Edge {
87 self.edges.get(dependency).copied().unwrap_or_default()
88 }
89}
90
91#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
93#[serde(rename_all = "camelCase")]
94pub struct Edge {
95 pub inputs: bool,
99 pub wait_for: WaitFor,
100}
101
102impl Default for Edge {
103 fn default() -> Self {
104 Self {
105 inputs: true,
106 wait_for: WaitFor::Started,
107 }
108 }
109}
110
111impl Edge {
112 fn merge(self, other: Self) -> Self {
115 Self {
116 inputs: self.inputs || other.inputs,
117 wait_for: self.wait_for.max(other.wait_for),
118 }
119 }
120}
121
122#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize)]
125#[serde(rename_all = "camelCase")]
126pub enum WaitFor {
127 #[default]
128 Started,
129 Ready,
130}
131
132#[derive(Default)]
134struct Dependencies {
135 ids: BTreeSet<String>,
136 edges: BTreeMap<String, Edge>,
137}
138
139impl Dependencies {
140 fn insert(&mut self, id: String, edge: Edge) {
141 let edge = if self.ids.insert(id.clone()) {
142 edge
143 } else {
144 self.edges.get(&id).copied().unwrap_or_default().merge(edge)
145 };
146 if edge == Edge::default() {
147 self.edges.remove(&id);
148 } else {
149 self.edges.insert(id, edge);
150 }
151 }
152}
153
154#[derive(Debug, Serialize)]
155pub struct TaskGraph {
156 pub roots: BTreeSet<String>,
157 pub tasks: BTreeMap<String, Task>,
158 #[serde(skip)]
160 pub cycles: Vec<Vec<String>>,
161}
162
163#[derive(Clone, Copy, Debug, Default)]
165pub struct BuildOptions {
166 pub exclude_task_dependencies: bool,
169 pub ignore_cycles: bool,
172}
173
174impl TaskGraph {
175 pub fn build(workspace: &Workspace, requests: &[Request]) -> Result<Self> {
176 Self::build_with(workspace, requests, BuildOptions::default())
177 }
178
179 pub fn build_with(
180 workspace: &Workspace,
181 requests: &[Request],
182 options: BuildOptions,
183 ) -> Result<Self> {
184 if requests.is_empty() {
185 bail!("no tasks selected");
186 }
187 let projects = ProjectGraph::build(workspace)?;
188 let mut builder = Builder {
189 workspace,
190 projects,
191 tasks: BTreeMap::new(),
192 visiting: Vec::new(),
193 visited: BTreeSet::new(),
194 ignore_cycles: options.ignore_cycles,
195 cycles: Vec::new(),
196 };
197 let mut roots = BTreeSet::new();
198 for request in requests {
199 let cli = request.args.clone();
200 let visit = Visit {
201 request: request.clone(),
202 overrides: Map::new(),
203 cli: &cli,
204 };
205 roots.insert(builder.visit(visit, true)?.with_context(|| {
206 format!(
207 "project {:?} has no target {:?}",
208 request.project, request.target
209 )
210 })?);
211 }
212 let mut graph = Self {
213 roots,
214 tasks: builder.tasks,
215 cycles: builder.cycles,
216 };
217 if !graph.cycles.is_empty() {
218 graph.make_acyclic();
219 }
220 if options.exclude_task_dependencies {
221 let roots = graph.roots.clone();
222 graph.tasks.retain(|id, _| roots.contains(id));
223 for task in graph.tasks.values_mut() {
224 task.dependencies.retain(|id| roots.contains(id));
225 task.edges.retain(|id, _| roots.contains(id));
226 }
227 }
228 Ok(graph)
229 }
230
231 fn make_acyclic(&mut self) {
234 fn visit(
235 graph: &mut BTreeMap<String, Task>,
236 id: &str,
237 visited: &mut BTreeSet<String>,
238 path: &mut Vec<String>,
239 ) {
240 if !visited.insert(id.to_owned()) {
241 return;
242 }
243 let dependencies: Vec<String> = graph[id].dependencies.iter().cloned().collect();
244 for dependency in dependencies {
245 if path.contains(&dependency) {
246 let task = graph.get_mut(id).unwrap();
247 task.dependencies.remove(&dependency);
248 task.edges.remove(&dependency);
249 } else {
250 path.push(dependency.clone());
251 visit(graph, &dependency, visited, path);
252 path.pop();
253 }
254 }
255 }
256 let ids: Vec<String> = self.tasks.keys().cloned().collect();
257 let mut visited = BTreeSet::new();
258 for id in ids {
259 let mut path = vec![id.clone()];
260 visit(&mut self.tasks, &id, &mut visited, &mut path);
261 }
262 }
263}
264
265struct Declared {
269 requests: Vec<Request>,
270 overrides: Map<String, Value>,
271 derived: bool,
272 edge: Edge,
273}
274
275struct Visit<'c> {
278 request: Request,
279 overrides: Map<String, Value>,
280 cli: &'c [String],
281}
282
283struct Builder<'a> {
284 workspace: &'a Workspace,
285 projects: ProjectGraph,
286 tasks: BTreeMap<String, Task>,
287 visiting: Vec<String>,
288 visited: BTreeSet<(String, Option<String>)>,
289 ignore_cycles: bool,
290 cycles: Vec<Vec<String>>,
291}
292
293impl Builder<'_> {
294 fn visit(&mut self, visit: Visit, required: bool) -> Result<Option<String>> {
295 let Visit {
296 request,
297 overrides,
298 cli,
299 } = visit;
300 let project = self
301 .workspace
302 .projects
303 .get(&request.project)
304 .with_context(|| format!("unknown project {:?}", request.project))?;
305 let Some(target) = project.targets.get(&request.target) else {
306 if required {
307 bail!(
308 "project {:?} has no target {:?}",
309 request.project,
310 request.target
311 );
312 }
313 return Ok(None);
314 };
315 let requested_configuration = request.requested_configuration.clone();
316 let configuration = match request.configuration {
317 Some(name) if target.configurations.contains_key(&name) => Some(name),
318 Some(name) if required => bail!(
319 "{}:{} has no configuration {name:?}",
320 request.project,
321 request.target
322 ),
323 _ => target
325 .default_configuration
326 .clone()
327 .filter(|name| !name.is_empty()),
328 };
329 let id = format!(
330 "{}:{}{}",
331 request.project,
332 request.target,
333 configuration
334 .as_ref()
335 .map(|c| format!(":{c}"))
336 .unwrap_or_default()
337 );
338 if let Some(position) = self.visiting.iter().position(|task| task == &id) {
339 let mut cycle = self.visiting[position..].to_vec();
340 cycle.push(id.clone());
341 if !self.ignore_cycles {
342 bail!("task dependency cycle: {}", cycle.join(" -> "));
343 }
344 self.cycles.push(cycle);
346 return Ok(Some(id));
347 }
348 if self
349 .visited
350 .contains(&(id.clone(), requested_configuration.clone()))
351 {
352 return Ok(Some(id));
353 }
354 let (args, definition) = match self.tasks.get(&id) {
357 Some(existing) => (existing.args.clone(), existing.definition.clone()),
358 None => {
359 let mut definition = target.clone();
360 if let Some(name) = &configuration {
361 let options = target
362 .configurations
363 .get(name)
364 .with_context(|| format!("{id}: default configuration does not exist"))?;
365 for (key, value) in options {
366 if key == "env" {
367 value.as_object().with_context(|| {
368 format!("{id}: configuration env must be an object")
369 })?;
370 }
371 definition.options.insert(key.clone(), value.clone());
372 }
373 }
374 definition.options.extend(overrides);
377 (request.args, definition)
378 }
379 };
380 if let Some(outputs) = &definition.outputs {
382 validate_outputs(outputs)?;
383 }
384 let task = Task {
385 id: id.clone(),
386 project: request.project,
387 target: request.target,
388 configuration,
389 args,
390 definition,
391 dependencies: BTreeSet::new(),
392 edges: BTreeMap::new(),
393 };
394 self.visiting.push(id.clone());
395 let mut dependencies = self
396 .tasks
397 .get(&id)
398 .map(|task| Dependencies {
399 ids: task.dependencies.clone(),
400 edges: task.edges.clone(),
401 })
402 .unwrap_or_default();
403 self.collect(
404 &task,
405 &task.project,
406 requested_configuration.as_deref(),
407 cli,
408 &mut dependencies,
409 &mut BTreeSet::new(),
410 )?;
411 self.visiting.pop();
412 self.visited.insert((id.clone(), requested_configuration));
413 self.tasks.insert(
414 id.clone(),
415 Task {
416 dependencies: dependencies.ids,
417 edges: dependencies.edges,
418 ..task
419 },
420 );
421 Ok(Some(id))
422 }
423
424 fn collect(
429 &mut self,
430 task: &Task,
431 derive_from: &str,
432 requested_configuration: Option<&str>,
433 cli: &[String],
434 dependencies: &mut Dependencies,
435 seen: &mut BTreeSet<String>,
436 ) -> Result<()> {
437 if !seen.insert(derive_from.to_owned()) {
438 return Ok(());
439 }
440 for dependency in task.definition.depends_on.as_deref().unwrap_or_default() {
441 let Declared {
442 requests,
443 overrides,
444 derived,
445 edge,
446 } = self
447 .dependencies(task, derive_from, requested_configuration, cli, dependency)
448 .with_context(|| format!("dependsOn of {}", task.id))?;
449 for request in requests {
450 let has_target = self.workspace.projects[&request.project]
451 .targets
452 .contains_key(&request.target);
453 if derived && !has_target {
454 let project = request.project;
455 self.collect(
456 task,
457 &project,
458 requested_configuration,
459 cli,
460 dependencies,
461 seen,
462 )?;
463 } else if request.project == task.project && request.target == task.target {
464 continue;
466 } else {
467 let visit = Visit {
468 request,
469 overrides: overrides.clone(),
470 cli,
471 };
472 if let Some(id) = self.visit(visit, false)? {
473 dependencies.insert(id, edge);
474 }
475 }
476 }
477 }
478 Ok(())
479 }
480
481 fn dependencies(
484 &self,
485 task: &Task,
486 derive_from: &str,
487 requested_configuration: Option<&str>,
488 cli: &[String],
489 value: &Value,
490 ) -> Result<Declared> {
491 let mut forward_options = false;
492 let mut edge = Edge::default();
493 let (target, projects, forward, derived) = match value {
494 Value::String(value) => {
495 if let Some(target) = value.strip_prefix('^') {
496 (
497 target.to_owned(),
498 self.project_dependencies(derive_from),
499 false,
500 true,
501 )
502 } else {
503 let (project, target) = self.target_reference(value, &task.project);
504 (target, vec![project], false, false)
505 }
506 }
507 Value::Object(object) => {
508 let target = object
511 .get("target")
512 .and_then(Value::as_str)
513 .context("dependency target must be a string")?;
514 let forward = object.get("params").and_then(Value::as_str) == Some("forward");
515 forward_options = object.get("options").and_then(Value::as_str) == Some("forward");
516 if let Some(inputs) = object.get("inputs") {
517 edge.inputs = inputs
518 .as_bool()
519 .context("dependency inputs must be boolean")?;
520 }
521 edge.wait_for = match object.get("waitFor").and_then(Value::as_str) {
522 None if !object.contains_key("waitFor") => WaitFor::Started,
523 Some("started") => WaitFor::Started,
524 Some("ready") => WaitFor::Ready,
525 _ => bail!("dependency waitFor must be started or ready"),
526 };
527 let dependencies = match object.get("dependencies") {
528 Some(value) => value
529 .as_bool()
530 .context("dependency dependencies must be boolean")?,
531 None => false,
532 };
533 let projects = if let Some(selectors) = object.get("projects") {
535 let selectors: Vec<String> = match selectors {
536 Value::String(selector)
539 if matches!(selector.as_str(), "self" | "dependencies")
540 && !self.workspace.projects.contains_key(selector) =>
541 {
542 bail!(
543 "projects: {selector:?} is no longer supported; leave projects \
544 out for the task's own project, or use dependencies: true"
545 )
546 }
547 Value::String(selector) => vec![selector.clone()],
548 value => serde_json::from_value(value.clone())
549 .context("dependency projects must be a string or string array")?,
550 };
551 qk_config::find_matching_projects(
552 &selectors,
553 &self.workspace.projects,
554 qk_config::Reader::Nx,
555 )
556 } else if dependencies {
557 self.project_dependencies(derive_from)
558 } else {
559 vec![task.project.clone()]
560 };
561 let derived = dependencies && !object.contains_key("projects");
562 (target.to_owned(), projects, forward, derived)
563 }
564 _ => bail!("dependency must be a string or object"),
565 };
566 let targets: Vec<String> = if qk_config::is_glob(&target) {
569 let matcher = qk_config::minimatch::Minimatch::new(&target, false);
570 self.workspace
571 .projects
572 .values()
573 .flat_map(|project| project.targets.keys())
574 .filter(|name| matcher.is_match(name))
575 .cloned()
576 .collect::<BTreeSet<_>>()
577 .into_iter()
578 .collect()
579 } else {
580 vec![target]
581 };
582 let mut overrides = Map::new();
586 if forward_options {
587 let source = &self.workspace.projects[&task.project].targets[&task.target];
588 overrides.extend(source.options.clone());
589 if let Some(options) = task
590 .configuration
591 .as_ref()
592 .and_then(|name| source.configurations.get(name))
593 {
594 overrides.extend(options.clone());
595 }
596 }
597 let args = if forward { cli.to_vec() } else { Vec::new() };
598 let requests = targets
599 .iter()
600 .flat_map(|target| {
601 projects.iter().map(|project| Request {
602 project: project.clone(),
603 target: target.clone(),
604 configuration: requested_configuration.map(str::to_owned),
605 requested_configuration: requested_configuration.map(str::to_owned),
606 args: args.clone(),
607 })
608 })
609 .collect();
610 Ok(Declared {
611 requests,
612 overrides,
613 derived,
614 edge,
615 })
616 }
617
618 fn target_reference(&self, text: &str, current: &str) -> (String, String) {
622 let parts = split_target(text, &self.workspace.projects, current);
623 match &parts[..] {
624 [target] => (current.to_owned(), target.clone()),
625 [project, rest @ ..] if self.workspace.projects.contains_key(project) => {
626 (project.clone(), rest.join(":"))
627 }
628 _ => (current.to_owned(), text.to_owned()),
629 }
630 }
631
632 fn project_dependencies(&self, project: &str) -> Vec<String> {
633 self.projects.dependencies[project]
635 .iter()
636 .map(|edge| edge.target.clone())
637 .collect::<BTreeSet<_>>()
638 .into_iter()
639 .collect()
640 }
641}
642
643fn split_by_colons(text: &str) -> Vec<String> {
646 let mut parts = vec![String::new()];
647 let mut chars = text.chars();
648 while let Some(c) = chars.next() {
649 match c {
650 ':' => parts.push(String::new()),
651 '"' => {
652 for c in chars.by_ref() {
653 if c == '"' {
654 break;
655 }
656 parts.last_mut().unwrap().push(c);
657 }
658 }
659 c => parts.last_mut().unwrap().push(c),
660 }
661 }
662 parts
663}
664
665fn readings(
669 parts: &[String],
670 projects: &BTreeMap<String, qk_config::Project>,
671 current: &str,
672) -> Vec<Vec<String>> {
673 let mut readings = Vec::new();
674 let read = |project: &str, rest: &[String], readings: &mut Vec<Vec<String>>| {
675 let targets = &projects[project].targets;
676 for end in 1..=rest.len() {
677 let target = rest[..end].join(":");
678 let Some(definition) = targets.get(&target) else {
679 continue;
680 };
681 if end == rest.len() {
682 readings.push(vec![project.to_owned(), target]);
683 } else {
684 let configuration = rest[end..].join(":");
685 if definition.configurations.contains_key(&configuration) {
686 readings.push(vec![project.to_owned(), target, configuration]);
687 }
688 }
689 }
690 };
691 if projects.contains_key(current) {
692 read(current, parts, &mut readings);
693 }
694 for end in 1..=parts.len() {
695 let project = parts[..end].join(":");
696 if !projects.contains_key(&project) {
697 continue;
698 }
699 if end == parts.len() {
700 readings.push(vec![project]);
701 } else {
702 read(&project, &parts[end..], &mut readings);
703 }
704 }
705 readings
706}
707
708fn best(readings: Vec<Vec<String>>, current: &str) -> Option<Vec<String>> {
711 let rank = |reading: &Vec<String>| {
712 let length = |index: usize| reading.get(index).map_or(0, String::len);
713 (reading[0] == current, length(0), length(1), length(2))
714 };
715 let mut best: Option<Vec<String>> = None;
716 for reading in readings {
717 if best.as_ref().is_none_or(|best| rank(&reading) > rank(best)) {
718 best = Some(reading);
719 }
720 }
721 best
722}
723
724fn split_target(
726 text: &str,
727 projects: &BTreeMap<String, qk_config::Project>,
728 current: &str,
729) -> Vec<String> {
730 let parts = split_by_colons(text);
731 if let Some(best) = best(readings(&parts, projects, current), current) {
732 return best;
733 }
734 let colon = match text.find(':') {
735 Some(0) => text[1..].find(':').map(|index| index + 1),
736 colon => colon,
737 };
738 if colon.is_none_or(|colon| colon == 0) {
739 return vec![text.to_owned()];
740 }
741 let mut project = parts[0].clone();
742 let mut rest = parts[1..].to_vec();
743 if project.is_empty() && !rest.is_empty() {
745 let mut absorbed = 1;
746 for count in (1..rest.len()).rev() {
747 if projects.contains_key(&format!(":{}", rest[..count].join(":"))) {
748 absorbed = count;
749 break;
750 }
751 }
752 project = format!(":{}", rest[..absorbed].join(":"));
753 rest.drain(..absorbed);
754 }
755 let Some(configuration) = rest.last().cloned() else {
756 return vec![project];
757 };
758 if let Some(head) = text.get(..text.len().saturating_sub(configuration.len() + 1))
760 && let Some(best) = best(readings(&split_by_colons(head), projects, current), current)
761 && best.len() == 2
762 {
763 return vec![best[0].clone(), best[1].clone(), configuration];
764 }
765 let mut result = vec![project.clone()];
766 let targets = projects.get(&project).map(|project| &project.targets);
767 for end in (1..=rest.len()).rev() {
768 let target = rest[..end].join(":");
769 if targets.is_some_and(|targets| targets.contains_key(&target)) {
770 result.push(target);
771 if end < rest.len() {
772 result.push(rest[end..].join(":"));
773 }
774 return result;
775 }
776 }
777 result.extend(rest);
778 result
779}
780
781pub fn validate_outputs(outputs: &[String]) -> Result<()> {
786 let missing_prefix: Vec<&String> = outputs
787 .iter()
788 .filter(|output| {
789 let mut chars = output.strip_prefix('!').unwrap_or(output).chars();
792 !(chars.next() == Some('{') && chars.next().is_some() && chars.any(|c| c == '}'))
793 })
794 .collect();
795 let workspace_globs: Vec<&String> = outputs
796 .iter()
797 .filter(|output| !missing_prefix.contains(output))
798 .filter(|output| {
799 ["!{workspaceRoot}", "{workspaceRoot}"]
800 .iter()
801 .any(|prefix| output.strip_prefix(prefix).is_some_and(has_no_directory))
802 })
803 .collect();
804 let mut message = String::new();
805 if !missing_prefix.is_empty() {
806 message.push_str(&format!(
807 "The following outputs are invalid: \n{}\n\nRun `nx repair` to fix this.",
808 missing_prefix
809 .iter()
810 .map(|output| format!(
811 " - {output}\n ** Reason: Outputs must start with either \"{{workspaceRoot}}/\" or \"{{projectRoot}}/\"."
812 ))
813 .collect::<Vec<_>>()
814 .join("\n")
815 ));
816 }
817 if !workspace_globs.is_empty() {
818 message.push_str(&format!(
819 "The following outputs are defined by a glob pattern from the workspace root: \n - {}\n\nThese can be slow, replace them with a more specific pattern.",
820 workspace_globs
821 .iter()
822 .map(|output| output.as_str())
823 .collect::<Vec<_>>()
824 .join("\n - ")
825 ));
826 }
827 if message.is_empty() {
828 Ok(())
829 } else {
830 bail!(message)
831 }
832}
833
834fn has_no_directory(glob: &str) -> bool {
839 let mut directory = Vec::new();
840 for segment in glob.split('/') {
841 let mut syntax = false;
842 let mut chars = segment.chars();
843 while let Some(c) = chars.next() {
844 match c {
845 '\\' => {
846 chars.next();
847 }
848 '*' | '?' | '[' | ']' | '{' | '}' | '(' | ')' | '!' => syntax = true,
849 _ => {}
850 }
851 }
852 if syntax {
853 return directory.join("/").is_empty();
854 }
855 directory.push(segment);
856 }
857 false
858}