Skip to main content

skif_js_syntax/
lib.rs

1//! The oxc boundary of skif: one JavaScript or TypeScript file in, skif-owned facts out.
2//!
3//! [`parse_module`] returns a [`ModuleInfo`] (imports, exports, in-file references, removal ranges) and
4//! [`extract_config_object`] statically evaluates the object a tool config file exports. [`extract_config`] returns an
5//! [`ExtractedConfig`]: that value and the evaluated arguments of the calls the evaluator cannot follow that the file
6//! exports, returns from its config function or merges, following imports when given a [`ModuleLoader`]. No oxc type
7//! appears in the public API, every public type is `Send + Sync`, and no oxc value outlives the call that parsed it:
8//! each thread parses into one arena it resets before every parse.
9//!
10//! Supported inputs are `.js .mjs .cjs .jsx .ts .mts .cts .tsx` and declaration files ([`is_declaration_file`]),
11//! plus the `<script>` blocks (and Astro frontmatter), the `<style>` imports and the markup identifiers of `.vue`,
12//! `.svelte` and `.astro` files: component tags and the expressions of the template count as uses of the imports they
13//! name, unless a template local of the same name (`v-for`, `{#each}`, an arrow parameter) shadows the import.
14//! `.mdx` files yield the facts of their top-level `import` and `export` statements, and the components their markup
15//! names count as uses.
16//! `.tsrx` files yield their imports only, since their templates are no JavaScript, and `.marko` files their imports and
17//! the imports of their style blocks. Stylesheets go through
18//! [`parse_stylesheet`]. Other extensions yield [`SyntaxError::Unsupported`]. [`registered_components`] reads the
19//! components a `components.d.ts` generated by `unplugin-vue-components` registers.
20//!
21//! [`parse_cached`] parses through a [`ModuleCache`] the host supplies, keyed by a [`ModuleKey`] over everything a
22//! parse depends on, so a long-lived or persisting host parses only the files that changed (ADR 0029).
23
24#![warn(missing_docs)]
25
26mod array_comments;
27mod cache;
28mod collect;
29mod commands;
30mod comments;
31mod components;
32mod config;
33mod custom_elements;
34mod declaration;
35mod error;
36mod finder;
37mod jsdoc;
38mod marko;
39mod mdx;
40mod members;
41mod model;
42mod semantic;
43mod sfc;
44mod sharing;
45mod signature_refs;
46mod style;
47mod template;
48mod tsrx;
49
50use skif_core::HashSet;
51use std::cell::RefCell;
52use std::path::Path;
53
54use oxc_allocator::Allocator;
55use oxc_ast_visit::Visit;
56use oxc_parser::{ParseOptions as OxcParseOptions, Parser};
57use oxc_span::SourceType;
58use skif_core::LineIndex;
59
60pub use array_comments::{
61    ConfigComments, ElementComment, PathSegment, PropertyComment, extract_array_comments, extract_config_comments,
62};
63pub use cache::{
64    CACHE_SCHEMA, CACHE_SCHEMA_DIGEST, CachedModule, MemoryModuleCache, ModuleCache, ModuleKey, ParseCoalescer,
65    cache_version, parse_cached,
66};
67pub use components::{
68    RegisteredComponent, camelize, capitalize, is_native_tag, registered_components, registered_name, resolved_names,
69};
70pub use config::{
71    ConfigValue, ExtractedConfig, FILE_DIR, ImportedCall, ModuleLoader, OWN_SPREAD, TEMPLATE_SUBSTITUTION,
72    UNKNOWN_SPREAD, command_in_call_dir, command_line_call, extract_command_calls, extract_config,
73    extract_config_object, extract_config_object_with, extract_constructor_options, extract_imported_calls,
74    extract_method_calls, extract_property_calls, extract_property_values, file_call_options, literal_command,
75    place_file_dir,
76};
77pub use declaration::{DECLARATION_SUFFIXES, is_declaration_file};
78pub use error::SyntaxError;
79pub use model::{
80    Export, ExportKind, Import, ImportKind, ImportedKind, ImportedName, Member, MockCall, ModuleInfo, ParseIssue,
81    ParseOptions, Registration, Removal, RemovalKind, coalesce_ranges, is_source_query,
82};
83pub use style::{is_stylesheet, parse_stylesheet};
84
85/// Parses `source` (the content of `path`) into a [`ModuleInfo`]. The extension of `path` selects the language.
86///
87/// # Errors
88///
89/// [`SyntaxError::Unsupported`] for a file type that is neither a script nor a supported component format.
90///
91/// Syntax errors are not errors of this function: the parser's recovered program is analysed and the problems are
92/// reported in [`ModuleInfo::parse_diagnostics`]. When the parser cannot recover at all, the returned info is empty
93/// and [`ModuleInfo::fatal`] is set.
94pub fn parse_module(path: &Path, source: &str, options: &ParseOptions) -> Result<ModuleInfo, SyntaxError> {
95    let lines = LineIndex::new(source);
96    let extension = path
97        .extension()
98        .and_then(|e| e.to_str())
99        .unwrap_or_default()
100        .to_ascii_lowercase();
101    match extension.as_str() {
102        "vue" | "svelte" | "astro" => parse_component(&extension, source, &lines, *options),
103        "mdx" => Ok(parse_mdx(source, &lines, *options)),
104        "tsrx" => Ok(parse_tsrx(source, &lines, *options)),
105        "marko" => Ok(parse_marko(source, &lines, *options)),
106        _ => {
107            let source_type = source_type_for(path).ok_or_else(|| unsupported(&extension))?;
108            Ok(parse_block(source, 0, source_type, &lines, *options, None))
109        }
110    }
111}
112
113/// Source type for `path`. JavaScript is parsed with JSX enabled because real projects write JSX in `.js` files.
114fn source_type_for(path: &Path) -> Option<SourceType> {
115    let source_type = SourceType::from_path(path).ok()?;
116    Some(if source_type.is_javascript() {
117        source_type.with_jsx(true)
118    } else {
119        source_type
120    })
121}
122
123fn unsupported(extension: &str) -> SyntaxError {
124    SyntaxError::Unsupported {
125        extension: extension.to_string(),
126    }
127}
128
129/// The arena capacity a thread keeps between parses. A file that grew its arena past it gives the memory back, so one
130/// huge file does not hold that much memory per thread for the rest of the run.
131const RETAINED_ARENA_BYTES: usize = 16 * 1024 * 1024;
132
133thread_local! {
134    /// The arena this thread parses into, reused across parses so a small file does not pay for a fresh one.
135    static ARENA: RefCell<Allocator> = RefCell::new(Allocator::default());
136}
137
138/// Runs `f` with this thread's arena, reset first. A nested call, made while the arena is in use (an evaluated
139/// config importing another module), gets a fresh arena of its own.
140pub(crate) fn with_arena<R>(f: impl FnOnce(&Allocator) -> R) -> R {
141    ARENA.with(|arena| {
142        let Ok(mut allocator) = arena.try_borrow_mut() else {
143            return f(&Allocator::default());
144        };
145        allocator.reset();
146        let result = f(&allocator);
147        if allocator.capacity() > RETAINED_ARENA_BYTES {
148            *allocator = Allocator::default();
149        }
150        result
151    })
152}
153
154/// Parses one block of code. With `uses`, also records how every name is used in the block.
155fn parse_block(
156    src: &str,
157    base: u32,
158    source_type: SourceType,
159    lines: &LineIndex,
160    options: ParseOptions,
161    uses: Option<&mut members::Uses>,
162) -> ModuleInfo {
163    with_arena(|allocator| parse_block_in(allocator, src, base, source_type, lines, options, uses))
164}
165
166fn parse_block_in(
167    allocator: &Allocator,
168    src: &str,
169    base: u32,
170    source_type: SourceType,
171    lines: &LineIndex,
172    options: ParseOptions,
173    uses: Option<&mut members::Uses>,
174) -> ModuleInfo {
175    let parser_options = OxcParseOptions {
176        preserve_parens: false,
177        allow_return_outside_function: true,
178        ..OxcParseOptions::default()
179    };
180    let ret = Parser::new(allocator, src, source_type)
181        .with_options(parser_options)
182        .parse();
183    let parse_diagnostics: Vec<ParseIssue> = ret
184        .diagnostics
185        .errors()
186        .map(|diagnostic| {
187            let (offset, len) = diagnostic
188                .labels
189                .as_slice()
190                .first()
191                .map_or((0, 0), |l| (l.offset(), l.len()));
192            ParseIssue {
193                message: diagnostic.message.to_string(),
194                span: lines.span(
195                    base.saturating_add(offset),
196                    base.saturating_add(offset).saturating_add(len),
197                ),
198            }
199        })
200        .collect();
201    if ret.fatal_error {
202        return ModuleInfo {
203            parse_diagnostics,
204            fatal: true,
205            ..ModuleInfo::default()
206        };
207    }
208
209    let ctx = collect::Ctx { src, base, lines };
210    let mut info = ModuleInfo {
211        parse_diagnostics,
212        ..ModuleInfo::default()
213    };
214    let collected = collect::collect(&ctx, &ret.program, &mut info);
215    let mut finder = finder::Finder::new(&ctx, &mut info, &ret.program);
216    finder.visit_program(&ret.program);
217    let cjs_shorthand = finder.into_cjs_shorthand();
218    info.imports.sort_by_key(|i| i.span.start);
219    // One semantic analysis serves every pass below that needs scopes; it is built only if one does.
220    let analysis = semantic::Analysis::new(&ret.program);
221    if options.member_accesses {
222        let excluded: HashSet<(u32, u32)> = collected.reexport_spans.iter().copied().collect();
223        members::track(&ret.program, &analysis, &mut info, &excluded, base);
224        if let Some(uses) = uses {
225            *uses = members::uses(&ret.program, &analysis);
226        }
227    }
228    if options.in_file_references {
229        mark_in_file_references(
230            &ret.program,
231            &analysis,
232            &collected.export_clause_spans,
233            &cjs_shorthand,
234            &mut info,
235        );
236        signature_refs::mark(&ret.program, &mut info);
237        members::mark_local_reads(&ret.program, &analysis, &mut info);
238    }
239    info.directives = ret
240        .program
241        .directives
242        .iter()
243        .map(|d| d.directive.as_str().to_string())
244        .collect();
245    custom_elements::mark(&ret.program, &analysis, &mut info);
246    comments::collect(&ctx, &ret.program, &mut info);
247    info.commands = commands::child_process_commands(&ret.program, &info.imports);
248    if src.contains("resolveComponent") {
249        info.template_tags = components::resolved_components(&ret.program);
250        info.template_tags.sort();
251        info.template_tags.dedup();
252    }
253    info
254}
255
256/// Parses a `.vue`, `.svelte` or `.astro` file: its script blocks (and Astro frontmatter), the imports of its style
257/// blocks and `src` attributes, and the identifiers its markup uses.
258///
259/// Spans of every fact are offsets into `source`. Script blocks are analysed as one module: an import of one block
260/// that another block or the markup reads counts as used there. Only the module script of a Svelte component
261/// declares exports.
262fn parse_component(
263    extension: &str,
264    source: &str,
265    lines: &LineIndex,
266    options: ParseOptions,
267) -> Result<ModuleInfo, SyntaxError> {
268    let blocks = sfc::blocks(source, extension == "astro");
269    let mut parts = Vec::new();
270    let mut all_uses = Vec::new();
271    for block in blocks.scripts.iter().filter(|block| block.parse) {
272        let name = match (block.typescript, block.jsx) {
273            (true, true) => "block.tsx",
274            (true, false) => "block.ts",
275            (false, true) => "block.jsx",
276            (false, false) => "block.js",
277        };
278        let source_type = source_type_for(Path::new(name)).ok_or_else(|| unsupported(extension))?;
279        let base = u32::try_from(block.start).unwrap_or(u32::MAX);
280        let mut uses = members::Uses::default();
281        let mut part = parse_block(
282            &source[block.start..block.end],
283            base,
284            source_type,
285            lines,
286            options,
287            Some(&mut uses),
288        );
289        if extension == "svelte" && block.context == sfc::ScriptContext::Instance {
290            // The exports of a Svelte instance script are props (`export let`) and accessors of the component
291            // instance, which the parent sets as attributes or reads through `bind:this`, not module exports.
292            part.exports.clear();
293        }
294        parts.push(part);
295        all_uses.push(uses);
296    }
297    if options.member_accesses {
298        for (index, part) in parts.iter_mut().enumerate() {
299            let mut others = members::Uses::default();
300            for (other, uses) in all_uses.iter().enumerate() {
301                if other != index {
302                    members::merge_uses(&mut others, uses);
303                }
304            }
305            members::credit(part, &others);
306        }
307    }
308    let mut info = ModuleInfo::default();
309    for part in parts {
310        info.merge(part);
311    }
312    add_style_and_source_imports(&mut info, &blocks, source, lines);
313    if options.member_accesses {
314        let flavor = match extension {
315            "vue" => template::Flavor::Vue,
316            "svelte" => template::Flavor::Svelte,
317            _ => template::Flavor::Astro,
318        };
319        let mut uses = members::Uses::default();
320        let mut tags = std::collections::BTreeSet::new();
321        for (start, end) in blocks.markup(source) {
322            tags.extend(template::scan(&source[start..end], flavor, &mut uses));
323        }
324        members::credit(&mut info, &uses);
325        if flavor == template::Flavor::Vue {
326            tags.extend(std::mem::take(&mut info.template_tags));
327            info.template_tags = tags.into_iter().collect();
328        }
329    }
330    Ok(info)
331}
332
333/// Parses a `.mdx` file: its top-level ESM as JavaScript with JSX, and the identifiers its markup uses.
334fn parse_mdx(source: &str, lines: &LineIndex, options: ParseOptions) -> ModuleInfo {
335    let extracted = mdx::extract(source);
336    let source_type = SourceType::jsx();
337    let mut info = parse_block(&extracted.esm, 0, source_type, lines, options, None);
338    if options.member_accesses {
339        let mut markup = members::Uses::default();
340        for (start, end) in extracted.markup {
341            // Code quoted in prose (`<Card/>`) is not a use; fenced code is already outside the markup.
342            let text = mdx::blank_inline_code(&source[start..end]);
343            template::scan(&text, template::Flavor::Astro, &mut markup);
344        }
345        members::credit(&mut info, &markup);
346    }
347    info
348}
349
350/// Parses a `.tsrx` file: its `import` statements and `import()` calls as TypeScript. Its templates are not read,
351/// so every imported binding counts as used as a whole.
352fn parse_tsrx(source: &str, lines: &LineIndex, options: ParseOptions) -> ModuleInfo {
353    let mut info = parse_block(&tsrx::extract(source), 0, SourceType::tsx(), lines, options, None);
354    for name in info.imports.iter_mut().flat_map(|import| &mut import.names) {
355        name.escapes = true;
356    }
357    info
358}
359
360/// Parses a `.marko` file as knip's Marko compiler reads it: the `import` statements and `import()` calls outside its
361/// style blocks, like a `.tsrx` file, and the imports of its style blocks. An import of a tag
362/// (`import Card from "<card>"`) names no module and is left out.
363fn parse_marko(source: &str, lines: &LineIndex, options: ParseOptions) -> ModuleInfo {
364    let blocks = sfc::Blocks {
365        styles: marko::styles(source),
366        ..sfc::Blocks::default()
367    };
368    let mut code = source.as_bytes().to_vec();
369    for style in &blocks.styles {
370        for byte in &mut code[style.start..style.end] {
371            if !matches!(*byte, b'\n' | b'\r') {
372                *byte = b' ';
373            }
374        }
375    }
376    // Whole blocks, which start and end at ASCII bytes, became ASCII spaces, so the text is still UTF-8.
377    let code = String::from_utf8(code).unwrap_or_default();
378    let mut info = parse_tsrx(&code, lines, options);
379    info.imports.retain(|import| !import.specifier.starts_with('<'));
380    add_style_and_source_imports(&mut info, &blocks, source, lines);
381    info
382}
383
384/// Adds the imports of `<style>` blocks and of `src="…"` attributes of `<script>` and `<style>` tags.
385fn add_style_and_source_imports(info: &mut ModuleInfo, blocks: &sfc::Blocks, source: &str, lines: &LineIndex) {
386    for style in &blocks.styles {
387        let path = format!("block.{}", style.extension);
388        let base = u32::try_from(style.start).unwrap_or(u32::MAX);
389        for mut import in parse_stylesheet(Path::new(&path), &source[style.start..style.end]).imports {
390            import.span = lines.span(
391                base.saturating_add(import.span.start),
392                base.saturating_add(import.span.end),
393            );
394            info.imports.push(import);
395        }
396    }
397    for reference in &blocks.sources {
398        info.imports.push(Import {
399            specifier: reference.specifier.clone(),
400            kind: ImportKind::SideEffect,
401            names: Vec::new(),
402            call: None,
403            span: lines.span(
404                u32::try_from(reference.start).unwrap_or(u32::MAX),
405                u32::try_from(reference.end).unwrap_or(u32::MAX),
406            ),
407        });
408    }
409    info.imports.sort_by_key(|import| import.span.start);
410}
411
412/// Marks the exports whose binding the file references outside the export itself: a local declaration, an import
413/// binding the file re-exports (`import a from './a'; export { a }`), read as a value, and the binding a
414/// `module.exports = { a }` property names. `clause_spans` are the identifiers of export clauses, and `cjs_shorthand`
415/// the bindings and identifier spans of the `module.exports` properties; neither is a reference.
416fn mark_in_file_references(
417    program: &oxc_ast::ast::Program<'_>,
418    analysis: &semantic::Analysis<'_>,
419    clause_spans: &[oxc_span::Span],
420    cjs_shorthand: &[(String, oxc_span::Span)],
421    info: &mut ModuleInfo,
422) {
423    let cjs_names: HashSet<&str> = cjs_shorthand.iter().map(|(name, _)| name.as_str()).collect();
424    let binding = |export: &Export| -> Option<String> {
425        match export.kind {
426            ExportKind::Default => None,
427            // `export { a } from './a'` has no local binding; `export { a }` of an import binding has.
428            ExportKind::ReExport | ExportKind::StarReExport => export.local_name.clone(),
429            _ if export.local_name.is_none() && export.from.is_none() => {
430                cjs_names.contains(export.name.as_str()).then(|| export.name.clone())
431            }
432            _ => export.local_name.clone(),
433        }
434    };
435    let bindings: Vec<Option<String>> = info.exports.iter().map(binding).collect();
436    let wanted: HashSet<&str> = bindings.iter().flatten().map(String::as_str).collect();
437    let excluded: Vec<oxc_span::Span> = clause_spans
438        .iter()
439        .copied()
440        .chain(cjs_shorthand.iter().map(|(_, span)| *span))
441        .collect();
442    let referenced = semantic::referenced_locals(program, analysis, &wanted, &excluded);
443    for (export, local) in info.exports.iter_mut().zip(&bindings) {
444        let used = local.as_deref().is_some_and(|l| referenced.contains(l));
445        export.referenced_in_file = used;
446        if used {
447            info.in_file_references.push(export.name.clone());
448        }
449    }
450    info.in_file_references.sort();
451    info.in_file_references.dedup();
452}
453
454/// An oxc string as an owned Rust string. Lone surrogates, which only escaped JS strings can hold,
455/// are replaced rather than dropped.
456pub(crate) fn js_string(value: oxc_str::JSStr<'_>) -> String {
457    value
458        .as_str()
459        .map_or_else(|| value.display().to_string(), str::to_owned)
460}
461
462#[cfg(test)]
463mod tests {
464    use super::{ParseOptions, Path, parse_module, with_arena};
465
466    #[test]
467    fn parses_in_a_reused_or_nested_arena_give_the_same_facts() {
468        let source =
469            "import { a } from 'a';\nimport * as ns from 'ns';\nexport const b = a.c + ns.d;\nexport { b as e };\n";
470        let parse = || parse_module(Path::new("x.ts"), source, &ParseOptions::default()).ok();
471        let first = parse();
472        let nested = with_arena(|outer| {
473            let held = outer.alloc_str("held");
474            let inner = parse();
475            // The nested parse took an arena of its own, so what the outer one holds is intact.
476            assert_eq!(held, "held");
477            inner
478        });
479        let again = parse();
480        assert!(first.as_ref().is_some_and(|info| info.exports.len() == 2));
481        assert_eq!(nested, first);
482        assert_eq!(again, first);
483    }
484}