Skip to main content

qk_graph/
lib.rs

1//! Deterministic workspace project graphs. External lockfile nodes come later.
2
3mod npm_range;
4pub use npm_range::satisfies;
5
6/// Whether a dependency declared with `range` in the package at `root` means
7/// the workspace package at `target`, as Nx decides it: a `workspace:`
8/// range, `*`, a `file:` path to it, or a semver range its version
9/// satisfies, prereleases included. Anything else, such as `catalog:`,
10/// `npm:` aliases or a range the version is outside of, is left to npm.
11fn 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
23/// Node's `path.posix.join`: segments joined and normalised, keeping a
24/// trailing slash.
25fn 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                    // Nx keeps an implicit edge alongside a static one for the same pair.
134                    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    /// Return transitive dependents, including the original projects.
177    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    /// The project, the projects it depends on and the projects depending on
200    /// it, transitively: the projects `nx graph --focus` keeps.
201    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    /// Keeps only the named projects. As in Nx, the kept projects' edges stay
217    /// as they are, including those to projects left out.
218    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
224/// A node's `type`, as Nx's `getProjectType` derives it.
225fn 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        // Nx assumes an application only when it can read an entry-point-free package.
252        Some(_) => "lib",
253        None => "lib",
254    }
255}
256
257/// Orders project names as Nx orders its graph nodes: by name, then stably by
258/// descending root length (`normalizeProjectNodes` in Nx).
259pub 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
269/// Positive selectors are unioned; exclusions win regardless of their order.
270/// With no positive selectors, start with all projects.
271pub 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}