Skip to main content

aube_linker/
builder.rs

1use aube_lockfile::dep_path_filename::{
2    DEFAULT_VIRTUAL_STORE_DIR_MAX_LENGTH, dep_path_to_filename,
3};
4use aube_lockfile::graph_hash::GraphHashes;
5use aube_store::Store;
6use std::path::{Path, PathBuf};
7
8use crate::{
9    HoistingLimits, LinkStrategy, Linker, NodeLinker, Patches, default_linker_parallelism,
10};
11
12impl Linker {
13    pub fn new(store: &Store, strategy: LinkStrategy) -> Self {
14        Self::new_with_gvs(store, strategy, !aube_util::env::is_ci())
15    }
16
17    pub(crate) fn new_with_gvs(
18        store: &Store,
19        strategy: LinkStrategy,
20        use_global_virtual_store: bool,
21    ) -> Self {
22        Self {
23            virtual_store: store.virtual_store_dir(),
24            store: store.clone(),
25            use_global_virtual_store,
26            project_local_dep_paths: rustc_hash::FxHashSet::default(),
27            strategy,
28            patches: Patches::new(),
29            hashes: None,
30            virtual_store_dir_max_length: DEFAULT_VIRTUAL_STORE_DIR_MAX_LENGTH,
31            shamefully_hoist: false,
32            public_hoist_patterns: Vec::new(),
33            public_hoist_negations: Vec::new(),
34            hoist: true,
35            hoist_patterns: vec![glob::Pattern::new("*").expect("'*' is a valid glob pattern")],
36            hoist_negations: Vec::new(),
37            hoist_workspace_packages: true,
38            hoisting_limits: HoistingLimits::None,
39            dedupe_direct_deps: false,
40            node_linker: NodeLinker::Isolated,
41            link_concurrency: None,
42            virtual_store_only: false,
43            modules_dir_name: "node_modules".to_string(),
44            aube_dir_override: None,
45        }
46    }
47
48    /// Select the layout mode. Defaults to `NodeLinker::Isolated`
49    /// (pnpm's `.aube/`-backed virtual-store layout); `Hoisted`
50    /// dispatches `link_all` / `link_workspace` to the flat
51    /// node_modules materializer in `crate::hoisted`.
52    pub fn with_node_linker(mut self, node_linker: NodeLinker) -> Self {
53        self.node_linker = node_linker;
54        self
55    }
56
57    /// Current layout mode. The install driver reads this after
58    /// linking to decide how to resolve per-package directories for
59    /// bin linking and lifecycle scripts — isolated uses the
60    /// `.aube/<dep_path>` convention, hoisted consults the
61    /// `HoistedPlacements` returned on `LinkStats`.
62    pub fn node_linker(&self) -> NodeLinker {
63        self.node_linker
64    }
65
66    /// Override the name of the project-level `node_modules` directory
67    /// (pnpm's `modules-dir` setting). Empty strings are coerced back
68    /// to the default so a `.npmrc` typo can't make the linker write
69    /// into the project root itself. The setting only affects the
70    /// outer directory name — the inner virtual-store layout still
71    /// uses the literal `node_modules` that Node's resolver expects
72    /// when walking up from inside a package.
73    pub fn with_modules_dir_name(mut self, name: impl Into<String>) -> Self {
74        let s = name.into();
75        self.modules_dir_name = if s.trim().is_empty() {
76            "node_modules".to_string()
77        } else {
78            s
79        };
80        self
81    }
82
83    /// Project-level modules directory name. `aube` reads this
84    /// when it needs the same path the linker writes into — keeping
85    /// the computation DRY with whatever the linker was built with.
86    pub fn modules_dir_name(&self) -> &str {
87        &self.modules_dir_name
88    }
89
90    /// Override the per-project virtual-store path (pnpm's
91    /// `virtualStoreDir`). The supplied path should be *absolute* —
92    /// `aube` resolves relative `.npmrc` / `pnpm-workspace.yaml`
93    /// values against the project dir before handing them here.
94    /// When not set, the linker derives the virtual store path as
95    /// `<project_dir>/<modules_dir_name>/.aube` at link time, which
96    /// matches the historical behavior.
97    pub fn with_aube_dir_override(mut self, path: PathBuf) -> Self {
98        self.aube_dir_override = Some(path);
99        self
100    }
101
102    /// Compute the effective virtual-store path for `project_dir`.
103    /// Consults the override installed by `with_aube_dir_override` if
104    /// any; otherwise falls back to `<project_dir>/<modules_dir>/.<name>`.
105    /// Used internally by `link_all`; also called by the install
106    /// driver's "already linked" fast path so both sites land on the
107    /// same directory when the user has overridden `virtualStoreDir`.
108    pub fn aube_dir_for(&self, project_dir: &Path) -> PathBuf {
109        self.aube_dir_override.clone().unwrap_or_else(|| {
110            // Virtual-store leaf from the active embedder's name: `.<name>`.
111            // Standalone aube → `.aube`.
112            let leaf = format!(".{}", aube_util::embedder().name);
113            project_dir.join(&self.modules_dir_name).join(leaf)
114        })
115    }
116
117    /// Override the package-level linker worker count. Values below 1
118    /// are ignored by the install driver before they reach this point.
119    pub fn with_link_concurrency(mut self, concurrency: Option<usize>) -> Self {
120        self.link_concurrency = concurrency;
121        self
122    }
123
124    /// Override the global-virtual-store toggle set by `Linker::new`
125    /// (which looks at `CI`). Callers use this to force per-project
126    /// materialization when they've detected a consumer that breaks on
127    /// directory symlinks escaping the project root — e.g. Next.js /
128    /// Turbopack, which canonicalizes `node_modules/<pkg>` and rejects
129    /// anything that lands outside its declared filesystem root.
130    pub fn with_use_global_virtual_store(mut self, enabled: bool) -> Self {
131        self.use_global_virtual_store = enabled;
132        self
133    }
134
135    /// Materialize selected dep paths into the project-local virtual
136    /// store while all other packages continue to use the GVS.
137    ///
138    /// Callers use this for package-specific compatibility transforms
139    /// that must never mutate a shared store entry.
140    pub fn with_project_local_dep_paths(
141        mut self,
142        dep_paths: impl IntoIterator<Item = String>,
143    ) -> Self {
144        self.project_local_dep_paths = dep_paths.into_iter().collect();
145        self
146    }
147
148    pub(crate) fn link_parallelism(&self) -> usize {
149        self.link_concurrency
150            .unwrap_or_else(default_linker_parallelism)
151            .max(1)
152    }
153
154    /// Enable pnpm's `shamefully-hoist` mode. When true, every package
155    /// in the graph gets a top-level `node_modules/<name>` symlink in
156    /// addition to the direct-dep entries, producing npm's flat
157    /// layout at the cost of phantom-dep correctness. First-write-wins
158    /// on duplicate names, so root deps always take precedence.
159    pub fn with_shamefully_hoist(mut self, shamefully_hoist: bool) -> Self {
160        self.shamefully_hoist = shamefully_hoist;
161        self
162    }
163
164    /// Configure pnpm's `public-hoist-pattern`. Each input is a glob
165    /// matched against package names; a leading `!` flips it into a
166    /// negation. After the usual direct-dep symlinks, every non-local
167    /// package whose name matches at least one positive pattern and
168    /// no negation gets a top-level `node_modules/<name>` symlink.
169    /// Invalid patterns are silently dropped (same tolerance as
170    /// pnpm), so a typo in `.npmrc` degrades to "not hoisted" instead
171    /// of failing the install.
172    pub fn with_public_hoist_pattern(mut self, patterns: &[String]) -> Self {
173        push_glob_patterns(
174            patterns,
175            &mut self.public_hoist_patterns,
176            &mut self.public_hoist_negations,
177        );
178        self
179    }
180
181    /// Toggle pnpm's `hoist` setting. When true (the default), the
182    /// hidden modules tree at `node_modules/.aube/node_modules/` is
183    /// populated via `with_hoist_pattern`. When false, that tree is
184    /// skipped and any existing directory is swept so stale symlinks
185    /// from a previous `hoist=true` run don't keep resolving.
186    pub fn with_hoist(mut self, hoist: bool) -> Self {
187        self.hoist = hoist;
188        self
189    }
190
191    /// Configure pnpm's `hoist-pattern`. Each input is a glob matched
192    /// against package names; a leading `!` flips it into a negation.
193    /// Every non-local package in the graph whose name matches at
194    /// least one positive pattern (and no negation) gets a
195    /// `node_modules/.aube/node_modules/<name>` symlink — the hidden
196    /// fallback dir for Node's parent-directory walk. Invalid
197    /// patterns are silently dropped (pnpm parity). Supplying an
198    /// empty list or only-negation list means "hoist nothing";
199    /// leaving this unconfigured keeps the default `*` match.
200    pub fn with_hoist_pattern(mut self, patterns: &[String]) -> Self {
201        self.hoist_patterns.clear();
202        self.hoist_negations.clear();
203        push_glob_patterns(
204            patterns,
205            &mut self.hoist_patterns,
206            &mut self.hoist_negations,
207        );
208        self
209    }
210
211    /// Toggle pnpm's `hoist-workspace-packages`. When false, the
212    /// linker skips creating `node_modules/<ws-pkg>` symlinks for
213    /// workspace packages in every importer, including the root.
214    /// Cross-importer `workspace:` deps already resolve through the
215    /// lockfile, so only direct `require('<ws-pkg>')` from a package
216    /// that doesn't declare it stops working. Default true (pnpm
217    /// parity).
218    pub fn with_hoist_workspace_packages(mut self, on: bool) -> Self {
219        self.hoist_workspace_packages = on;
220        self
221    }
222
223    /// Configure pnpm's `hoistingLimits` for `node-linker=hoisted`.
224    /// No-op for the default isolated linker.
225    pub fn with_hoisting_limits(mut self, limits: HoistingLimits) -> Self {
226        self.hoisting_limits = limits;
227        self
228    }
229
230    /// Toggle pnpm's `dedupe-direct-deps`. When true, the linker
231    /// skips creating a per-importer `node_modules/<name>` symlink for
232    /// any direct dep whose root importer already declares the same
233    /// package at the same resolved version — Node's parent-directory
234    /// walk from inside the workspace package still resolves the same
235    /// copy via the root-level symlink, so consumer code is
236    /// unaffected. Default false (pnpm parity). No-op under
237    /// `virtualStoreOnly=true` (no per-importer symlink pass runs)
238    /// and under `NodeLinker::Hoisted` (its workspace-wide placement
239    /// plan deduplicates compatible packages independently).
240    pub fn with_dedupe_direct_deps(mut self, on: bool) -> Self {
241        self.dedupe_direct_deps = on;
242        self
243    }
244
245    /// Whether `pkg_name` should be symlinked into the hidden hoist
246    /// tree. Returns false when `hoist == false` regardless of
247    /// patterns, or when no positive pattern matches. Matching is
248    /// case-insensitive, matching pnpm.
249    pub(crate) fn hoist_matches(&self, pkg_name: &str) -> bool {
250        self.hoist && matches_with_negations(pkg_name, &self.hoist_patterns, &self.hoist_negations)
251    }
252
253    /// Whether `pkg_name` should be promoted to the root
254    /// `node_modules` under the configured `public-hoist-pattern`.
255    /// Names with no positive match are rejected; a name that
256    /// matches a positive pattern is still rejected if any negation
257    /// also matches. Matching is case-insensitive.
258    pub(crate) fn public_hoist_matches(&self, pkg_name: &str) -> bool {
259        matches_with_negations(
260            pkg_name,
261            &self.public_hoist_patterns,
262            &self.public_hoist_negations,
263        )
264    }
265
266    /// Override the virtual-store directory name length cap. Primarily
267    /// a hook for tests and for parity with pnpm's
268    /// `virtual-store-dir-max-length` config; most callers should
269    /// leave it at the default.
270    pub fn with_virtual_store_dir_max_length(mut self, max_length: usize) -> Self {
271        self.virtual_store_dir_max_length = max_length;
272        self
273    }
274
275    /// Toggle pnpm's `virtual-store-only`. When enabled, `link_all` /
276    /// `link_workspace` still populate `.aube/<dep_path>/node_modules`
277    /// (and the shared global virtual store under
278    /// `~/.cache/aube/virtual-store/`) but skip the pass that writes
279    /// top-level `node_modules/<name>` symlinks and the hoisting
280    /// passes that target the same directory. No-op under
281    /// `NodeLinker::Hoisted` — that layout is inherently a flat
282    /// top-level materialization.
283    pub fn with_virtual_store_only(mut self, only: bool) -> Self {
284        self.virtual_store_only = only;
285        self
286    }
287
288    /// Whether this linker will skip the top-level `node_modules/<name>`
289    /// symlink pass. Exposed so the install driver can omit root-level
290    /// bin linking and lifecycle-script invocations when the user has
291    /// asked for a virtual-store-only install — both operate on the
292    /// top-level tree that won't exist.
293    pub fn virtual_store_only(&self) -> bool {
294        self.virtual_store_only
295    }
296
297    /// Install a set of pre-computed graph hashes. Every virtual-store
298    /// path the linker constructs after this point will use the
299    /// hashed subdir name for the matching `dep_path`. Callers
300    /// normally derive the hashes once per install via
301    /// `aube_lockfile::graph_hash::compute_graph_hashes` and pass the
302    /// result in here.
303    pub fn with_graph_hashes(mut self, hashes: GraphHashes) -> Self {
304        self.hashes = Some(hashes);
305        self
306    }
307
308    /// Directory name for `dep_path` inside the global virtual store.
309    /// Applies the graph hash (if any) to fold in build state, then
310    /// runs the result through `dep_path_to_filename` so the final
311    /// name is both filesystem-safe and bounded.
312    pub(crate) fn virtual_store_subdir(&self, dep_path: &str) -> String {
313        let hashed = match &self.hashes {
314            Some(h) => h.hashed_dep_path(dep_path),
315            None => dep_path.to_string(),
316        };
317        dep_path_to_filename(&hashed, self.virtual_store_dir_max_length)
318    }
319
320    /// Directory name for `dep_path` inside a project's local
321    /// `node_modules/.aube/`. Same filename-bounding as the global
322    /// store, but without the graph-hash fold — local `.aube/` is
323    /// keyed by dep_path alone because node's resolver walks by
324    /// dep_path and never inspects the shared-store identity.
325    pub(crate) fn aube_dir_entry_name(&self, dep_path: &str) -> String {
326        dep_path_to_filename(dep_path, self.virtual_store_dir_max_length)
327    }
328
329    /// Whether this linker populates the project's `.aube/` entries as
330    /// symlinks into the shared virtual store (true) or materializes a
331    /// per-project copy (false). Callers that want to mutate package
332    /// directories after linking — e.g. running allowBuilds lifecycle
333    /// scripts — need to know because shared-store writes leak across
334    /// projects.
335    pub fn uses_global_virtual_store(&self) -> bool {
336        self.use_global_virtual_store
337    }
338
339    /// Install a set of patch contents to apply at materialize time.
340    /// Replaces any previously installed patches. Pair with
341    /// `with_graph_hashes` whose `patch_hash` callback returns the same
342    /// per-`(name, version)` digest, so the patched bytes land at a
343    /// distinct virtual-store path from the unpatched ones.
344    pub fn with_patches(mut self, patches: Patches) -> Self {
345        self.patches = patches;
346        self
347    }
348}
349
350fn push_glob_patterns(
351    raw: &[String],
352    positives: &mut Vec<glob::Pattern>,
353    negations: &mut Vec<glob::Pattern>,
354) {
355    for r in raw {
356        let (neg, body) = match r.strip_prefix('!') {
357            Some(rest) => (true, rest),
358            None => (false, r.as_str()),
359        };
360        let Ok(pat) = glob::Pattern::new(body) else {
361            continue;
362        };
363        if neg {
364            negations.push(pat);
365        } else {
366            positives.push(pat);
367        }
368    }
369}
370
371fn matches_with_negations(
372    name: &str,
373    positives: &[glob::Pattern],
374    negations: &[glob::Pattern],
375) -> bool {
376    if positives.is_empty() {
377        return false;
378    }
379    let opts = glob::MatchOptions {
380        case_sensitive: false,
381        require_literal_separator: false,
382        require_literal_leading_dot: false,
383    };
384    positives.iter().any(|p| p.matches_with(name, opts))
385        && !negations.iter().any(|p| p.matches_with(name, opts))
386}