skif_js_syntax/model.rs
1//! Skif-owned description of one module. No oxc type appears here.
2
3use std::collections::{HashMap, HashSet};
4
5use serde::{Deserialize, Serialize};
6use skif_core::Span;
7
8/// How a module is pulled in.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
10#[serde(rename_all = "kebab-case")]
11pub enum ImportKind {
12 /// `import a from "x"` (not type-only, has bindings).
13 Static,
14 /// `import type … from "x"`: the whole declaration is type-only.
15 TypeOnly,
16 /// `import "x"`.
17 SideEffect,
18 /// `import("x")` with a literal specifier.
19 Dynamic,
20 /// `require("x")`, `require.resolve("x")`, `import.meta.resolve("x")` and `import x = require("x")`.
21 Require,
22 /// One pattern of `import.meta.glob(...)`, or the glob of a computed relative `require` or `import()`
23 /// (`./commands/${name}`): a glob, not a module specifier.
24 ImportMetaGlob,
25 /// One pattern of an `import.meta.glob(...)` whose options read the matched files as text (`query: '?raw'`,
26 /// legacy `as: 'raw'`): the files are not loaded as modules, so the glob keeps none of them in use.
27 SourceGlob,
28 /// `export … from "x"`; the matching [`Export`] is recorded too.
29 ExportFrom,
30 /// `vi.mock("x")`, `jest.mock("x")` and the other test-runner calls that name a module: the test loads or
31 /// replaces the module, which keeps a local file reachable but is not an import of a dependency.
32 Mock,
33}
34
35/// Which export of the target module a binding refers to.
36#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
37#[serde(rename_all = "kebab-case")]
38pub enum ImportedKind {
39 /// The default export.
40 Default,
41 /// The whole namespace (`import * as ns`, `export * from`, `require("x")`, `import("x")`).
42 Namespace,
43 /// A named export.
44 Named(String),
45}
46
47/// One binding brought in by an [`Import`].
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(rename_all = "kebab-case")]
50pub struct ImportedName {
51 /// What is imported.
52 pub kind: ImportedKind,
53 /// Local binding name; `None` for re-exports, side effects and unbound `require`/`import()`.
54 pub local: Option<String>,
55 /// `import { type A }` or a type-only declaration.
56 pub is_type: bool,
57 /// Members read through the local binding in this file (`ns.a`, `Enum.A`, `const { a } = ns`, `ns.A` in types).
58 /// Sorted and deduplicated. Meaningful only when `local` is set.
59 pub accessed_members: Vec<String>,
60 /// The binding is used in a way that cannot be reduced to members (passed around, re-exported, computed access,
61 /// destructured with a rest element), or the module value is not bound at all (`import("x")`, bare `require("x")`).
62 pub escapes: bool,
63}
64
65/// A module reference.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(rename_all = "kebab-case")]
68pub struct Import {
69 /// The specifier as written (a glob pattern for [`ImportKind::ImportMetaGlob`]).
70 pub specifier: String,
71 /// How it is referenced.
72 pub kind: ImportKind,
73 /// Bindings it brings in.
74 pub names: Vec<ImportedName>,
75 /// Location of the whole statement or call.
76 pub span: Span,
77}
78
79/// What an export is.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
81#[serde(rename_all = "kebab-case")]
82pub enum ExportKind {
83 /// `const`, `let`, `var`, and `export { x }` of anything not recognised below.
84 Value,
85 /// `interface` and `type` aliases.
86 Type,
87 /// `enum` and `const enum`.
88 Enum,
89 /// TypeScript `namespace`/`module`.
90 Namespace,
91 /// `class`.
92 Class,
93 /// `function`.
94 Function,
95 /// `export default …` (named `default`).
96 Default,
97 /// `export { a as b } from "x"`.
98 ReExport,
99 /// `export * from "x"` (named `*`) and `export * as ns from "x"` (named `ns`).
100 StarReExport,
101}
102
103/// A member of an enum or TypeScript namespace.
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(rename_all = "kebab-case")]
106pub struct Member {
107 /// Member name.
108 pub name: String,
109 /// Location of the member name.
110 pub span: Span,
111 /// `JSDoc` tag names (without `@`) on the member.
112 pub tags: Vec<String>,
113 /// The byte range (absolute, end exclusive) that deletes the member: its line when it stands alone on it, else
114 /// the member with its comma and the blanks after it. `None` when deleting it would break the file: another
115 /// member mentions its name, or one statement declares it with siblings.
116 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub removal: Option<(u32, u32)>,
118}
119
120/// What a removal edit deletes.
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
122#[serde(rename_all = "kebab-case")]
123pub enum RemovalKind {
124 /// Only the `export` (or `export default`) keyword and following whitespace: the declaration stays.
125 ExportKeyword,
126 /// Like [`RemovalKind::ExportKeyword`], but the keyword is shared by several exports of one statement
127 /// (`export const a = 1, b = 2`); apply it only when every sibling is unused.
128 SharedExportKeyword,
129 /// One specifier with its separating comma. Ranges of adjacent specifiers of one statement overlap; use
130 /// [`ModuleInfo::removal_edits`] (or [`coalesce_ranges`]) to get disjoint edits.
131 Specifier,
132 /// The whole statement, including one trailing line break.
133 Statement,
134}
135
136/// A byte range to delete to fix an unused export.
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
138#[serde(rename_all = "kebab-case")]
139pub struct Removal {
140 /// Start byte offset.
141 pub start: u32,
142 /// End byte offset (exclusive).
143 pub end: u32,
144 /// What the range deletes.
145 pub kind: RemovalKind,
146 /// Byte range of the whole export statement including one trailing line break.
147 pub statement: (u32, u32),
148 /// Number of exports declared by that statement (including this one).
149 pub siblings: u32,
150 /// The statement is `export default <expression>` (or an anonymous default declaration) and
151 /// [`RemovalKind::Statement`] deletes the expression too, not only the export. Only an expression whose evaluation
152 /// has no effect gets a removal (a literal, an identifier, a function, a class that runs no code, or an array or
153 /// object of those); callers that want a conservative fix can still skip or flag these.
154 pub default_expression: bool,
155}
156
157/// One export of a module.
158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
159#[serde(rename_all = "kebab-case")]
160pub struct Export {
161 /// Exported name: `default`, `*` for `export * from`, otherwise the name importers use.
162 pub name: String,
163 /// What it is.
164 pub kind: ExportKind,
165 /// Exists only at type level (`interface`, `type`, `export type { … }`).
166 pub is_type: bool,
167 /// Local binding behind the export, when it has one.
168 pub local_name: Option<String>,
169 /// Source module of a re-export.
170 pub from: Option<String>,
171 /// Name taken from the source module of a re-export (`*` for a star re-export).
172 pub imported_name: Option<String>,
173 /// `JSDoc` tag names (without `@`) in the comment directly above, such as `public`, `internal`, `alias`.
174 pub tags: Vec<String>,
175 /// Location of the exported name (the statement for star re-exports).
176 pub span: Span,
177 /// Members of an enum export.
178 pub enum_members: Vec<Member>,
179 /// Exported members of a namespace export.
180 pub namespace_members: Vec<Member>,
181 /// How to remove the export, when a safe edit exists.
182 pub removal: Option<Removal>,
183 /// The local binding is referenced elsewhere in this file (excluding export clauses).
184 pub referenced_in_file: bool,
185 /// Names of the other value exports of this file whose signature mentions this export's local binding.
186 #[serde(default)]
187 pub referenced_in: Vec<String>,
188 /// Members of an enum or namespace export read in this file by name (`E.A`, `E['A']`, `const { A } = E`, or a
189 /// namespace member's bare name inside the namespace body); sorted.
190 #[serde(default)]
191 pub members_read_in_file: Vec<String>,
192 /// `export const b = a`: the identifier the exported constant is initialised with.
193 #[serde(default)]
194 pub alias_of: Option<String>,
195 /// How the class behind the export is registered as a custom element, in source order. A registered element is
196 /// used by the browser through its tag name, not through an import.
197 #[serde(default)]
198 pub registrations: Vec<Registration>,
199}
200
201/// How a class is registered as a custom element (ADR 0062).
202///
203/// [`Registration::Define`] is unambiguous on its own. The other two only name the binding the registration goes
204/// through; whether that binding registers anything depends on the library it comes from, which the caller decides
205/// (`lit/decorators.js` registers with `customElement`, `@stencil/core` with `Component`).
206#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
207#[serde(rename_all = "kebab-case")]
208pub enum Registration {
209 /// `customElements.define('x-y', Class)`, also through `window.customElements`, a variable holding the registry
210 /// (resolved by scope) or a `new CustomElementRegistry()`, and `this` in a static block of the class.
211 Define,
212 /// A decorator of a top-level class, `@name(…)`, `@name` or `@ns.name(…)`, whose binding is imported from
213 /// `module`.
214 Decorator {
215 /// The specifier the decorator is imported from.
216 module: String,
217 /// The imported name (the member name for a namespace import).
218 name: String,
219 },
220 /// A top-level class extends `name` imported from `module` (directly or as an argument of a mixin call,
221 /// `extends Mixin(Base)`), and `Class.define(…)` or `Class.defineAsync(…)` is called; or `name.define(Class, …)`, the
222 /// static form on the imported class, is called with the class first.
223 BaseClassDefine {
224 /// The specifier the base class is imported from.
225 module: String,
226 /// The imported name of the base class.
227 name: String,
228 },
229}
230
231/// A syntax problem reported by the parser. The analysis of the file continues on the parser's recovered program.
232#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
233#[serde(rename_all = "kebab-case")]
234pub struct ParseIssue {
235 /// The parser's message.
236 pub message: String,
237 /// Location of the problem in the original file.
238 pub span: Span,
239}
240
241/// Everything skif needs to know about one source file.
242#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
243#[serde(rename_all = "kebab-case")]
244pub struct ModuleInfo {
245 /// Module references, in source order.
246 pub imports: Vec<Import>,
247 /// Exports, in source order.
248 pub exports: Vec<Export>,
249 /// Export names referenced inside the same file; sorted, deduplicated.
250 pub in_file_references: Vec<String>,
251 /// `module.exports = …`, `module.exports.x = …` or `exports.x = …` anywhere in the file. The file is then a
252 /// `CommonJS` module whose export names are not tracked: treat all of its exports as used.
253 pub has_cjs_exports: bool,
254 /// The file contains JSX.
255 pub has_jsx: bool,
256 /// Value of a `@jsxImportSource` pragma.
257 pub jsx_import_source: Option<String>,
258 /// Location of the value of the `@jsxImportSource` pragma. Only a pragma in the comments before the first
259 /// statement counts.
260 pub jsx_import_source_span: Option<Span>,
261 /// Directive prologue strings of the top level, such as `use client` and `use server`. See
262 /// [`ModuleInfo::is_client_boundary`] and [`ModuleInfo::is_server_actions`].
263 pub directives: Vec<String>,
264 /// `import()`, `require()` and `import.meta.glob()` calls whose argument is not static.
265 pub unresolved_dynamic: Vec<Span>,
266 /// Names declared with `declare module 'name' { … }` in a script (a file without top-level `import` or
267 /// `export`): the declaration is the module, for every file of the program.
268 #[serde(default)]
269 pub ambient_modules: Vec<String>,
270 /// Names augmented with `declare module 'name' { … }` in a module file: the block adds to a module that has to
271 /// exist already and declares nothing on its own.
272 #[serde(default)]
273 pub augmented_modules: Vec<String>,
274 /// Syntax errors the parser recovered from; the other fields describe the recovered program. For component
275 /// files, the issues of every script block.
276 pub parse_diagnostics: Vec<ParseIssue>,
277 /// The parser could not recover (or, for a component file, could not recover in at least one script block): the
278 /// facts of the affected code are missing. Report the file as unparseable instead of treating it as empty.
279 pub fatal: bool,
280}
281
282impl ModuleInfo {
283 /// Removal edits of every export that has one, in source order.
284 pub fn removal_ranges(&self) -> impl Iterator<Item = &Removal> {
285 self.exports.iter().filter_map(|e| e.removal.as_ref())
286 }
287
288 /// The file starts with the `"use client"` directive: a React Server Components client boundary whose exports
289 /// are consumed by the framework.
290 #[must_use]
291 pub fn is_client_boundary(&self) -> bool {
292 self.directives.iter().any(|d| d == "use client")
293 }
294
295 /// The file starts with the `"use server"` directive: every export is a server action the framework calls.
296 #[must_use]
297 pub fn is_server_actions(&self) -> bool {
298 self.directives.iter().any(|d| d == "use server")
299 }
300
301 /// Disjoint, sorted deletions that remove every export for which `is_unused` holds.
302 ///
303 /// Specifier ranges of one statement are merged; a statement whose specifiers are all unused is deleted whole
304 /// instead of leaving `export {}`; a [`RemovalKind::SharedExportKeyword`] applies only when every sibling is
305 /// unused.
306 pub fn removal_edits(&self, is_unused: impl Fn(&Export) -> bool) -> Vec<(u32, u32)> {
307 let mut selected: HashMap<(u32, u32), u32> = HashMap::new();
308 for export in &self.exports {
309 if let Some(removal) = &export.removal
310 && is_unused(export)
311 {
312 *selected.entry(removal.statement).or_default() += 1;
313 }
314 }
315 let mut ranges = Vec::new();
316 let mut whole_statements = HashSet::new();
317 let mut clauses: HashMap<(u32, u32), Vec<(Span, bool)>> = HashMap::new();
318 // Every specifier of a clause, used or not: a run's separator depends on its neighbours.
319 for export in &self.exports {
320 if let Some(removal) = &export.removal
321 && removal.kind == RemovalKind::Specifier
322 {
323 clauses
324 .entry(removal.statement)
325 .or_default()
326 .push((export.span, is_unused(export)));
327 }
328 }
329 for export in &self.exports {
330 let Some(removal) = &export.removal else { continue };
331 if !is_unused(export) {
332 continue;
333 }
334 let all_selected = selected.get(&removal.statement).copied().unwrap_or(0) >= removal.siblings;
335 match removal.kind {
336 RemovalKind::ExportKeyword | RemovalKind::Statement => ranges.push((removal.start, removal.end)),
337 RemovalKind::SharedExportKeyword if all_selected => ranges.push((removal.start, removal.end)),
338 RemovalKind::Specifier if all_selected => {
339 whole_statements.insert(removal.statement);
340 }
341 // Partly unused shared declarations stay; partly unused clauses are handled per run below.
342 RemovalKind::SharedExportKeyword | RemovalKind::Specifier => {}
343 }
344 }
345 for (statement, specifiers) in &mut clauses {
346 if whole_statements.contains(statement) {
347 continue;
348 }
349 specifiers.sort_by_key(|(span, _)| span.start);
350 ranges.extend(specifier_runs(specifiers));
351 }
352 ranges.extend(whole_statements);
353 coalesce_ranges(ranges)
354 }
355
356 pub(crate) fn merge(&mut self, other: Self) {
357 self.imports.extend(other.imports);
358 self.exports.extend(other.exports);
359 self.in_file_references.extend(other.in_file_references);
360 self.in_file_references.sort();
361 self.in_file_references.dedup();
362 self.has_cjs_exports |= other.has_cjs_exports;
363 self.has_jsx |= other.has_jsx;
364 if self.jsx_import_source.is_none() {
365 self.jsx_import_source = other.jsx_import_source;
366 self.jsx_import_source_span = other.jsx_import_source_span;
367 }
368 self.directives.extend(other.directives);
369 self.unresolved_dynamic.extend(other.unresolved_dynamic);
370 self.ambient_modules.extend(other.ambient_modules);
371 self.augmented_modules.extend(other.augmented_modules);
372 self.parse_diagnostics.extend(other.parse_diagnostics);
373 self.fatal |= other.fatal;
374 }
375}
376
377/// Deletions for the unused specifiers of one export clause (in source order, at least one kept): each run of
378/// adjacent unused specifiers takes the comma after it, or, at the end of the clause, the comma before it.
379fn specifier_runs(specifiers: &[(Span, bool)]) -> Vec<(u32, u32)> {
380 let mut out = Vec::new();
381 let mut i = 0;
382 while i < specifiers.len() {
383 if !specifiers[i].1 {
384 i += 1;
385 continue;
386 }
387 let first = i;
388 while i < specifiers.len() && specifiers[i].1 {
389 i += 1;
390 }
391 let last = i - 1;
392 if let Some(next) = specifiers.get(i) {
393 out.push((specifiers[first].0.start, next.0.start));
394 } else if let Some(before) = first.checked_sub(1).and_then(|k| specifiers.get(k)) {
395 out.push((before.0.end, specifiers[last].0.end));
396 }
397 }
398 out
399}
400
401/// Merges overlapping and touching byte ranges into sorted, disjoint ones.
402#[must_use]
403pub fn coalesce_ranges(ranges: impl IntoIterator<Item = (u32, u32)>) -> Vec<(u32, u32)> {
404 let mut sorted: Vec<(u32, u32)> = ranges.into_iter().filter(|(s, e)| s < e).collect();
405 sorted.sort_unstable();
406 let mut out: Vec<(u32, u32)> = Vec::with_capacity(sorted.len());
407 for (start, end) in sorted {
408 match out.last_mut() {
409 Some(last) if start <= last.1 => last.1 = last.1.max(end),
410 _ => out.push((start, end)),
411 }
412 }
413 out
414}
415
416/// Whether a query (`?raw`, `raw&inline`, with or without its `?`) makes Vite load a file's text instead of the
417/// module, by Vite's own test: a `raw` parameter with no value. `?url` is not one: it names the file to load it some
418/// other way (a worklet, a worker), so the file still counts as used.
419#[must_use]
420pub fn is_source_query(query: &str) -> bool {
421 query
422 .strip_prefix('?')
423 .unwrap_or(query)
424 .split('&')
425 .any(|parameter| parameter == "raw")
426}
427
428/// Options for [`crate::parse_module`].
429#[derive(Debug, Clone, Copy, PartialEq, Eq)]
430pub struct ParseOptions {
431 /// Resolve references to exports inside the file with semantic analysis.
432 pub in_file_references: bool,
433 /// Track members accessed through imported bindings.
434 pub member_accesses: bool,
435}
436
437impl Default for ParseOptions {
438 fn default() -> Self {
439 Self {
440 in_file_references: true,
441 member_accesses: true,
442 }
443 }
444}
445
446const _: fn() = || {
447 fn assert_send_sync<T: Send + Sync>() {}
448 assert_send_sync::<ModuleInfo>();
449 assert_send_sync::<ParseOptions>();
450};