1mod npm_range;
4pub use npm_range::satisfies;
5
6fn 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::{Context, Result, bail};
55use globset::Glob;
56use qk_config::{Project, Workspace};
57use serde::Serialize;
58
59#[derive(Clone, Debug, Serialize)]
60pub struct Node {
61 pub name: String,
62 #[serde(rename = "type")]
63 pub kind: String,
64 pub data: Project,
65}
66
67#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
68pub struct Dependency {
69 pub source: String,
70 pub target: String,
71 #[serde(rename = "type")]
72 pub kind: String,
73}
74
75#[derive(Clone, Debug, Serialize)]
76pub struct ProjectGraph {
77 pub nodes: BTreeMap<String, Node>,
78 pub dependencies: BTreeMap<String, Vec<Dependency>>,
79}
80
81#[derive(Serialize)]
82pub struct GraphReport<'a> {
83 pub graph: &'a ProjectGraph,
84}
85
86impl ProjectGraph {
87 pub fn build(workspace: &Workspace) -> Result<Self> {
88 let mut package_names = BTreeMap::new();
89 for (project_name, package) in &workspace.packages {
90 if let Some(name) = &package.name
91 && let Some(previous) = package_names.insert(name, project_name)
92 {
93 bail!(
94 "duplicate workspace package name {name:?} in projects {previous:?} and {project_name:?}"
95 );
96 }
97 }
98 let mut graph = Self {
99 nodes: BTreeMap::new(),
100 dependencies: BTreeMap::new(),
101 };
102 for (name, project) in &workspace.projects {
103 let mut edges = BTreeMap::new();
104 if let Some(package) = workspace.packages.get(name) {
105 for (dependency, range) in package.dependency_ranges() {
106 if let Some(target) = package_names.get(&dependency.to_owned())
107 && *target != name
108 && links(
109 range,
110 &project.root,
111 &workspace.projects[*target].root,
112 workspace.packages[*target].version.as_deref(),
113 )
114 {
115 edges
116 .entry((*target).clone())
117 .or_insert_with(BTreeSet::new)
118 .insert("static");
119 }
120 }
121 }
122 for selector in project
123 .implicit_dependencies
124 .iter()
125 .filter(|s| !s.starts_with('!'))
126 {
127 let selected = matches(&workspace.projects, selector)
128 .with_context(|| format!("implicitDependencies of project {name:?}"))?;
129 if selected.is_empty() && !is_pattern(selector) {
130 bail!("project {name:?} has unknown implicit dependency {selector:?}");
131 }
132 for target in selected {
133 if target != *name {
135 edges
136 .entry(target)
137 .or_insert_with(BTreeSet::new)
138 .insert("implicit");
139 }
140 }
141 }
142 for selector in project
143 .implicit_dependencies
144 .iter()
145 .filter_map(|s| s.strip_prefix('!'))
146 {
147 for target in matches(&workspace.projects, selector)? {
148 edges.remove(&target);
149 }
150 }
151 graph.dependencies.insert(
152 name.clone(),
153 edges
154 .into_iter()
155 .flat_map(|(target, kinds)| {
156 kinds.into_iter().map(move |kind| Dependency {
157 source: name.clone(),
158 target: target.clone(),
159 kind: kind.into(),
160 })
161 })
162 .collect(),
163 );
164 graph.nodes.insert(
165 name.clone(),
166 Node {
167 name: name.clone(),
168 kind: node_type(workspace, name, project).into(),
169 data: project.clone(),
170 },
171 );
172 }
173 Ok(graph)
174 }
175
176 pub fn dependents_of(&self, projects: &BTreeSet<String>) -> Result<BTreeSet<String>> {
178 let mut reverse: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
179 for edges in self.dependencies.values() {
180 for edge in edges {
181 reverse.entry(&edge.target).or_default().push(&edge.source);
182 }
183 }
184 let mut visited = projects.clone();
185 let mut pending: Vec<String> = projects.iter().cloned().collect();
186 while let Some(name) = pending.pop() {
187 if !self.nodes.contains_key(&name) {
188 bail!("unknown project {name:?}");
189 }
190 for dependent in reverse.get(name.as_str()).into_iter().flatten() {
191 if visited.insert((*dependent).to_owned()) {
192 pending.push((*dependent).to_owned());
193 }
194 }
195 }
196 Ok(visited)
197 }
198
199 pub fn related_to(&self, project: &str) -> Result<BTreeSet<String>> {
202 let mut related = self.dependents_of(&BTreeSet::from([project.to_owned()]))?;
203 let mut visited = BTreeSet::from([project]);
204 let mut pending = vec![project];
205 while let Some(name) = pending.pop() {
206 for edge in self.dependencies.get(name).into_iter().flatten() {
207 if visited.insert(&edge.target) {
208 related.insert(edge.target.clone());
209 pending.push(&edge.target);
210 }
211 }
212 }
213 Ok(related)
214 }
215
216 pub fn retain(&mut self, projects: &BTreeSet<String>) {
219 self.nodes.retain(|name, _| projects.contains(name));
220 self.dependencies.retain(|name, _| projects.contains(name));
221 }
222}
223
224fn node_type(workspace: &Workspace, name: &str, project: &Project) -> &'static str {
226 if let Some(project_type) = &project.project_type {
227 return if project_type == "library" {
228 "lib"
229 } else if name.ends_with("-e2e") || name == "e2e" {
230 "e2e"
231 } else {
232 "app"
233 };
234 }
235 let root = workspace.root.join(&project.root);
236 if root.join("tsconfig.lib.json").exists() {
237 return "lib";
238 }
239 if root.join("tsconfig.app.json").exists() {
240 return "app";
241 }
242 match workspace.packages.get(name) {
243 Some(package)
244 if package.exports.is_none()
245 && package.main.is_none()
246 && package.module.is_none()
247 && package.bin.is_none() =>
248 {
249 "app"
250 }
251 Some(_) => "lib",
253 None => "lib",
254 }
255}
256
257pub fn graph_order(
260 projects: &BTreeMap<String, Project>,
261 names: impl IntoIterator<Item = String>,
262) -> Vec<String> {
263 let mut names: Vec<_> = names.into_iter().collect();
264 names.sort();
265 names.sort_by_key(|name| std::cmp::Reverse(projects[name].root.len()));
266 names
267}
268
269pub fn select_projects(
272 projects: &BTreeMap<String, Project>,
273 selectors: &[String],
274 excludes: &[String],
275) -> Result<BTreeSet<String>> {
276 let positives: Vec<_> = selectors.iter().filter(|s| !s.starts_with('!')).collect();
277 let mut selected = if positives.is_empty() {
278 projects.keys().cloned().collect()
279 } else {
280 BTreeSet::new()
281 };
282 for selector in positives {
283 selected.extend(matches(projects, selector)?);
284 }
285 for selector in selectors
286 .iter()
287 .filter_map(|s| s.strip_prefix('!'))
288 .chain(excludes.iter().map(String::as_str))
289 {
290 for name in matches(projects, selector)? {
291 selected.remove(&name);
292 }
293 }
294 Ok(selected)
295}
296
297fn matches(projects: &BTreeMap<String, Project>, selector: &str) -> Result<BTreeSet<String>> {
298 if selector.is_empty() {
299 bail!("project selector must not be empty");
300 }
301 let tag = selector.strip_prefix("tag:");
302 let glob = Glob::new(tag.unwrap_or(selector))
303 .with_context(|| format!("invalid project selector {selector:?}"))?
304 .compile_matcher();
305 Ok(projects
306 .iter()
307 .filter(|(name, project)| {
308 if tag.is_some() {
309 project.tags.iter().any(|tag| glob.is_match(tag))
310 } else {
311 glob.is_match(name.as_str())
312 }
313 })
314 .map(|(name, _)| name.clone())
315 .collect())
316}
317
318fn is_pattern(selector: &str) -> bool {
319 selector.starts_with("tag:") || selector.contains(['*', '?', '[', '{'])
320}