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
//! Skif-owned description of one module. No oxc type appears here.
use std::collections::{HashMap, HashSet};
use serde::{Deserialize, Serialize};
use skif_core::Span;
/// How a module is pulled in.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ImportKind {
/// `import a from "x"` (not type-only, has bindings).
Static,
/// `import type … from "x"`: the whole declaration is type-only.
TypeOnly,
/// `import "x"`.
SideEffect,
/// `import("x")` with a literal specifier.
Dynamic,
/// `require("x")`, `require.resolve("x")` and `import x = require("x")`.
Require,
/// One pattern of `import.meta.glob(...)` (a glob, not a module specifier).
ImportMetaGlob,
/// `export … from "x"`; the matching [`Export`] is recorded too.
ExportFrom,
}
/// Which export of the target module a binding refers to.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ImportedKind {
/// The default export.
Default,
/// The whole namespace (`import * as ns`, `export * from`, `require("x")`, `import("x")`).
Namespace,
/// A named export.
Named(String),
}
/// One binding brought in by an [`Import`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct ImportedName {
/// What is imported.
pub kind: ImportedKind,
/// Local binding name; `None` for re-exports, side effects and unbound `require`/`import()`.
pub local: Option<String>,
/// `import { type A }` or a type-only declaration.
pub is_type: bool,
/// Members read through the local binding in this file (`ns.a`, `Enum.A`, `const { a } = ns`, `ns.A` in types).
/// Sorted and deduplicated. Meaningful only when `local` is set.
pub accessed_members: Vec<String>,
/// The binding is used in a way that cannot be reduced to members (passed around, re-exported, computed access,
/// destructured with a rest element), or the module value is not bound at all (`import("x")`, bare `require("x")`).
pub escapes: bool,
}
/// A module reference.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct Import {
/// The specifier as written (a glob pattern for [`ImportKind::ImportMetaGlob`]).
pub specifier: String,
/// How it is referenced.
pub kind: ImportKind,
/// Bindings it brings in.
pub names: Vec<ImportedName>,
/// Location of the whole statement or call.
pub span: Span,
}
/// What an export is.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ExportKind {
/// `const`, `let`, `var`, and `export { x }` of anything not recognised below.
Value,
/// `interface` and `type` aliases.
Type,
/// `enum` and `const enum`.
Enum,
/// TypeScript `namespace`/`module`.
Namespace,
/// `class`.
Class,
/// `function`.
Function,
/// `export default …` (named `default`).
Default,
/// `export { a as b } from "x"`.
ReExport,
/// `export * from "x"` (named `*`) and `export * as ns from "x"` (named `ns`).
StarReExport,
}
/// A member of an enum or TypeScript namespace.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct Member {
/// Member name.
pub name: String,
/// Location of the member name.
pub span: Span,
/// `JSDoc` tag names (without `@`) on the member.
pub tags: Vec<String>,
}
/// What a removal edit deletes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RemovalKind {
/// Only the `export` (or `export default`) keyword and following whitespace: the declaration stays.
ExportKeyword,
/// Like [`RemovalKind::ExportKeyword`], but the keyword is shared by several exports of one statement
/// (`export const a = 1, b = 2`); apply it only when every sibling is unused.
SharedExportKeyword,
/// One specifier with its separating comma. Ranges of adjacent specifiers of one statement overlap; use
/// [`ModuleInfo::removal_edits`] (or [`coalesce_ranges`]) to get disjoint edits.
Specifier,
/// The whole statement, including one trailing line break.
Statement,
}
/// A byte range to delete to fix an unused export.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct Removal {
/// Start byte offset.
pub start: u32,
/// End byte offset (exclusive).
pub end: u32,
/// What the range deletes.
pub kind: RemovalKind,
/// Byte range of the whole export statement including one trailing line break.
pub statement: (u32, u32),
/// Number of exports declared by that statement (including this one).
pub siblings: u32,
/// The statement is `export default <expression>` (or an anonymous default declaration) and
/// [`RemovalKind::Statement`] deletes the expression too, not only the export: side effects of the expression
/// disappear. Callers that want a conservative fix should skip or flag these.
pub default_expression: bool,
}
/// One export of a module.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct Export {
/// Exported name: `default`, `*` for `export * from`, otherwise the name importers use.
pub name: String,
/// What it is.
pub kind: ExportKind,
/// Exists only at type level (`interface`, `type`, `export type { … }`).
pub is_type: bool,
/// Local binding behind the export, when it has one.
pub local_name: Option<String>,
/// Source module of a re-export.
pub from: Option<String>,
/// Name taken from the source module of a re-export (`*` for a star re-export).
pub imported_name: Option<String>,
/// `JSDoc` tag names (without `@`) in the comment directly above, such as `public`, `internal`, `alias`.
pub tags: Vec<String>,
/// Location of the exported name (the statement for star re-exports).
pub span: Span,
/// Members of an enum export.
pub enum_members: Vec<Member>,
/// Exported members of a namespace export.
pub namespace_members: Vec<Member>,
/// How to remove the export, when a safe edit exists.
pub removal: Option<Removal>,
/// The local binding is referenced elsewhere in this file (excluding export clauses).
pub referenced_in_file: bool,
/// Names of the other value exports of this file whose signature mentions this export's local binding.
#[serde(default)]
pub referenced_in: Vec<String>,
/// Members of an enum or namespace export read in this file by name (`E.A`, `E['A']`, `const { A } = E`); sorted.
#[serde(default)]
pub members_read_in_file: Vec<String>,
/// `export const b = a`: the identifier the exported constant is initialised with.
#[serde(default)]
pub alias_of: Option<String>,
}
/// A syntax problem reported by the parser. The analysis of the file continues on the parser's recovered program.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct ParseIssue {
/// The parser's message.
pub message: String,
/// Location of the problem in the original file.
pub span: Span,
}
/// Everything skif needs to know about one source file.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct ModuleInfo {
/// Module references, in source order.
pub imports: Vec<Import>,
/// Exports, in source order.
pub exports: Vec<Export>,
/// Export names referenced inside the same file; sorted, deduplicated.
pub in_file_references: Vec<String>,
/// `module.exports = …`, `module.exports.x = …` or `exports.x = …` anywhere in the file. The file is then a
/// `CommonJS` module whose export names are not tracked: treat all of its exports as used.
pub has_cjs_exports: bool,
/// The file contains JSX.
pub has_jsx: bool,
/// Value of a `@jsxImportSource` pragma.
pub jsx_import_source: Option<String>,
/// Location of the value of the `@jsxImportSource` pragma. Only a pragma in the comments before the first
/// statement counts.
pub jsx_import_source_span: Option<Span>,
/// Directive prologue strings of the top level, such as `use client` and `use server`. See
/// [`ModuleInfo::is_client_boundary`] and [`ModuleInfo::is_server_actions`].
pub directives: Vec<String>,
/// `import()`, `require()` and `import.meta.glob()` calls whose argument is not static.
pub unresolved_dynamic: Vec<Span>,
/// Names declared or augmented with `declare module 'name' { … }`.
#[serde(default)]
pub ambient_modules: Vec<String>,
/// Syntax errors the parser recovered from; the other fields describe the recovered program. For component
/// files, the issues of every script block.
pub parse_diagnostics: Vec<ParseIssue>,
/// The parser could not recover (or, for a component file, could not recover in at least one script block): the
/// facts of the affected code are missing. Report the file as unparseable instead of treating it as empty.
pub fatal: bool,
}
impl ModuleInfo {
/// Removal edits of every export that has one, in source order.
pub fn removal_ranges(&self) -> impl Iterator<Item = &Removal> {
self.exports.iter().filter_map(|e| e.removal.as_ref())
}
/// The file starts with the `"use client"` directive: a React Server Components client boundary whose exports
/// are consumed by the framework.
#[must_use]
pub fn is_client_boundary(&self) -> bool {
self.directives.iter().any(|d| d == "use client")
}
/// The file starts with the `"use server"` directive: every export is a server action the framework calls.
#[must_use]
pub fn is_server_actions(&self) -> bool {
self.directives.iter().any(|d| d == "use server")
}
/// Disjoint, sorted deletions that remove every export for which `is_unused` holds.
///
/// Specifier ranges of one statement are merged; a statement whose specifiers are all unused is deleted whole
/// instead of leaving `export {}`; a [`RemovalKind::SharedExportKeyword`] applies only when every sibling is
/// unused.
pub fn removal_edits(&self, is_unused: impl Fn(&Export) -> bool) -> Vec<(u32, u32)> {
let mut selected: HashMap<(u32, u32), u32> = HashMap::new();
for export in &self.exports {
if let Some(removal) = &export.removal
&& is_unused(export)
{
*selected.entry(removal.statement).or_default() += 1;
}
}
let mut ranges = Vec::new();
let mut whole_statements = HashSet::new();
let mut clauses: HashMap<(u32, u32), Vec<(Span, bool)>> = HashMap::new();
// Every specifier of a clause, used or not: a run's separator depends on its neighbours.
for export in &self.exports {
if let Some(removal) = &export.removal
&& removal.kind == RemovalKind::Specifier
{
clauses
.entry(removal.statement)
.or_default()
.push((export.span, is_unused(export)));
}
}
for export in &self.exports {
let Some(removal) = &export.removal else { continue };
if !is_unused(export) {
continue;
}
let all_selected = selected.get(&removal.statement).copied().unwrap_or(0) >= removal.siblings;
match removal.kind {
RemovalKind::ExportKeyword | RemovalKind::Statement => ranges.push((removal.start, removal.end)),
RemovalKind::SharedExportKeyword if all_selected => ranges.push((removal.start, removal.end)),
RemovalKind::Specifier if all_selected => {
whole_statements.insert(removal.statement);
}
// Partly unused shared declarations stay; partly unused clauses are handled per run below.
RemovalKind::SharedExportKeyword | RemovalKind::Specifier => {}
}
}
for (statement, specifiers) in &mut clauses {
if whole_statements.contains(statement) {
continue;
}
specifiers.sort_by_key(|(span, _)| span.start);
ranges.extend(specifier_runs(specifiers));
}
ranges.extend(whole_statements);
coalesce_ranges(ranges)
}
pub(crate) fn merge(&mut self, other: Self) {
self.imports.extend(other.imports);
self.exports.extend(other.exports);
self.in_file_references.extend(other.in_file_references);
self.in_file_references.sort();
self.in_file_references.dedup();
self.has_cjs_exports |= other.has_cjs_exports;
self.has_jsx |= other.has_jsx;
if self.jsx_import_source.is_none() {
self.jsx_import_source = other.jsx_import_source;
self.jsx_import_source_span = other.jsx_import_source_span;
}
self.directives.extend(other.directives);
self.unresolved_dynamic.extend(other.unresolved_dynamic);
self.ambient_modules.extend(other.ambient_modules);
self.parse_diagnostics.extend(other.parse_diagnostics);
self.fatal |= other.fatal;
}
}
/// Deletions for the unused specifiers of one export clause (in source order, at least one kept): each run of
/// adjacent unused specifiers takes the comma after it, or, at the end of the clause, the comma before it.
fn specifier_runs(specifiers: &[(Span, bool)]) -> Vec<(u32, u32)> {
let mut out = Vec::new();
let mut i = 0;
while i < specifiers.len() {
if !specifiers[i].1 {
i += 1;
continue;
}
let first = i;
while i < specifiers.len() && specifiers[i].1 {
i += 1;
}
let last = i - 1;
if let Some(next) = specifiers.get(i) {
out.push((specifiers[first].0.start, next.0.start));
} else if let Some(before) = first.checked_sub(1).and_then(|k| specifiers.get(k)) {
out.push((before.0.end, specifiers[last].0.end));
}
}
out
}
/// Merges overlapping and touching byte ranges into sorted, disjoint ones.
#[must_use]
pub fn coalesce_ranges(ranges: impl IntoIterator<Item = (u32, u32)>) -> Vec<(u32, u32)> {
let mut sorted: Vec<(u32, u32)> = ranges.into_iter().filter(|(s, e)| s < e).collect();
sorted.sort_unstable();
let mut out: Vec<(u32, u32)> = Vec::with_capacity(sorted.len());
for (start, end) in sorted {
match out.last_mut() {
Some(last) if start <= last.1 => last.1 = last.1.max(end),
_ => out.push((start, end)),
}
}
out
}
/// Options for [`crate::parse_module`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ParseOptions {
/// Resolve references to exports inside the file with semantic analysis.
pub in_file_references: bool,
/// Track members accessed through imported bindings.
pub member_accesses: bool,
}
impl Default for ParseOptions {
fn default() -> Self {
Self {
in_file_references: true,
member_accesses: true,
}
}
}
const _: fn() = || {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<ModuleInfo>();
assert_send_sync::<ParseOptions>();
};