Skip to content

Commit 857d611

Browse files
committed
refactor(migrate): replace Babel with native Oxc analysis
1 parent 6df85af commit 857d611

14 files changed

Lines changed: 820 additions & 399 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/vp_migration/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ ast-grep-core = { workspace = true }
1313
ast-grep-language = { workspace = true }
1414
brush-parser = { workspace = true }
1515
ignore = { workspace = true }
16+
oxc = { workspace = true }
1617
rayon = { workspace = true }
1718
regex = { workspace = true }
1819
serde_json = { workspace = true, features = ["preserve_order"] }

crates/vp_migration/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ mod pack_config;
1414
mod package;
1515
mod prettier;
1616
mod script_rewrite;
17+
mod source_analysis;
1718
mod vite_config;
1819

1920
pub use file_walker::{WalkResult, find_ts_files};
@@ -22,6 +23,7 @@ pub use import_rewriter::{
2223
rewrite_imports_in_directory_with_options,
2324
};
2425
pub use package::{rewrite_eslint, rewrite_prettier, rewrite_scripts};
26+
pub use source_analysis::analyze_migration_source;
2527
pub use vite_config::{
2628
MergeResult, has_config_key, merge_json_config, merge_tsdown_config, upsert_json_config,
2729
wrap_lazy_plugins,
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
use std::collections::HashMap;
2+
3+
use oxc::{
4+
allocator::Allocator,
5+
ast::AstKind,
6+
ast_visit::utf8_to_utf16::Utf8ToUtf16,
7+
parser::{ParseOptions, Parser},
8+
semantic::SemanticBuilder,
9+
span::{GetSpan, SourceType},
10+
};
11+
use serde_json::{Value, json};
12+
13+
/// Parse migration input and resolve bindings with the Oxc already shipped by Rolldown.
14+
/// Keep transformation rules in TypeScript; export only the semantic facts they need,
15+
/// rather than duplicating JavaScript scope analysis or bundling another parser.
16+
pub fn analyze_migration_source(filename: &str, source: &str) -> Result<String, String> {
17+
let allocator = Allocator::default();
18+
let source_type =
19+
SourceType::from_path(filename).map_err(|error| error.to_string())?.with_unambiguous(true);
20+
// JavaScript migration input can contain JSX. For TypeScript, retain the
21+
// extension's TS/TSX mode so `<T>(value: T) => value` is not parsed as JSX.
22+
let source_type = source_type.with_jsx(source_type.is_javascript() || source_type.is_jsx());
23+
let parsed = Parser::new(&allocator, source, source_type)
24+
.with_options(ParseOptions { preserve_parens: false, ..ParseOptions::default() })
25+
.parse();
26+
if let Some(error) = parsed.diagnostics.first() {
27+
return Err(error.to_string());
28+
}
29+
let mut program = parsed.program;
30+
let analyzed = SemanticBuilder::new_compiler().with_build_nodes(true).build(&program);
31+
if let Some(error) = analyzed.diagnostics.first() {
32+
return Err(error.to_string());
33+
}
34+
35+
// JavaScript slices and diagnostic columns use UTF-16, whereas Rust spans use
36+
// UTF-8 bytes. Convert both semantic offsets and AST/comment spans together.
37+
let spans = Utf8ToUtf16::new(source);
38+
let mut converter = spans.converter();
39+
let mut offset = |mut value| {
40+
if let Some(converter) = converter.as_mut() {
41+
converter.convert_offset(&mut value);
42+
}
43+
value
44+
};
45+
let semantic = analyzed.semantic;
46+
let scoping = semantic.scoping();
47+
// Oxc keeps type and value references separate. An invalid runtime use of
48+
// a type-only import is unresolved, but must not be mistaken for a Vitest
49+
// global by migration rules. Preserve its lexical import binding too.
50+
let mut type_import_uses = HashMap::<_, Vec<u32>>::new();
51+
for node in semantic.nodes().iter() {
52+
if let AstKind::IdentifierReference(identifier) = node.kind()
53+
&& !scoping.has_binding(identifier.reference_id.get().unwrap())
54+
&& let Some(symbol) = scoping.find_binding(node.scope_id(), identifier.name)
55+
&& scoping.symbol_flags(symbol).is_type_import()
56+
{
57+
type_import_uses.entry(symbol).or_default().push(offset(identifier.span.start));
58+
}
59+
}
60+
let bindings: Vec<Value> = scoping
61+
.symbol_ids()
62+
.map(|symbol| {
63+
let mut references: Vec<u32> = scoping
64+
.get_resolved_references(symbol)
65+
.map(|reference| offset(semantic.nodes().kind(reference.node_id()).span().start))
66+
.collect();
67+
references.extend(type_import_uses.remove(&symbol).unwrap_or_default());
68+
// Do not follow reassigned or redeclared aliases, including invalid
69+
// writes to const bindings. `symbol_is_mutated` alone ignores those.
70+
let constant = scoping.symbol_redeclarations(symbol).is_empty()
71+
&& !scoping.get_resolved_references(symbol).any(|reference| reference.is_write());
72+
json!({
73+
"start": offset(scoping.symbol_span(symbol).start),
74+
"references": references,
75+
"constant": constant,
76+
})
77+
})
78+
.collect();
79+
drop(semantic);
80+
spans.convert_program_and_comments(&mut program);
81+
let comments: Vec<Value> = program
82+
.comments
83+
.iter()
84+
.map(|comment| json!({ "start": comment.span.start, "end": comment.span.end }))
85+
.collect();
86+
87+
// Reuse the serializer already instantiated by oxc_parser_napi. Migration
88+
// reads literal spelling from source, so it does not need the JS-only fixes
89+
// that reconstruct RegExp/BigInt values from this ESTree JSON representation.
90+
let mut result: Value = serde_json::from_str(&program.to_estree_json_with_fixes(true, false))
91+
.map_err(|error| error.to_string())?;
92+
serde_json::to_string(&json!({
93+
"program": result["node"].take(),
94+
"bindings": bindings,
95+
"comments": comments,
96+
}))
97+
.map_err(|error| error.to_string())
98+
}
99+
100+
#[cfg(test)]
101+
mod tests {
102+
use serde_json::Value;
103+
104+
use super::analyze_migration_source;
105+
106+
#[test]
107+
fn rejects_flow_and_invalid_syntax() {
108+
for source in ["// @flow\nconst x: string = 'x';", "const = ;"] {
109+
assert!(analyze_migration_source("test.js", source).is_err());
110+
}
111+
}
112+
113+
#[test]
114+
fn preserves_type_imports_for_unresolved_value_uses() {
115+
for source in [
116+
"import type { expect } from 'vitest'; expect();",
117+
"import { type expect } from 'vitest'; expect();",
118+
"import type * as expect from 'vitest'; expect();",
119+
] {
120+
let result: Value =
121+
serde_json::from_str(&analyze_migration_source("test.ts", source).unwrap())
122+
.unwrap();
123+
assert_eq!(
124+
result["bindings"][0]["references"],
125+
serde_json::json!([source.rfind("expect").unwrap()])
126+
);
127+
}
128+
}
129+
130+
#[test]
131+
fn resolves_shadowing_and_mutated_aliases_with_utf16_offsets() {
132+
let source = "// 😀\nimport { vi } from 'vitest'; vi.fn(); function f(vi) { vi.fn(); } let alias = vi; alias = other; alias.fn();";
133+
let result: Value =
134+
serde_json::from_str(&analyze_migration_source("test.ts", source).unwrap()).unwrap();
135+
let bindings = result["bindings"].as_array().unwrap();
136+
let utf16 = |text: &str| source[..source.find(text).unwrap()].encode_utf16().count();
137+
let imported = bindings.iter().find(|binding| binding["start"] == utf16("vi }")).unwrap();
138+
assert_eq!(
139+
imported["references"],
140+
serde_json::json!([utf16("vi.fn()"), utf16("vi; alias")])
141+
);
142+
assert_eq!(imported["constant"], true);
143+
let alias =
144+
bindings.iter().find(|binding| binding["start"] == utf16("alias = vi")).unwrap();
145+
assert_eq!(alias["constant"], false);
146+
assert_eq!(result["program"]["body"][0]["start"], utf16("import"));
147+
assert_eq!(result["comments"][0]["end"], "// 😀".encode_utf16().count());
148+
}
149+
}

packages/cli/binding/index.cjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -962,6 +962,7 @@ module.exports.resetNativeMemoryStats = nativeBinding.resetNativeMemoryStats;
962962
module.exports.resolveTsconfig = nativeBinding.resolveTsconfig;
963963
module.exports.shutdownAsyncRuntime = nativeBinding.shutdownAsyncRuntime;
964964
module.exports.startAsyncRuntime = nativeBinding.startAsyncRuntime;
965+
module.exports.analyzeMigrationSource = nativeBinding.analyzeMigrationSource;
965966
module.exports.detectWorkspace = nativeBinding.detectWorkspace;
966967
module.exports.downloadPackageManager = nativeBinding.downloadPackageManager;
967968
module.exports.ensureBlockingStdio = nativeBinding.ensureBlockingStdio;

packages/cli/binding/index.d.cts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3434,6 +3434,9 @@ export declare function startAsyncRuntime(): void;
34343434
export interface ViteImportGlobMeta {
34353435
isSubImportsPattern?: boolean;
34363436
}
3437+
/** Parse source and resolve lexical bindings for the TypeScript migration rules. */
3438+
export declare function analyzeMigrationSource(filename: string, source: string): string;
3439+
34373440
/** Error from batch import rewriting */
34383441
export interface BatchRewriteError {
34393442
/** The file path that had an error */

packages/cli/binding/src/migration.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ use std::path::Path;
33
use napi::{anyhow, bindgen_prelude::*};
44
use napi_derive::napi;
55

6+
/// Parse source and resolve lexical bindings for the TypeScript migration rules.
7+
#[napi]
8+
pub fn analyze_migration_source(filename: String, source: String) -> Result<String> {
9+
vp_migration::analyze_migration_source(&filename, &source).map_err(Error::from_reason)
10+
}
11+
612
/// Rewrite scripts json content using rules from rules_yaml
713
///
814
/// # Arguments

packages/cli/package.json

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -399,14 +399,10 @@
399399
"vitest": "catalog:"
400400
},
401401
"devDependencies": {
402-
"@babel/parser": "catalog:",
403-
"@babel/traverse": "catalog:",
404-
"@babel/types": "catalog:",
405402
"@emnapi/core": "catalog:",
406403
"@emnapi/runtime": "catalog:",
407404
"@napi-rs/cli": "catalog:",
408405
"@nkzw/safe-word-list": "catalog:",
409-
"@types/babel__traverse": "catalog:",
410406
"@types/cross-spawn": "catalog:",
411407
"@types/semver": "catalog:",
412408
"@types/validate-npm-package-name": "catalog:",
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { SourceEditor, parseSource } from '../vitest-v5/ast.ts';
4+
import { migrateVitestV5Config } from '../vitest-v5/config.ts';
5+
import { migrateVitestV5Source } from '../vitest-v5/source.ts';
6+
7+
const options = { preserveV4: true, browser: true, globals: true };
8+
9+
describe('Oxc migration analysis', () => {
10+
it.each([
11+
['test.js', 'const element = <div />;'],
12+
['test.jsx', 'export const element = <div />;'],
13+
['test.ts', 'const identity = <T>(value: T): T => value;'],
14+
['test.tsx', 'const element = <div />; interface Props { value: string }'],
15+
['test.mts', 'export const value: number = 1;'],
16+
['test.cts', 'const value: number = 1; module.exports = value;'],
17+
['test.js', '@decorate class Example { method() {} }'],
18+
['test.ts', 'const value = 123n; const expression = /partial/v;'],
19+
])('parses %s syntax without another parser dependency', (file, content) => {
20+
expect(parseSource(file, content).program.type).toBe('Program');
21+
});
22+
23+
it('rejects Flow and malformed syntax instead of returning a partial AST', () => {
24+
expect(() => parseSource('test.js', '// @flow\nconst value: string = "x";')).toThrow(
25+
'Flow is not supported',
26+
);
27+
expect(() => parseSource('test.ts', 'const = ;')).toThrow();
28+
});
29+
30+
it('preserves shadowed globals in destructuring, catch clauses, and hoisted declarations', () => {
31+
const shadowed = `function parameter({ expect }) { expect(() => {}).toThrow(''); }
32+
try {} catch (expect) { expect(() => {}).toThrow(''); }
33+
function hoisted() { expect(() => {}).toThrow(''); var expect; }
34+
{ expect(() => {}).toThrow(''); let expect; }`;
35+
const input = `${shadowed}\nexpect(() => {}).toThrow('');`;
36+
const result = migrateVitestV5Source('test.ts', input, options);
37+
expect(result.content).toBe(`${shadowed}\nexpect(() => {}).toThrow(/^$/);`);
38+
expect(result.findings).toEqual([]);
39+
});
40+
41+
it('does not follow reassigned runner aliases', () => {
42+
const input = `import { createVitest } from 'vitest/node';
43+
let runner = await createVitest('test', {});
44+
runner = other;
45+
runner.collect();`;
46+
const result = migrateVitestV5Source('test.ts', input, options);
47+
expect(result.content).toBe(input);
48+
expect(result.findings).toEqual([expect.objectContaining({ code: 'static-collect' })]);
49+
});
50+
51+
it('resolves constructor references before and after their declaration', () => {
52+
const input = `import { vi } from 'vitest';
53+
new mock(); const mock = vi.fn(); new mock();`;
54+
const result = migrateVitestV5Source('test.ts', input, options);
55+
expect(result.findings).toEqual([expect.objectContaining({ code: 'class-mock' })]);
56+
});
57+
58+
it('reserves generated names across bindings, unresolved references, and earlier edits', () => {
59+
const input = `import { getFn } from '@vitest/runner';
60+
import { getHooks } from 'vitest/suite';
61+
const _VitestTestRunner = 1; use(_VitestTestRunner2);`;
62+
const result = migrateVitestV5Source('test.ts', input, options);
63+
expect(result.content).toContain('TestRunner as _VitestTestRunner3');
64+
expect(result.content).toContain('TestRunner as _VitestTestRunner4');
65+
expect(result.findings).toEqual([]);
66+
expect(parseSource('test.ts', result.content).program.type).toBe('Program');
67+
});
68+
69+
it.each([
70+
`import type * as v from 'vitest'; v.expect(() => {}).toThrow('');`,
71+
`import type * as expect from 'vitest'; expect(() => {}).toThrow('');`,
72+
`import type { expect } from 'vitest'; expect(() => {}).toThrow('');`,
73+
`import { type expect } from 'vitest'; expect(() => {}).toThrow('');`,
74+
])('does not mistake a type-only import for a runtime API or global: %s', (input) => {
75+
expect(migrateVitestV5Source('test.ts', input, options).content).toBe(input);
76+
});
77+
78+
it.each([
79+
`import { expect } from 'vitest'; expect?.(() => {}).toThrow('');`,
80+
`import { expect } from 'vitest'; expect(() => {})?.toThrow('');`,
81+
`import * as v from 'vitest'; v?.expect(() => {}).toThrow('');`,
82+
])('preserves optional API calls: %s', (input) => {
83+
expect(migrateVitestV5Source('test.ts', input, options).content).toBe(input);
84+
});
85+
86+
it('retains dynamic imports, CommonJS imports, and TS import-type diagnostics', () => {
87+
const input = `const runner = await import('@vitest/runner');
88+
const other = require('vitest/runners');
89+
type Runner = import('vitest/internal/module-runner').ModuleRunner;
90+
function local(require) { return require('vitest/runners'); }`;
91+
const result = migrateVitestV5Source('test.ts', input, options);
92+
expect(result.content).toBe(input);
93+
expect(result.findings.map(({ code, severity }) => [code, severity])).toEqual([
94+
['removed-api', 'block'],
95+
['removed-api', 'block'],
96+
['removed-api', 'review'],
97+
]);
98+
});
99+
100+
it('preserves non-ASCII text and reports UTF-16 columns with CRLF line endings', () => {
101+
const input = `// 中文 😀\r\nimport { expect } from 'vitest';\r\nconst label = '😀'; expect.poll(() => label).toBe('x');`;
102+
const result = migrateVitestV5Source('test.ts', input, options);
103+
expect(result.content).toBe(input);
104+
expect(result.findings).toContainEqual(
105+
expect.objectContaining({
106+
code: 'poll-timeout',
107+
line: 3,
108+
column: "const label = '😀'; ".length + 1,
109+
}),
110+
);
111+
const edited = migrateVitestV5Source(
112+
'test.ts',
113+
`${input}\r\nexpect(() => {}).toThrow('');`,
114+
options,
115+
);
116+
expect(edited.content).toBe(`${input}\r\nexpect(() => {}).toThrow(/^$/);`);
117+
});
118+
119+
it.each([
120+
'api: { port: 1 } /* keep , 😀 */, enabled: true',
121+
'enabled: true, /* keep , 😀 */ api: { port: 1 }',
122+
"enabled: true, api: { port: 1 }, /* keep , 😀 */ name: 'browser'",
123+
'api: { port: 1 } /* keep , 😀 */,',
124+
'api: { port: 1 } // keep , 😀\n, enabled: true',
125+
])('removes property punctuation without consuming comments: %s', (properties) => {
126+
const input = `// 😀\nexport default { test: { browser: { ${properties} } } };`;
127+
const result = migrateVitestV5Config('vite.config.ts', input, { preserveV4: false });
128+
expect(result.findings).toEqual([]);
129+
expect(result.content).toContain('keep , 😀');
130+
expect(result.content.match(/api:/g)).toHaveLength(1);
131+
expect(parseSource('vite.config.ts', result.content).program.type).toBe('Program');
132+
expect(
133+
migrateVitestV5Config('vite.config.ts', result.content, { preserveV4: false }).content,
134+
).toBe(result.content);
135+
});
136+
137+
it('does not treat methods or getters as static config properties', () => {
138+
const input = 'export default { test: { get browser() { return settings; } } };';
139+
expect(migrateVitestV5Config('vite.config.ts', input, options).content).toBe(input);
140+
});
141+
142+
it('returns the original source when an edit would produce invalid syntax', () => {
143+
const editor = new SourceEditor('test.ts', 'const value = 1;');
144+
editor.edit(0, 5, 'const =');
145+
expect(editor.finish()).toEqual({
146+
content: 'const value = 1;',
147+
findings: [expect.objectContaining({ code: 'unsafe-syntax' })],
148+
});
149+
});
150+
});

0 commit comments

Comments
 (0)