Skip to content

Commit c0c8ebd

Browse files
authored
feat(migrate): Prettier → Oxfmt migration via vp fmt --migrate=prettier (#742)
Add Prettier → Oxfmt migration support to `vp migrate`, following the same architecture as the existing ESLint → Oxlint migration: Rust shell script rewriter → NAPI binding → TypeScript orchestration. - Rust rewriter rewrites `prettier` → `vp fmt`, strips Prettier-only flags (--write, --cache, --single-quote, --config, --plugin, etc.), and converts --list-different/-l → --check - Detects all Prettier config files (.prettierrc*, prettier.config.*, package.json#prettier) and .prettierignore - Removes prettier + prettier-plugin-* dependencies - Rewrites scripts in package.json and lint-staged configs - Supports both fresh migration and re-migration (already-vite-plus) - Includes snap test fixtures for basic, rerun, lint-staged, and eslint+prettier combo scenarios
1 parent 8f053ef commit c0c8ebd

34 files changed

Lines changed: 1639 additions & 346 deletions

File tree

Lines changed: 25 additions & 331 deletions
Original file line numberDiff line numberDiff line change
@@ -1,325 +1,31 @@
1-
use brush_parser::ast;
2-
3-
// ESLint-only flags that should be stripped when rewriting eslint → vp lint.
4-
// Value flags consume the next token (e.g., --ext .ts,.tsx) or use = form (--ext=.ts,.tsx).
5-
const ESLINT_ONLY_VALUE_FLAGS: &[&str] = &[
6-
"--ext",
7-
"--rulesdir",
8-
"--resolve-plugins-relative-to",
9-
"--parser",
10-
"--parser-options",
11-
"--plugin",
12-
"--output-file",
13-
"--env",
14-
];
15-
16-
// Shell keywords after which a newline is cosmetic (not a statement terminator).
17-
const SHELL_CONTINUATION_KEYWORDS: &[&str] = &["then", "do", "else", "elif", "in"];
18-
19-
// Boolean flags are stripped on their own.
20-
const ESLINT_ONLY_BOOLEAN_FLAGS: &[&str] = &[
21-
"--cache",
22-
"--no-eslintrc",
23-
"--no-error-on-unmatched-pattern",
24-
"--debug",
25-
"--no-inline-config",
26-
];
1+
use crate::script_rewrite::{ScriptRewriteConfig, rewrite_script};
2+
3+
const ESLINT_CONFIG: ScriptRewriteConfig = ScriptRewriteConfig {
4+
source_command: "eslint",
5+
target_subcommand: "lint",
6+
boolean_flags: &[
7+
"--cache",
8+
"--no-eslintrc",
9+
"--no-error-on-unmatched-pattern",
10+
"--debug",
11+
"--no-inline-config",
12+
],
13+
value_flags: &[
14+
"--ext",
15+
"--rulesdir",
16+
"--resolve-plugins-relative-to",
17+
"--parser",
18+
"--parser-options",
19+
"--plugin",
20+
"--output-file",
21+
"--env",
22+
],
23+
flag_conversions: &[],
24+
};
2725

2826
/// Rewrite a single script: rename `eslint` → `vp lint` and strip ESLint-only flags.
29-
/// Uses brush-parser for proper shell AST parsing instead of manual tokenization.
3027
pub(crate) fn rewrite_eslint_script(script: &str) -> String {
31-
let mut parser = brush_parser::Parser::new(
32-
script.as_bytes(),
33-
&brush_parser::ParserOptions::default(),
34-
&brush_parser::SourceInfo::default(),
35-
);
36-
let Ok(mut program) = parser.parse_program() else {
37-
return script.to_owned(); // fallback: return unchanged
38-
};
39-
40-
if !rewrite_eslint_in_program(&mut program) {
41-
return script.to_owned(); // no eslint found — return original unchanged
42-
}
43-
let output = normalize_pipe_spacing(&program.to_string());
44-
// brush-parser reformats compound commands (if/while/brace groups) with newlines
45-
// and indentation, but package.json scripts must be single-line.
46-
collapse_newlines(&output)
47-
}
48-
49-
fn rewrite_eslint_in_program(program: &mut ast::Program) -> bool {
50-
let mut changed = false;
51-
for cmd in &mut program.complete_commands {
52-
changed |= rewrite_eslint_in_compound_list(cmd);
53-
}
54-
changed
55-
}
56-
57-
fn rewrite_eslint_in_compound_list(list: &mut ast::CompoundList) -> bool {
58-
let mut changed = false;
59-
for item in &mut list.0 {
60-
changed |= rewrite_eslint_in_and_or_list(&mut item.0);
61-
}
62-
changed
63-
}
64-
65-
fn rewrite_eslint_in_and_or_list(list: &mut ast::AndOrList) -> bool {
66-
let mut changed = rewrite_eslint_in_pipeline(&mut list.first);
67-
for and_or in &mut list.additional {
68-
match and_or {
69-
ast::AndOr::And(p) | ast::AndOr::Or(p) => {
70-
changed |= rewrite_eslint_in_pipeline(p);
71-
}
72-
}
73-
}
74-
changed
75-
}
76-
77-
fn rewrite_eslint_in_pipeline(pipeline: &mut ast::Pipeline) -> bool {
78-
let mut changed = false;
79-
for cmd in &mut pipeline.seq {
80-
match cmd {
81-
ast::Command::Simple(simple) => {
82-
changed |= rewrite_eslint_in_simple_command(simple);
83-
}
84-
ast::Command::Compound(compound, _redirects) => {
85-
changed |= rewrite_eslint_in_compound_command(compound);
86-
}
87-
_ => {}
88-
}
89-
}
90-
changed
91-
}
92-
93-
fn rewrite_eslint_in_compound_command(cmd: &mut ast::CompoundCommand) -> bool {
94-
match cmd {
95-
ast::CompoundCommand::BraceGroup(bg) => rewrite_eslint_in_compound_list(&mut bg.list),
96-
ast::CompoundCommand::Subshell(sub) => rewrite_eslint_in_compound_list(&mut sub.list),
97-
ast::CompoundCommand::IfClause(if_cmd) => {
98-
let mut changed = rewrite_eslint_in_compound_list(&mut if_cmd.condition);
99-
changed |= rewrite_eslint_in_compound_list(&mut if_cmd.then);
100-
if let Some(elses) = &mut if_cmd.elses {
101-
for else_clause in elses {
102-
if let Some(cond) = &mut else_clause.condition {
103-
changed |= rewrite_eslint_in_compound_list(cond);
104-
}
105-
changed |= rewrite_eslint_in_compound_list(&mut else_clause.body);
106-
}
107-
}
108-
changed
109-
}
110-
ast::CompoundCommand::WhileClause(wc) | ast::CompoundCommand::UntilClause(wc) => {
111-
let mut changed = rewrite_eslint_in_compound_list(&mut wc.0);
112-
changed |= rewrite_eslint_in_compound_list(&mut wc.1.list);
113-
changed
114-
}
115-
ast::CompoundCommand::ForClause(fc) => rewrite_eslint_in_compound_list(&mut fc.body.list),
116-
ast::CompoundCommand::ArithmeticForClause(afc) => {
117-
rewrite_eslint_in_compound_list(&mut afc.body.list)
118-
}
119-
ast::CompoundCommand::CaseClause(cc) => {
120-
let mut changed = false;
121-
for case_item in &mut cc.cases {
122-
if let Some(cmd_list) = &mut case_item.cmd {
123-
changed |= rewrite_eslint_in_compound_list(cmd_list);
124-
}
125-
}
126-
changed
127-
}
128-
ast::CompoundCommand::Arithmetic(_) => false,
129-
}
130-
}
131-
132-
fn make_suffix_word(value: &str) -> ast::CommandPrefixOrSuffixItem {
133-
ast::CommandPrefixOrSuffixItem::Word(ast::Word { value: value.to_owned(), loc: None })
134-
}
135-
136-
fn rewrite_eslint_in_simple_command(cmd: &mut ast::SimpleCommand) -> bool {
137-
let cmd_name = cmd.word_or_name.as_ref().map(|w| w.value.as_str());
138-
139-
if cmd_name == Some("eslint") {
140-
// Direct eslint invocation: rename eslint → vp lint
141-
if let Some(word) = &mut cmd.word_or_name {
142-
word.value = "vp".to_owned();
143-
}
144-
match &mut cmd.suffix {
145-
Some(suffix) => suffix.0.insert(0, make_suffix_word("lint")),
146-
None => cmd.suffix = Some(ast::CommandSuffix(vec![make_suffix_word("lint")])),
147-
}
148-
strip_eslint_flags_from_suffix(cmd, 1); // skip index 0 ("lint")
149-
return true;
150-
}
151-
152-
if cmd_name == Some("cross-env") || cmd_name == Some("cross-env-shell") {
153-
// cross-env wrapper: scan suffix for eslint word
154-
return rewrite_eslint_in_cross_env(cmd);
155-
}
156-
157-
false
158-
}
159-
160-
/// Rewrite `cross-env ... eslint [flags] [args]` → `cross-env ... vp lint [args]`.
161-
/// The eslint word in the suffix marks the boundary between env-var args and the command.
162-
fn rewrite_eslint_in_cross_env(cmd: &mut ast::SimpleCommand) -> bool {
163-
let suffix = match &mut cmd.suffix {
164-
Some(s) => s,
165-
None => return false,
166-
};
167-
168-
// Find the index of the "eslint" word in the suffix
169-
let eslint_idx = suffix.0.iter().position(
170-
|item| matches!(item, ast::CommandPrefixOrSuffixItem::Word(w) if w.value == "eslint"),
171-
);
172-
let Some(idx) = eslint_idx else {
173-
return false;
174-
};
175-
176-
// Rename "eslint" → "vp" and insert "lint" after it
177-
if let ast::CommandPrefixOrSuffixItem::Word(w) = &mut suffix.0[idx] {
178-
w.value = "vp".to_owned();
179-
}
180-
suffix.0.insert(idx + 1, make_suffix_word("lint"));
181-
182-
// Strip ESLint-only flags starting after "lint"
183-
strip_eslint_flags_from_suffix(cmd, idx + 2);
184-
true
185-
}
186-
187-
/// Strip ESLint-only flags from the suffix, starting at `start_idx`.
188-
/// Items before `start_idx` are kept unconditionally.
189-
fn strip_eslint_flags_from_suffix(cmd: &mut ast::SimpleCommand, start_idx: usize) {
190-
let suffix = cmd.suffix.as_mut().expect("suffix was just set");
191-
let items = std::mem::take(&mut suffix.0);
192-
let mut iter = items.into_iter().enumerate();
193-
194-
// Keep items before start_idx unconditionally
195-
for (i, item) in iter.by_ref() {
196-
suffix.0.push(item);
197-
if i + 1 >= start_idx {
198-
break;
199-
}
200-
}
201-
202-
let mut skip_next = false;
203-
for (_, item) in iter {
204-
if skip_next {
205-
skip_next = false;
206-
continue;
207-
}
208-
if let ast::CommandPrefixOrSuffixItem::Word(ref w) = item {
209-
let val = w.value.as_str();
210-
211-
// Boolean flags: strip just this token
212-
if ESLINT_ONLY_BOOLEAN_FLAGS.contains(&val) {
213-
continue;
214-
}
215-
216-
// Value flags: --flag=value form
217-
if let Some(eq_pos) = val.find('=')
218-
&& ESLINT_ONLY_VALUE_FLAGS.contains(&&val[..eq_pos])
219-
{
220-
continue;
221-
}
222-
223-
// Value flags: --flag value form (strip flag + next token)
224-
if ESLINT_ONLY_VALUE_FLAGS.contains(&val) {
225-
skip_next = true;
226-
continue;
227-
}
228-
}
229-
suffix.0.push(item);
230-
}
231-
232-
// If suffix is empty, clear it
233-
if suffix.0.is_empty() {
234-
cmd.suffix = None;
235-
}
236-
}
237-
238-
/// Collapse newlines and surrounding whitespace into single-line form.
239-
/// brush-parser reformats compound commands with newlines + indentation,
240-
/// but package.json scripts must remain single-line.
241-
///
242-
/// In shell syntax, newlines serve as statement terminators (like `;`).
243-
/// After keywords like `then`, `do`, `else`, `{`, the newline is cosmetic
244-
/// and can be replaced with a space. But before `fi`, `done`, `}`, `esac`,
245-
/// the newline terminates the preceding command and must become `; `.
246-
fn collapse_newlines(s: &str) -> String {
247-
if !s.contains('\n') {
248-
return s.to_owned();
249-
}
250-
let mut result = String::with_capacity(s.len());
251-
let mut chars = s.chars().peekable();
252-
while let Some(c) = chars.next() {
253-
if c == '\n' {
254-
// Strip trailing whitespace before the newline
255-
while result.ends_with(' ') || result.ends_with('\t') {
256-
result.pop();
257-
}
258-
// Skip leading whitespace on the next line
259-
while chars.peek().is_some_and(|&ch| ch == ' ' || ch == '\t') {
260-
chars.next();
261-
}
262-
// Decide: space or semicolon?
263-
// If the line ended with a keyword/separator, newline is cosmetic → space
264-
// Otherwise the newline terminates a command → semicolon + space
265-
if needs_semicolon(&result) {
266-
result.push_str("; ");
267-
} else {
268-
result.push(' ');
269-
}
270-
} else {
271-
result.push(c);
272-
}
273-
}
274-
result
275-
}
276-
277-
/// Check if the content before a newline needs a semicolon to terminate the command.
278-
/// Returns false when the content ends with a shell keyword or separator where
279-
/// the newline is just cosmetic whitespace.
280-
fn needs_semicolon(before: &str) -> bool {
281-
let trimmed = before.trim_end();
282-
if trimmed.is_empty() {
283-
return false;
284-
}
285-
// Check single-char separators
286-
let last_byte = trimmed.as_bytes()[trimmed.len() - 1];
287-
if matches!(last_byte, b'{' | b'(' | b';' | b'|' | b'&' | b'!') {
288-
return false;
289-
}
290-
// Check keyword endings
291-
for kw in SHELL_CONTINUATION_KEYWORDS {
292-
if trimmed.ends_with(kw) {
293-
// Make sure it's a whole word (preceded by whitespace or start of string)
294-
let prefix_len = trimmed.len() - kw.len();
295-
if prefix_len == 0 || !trimmed.as_bytes()[prefix_len - 1].is_ascii_alphanumeric() {
296-
return false;
297-
}
298-
}
299-
}
300-
true
301-
}
302-
303-
/// Fix pipe spacing in brush-parser Display output.
304-
/// brush-parser renders pipes as `cmd1 |cmd2` instead of `cmd1 | cmd2`.
305-
fn normalize_pipe_spacing(s: &str) -> String {
306-
let bytes = s.as_bytes();
307-
let mut result = Vec::with_capacity(bytes.len() + 16);
308-
for i in 0..bytes.len() {
309-
result.push(bytes[i]);
310-
// Insert space after | when it's a pipe operator (not ||) and not already spaced
311-
if bytes[i] == b'|'
312-
&& i > 0
313-
&& bytes[i - 1] == b' '
314-
&& i + 1 < bytes.len()
315-
&& bytes[i + 1] != b'|'
316-
&& bytes[i + 1] != b' '
317-
{
318-
result.push(b' ');
319-
}
320-
}
321-
// Safety: only ASCII space bytes inserted into valid UTF-8
322-
String::from_utf8(result).unwrap_or_else(|_| s.to_owned())
28+
rewrite_script(script, &ESLINT_CONFIG)
32329
}
32430

32531
#[cfg(test)]
@@ -450,16 +156,4 @@ mod tests {
450156
"cross-env NODE_ENV=test CI=true vp lint ."
451157
);
452158
}
453-
454-
#[test]
455-
fn test_normalize_pipe_spacing() {
456-
// Single pipe gets space added
457-
assert_eq!(normalize_pipe_spacing("cmd1 |cmd2"), "cmd1 | cmd2");
458-
// Already spaced pipe is unchanged
459-
assert_eq!(normalize_pipe_spacing("cmd1 | cmd2"), "cmd1 | cmd2");
460-
// Double pipe (||) is unchanged
461-
assert_eq!(normalize_pipe_spacing("cmd1 || cmd2"), "cmd1 || cmd2");
462-
// No pipe
463-
assert_eq!(normalize_pipe_spacing("cmd1 && cmd2"), "cmd1 && cmd2");
464-
}
465159
}

crates/vite_migration/src/lib.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ mod eslint;
33
mod file_walker;
44
mod import_rewriter;
55
mod package;
6+
mod prettier;
7+
mod script_rewrite;
68
mod vite_config;
79

810
pub use file_walker::{WalkResult, find_ts_files};
911
pub use import_rewriter::{BatchRewriteResult, rewrite_imports_in_directory};
10-
pub use package::{rewrite_eslint, rewrite_scripts};
12+
pub use package::{rewrite_eslint, rewrite_prettier, rewrite_scripts};
1113
pub use vite_config::{MergeResult, merge_json_config, merge_tsdown_config};

0 commit comments

Comments
 (0)