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.
11pub 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
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::{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        // As in Nx, a package name two projects share means the later one,
88        // in the order of their package.json paths.
89        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                    // Nx keeps an implicit edge alongside a static one for the same pair.
154                    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    /// Return transitive dependents, including the original projects.
190    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    /// The project, the projects it depends on and the projects depending on
213    /// it, transitively: the projects `nx graph --focus` keeps.
214    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    /// Keeps only the named projects. As in Nx, the kept projects' edges stay
230    /// as they are, including those to projects left out.
231    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
237/// A node's `type`, as Nx's `getProjectType` derives it.
238fn 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        // Nx assumes an application only when it can read an entry-point-free package.
265        Some(_) => "lib",
266        None => "lib",
267    }
268}
269
270/// Orders project names as Nx orders its graph nodes: by name, then stably by
271/// descending root length (`normalizeProjectNodes` in Nx).
272pub 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
282/// The projects `--projects` and `--exclude` select, as Nx's `run-many`
283/// selects them: every project without patterns, else what the patterns
284/// match, in order, less what the exclusions match among those.
285pub 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
313/// Nx's `assertWorkspaceValidity`: every implicit dependency, `!` ones
314/// included, names a project or a pattern matching one. `nx-cloud:`
315/// references to other workspaces are not checked.
316fn 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
347/// Nx's `normalizeImplicitDependencies`: the projects the patterns select,
348/// the project itself left out, then a `!` and the name of each project an
349/// exclusion matches, which removes any other edge to it.
350pub 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}