skif-js-syntax 0.3.0

oxc-based JS/TS module facts and static config extraction for skif; never executes code.
Documentation
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
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
//! The oxc boundary of skif: one JavaScript or TypeScript file in, skif-owned facts out.
//!
//! [`parse_module`] returns a [`ModuleInfo`] (imports, exports, in-file references, removal ranges) and
//! [`extract_config_object`] statically evaluates the object a tool config file exports. [`extract_config`] returns an
//! [`ExtractedConfig`]: that value and the evaluated arguments of the calls the evaluator cannot follow that the file
//! exports, returns from its config function or merges, following imports when given a [`ModuleLoader`]. No oxc type
//! appears in the public API, every public type is `Send + Sync`, and no oxc value outlives the call that parsed it:
//! each thread parses into one arena it resets before every parse.
//!
//! Supported inputs are `.js .mjs .cjs .jsx .ts .mts .cts .tsx` and `.d.ts` files, plus the `<script>` blocks (and
//! Astro frontmatter), the `<style>` imports and the markup identifiers of `.vue`, `.svelte` and `.astro` files:
//! component tags and the expressions of the template count as uses of the imports they name, unless a template local
//! of the same name (`v-for`, `{#each}`, an arrow parameter) shadows the import. `.mdx` files yield the
//! facts of their top-level `import` and `export` statements, and the components their markup names count as uses.
//! `.tsrx` files yield their imports only, since their templates are no JavaScript, and `.marko` files their imports and
//! the imports of their style blocks. Stylesheets go through
//! [`parse_stylesheet`]. Other extensions yield [`SyntaxError::Unsupported`]. [`registered_components`] reads the
//! components a `components.d.ts` generated by `unplugin-vue-components` registers.
//!
//! [`parse_cached`] parses through a [`ModuleCache`] the host supplies, keyed by a [`ModuleKey`] over everything a
//! parse depends on, so a long-lived or persisting host parses only the files that changed (ADR 0066).

#![warn(missing_docs)]

mod array_comments;
mod cache;
mod collect;
mod commands;
mod comments;
mod components;
mod config;
mod custom_elements;
mod error;
mod finder;
mod jsdoc;
mod marko;
mod mdx;
mod members;
mod model;
mod semantic;
mod sfc;
mod sharing;
mod signature_refs;
mod style;
mod template;
mod tsrx;

use skif_core::HashSet;
use std::cell::RefCell;
use std::path::Path;

use oxc_allocator::Allocator;
use oxc_ast_visit::Visit;
use oxc_parser::{ParseOptions as OxcParseOptions, Parser};
use oxc_span::SourceType;
use skif_core::LineIndex;

pub use array_comments::{ElementComment, PathSegment, extract_array_comments};
pub use cache::{
    CACHE_SCHEMA, CACHE_SCHEMA_DIGEST, CachedModule, MemoryModuleCache, ModuleCache, ModuleKey, ParseCoalescer,
    cache_version, parse_cached,
};
pub use components::{
    RegisteredComponent, camelize, capitalize, is_native_tag, registered_components, registered_name, resolved_names,
};
pub use config::{
    ConfigValue, ExtractedConfig, FILE_DIR, ImportedCall, ModuleLoader, OWN_SPREAD, TEMPLATE_SUBSTITUTION,
    UNKNOWN_SPREAD, command_in_call_dir, command_line_call, extract_command_calls, extract_config,
    extract_config_object, extract_config_object_with, extract_constructor_options, extract_imported_calls,
    extract_method_calls, extract_property_calls, extract_property_values, file_call_options, literal_command,
    place_file_dir,
};
pub use error::SyntaxError;
pub use model::{
    Export, ExportKind, Import, ImportKind, ImportedKind, ImportedName, Member, MockCall, ModuleInfo, ParseIssue,
    ParseOptions, Registration, Removal, RemovalKind, coalesce_ranges, is_source_query,
};
pub use style::{is_stylesheet, parse_stylesheet};

/// Parses `source` (the content of `path`) into a [`ModuleInfo`]. The extension of `path` selects the language.
///
/// # Errors
///
/// [`SyntaxError::Unsupported`] for a file type that is neither a script nor a supported component format.
///
/// Syntax errors are not errors of this function: the parser's recovered program is analysed and the problems are
/// reported in [`ModuleInfo::parse_diagnostics`]. When the parser cannot recover at all, the returned info is empty
/// and [`ModuleInfo::fatal`] is set.
pub fn parse_module(path: &Path, source: &str, options: &ParseOptions) -> Result<ModuleInfo, SyntaxError> {
    let lines = LineIndex::new(source);
    let extension = path
        .extension()
        .and_then(|e| e.to_str())
        .unwrap_or_default()
        .to_ascii_lowercase();
    match extension.as_str() {
        "vue" | "svelte" | "astro" => parse_component(&extension, source, &lines, *options),
        "mdx" => Ok(parse_mdx(source, &lines, *options)),
        "tsrx" => Ok(parse_tsrx(source, &lines, *options)),
        "marko" => Ok(parse_marko(source, &lines, *options)),
        _ => {
            let source_type = source_type_for(path).ok_or_else(|| unsupported(&extension))?;
            Ok(parse_block(source, 0, source_type, &lines, *options, None))
        }
    }
}

/// Source type for `path`. JavaScript is parsed with JSX enabled because real projects write JSX in `.js` files.
fn source_type_for(path: &Path) -> Option<SourceType> {
    let source_type = SourceType::from_path(path).ok()?;
    Some(if source_type.is_javascript() {
        source_type.with_jsx(true)
    } else {
        source_type
    })
}

fn unsupported(extension: &str) -> SyntaxError {
    SyntaxError::Unsupported {
        extension: extension.to_string(),
    }
}

/// The arena capacity a thread keeps between parses. A file that grew its arena past it gives the memory back, so one
/// huge file does not hold that much memory per thread for the rest of the run.
const RETAINED_ARENA_BYTES: usize = 16 * 1024 * 1024;

thread_local! {
    /// The arena this thread parses into, reused across parses so a small file does not pay for a fresh one.
    static ARENA: RefCell<Allocator> = RefCell::new(Allocator::default());
}

/// Runs `f` with this thread's arena, reset first. A nested call, made while the arena is in use (an evaluated
/// config importing another module), gets a fresh arena of its own.
pub(crate) fn with_arena<R>(f: impl FnOnce(&Allocator) -> R) -> R {
    ARENA.with(|arena| {
        let Ok(mut allocator) = arena.try_borrow_mut() else {
            return f(&Allocator::default());
        };
        allocator.reset();
        let result = f(&allocator);
        if allocator.capacity() > RETAINED_ARENA_BYTES {
            *allocator = Allocator::default();
        }
        result
    })
}

/// Parses one block of code. With `uses`, also records how every name is used in the block.
fn parse_block(
    src: &str,
    base: u32,
    source_type: SourceType,
    lines: &LineIndex,
    options: ParseOptions,
    uses: Option<&mut members::Uses>,
) -> ModuleInfo {
    with_arena(|allocator| parse_block_in(allocator, src, base, source_type, lines, options, uses))
}

fn parse_block_in(
    allocator: &Allocator,
    src: &str,
    base: u32,
    source_type: SourceType,
    lines: &LineIndex,
    options: ParseOptions,
    uses: Option<&mut members::Uses>,
) -> ModuleInfo {
    let parser_options = OxcParseOptions {
        preserve_parens: false,
        allow_return_outside_function: true,
        ..OxcParseOptions::default()
    };
    let ret = Parser::new(allocator, src, source_type)
        .with_options(parser_options)
        .parse();
    let parse_diagnostics: Vec<ParseIssue> = ret
        .diagnostics
        .errors()
        .map(|diagnostic| {
            let (offset, len) = diagnostic
                .labels
                .as_slice()
                .first()
                .map_or((0, 0), |l| (l.offset(), l.len()));
            ParseIssue {
                message: diagnostic.message.to_string(),
                span: lines.span(
                    base.saturating_add(offset),
                    base.saturating_add(offset).saturating_add(len),
                ),
            }
        })
        .collect();
    if ret.fatal_error {
        return ModuleInfo {
            parse_diagnostics,
            fatal: true,
            ..ModuleInfo::default()
        };
    }

    let ctx = collect::Ctx { src, base, lines };
    let mut info = ModuleInfo {
        parse_diagnostics,
        ..ModuleInfo::default()
    };
    let collected = collect::collect(&ctx, &ret.program, &mut info);
    finder::Finder::new(&ctx, &mut info, &ret.program).visit_program(&ret.program);
    info.imports.sort_by_key(|i| i.span.start);
    // One semantic analysis serves every pass below that needs scopes; it is built only if one does.
    let analysis = semantic::Analysis::new(&ret.program);
    if options.member_accesses {
        let excluded: HashSet<(u32, u32)> = collected.reexport_spans.iter().copied().collect();
        members::track(&ret.program, &analysis, &mut info, &excluded, base);
        if let Some(uses) = uses {
            *uses = members::uses(&ret.program, &analysis);
        }
    }
    if options.in_file_references {
        mark_in_file_references(&ret.program, &analysis, &collected.export_clause_spans, &mut info);
        signature_refs::mark(&ret.program, &mut info);
        members::mark_local_reads(&ret.program, &analysis, &mut info);
    }
    info.directives = ret
        .program
        .directives
        .iter()
        .map(|d| d.directive.as_str().to_string())
        .collect();
    custom_elements::mark(&ret.program, &analysis, &mut info);
    comments::collect(&ctx, &ret.program, &mut info);
    info.commands = commands::child_process_commands(&ret.program, &info.imports);
    if src.contains("resolveComponent") {
        info.template_tags = components::resolved_components(&ret.program);
        info.template_tags.sort();
        info.template_tags.dedup();
    }
    info
}

/// Parses a `.vue`, `.svelte` or `.astro` file: its script blocks (and Astro frontmatter), the imports of its style
/// blocks and `src` attributes, and the identifiers its markup uses.
///
/// Spans of every fact are offsets into `source`. Script blocks are analysed as one module: an import of one block
/// that another block or the markup reads counts as used there.
fn parse_component(
    extension: &str,
    source: &str,
    lines: &LineIndex,
    options: ParseOptions,
) -> Result<ModuleInfo, SyntaxError> {
    let blocks = sfc::blocks(source, extension == "astro");
    let mut parts = Vec::new();
    let mut all_uses = Vec::new();
    for block in blocks.scripts.iter().filter(|block| block.parse) {
        let name = match (block.typescript, block.jsx) {
            (true, true) => "block.tsx",
            (true, false) => "block.ts",
            (false, true) => "block.jsx",
            (false, false) => "block.js",
        };
        let source_type = source_type_for(Path::new(name)).ok_or_else(|| unsupported(extension))?;
        let base = u32::try_from(block.start).unwrap_or(u32::MAX);
        let mut uses = members::Uses::default();
        let part = parse_block(
            &source[block.start..block.end],
            base,
            source_type,
            lines,
            options,
            Some(&mut uses),
        );
        parts.push(part);
        all_uses.push(uses);
    }
    if options.member_accesses {
        for (index, part) in parts.iter_mut().enumerate() {
            let mut others = members::Uses::default();
            for (other, uses) in all_uses.iter().enumerate() {
                if other != index {
                    members::merge_uses(&mut others, uses);
                }
            }
            members::credit(part, &others);
        }
    }
    let mut info = ModuleInfo::default();
    for part in parts {
        info.merge(part);
    }
    add_style_and_source_imports(&mut info, &blocks, source, lines);
    if options.member_accesses {
        let flavor = match extension {
            "vue" => template::Flavor::Vue,
            "svelte" => template::Flavor::Svelte,
            _ => template::Flavor::Astro,
        };
        let mut uses = members::Uses::default();
        let mut tags = std::collections::BTreeSet::new();
        for (start, end) in blocks.markup(source) {
            tags.extend(template::scan(&source[start..end], flavor, &mut uses));
        }
        members::credit(&mut info, &uses);
        if flavor == template::Flavor::Vue {
            tags.extend(std::mem::take(&mut info.template_tags));
            info.template_tags = tags.into_iter().collect();
        }
    }
    Ok(info)
}

/// Parses a `.mdx` file: its top-level ESM as JavaScript with JSX, and the identifiers its markup uses.
fn parse_mdx(source: &str, lines: &LineIndex, options: ParseOptions) -> ModuleInfo {
    let extracted = mdx::extract(source);
    let source_type = SourceType::jsx();
    let mut info = parse_block(&extracted.esm, 0, source_type, lines, options, None);
    if options.member_accesses {
        let mut markup = members::Uses::default();
        for (start, end) in extracted.markup {
            // Code quoted in prose (`<Card/>`) is not a use; fenced code is already outside the markup.
            let text = mdx::blank_inline_code(&source[start..end]);
            template::scan(&text, template::Flavor::Astro, &mut markup);
        }
        members::credit(&mut info, &markup);
    }
    info
}

/// Parses a `.tsrx` file: its `import` statements and `import()` calls as TypeScript. Its templates are not read,
/// so every imported binding counts as used as a whole.
fn parse_tsrx(source: &str, lines: &LineIndex, options: ParseOptions) -> ModuleInfo {
    let mut info = parse_block(&tsrx::extract(source), 0, SourceType::tsx(), lines, options, None);
    for name in info.imports.iter_mut().flat_map(|import| &mut import.names) {
        name.escapes = true;
    }
    info
}

/// Parses a `.marko` file as knip's Marko compiler reads it: the `import` statements and `import()` calls outside its
/// style blocks, like a `.tsrx` file, and the imports of its style blocks. An import of a tag
/// (`import Card from "<card>"`) names no module and is left out.
fn parse_marko(source: &str, lines: &LineIndex, options: ParseOptions) -> ModuleInfo {
    let blocks = sfc::Blocks {
        styles: marko::styles(source),
        ..sfc::Blocks::default()
    };
    let mut code = source.as_bytes().to_vec();
    for style in &blocks.styles {
        for byte in &mut code[style.start..style.end] {
            if !matches!(*byte, b'\n' | b'\r') {
                *byte = b' ';
            }
        }
    }
    // Whole blocks, which start and end at ASCII bytes, became ASCII spaces, so the text is still UTF-8.
    let code = String::from_utf8(code).unwrap_or_default();
    let mut info = parse_tsrx(&code, lines, options);
    info.imports.retain(|import| !import.specifier.starts_with('<'));
    add_style_and_source_imports(&mut info, &blocks, source, lines);
    info
}

/// Adds the imports of `<style>` blocks and of `src="…"` attributes of `<script>` and `<style>` tags.
fn add_style_and_source_imports(info: &mut ModuleInfo, blocks: &sfc::Blocks, source: &str, lines: &LineIndex) {
    for style in &blocks.styles {
        let path = format!("block.{}", style.extension);
        let base = u32::try_from(style.start).unwrap_or(u32::MAX);
        for mut import in parse_stylesheet(Path::new(&path), &source[style.start..style.end]).imports {
            import.span = lines.span(
                base.saturating_add(import.span.start),
                base.saturating_add(import.span.end),
            );
            info.imports.push(import);
        }
    }
    for reference in &blocks.sources {
        info.imports.push(Import {
            specifier: reference.specifier.clone(),
            kind: ImportKind::SideEffect,
            names: Vec::new(),
            call: None,
            span: lines.span(
                u32::try_from(reference.start).unwrap_or(u32::MAX),
                u32::try_from(reference.end).unwrap_or(u32::MAX),
            ),
        });
    }
    info.imports.sort_by_key(|import| import.span.start);
}

fn mark_in_file_references(
    program: &oxc_ast::ast::Program<'_>,
    analysis: &semantic::Analysis<'_>,
    excluded: &[oxc_span::Span],
    info: &mut ModuleInfo,
) {
    let local = |export: &Export| {
        !matches!(
            export.kind,
            ExportKind::ReExport | ExportKind::StarReExport | ExportKind::Default
        )
    };
    let wanted: HashSet<&str> = info
        .exports
        .iter()
        .filter(|e| local(e))
        .filter_map(|e| e.local_name.as_deref())
        .collect();
    let referenced = semantic::referenced_locals(program, analysis, &wanted, excluded);
    for export in &mut info.exports {
        let used = local(export) && export.local_name.as_deref().is_some_and(|l| referenced.contains(l));
        export.referenced_in_file = used;
        if used {
            info.in_file_references.push(export.name.clone());
        }
    }
    info.in_file_references.sort();
    info.in_file_references.dedup();
}

/// An oxc string as an owned Rust string. Lone surrogates, which only escaped JS strings can hold,
/// are replaced rather than dropped.
pub(crate) fn js_string(value: oxc_str::JSStr<'_>) -> String {
    value
        .as_str()
        .map_or_else(|| value.display().to_string(), str::to_owned)
}

#[cfg(test)]
mod tests {
    use super::{ParseOptions, Path, parse_module, with_arena};

    #[test]
    fn parses_in_a_reused_or_nested_arena_give_the_same_facts() {
        let source =
            "import { a } from 'a';\nimport * as ns from 'ns';\nexport const b = a.c + ns.d;\nexport { b as e };\n";
        let parse = || parse_module(Path::new("x.ts"), source, &ParseOptions::default()).ok();
        let first = parse();
        let nested = with_arena(|outer| {
            let held = outer.alloc_str("held");
            let inner = parse();
            // The nested parse took an arena of its own, so what the outer one holds is intact.
            assert_eq!(held, "held");
            inner
        });
        let again = parse();
        assert!(first.as_ref().is_some_and(|info| info.exports.len() == 2));
        assert_eq!(nested, first);
        assert_eq!(again, first);
    }
}