|
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 | +}; |
27 | 25 |
|
28 | 26 | /// 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. |
30 | 27 | 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) |
323 | 29 | } |
324 | 30 |
|
325 | 31 | #[cfg(test)] |
@@ -450,16 +156,4 @@ mod tests { |
450 | 156 | "cross-env NODE_ENV=test CI=true vp lint ." |
451 | 157 | ); |
452 | 158 | } |
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 | | - } |
465 | 159 | } |
0 commit comments