1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
//! The seam through which tool knowledge (plugins) feeds the graph.
//!
//! The graph never knows which tools a repository uses. A host registers any number of [`InputSource`]s; for every
//! workspace package each one returns a [`WorkspaceInputs`] with the entry and project globs, ignore lists and
//! references the tool implies. The plugin table of `skif-js-plugins` implements this trait.
use std::path::PathBuf;
use std::sync::OnceLock;
use skif_core::{FileSystem, Span};
pub use skif_core::{IncompleteCause, IncompleteEntries, IncompleteScope};
use skif_js_plugins::GlobCache;
use skif_workspace::{Package, Workspace};
/// A module alias a tool configuration declares: imports of `find` (and `find/…`) point at `target`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PathAlias {
/// The aliased prefix, without a trailing `/` or `*`.
pub find: String,
/// The directory or file it stands for, relative to the package directory with `/` separators. `None` when the
/// configuration computes it, in which case imports of the alias are known not to be packages but cannot be
/// followed.
pub target: Option<String>,
}
/// A binary that a command read from a file runs: a step of a workflow, a hook, a task of a tool config, or a source
/// file calling `execa` or `zx`. See [`WorkspaceInputs::run_binaries`].
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct RunBinary {
/// The binary's name as the command spells it.
pub name: String,
/// The file the command is read from, absolute.
pub file: PathBuf,
/// The directory the command runs the binary in after `cd` or `pushd`, relative to the package directory where
/// the command starts, `/`-separated; `None` there and when the directory is not a static relative path.
pub dir: Option<String>,
/// The command runs the binary in `dir` without naming a workspace (`pnpm -C sub exec rspack`): it is credited to
/// the package there and never judged where the command starts.
pub filtered: bool,
/// A binary nothing provides is unlisted; otherwise it is only a credit (fetched on demand, or run by name in a
/// file whose binaries are optional).
pub judged: bool,
}
impl RunBinary {
/// The binary `name` that a command read from `file` runs, in `dir` (see [`RunBinary::dir`] and
/// [`RunBinary::filtered`]), judged.
#[must_use]
pub fn new(name: String, file: PathBuf, dir: Option<String>, filtered: bool) -> Self {
Self {
name,
file,
dir,
filtered,
judged: true,
}
}
/// The binary as a credit only, never unlisted (see [`RunBinary::judged`]).
#[must_use]
pub fn credit_only(mut self) -> Self {
self.judged = false;
self
}
}
/// A package that a tool's config file names itself (a `tsconfig` `extends`, an eslint plugin, a Prisma generator):
/// the config needs it installed. See [`WorkspaceInputs::config_dependencies`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigDependency {
/// The package name, without version or subpath.
pub name: String,
/// The file that names it, absolute.
pub file: PathBuf,
/// Where in `file` it is named, when the plugin knows.
pub span: Option<Span>,
/// The reference loads only the package's types (`compilerOptions.types`): its `DefinitelyTyped` package
/// (`@types/…`) satisfies it as well.
pub type_only: bool,
/// The package reading the config may list it instead of the package that owns `file`: a compiler option of a
/// shared base `tsconfig` applies to each package extending it.
pub reader_may_list: bool,
/// Only the package reading the config may list it, never the package that owns `file`: a Bazel label of the
/// reader's `node_modules` in a nested package's `BUILD` file names the reader's install.
pub reader_lists: bool,
}
/// Build output that maps back to source: `dist` to `src` for a `tsconfig` with `outDir` and `rootDir`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BuildDir {
/// The output directory, relative to the package directory.
pub out_dir: String,
/// The source directory, relative to the package directory.
pub root_dir: String,
}
/// A custom element library: a class it registers is used by the browser through its tag name, so it is no unused
/// export (ADR 0020). `customElements.define('x-y', Class)` counts without one.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CustomElementLibrary {
/// Specifiers the library is imported from; one that ends with `/` matches every specifier below it.
pub modules: Vec<String>,
/// The name of the registering decorator as the library exports it (`customElement`).
pub decorator: String,
/// The base class whose subclasses register with a static `define(…)` (`FASTElement`), when there is one.
pub base_class: Option<String>,
}
impl CustomElementLibrary {
/// Whether `specifier` imports this library.
#[must_use]
pub fn is_module(&self, specifier: &str) -> bool {
self.modules
.iter()
.any(|module| module == specifier || (module.ends_with('/') && specifier.starts_with(module.as_str())))
}
}
/// What an [`InputSource`] contributes for one workspace package.
///
/// Globs are relative to the package directory and use `/` separators. A leading `!` excludes. A source starts from
/// [`WorkspaceInputs::default`] and fills in the fields it knows, as fields may be added.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct WorkspaceInputs {
/// Entry files in every mode, such as test files or framework routes.
pub entry: Vec<String>,
/// Entry files that are part of the shipped product; the only plugin entries used in production mode.
pub production_entry: Vec<String>,
/// Files a tool builds from that are no entries (such as the `srcs` and `data` of a Bazel rule), in every mode:
/// they are used and what they import is followed, but their exports are judged as any module's.
pub build_source: Vec<String>,
/// The build sources that are part of the shipped product; the only ones used in production mode.
pub production_build_source: Vec<String>,
/// Entry files given as regular expressions in the syntax of the `regex` crate (Jest's `testRegex`), matched
/// against `/` followed by the path relative to the package directory; besides `entry`, and like it not used in
/// production mode.
pub entry_regex: Vec<String>,
/// Entry expressions of the package's Nx project scopes (ADR 0032): each selects files below its scope, matched
/// like `entry_regex` against `/` followed by the path relative to the scope directory.
pub scoped_entry_regex: Vec<ScopedEntryRegex>,
/// Files that belong to the project, added to the default or configured project globs.
pub project: Vec<String>,
/// Files whose issues are never reported, relative to the package directory.
pub ignore: Vec<String>,
/// Package names (globs allowed) that are never reported as unused, unlisted or optional peers.
pub ignore_dependencies: Vec<String>,
/// Binary names (globs allowed) that are never reported as unlisted.
pub ignore_binaries: Vec<String>,
/// Specifier patterns (globs allowed) the tool resolves at build time, such as `@generated/*` or `#imports`. They
/// are no dependencies and no file backs them, so they are never reported as unlisted or unresolved.
pub virtual_modules: Vec<String>,
/// Packages the tool uses without importing them, such as an `ESLint` parser named in a config.
pub dependencies: Vec<String>,
/// Packages that a command read from a file loads in another directory (after `cd`, or in a workflow step's
/// `working-directory`): the directory relative to the package directory, `/`-separated, and the package name.
/// They are needed by the workspace package that directory is in.
pub relocated_dependencies: Vec<(String, String)>,
/// The packages of `dependencies` that a config file names itself and needs installed, with that file:
/// `unlisted-dependencies` reports the ones no manifest lists at the file. Packages that a command in a config
/// names, optional ones and those a tool assumes without a config are not among them.
pub config_dependencies: Vec<ConfigDependency>,
/// Binaries the tool runs without a script naming them.
pub binaries: Vec<String>,
/// The binaries of `binaries` that a command read from a file runs, with that file: `unlisted-binaries` reports
/// the ones no dependency provides. Binaries fetched on demand, run in another workspace or installed globally
/// are not among them.
pub run_binaries: Vec<RunBinary>,
/// Configuration files of the tools, absolute. They are entries in every mode so what they import counts.
pub config_files: Vec<PathBuf>,
/// Module aliases declared by bundler and compiler configuration.
pub aliases: Vec<PathAlias>,
/// Output directories and the source directories they are built from.
pub build_dirs: Vec<BuildDir>,
/// Extra `exports` conditions that select source files (`customConditions`).
pub conditions: Vec<String>,
/// Directories bare specifiers are looked up in (`baseUrl`, `paths` wildcards, `resolve.modules`), relative to the
/// package directory with `/` separators, in lookup order.
pub module_dirs: Vec<String>,
/// File extensions (with the leading dot) imports may omit, from bundler `resolve.extensions`.
pub extensions: Vec<String>,
/// Custom element libraries the package uses, whose registered classes are used exports.
pub custom_elements: Vec<CustomElementLibrary>,
/// Whether a tool's entries could only be partly read (a computed `entry`), so unused files and dependencies of
/// the package cannot be told apart from ones reached through the unknown entries.
pub entries_incomplete: bool,
/// Why the entries are incomplete, when `entries_incomplete` is set and the source knows.
pub incomplete_reasons: Vec<IncompleteEntries>,
/// Where the entries the source could not read can lie, when `entries_incomplete` is set: only files there are
/// left unjudged by `unused-files` (ADR 0016).
pub incomplete_scope: IncompleteScope,
}
impl WorkspaceInputs {
/// Appends everything in `other`.
pub fn extend(&mut self, other: Self) {
self.entry.extend(other.entry);
self.production_entry.extend(other.production_entry);
self.build_source.extend(other.build_source);
self.production_build_source.extend(other.production_build_source);
self.entry_regex.extend(other.entry_regex);
self.scoped_entry_regex.extend(other.scoped_entry_regex);
self.project.extend(other.project);
self.ignore.extend(other.ignore);
self.ignore_dependencies.extend(other.ignore_dependencies);
self.ignore_binaries.extend(other.ignore_binaries);
self.virtual_modules.extend(other.virtual_modules);
self.dependencies.extend(other.dependencies);
self.relocated_dependencies.extend(other.relocated_dependencies);
self.config_dependencies.extend(other.config_dependencies);
self.binaries.extend(other.binaries);
self.run_binaries.extend(other.run_binaries);
self.config_files.extend(other.config_files);
self.aliases.extend(other.aliases);
self.build_dirs.extend(other.build_dirs);
self.conditions.extend(other.conditions);
self.module_dirs.extend(other.module_dirs);
self.extensions.extend(other.extensions);
self.custom_elements.extend(other.custom_elements);
if other.entries_incomplete {
self.incomplete_scope = if self.entries_incomplete {
std::mem::take(&mut self.incomplete_scope).union(other.incomplete_scope)
} else {
other.incomplete_scope
};
}
self.entries_incomplete |= other.entries_incomplete;
self.incomplete_reasons.extend(other.incomplete_reasons);
}
}
/// An entry expression that selects files below a directory of the package; see
/// [`WorkspaceInputs::scoped_entry_regex`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScopedEntryRegex {
/// The directory, relative to the package directory with `/` separators.
pub scope: String,
/// The expression, in the syntax of the `regex` crate.
pub regex: String,
}
/// Listings of the workspace that every [`InputRequest`] of one run shares, each made on first use, so the input
/// sources of the packages do not walk the same directories again.
#[derive(Debug)]
pub struct WorkspaceListing {
gitignore: bool,
sequential: bool,
nx_projects: OnceLock<Vec<(String, String)>>,
globs: GlobCache,
}
impl WorkspaceListing {
/// An empty listing, whose walks honour `.gitignore` files unless `gitignore` is off (`--no-gitignore`) and run
/// on the calling thread with `sequential`.
#[must_use]
pub fn new(gitignore: bool, sequential: bool) -> Self {
Self {
gitignore,
sequential,
nx_projects: OnceLock::new(),
globs: GlobCache::new(),
}
}
/// The globs the plugins of the run compiled, which every package of the run searches for again.
#[must_use]
pub const fn globs(&self) -> &GlobCache {
&self.globs
}
/// The directories of the `project.json` files that the source walk reaches in `workspace`, relative to its root
/// and sorted, each with the directory of the package that owns it (`""` for the root): the Nx projects of ADR
/// 0109, below no build output, hidden or gitignored directory. One walk serves the whole run.
pub fn nx_projects(&self, fs: &dyn FileSystem, workspace: &Workspace) -> &[(String, String)] {
// The walk runs on rayon while other packages' setups ask for the same listing.
skif_core::get_or_compute(&self.nx_projects, || {
crate::discover::nx_project_dirs(fs, workspace, self.sequential, self.gitignore)
})
}
}
/// The package an [`InputSource`] is asked about.
#[derive(Clone, Copy)]
pub struct InputRequest<'a> {
/// The whole workspace.
pub workspace: &'a Workspace,
/// The package whose inputs are wanted.
pub package: &'a Package,
/// File access, for reading tool configuration files.
pub fs: &'a dyn FileSystem,
/// The dependencies listed by the `package.json` files above the workspace root (a nested root sees the tools
/// of the project that contains it), sorted and without duplicates.
pub ancestor_dependencies: &'a [String],
/// The directory of `package` as the `[js.workspaces]` keys select it: `.` for the root package and its directory
/// otherwise, both joined to the workspace's [`workspace_keys_anchor`](Workspace::workspace_keys_anchor) when it
/// has one (`tools/sub` for the root package of the root in `tools/sub` of a run with an explicit configuration).
pub workspace_key: &'a str,
/// The listings every request of the run shares.
pub listing: &'a WorkspaceListing,
}
impl std::fmt::Debug for InputRequest<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("InputRequest")
.field("package", &self.package.display_name())
.finish_non_exhaustive()
}
}
/// A provider of tool-specific entries, project files, ignores and references.
pub trait InputSource: Send + Sync {
/// The inputs for `request.package`; an empty value when the tool is not used there.
fn inputs(&self, request: &InputRequest<'_>) -> WorkspaceInputs;
}
const _: () = {
fn assert_send_sync<T: Send + Sync>() {}
let _ = assert_send_sync::<WorkspaceInputs>;
};
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use std::io;
use std::path::Path;
use std::sync::{Arc, Mutex};
use skif_config::Config;
use skif_core::{DirEntry, MemoryFs};
use super::*;
/// A file system that records the directories it lists.
struct Counting {
inner: MemoryFs,
listed: Mutex<Vec<PathBuf>>,
}
impl FileSystem for Counting {
fn read_to_string(&self, path: &Path) -> io::Result<Arc<str>> {
self.inner.read_to_string(path)
}
fn read_dir(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
self.listed.lock().unwrap().push(path.to_path_buf());
self.inner.read_dir(path)
}
fn is_file(&self, path: &Path) -> bool {
self.inner.is_file(path)
}
fn is_dir(&self, path: &Path) -> bool {
self.inner.is_dir(path)
}
}
/// The Nx projects of a run are listed by one walk, however many packages ask for them: each directory is listed
/// once.
#[test]
fn the_nx_projects_are_listed_once_per_run() {
let memory = MemoryFs::new()
.with_file("/ws/package.json", r#"{"name":"r","workspaces":["packages/*"]}"#)
.with_file("/ws/apps/web/project.json", "{}")
.with_file("/ws/packages/b/package.json", r#"{"name":"b"}"#)
.with_file("/ws/packages/b/project.json", "{}");
let config = Config::from_toml_str("").unwrap();
let workspace = Workspace::discover_with_fs(Path::new("/ws"), &config, &memory).unwrap();
let fs = Counting {
inner: memory,
listed: Mutex::new(Vec::new()),
};
let listing = WorkspaceListing::new(true, true);
let expected = [
("apps/web".to_owned(), String::new()),
("packages/b".to_owned(), "packages/b".to_owned()),
];
assert_eq!(listing.nx_projects(&fs, &workspace), expected);
let first_walk = fs.listed.lock().unwrap().clone();
assert_eq!(listing.nx_projects(&fs, &workspace), expected);
let listed = fs.listed.lock().unwrap().clone();
assert_eq!(listed, first_walk);
let apps = listed.iter().filter(|dir| *dir == Path::new("/ws/apps")).count();
assert_eq!(apps, 1, "{listed:?}");
}
}