Skip to main content

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};