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}