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, Default, 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 #[serde(skip)]
164 pub requests: Vec<Request>,
165 #[serde(skip)]
166 pub options: BuildOptions,
167}
168
169#[derive(Clone, Copy, Debug, Default)]
171pub struct BuildOptions {
172 pub exclude_task_dependencies: bool,
175 pub ignore_cycles: bool,
178}
179
180impl TaskGraph {
181 pub fn build(workspace: &Workspace, requests: &[Request]) -> Result<Self> {
182 Self::build_with(workspace, requests, BuildOptions::default())
183 }
184
185 pub fn build_with(
186 workspace: &Workspace,
187 requests: &[Request],
188 options: BuildOptions,
189 ) -> Result<Self> {
190 if requests.is_empty() {
191 bail!("no tasks selected");
192 }
193 let projects = ProjectGraph::build(workspace)?;
194 let mut builder = Builder {
195 workspace,
196 projects,
197 tasks: BTreeMap::new(),
198 visiting: Vec::new(),
199 visited: BTreeSet::new(),
200 ignore_cycles: options.ignore_cycles,
201 cycles: Vec::new(),
202 };
203 let mut roots = BTreeSet::new();
204 for request in requests {
205 let cli = request.args.clone();
206 let visit = Visit {
207 request: request.clone(),
208 overrides: Map::new(),
209 cli: &cli,
210 };
211 roots.insert(builder.visit(visit, true)?.with_context(|| {
212 format!(
213 "project {:?} has no target {:?}",
214 request.project, request.target
215 )
216 })?);
217 }
218 let mut graph = Self {
219 roots,
220 tasks: builder.tasks,
221 cycles: builder.cycles,
222 requests: requests.to_vec(),
223 options,
224 };
225 if !graph.cycles.is_empty() {
226 graph.make_acyclic();
227 }
228 if options.exclude_task_dependencies {
229 let roots = graph.roots.clone();
230 graph.tasks.retain(|id, _| roots.contains(id));
231 for task in graph.tasks.values_mut() {
232 task.dependencies.retain(|id| roots.contains(id));
233 task.edges.retain(|id, _| roots.contains(id));
234 }
235 }
236 Ok(graph)
237 }
238
239 pub fn rebuild(&self, workspace: &Workspace, extra: &[Request]) -> Result<Self> {
242 let mut requests = self.requests.clone();
243 requests.extend_from_slice(extra);
244 Self::build_with(workspace, &requests, self.options)
245 }
246
247 fn make_acyclic(&mut self) {
250 fn visit(
251 graph: &mut BTreeMap<String, Task>,
252 id: &str,
253 visited: &mut BTreeSet<String>,
254 path: &mut Vec<String>,
255 ) {
256 if !visited.insert(id.to_owned()) {
257 return;
258 }
259 let dependencies: Vec<String> = graph[id].dependencies.iter().cloned().collect();
260 for dependency in dependencies {
261 if path.contains(&dependency) {
262 let task = graph.get_mut(id).unwrap();
263 task.dependencies.remove(&dependency);
264 task.edges.remove(&dependency);
265 } else {
266 path.push(dependency.clone());
267 visit(graph, &dependency, visited, path);
268 path.pop();
269 }
270 }
271 }
272 let ids: Vec<String> = self.tasks.keys().cloned().collect();
273 let mut visited = BTreeSet::new();
274 for id in ids {
275 let mut path = vec![id.clone()];
276 visit(&mut self.tasks, &id, &mut visited, &mut path);
277 }
278 }
279}
280
281struct Declared {
285 requests: Vec<Request>,
286 overrides: Map<String, Value>,
287 derived: bool,
288 edge: Edge,
289}
290
291struct Visit<'c> {
294 request: Request,
295 overrides: Map<String, Value>,
296 cli: &'c [String],
297}
298
299struct Builder<'a> {
300 workspace: &'a Workspace,
301 projects: ProjectGraph,
302 tasks: BTreeMap<String, Task>,
303 visiting: Vec<String>,
304 visited: BTreeSet<(String, Option<String>)>,
305 ignore_cycles: bool,
306 cycles: Vec<Vec<String>>,
307}
308
309impl Builder<'_> {
310 fn visit(&mut self, visit: Visit, required: bool) -> Result<Option<String>> {
311 let Visit {
312 request,
313 overrides,
314 cli,
315 } = visit;
316 let project = self
317 .workspace
318 .projects
319 .get(&request.project)
320 .with_context(|| format!("unknown project {:?}", request.project))?;
321 let Some(target) = project.targets.get(&request.target) else {
322 if required {
323 bail!(
324 "project {:?} has no target {:?}",
325 request.project,
326 request.target
327 );
328 }
329 return Ok(None);
330 };
331 let requested_configuration = request.requested_configuration.clone();
332 let configuration = match request.configuration {
333 Some(name) if target.configurations.contains_key(&name) => Some(name),
334 Some(name) if required => bail!(
335 "{}:{} has no configuration {name:?}",
336 request.project,
337 request.target
338 ),
339 _ => target
341 .default_configuration
342 .clone()
343 .filter(|name| !name.is_empty()),
344 };
345 let id = format!(
346 "{}:{}{}",
347 request.project,
348 request.target,
349 configuration
350 .as_ref()
351 .map(|c| format!(":{c}"))
352 .unwrap_or_default()
353 );
354 if let Some(position) = self.visiting.iter().position(|task| task == &id) {
355 let mut cycle = self.visiting[position..].to_vec();
356 cycle.push(id.clone());
357 if !self.ignore_cycles {
358 bail!("task dependency cycle: {}", cycle.join(" -> "));
359 }
360 self.cycles.push(cycle);
362 return Ok(Some(id));
363 }
364 if self
365 .visited
366 .contains(&(id.clone(), requested_configuration.clone()))
367 {
368 return Ok(Some(id));
369 }
370 let (args, definition) = match self.tasks.get(&id) {
373 Some(existing) => (existing.args.clone(), existing.definition.clone()),
374 None => {
375 let mut definition = target.clone();
376 if let Some(name) = &configuration {
377 let options = target
378 .configurations
379 .get(name)
380 .with_context(|| format!("{id}: default configuration does not exist"))?;
381 for (key, value) in options {
382 if key == "env" {
383 value.as_object().with_context(|| {
384 format!("{id}: configuration env must be an object")
385 })?;
386 }
387 definition.options.insert(key.clone(), value.clone());
388 }
389 }
390 definition.options.extend(overrides);
393 (request.args, definition)
394 }
395 };
396 if let Some(outputs) = &definition.outputs {
398 validate_outputs(outputs)?;
399 }
400 let task = Task {
401 id: id.clone(),
402 project: request.project,
403 target: request.target,
404 configuration,
405 args,
406 definition,
407 dependencies: BTreeSet::new(),
408 edges: BTreeMap::new(),
409 };
410 self.visiting.push(id.clone());
411 let mut dependencies = self
412 .tasks
413 .get(&id)
414 .map(|task| Dependencies {
415 ids: task.dependencies.clone(),
416 edges: task.edges.clone(),
417 })
418 .unwrap_or_default();
419 self.collect(
420 &task,
421 &task.project,
422 requested_configuration.as_deref(),
423 cli,
424 &mut dependencies,
425 &mut BTreeSet::new(),
426 )?;
427 self.visiting.pop();
428 self.visited.insert((id.clone(), requested_configuration));
429 self.tasks.insert(
430 id.clone(),
431 Task {
432 dependencies: dependencies.ids,
433 edges: dependencies.edges,
434 ..task
435 },
436 );
437 Ok(Some(id))
438 }
439
440 fn collect(
445 &mut self,
446 task: &Task,
447 derive_from: &str,
448 requested_configuration: Option<&str>,
449 cli: &[String],
450 dependencies: &mut Dependencies,
451 seen: &mut BTreeSet<String>,
452 ) -> Result<()> {
453 if !seen.insert(derive_from.to_owned()) {
454 return Ok(());
455 }
456 for dependency in task.definition.depends_on.as_deref().unwrap_or_default() {
457 let Declared {
458 requests,
459 overrides,
460 derived,
461 edge,
462 } = self
463 .dependencies(task, derive_from, requested_configuration, cli, dependency)
464 .with_context(|| format!("dependsOn of {}", task.id))?;
465 for request in requests {
466 let has_target = self.workspace.projects[&request.project]
467 .targets
468 .contains_key(&request.target);
469 if derived && !has_target {
470 let project = request.project;
471 self.collect(
472 task,
473 &project,
474 requested_configuration,
475 cli,
476 dependencies,
477 seen,
478 )?;
479 } else if request.project == task.project && request.target == task.target {
480 continue;
482 } else {
483 let visit = Visit {
484 request,
485 overrides: overrides.clone(),
486 cli,
487 };
488 if let Some(id) = self.visit(visit, false)? {
489 dependencies.insert(id, edge);
490 }
491 }
492 }
493 }
494 Ok(())
495 }
496
497 fn dependencies(
500 &self,
501 task: &Task,
502 derive_from: &str,
503 requested_configuration: Option<&str>,
504 cli: &[String],
505 value: &Value,
506 ) -> Result<Declared> {
507 let mut forward_options = false;
508 let mut edge = Edge::default();
509 let (target, projects, forward, derived) = match value {
510 Value::String(value) => {
511 if let Some(target) = value.strip_prefix('^') {
512 (
513 target.to_owned(),
514 self.project_dependencies(derive_from),
515 false,
516 true,
517 )
518 } else {
519 let (project, target) = self.target_reference(value, &task.project);
520 (target, vec![project], false, false)
521 }
522 }
523 Value::Object(object) => {
524 let target = object
527 .get("target")
528 .and_then(Value::as_str)
529 .context("dependency target must be a string")?;
530 let forward = object.get("params").and_then(Value::as_str) == Some("forward");
531 forward_options = object.get("options").and_then(Value::as_str) == Some("forward");
532 if let Some(inputs) = object.get("inputs") {
533 edge.inputs = inputs
534 .as_bool()
535 .context("dependency inputs must be boolean")?;
536 }
537 edge.wait_for = match object.get("waitFor").and_then(Value::as_str) {
538 None if !object.contains_key("waitFor") => WaitFor::Started,
539 Some("started") => WaitFor::Started,
540 Some("ready") => WaitFor::Ready,
541 _ => bail!("dependency waitFor must be started or ready"),
542 };
543 let dependencies = match object.get("dependencies") {
544 Some(value) => value
545 .as_bool()
546 .context("dependency dependencies must be boolean")?,
547 None => false,
548 };
549 let projects = if let Some(selectors) = object.get("projects") {
551 let selectors: Vec<String> = match selectors {
552 Value::String(selector)
555 if matches!(selector.as_str(), "self" | "dependencies")
556 && !self.workspace.projects.contains_key(selector) =>
557 {
558 bail!(
559 "projects: {selector:?} is no longer supported; leave projects \
560 out for the task's own project, or use dependencies: true"
561 )
562 }
563 Value::String(selector) => vec![selector.clone()],
564 value => serde_json::from_value(value.clone())
565 .context("dependency projects must be a string or string array")?,
566 };
567 qk_config::find_matching_projects(
568 &selectors,
569 &self.workspace.projects,
570 qk_config::Reader::Nx,
571 )
572 } else if dependencies {
573 self.project_dependencies(derive_from)
574 } else {
575 vec![task.project.clone()]
576 };
577 let derived = dependencies && !object.contains_key("projects");
578 (target.to_owned(), projects, forward, derived)
579 }
580 _ => bail!("dependency must be a string or object"),
581 };
582 let targets: Vec<String> = if qk_config::is_glob(&target) {
585 let matcher = qk_config::minimatch::Minimatch::new(&target, false);
586 self.workspace
587 .projects
588 .values()
589 .flat_map(|project| project.targets.keys())
590 .filter(|name| matcher.is_match(name))
591 .cloned()
592 .collect::<BTreeSet<_>>()
593 .into_iter()
594 .collect()
595 } else {
596 vec![target]
597 };
598 let mut overrides = Map::new();
602 if forward_options {
603 let source = &self.workspace.projects[&task.project].targets[&task.target];
604 overrides.extend(source.options.clone());
605 if let Some(options) = task
606 .configuration
607 .as_ref()
608 .and_then(|name| source.configurations.get(name))
609 {
610 overrides.extend(options.clone());
611 }
612 }
613 let args = if forward { cli.to_vec() } else { Vec::new() };
614 let requests = targets
615 .iter()
616 .flat_map(|target| {
617 projects.iter().map(|project| Request {
618 project: project.clone(),
619 target: target.clone(),
620 configuration: requested_configuration.map(str::to_owned),
621 requested_configuration: requested_configuration.map(str::to_owned),
622 args: args.clone(),
623 })
624 })
625 .collect();
626 Ok(Declared {
627 requests,
628 overrides,
629 derived,
630 edge,
631 })
632 }
633
634 fn target_reference(&self, text: &str, current: &str) -> (String, String) {
638 let parts = split_target(text, &self.workspace.projects, current);
639 match &parts[..] {
640 [target] => (current.to_owned(), target.clone()),
641 [project, rest @ ..] if self.workspace.projects.contains_key(project) => {
642 (project.clone(), rest.join(":"))
643 }
644 _ => (current.to_owned(), text.to_owned()),
645 }
646 }
647
648 fn project_dependencies(&self, project: &str) -> Vec<String> {
649 self.projects.dependencies[project]
651 .iter()
652 .map(|edge| edge.target.clone())
653 .collect::<BTreeSet<_>>()
654 .into_iter()
655 .collect()
656 }
657}
658
659fn split_by_colons(text: &str) -> Vec<String> {
662 let mut parts = vec![String::new()];
663 let mut chars = text.chars();
664 while let Some(c) = chars.next() {
665 match c {
666 ':' => parts.push(String::new()),
667 '"' => {
668 for c in chars.by_ref() {
669 if c == '"' {
670 break;
671 }
672 parts.last_mut().unwrap().push(c);
673 }
674 }
675 c => parts.last_mut().unwrap().push(c),
676 }
677 }
678 parts
679}
680
681fn readings(
685 parts: &[String],
686 projects: &BTreeMap<String, qk_config::Project>,
687 current: &str,
688) -> Vec<Vec<String>> {
689 let mut readings = Vec::new();
690 let read = |project: &str, rest: &[String], readings: &mut Vec<Vec<String>>| {
691 let targets = &projects[project].targets;
692 for end in 1..=rest.len() {
693 let target = rest[..end].join(":");
694 let Some(definition) = targets.get(&target) else {
695 continue;
696 };
697 if end == rest.len() {
698 readings.push(vec![project.to_owned(), target]);
699 } else {
700 let configuration = rest[end..].join(":");
701 if definition.configurations.contains_key(&configuration) {
702 readings.push(vec![project.to_owned(), target, configuration]);
703 }
704 }
705 }
706 };
707 if projects.contains_key(current) {
708 read(current, parts, &mut readings);
709 }
710 for end in 1..=parts.len() {
711 let project = parts[..end].join(":");
712 if !projects.contains_key(&project) {
713 continue;
714 }
715 if end == parts.len() {
716 readings.push(vec![project]);
717 } else {
718 read(&project, &parts[end..], &mut readings);
719 }
720 }
721 readings
722}
723
724fn best(readings: Vec<Vec<String>>, current: &str) -> Option<Vec<String>> {
727 let rank = |reading: &Vec<String>| {
728 let length = |index: usize| reading.get(index).map_or(0, String::len);
729 (reading[0] == current, length(0), length(1), length(2))
730 };
731 let mut best: Option<Vec<String>> = None;
732 for reading in readings {
733 if best.as_ref().is_none_or(|best| rank(&reading) > rank(best)) {
734 best = Some(reading);
735 }
736 }
737 best
738}
739
740fn split_target(
742 text: &str,
743 projects: &BTreeMap<String, qk_config::Project>,
744 current: &str,
745) -> Vec<String> {
746 let parts = split_by_colons(text);
747 if let Some(best) = best(readings(&parts, projects, current), current) {
748 return best;
749 }
750 let colon = match text.find(':') {
751 Some(0) => text[1..].find(':').map(|index| index + 1),
752 colon => colon,
753 };
754 if colon.is_none_or(|colon| colon == 0) {
755 return vec![text.to_owned()];
756 }
757 let mut project = parts[0].clone();
758 let mut rest = parts[1..].to_vec();
759 if project.is_empty() && !rest.is_empty() {
761 let mut absorbed = 1;
762 for count in (1..rest.len()).rev() {
763 if projects.contains_key(&format!(":{}", rest[..count].join(":"))) {
764 absorbed = count;
765 break;
766 }
767 }
768 project = format!(":{}", rest[..absorbed].join(":"));
769 rest.drain(..absorbed);
770 }
771 let Some(configuration) = rest.last().cloned() else {
772 return vec![project];
773 };
774 if let Some(head) = text.get(..text.len().saturating_sub(configuration.len() + 1))
776 && let Some(best) = best(readings(&split_by_colons(head), projects, current), current)
777 && best.len() == 2
778 {
779 return vec![best[0].clone(), best[1].clone(), configuration];
780 }
781 let mut result = vec![project.clone()];
782 let targets = projects.get(&project).map(|project| &project.targets);
783 for end in (1..=rest.len()).rev() {
784 let target = rest[..end].join(":");
785 if targets.is_some_and(|targets| targets.contains_key(&target)) {
786 result.push(target);
787 if end < rest.len() {
788 result.push(rest[end..].join(":"));
789 }
790 return result;
791 }
792 }
793 result.extend(rest);
794 result
795}
796
797pub fn validate_outputs(outputs: &[String]) -> Result<()> {
802 let missing_prefix: Vec<&String> = outputs
803 .iter()
804 .filter(|output| {
805 let mut chars = output.strip_prefix('!').unwrap_or(output).chars();
808 !(chars.next() == Some('{') && chars.next().is_some() && chars.any(|c| c == '}'))
809 })
810 .collect();
811 let workspace_globs: Vec<&String> = outputs
812 .iter()
813 .filter(|output| !missing_prefix.contains(output))
814 .filter(|output| {
815 ["!{workspaceRoot}", "{workspaceRoot}"]
816 .iter()
817 .any(|prefix| output.strip_prefix(prefix).is_some_and(has_no_directory))
818 })
819 .collect();
820 let mut message = String::new();
821 if !missing_prefix.is_empty() {
822 message.push_str(&format!(
823 "The following outputs are invalid: \n{}\n\nRun `nx repair` to fix this.",
824 missing_prefix
825 .iter()
826 .map(|output| format!(
827 " - {output}\n ** Reason: Outputs must start with either \"{{workspaceRoot}}/\" or \"{{projectRoot}}/\"."
828 ))
829 .collect::<Vec<_>>()
830 .join("\n")
831 ));
832 }
833 if !workspace_globs.is_empty() {
834 message.push_str(&format!(
835 "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.",
836 workspace_globs
837 .iter()
838 .map(|output| output.as_str())
839 .collect::<Vec<_>>()
840 .join("\n - ")
841 ));
842 }
843 if message.is_empty() {
844 Ok(())
845 } else {
846 bail!(message)
847 }
848}
849
850fn has_no_directory(glob: &str) -> bool {
855 let mut directory = Vec::new();
856 for segment in glob.split('/') {
857 let mut syntax = false;
858 let mut chars = segment.chars();
859 while let Some(c) = chars.next() {
860 match c {
861 '\\' => {
862 chars.next();
863 }
864 '*' | '?' | '[' | ']' | '{' | '}' | '(' | ')' | '!' => syntax = true,
865 _ => {}
866 }
867 }
868 if syntax {
869 return directory.join("/").is_empty();
870 }
871 directory.push(segment);
872 }
873 false
874}