1mod npm_range;
4pub use npm_range::satisfies;
5
6pub fn links(range: &str, root: &str, target: &str, version: Option<&str>) -> bool {
12 if range.starts_with("workspace:") || range == "*" {
13 return true;
14 }
15 if let Some(path) = range.strip_prefix("file:")
16 && join(root, path) == target
17 {
18 return true;
19 }
20 version.is_some_and(|version| satisfies(version, range))
21}
22
23fn join(base: &str, path: &str) -> String {
26 let mut parts: Vec<&str> = Vec::new();
27 let mut up = 0;
28 for part in base.split('/').chain(path.split('/')) {
29 match part {
30 "" | "." => {}
31 ".." => {
32 if parts.pop().is_none() {
33 up += 1;
34 }
35 }
36 part => parts.push(part),
37 }
38 }
39 let mut joined = vec![".."; up];
40 joined.extend(parts);
41 let mut result = if joined.is_empty() {
42 ".".to_owned()
43 } else {
44 joined.join("/")
45 };
46 if path.ends_with('/') && result != "." {
47 result.push('/');
48 }
49 result
50}
51
52use std::collections::{BTreeMap, BTreeSet};
53
54use anyhow::{Result, bail};
55use qk_config::{Project, Reader, Workspace, find_matching_projects};
56use serde::Serialize;
57
58#[derive(Clone, Debug, Serialize)]
59pub struct Node {
60 pub name: String,
61 #[serde(rename = "type")]
62 pub kind: String,
63 pub data: Project,
64}
65
66#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
67pub struct Dependency {
68 pub source: String,
69 pub target: String,
70 #[serde(rename = "type")]
71 pub kind: String,
72}
73
74#[derive(Clone, Debug, Serialize)]
75pub struct ProjectGraph {
76 pub nodes: BTreeMap<String, Node>,
77 pub dependencies: BTreeMap<String, Vec<Dependency>>,
78}
79
80#[derive(Serialize)]
81pub struct GraphReport<'a> {
82 pub graph: &'a ProjectGraph,
83}
84
85impl ProjectGraph {
86 pub fn build(workspace: &Workspace) -> Result<Self> {
87 let mut packages: Vec<(String, &String)> = workspace
90 .packages
91 .keys()
92 .map(|name| {
93 let manifest = match workspace.projects[name].root.as_str() {
94 "." => "package.json".to_owned(),
95 root => format!("{root}/package.json"),
96 };
97 (manifest, name)
98 })
99 .collect();
100 packages.sort();
101 let mut package_names = BTreeMap::new();
102 if workspace.analyzes_package_json() {
103 for (_, project_name) in packages {
104 if let Some(name) = &workspace.packages[project_name].name {
105 package_names.insert(name, project_name);
106 }
107 }
108 }
109 check_implicit_dependencies(&workspace.projects)?;
110 let implicit: BTreeMap<&String, Vec<String>> = workspace
111 .projects
112 .iter()
113 .map(|(name, project)| {
114 (
115 name,
116 expand_implicit_dependencies(
117 name,
118 &project.implicit_dependencies,
119 &workspace.projects,
120 ),
121 )
122 })
123 .collect();
124 let mut graph = Self {
125 nodes: BTreeMap::new(),
126 dependencies: BTreeMap::new(),
127 };
128 for (name, project) in &workspace.projects {
129 let mut edges = BTreeMap::new();
130 if let Some(package) = workspace.packages.get(name) {
131 for (dependency, range) in package.dependency_ranges() {
132 if let Some(target) = package_names.get(&dependency.to_owned())
133 && *target != name
134 && links(
135 range,
136 &project.root,
137 &workspace.projects[*target].root,
138 workspace.packages[*target].version.as_deref(),
139 )
140 {
141 edges
142 .entry((*target).clone())
143 .or_insert_with(BTreeSet::new)
144 .insert("static");
145 }
146 }
147 }
148 for target in &implicit[name] {
149 match target.strip_prefix('!') {
150 Some(target) => {
151 edges.remove(target);
152 }
153 None => {
155 edges
156 .entry(target.clone())
157 .or_insert_with(BTreeSet::new)
158 .insert("implicit");
159 }
160 }
161 }
162 graph.dependencies.insert(
163 name.clone(),
164 edges
165 .into_iter()
166 .flat_map(|(target, kinds)| {
167 kinds.into_iter().map(move |kind| Dependency {
168 source: name.clone(),
169 target: target.clone(),
170 kind: kind.into(),
171 })
172 })
173 .collect(),
174 );
175 let mut data = project.clone();
176 data.implicit_dependencies = implicit[name].clone();
177 graph.nodes.insert(
178 name.clone(),
179 Node {
180 name: name.clone(),
181 kind: node_type(workspace, name, project).into(),
182 data,
183 },
184 );
185 }
186 Ok(graph)
187 }
188
189 pub fn dependents_of(&self, projects: &BTreeSet<String>) -> Result<BTreeSet<String>> {
191 let mut reverse: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
192 for edges in self.dependencies.values() {
193 for edge in edges {
194 reverse.entry(&edge.target).or_default().push(&edge.source);
195 }
196 }
197 let mut visited = projects.clone();
198 let mut pending: Vec<String> = projects.iter().cloned().collect();
199 while let Some(name) = pending.pop() {
200 if !self.nodes.contains_key(&name) {
201 bail!("unknown project {name:?}");
202 }
203 for dependent in reverse.get(name.as_str()).into_iter().flatten() {
204 if visited.insert((*dependent).to_owned()) {
205 pending.push((*dependent).to_owned());
206 }
207 }
208 }
209 Ok(visited)
210 }
211
212 pub fn related_to(&self, project: &str) -> Result<BTreeSet<String>> {
215 let mut related = self.dependents_of(&BTreeSet::from([project.to_owned()]))?;
216 let mut visited = BTreeSet::from([project]);
217 let mut pending = vec![project];
218 while let Some(name) = pending.pop() {
219 for edge in self.dependencies.get(name).into_iter().flatten() {
220 if visited.insert(&edge.target) {
221 related.insert(edge.target.clone());
222 pending.push(&edge.target);
223 }
224 }
225 }
226 Ok(related)
227 }
228
229 pub fn retain(&mut self, projects: &BTreeSet<String>) {
232 self.nodes.retain(|name, _| projects.contains(name));
233 self.dependencies.retain(|name, _| projects.contains(name));
234 }
235}
236
237fn node_type(workspace: &Workspace, name: &str, project: &Project) -> &'static str {
239 if let Some(project_type) = &project.project_type {
240 return if project_type == "library" {
241 "lib"
242 } else if name.ends_with("-e2e") || name == "e2e" {
243 "e2e"
244 } else {
245 "app"
246 };
247 }
248 let root = workspace.root.join(&project.root);
249 if root.join("tsconfig.lib.json").exists() {
250 return "lib";
251 }
252 if root.join("tsconfig.app.json").exists() {
253 return "app";
254 }
255 match workspace.packages.get(name) {
256 Some(package)
257 if package.exports.is_none()
258 && package.main.is_none()
259 && package.module.is_none()
260 && package.bin.is_none() =>
261 {
262 "app"
263 }
264 Some(_) => "lib",
266 None => "lib",
267 }
268}
269
270pub fn graph_order(
273 projects: &BTreeMap<String, Project>,
274 names: impl IntoIterator<Item = String>,
275) -> Vec<String> {
276 let mut names: Vec<_> = names.into_iter().collect();
277 names.sort();
278 names.sort_by_key(|name| std::cmp::Reverse(projects[name].root.len()));
279 names
280}
281
282pub fn select_projects(
286 projects: &BTreeMap<String, Project>,
287 selectors: &[String],
288 excludes: &[String],
289) -> Result<BTreeSet<String>> {
290 let selected: BTreeMap<String, &Project> = if selectors.is_empty() {
291 projects
292 .iter()
293 .map(|(name, project)| (name.clone(), project))
294 .collect()
295 } else {
296 find_matching_projects(selectors, projects, Reader::Nx)
297 .into_iter()
298 .map(|name| {
299 let project = &projects[&name];
300 (name, project)
301 })
302 .collect()
303 };
304 let excluded: BTreeSet<String> = find_matching_projects(excludes, &selected, Reader::Nx)
305 .into_iter()
306 .collect();
307 Ok(selected
308 .into_keys()
309 .filter(|name| !excluded.contains(name))
310 .collect())
311}
312
313fn check_implicit_dependencies(projects: &BTreeMap<String, Project>) -> Result<()> {
317 let mut invalid = Vec::new();
318 for (name, project) in projects {
319 let missing: Vec<&String> = project
320 .implicit_dependencies
321 .iter()
322 .filter(|dependency| {
323 let target = dependency.strip_prefix('!').unwrap_or(dependency);
324 !target.starts_with("nx-cloud:")
325 && !projects.contains_key(target)
326 && find_matching_projects(&[dependency.as_str()], projects, Reader::Nx)
327 .is_empty()
328 })
329 .collect();
330 if !missing.is_empty() {
331 let mut message = format!(" {name}");
332 for dependency in missing {
333 message.push_str(&format!("\n {dependency}"));
334 }
335 invalid.push(message);
336 }
337 }
338 if !invalid.is_empty() {
339 bail!(
340 "The following implicitDependencies point to non-existent project(s):\n{}",
341 invalid.join("\n\n")
342 );
343 }
344 Ok(())
345}
346
347pub fn expand_implicit_dependencies(
351 source: &str,
352 dependencies: &[String],
353 projects: &BTreeMap<String, Project>,
354) -> Vec<String> {
355 let (negative, positive): (Vec<&String>, Vec<&String>) = dependencies
356 .iter()
357 .partition(|dependency| dependency.starts_with('!'));
358 let mut expanded: Vec<String> = if positive.is_empty() {
359 Vec::new()
360 } else {
361 let patterns: Vec<&String> = positive.iter().chain(&negative).copied().collect();
362 find_matching_projects(&patterns, projects, Reader::Nx)
363 .into_iter()
364 .filter(|name| name != source)
365 .collect()
366 };
367 let negative: Vec<&str> = negative.iter().map(|dependency| &dependency[1..]).collect();
368 expanded.extend(
369 find_matching_projects(&negative, projects, Reader::Nx)
370 .into_iter()
371 .map(|name| format!("!{name}")),
372 );
373 expanded
374}