@openrewrite/rewrite 8.69.0-20251205-210445 → 8.69.0-20251207-092603
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/cli-utils.d.ts +12 -2
- package/dist/cli/cli-utils.d.ts.map +1 -1
- package/dist/cli/cli-utils.js +53 -13
- package/dist/cli/cli-utils.js.map +1 -1
- package/dist/cli/rewrite.d.ts.map +1 -1
- package/dist/cli/rewrite.js +230 -82
- package/dist/cli/rewrite.js.map +1 -1
- package/dist/cli/validate-parsing-recipe.d.ts +23 -0
- package/dist/cli/validate-parsing-recipe.d.ts.map +1 -0
- package/dist/cli/validate-parsing-recipe.js +149 -0
- package/dist/cli/validate-parsing-recipe.js.map +1 -0
- package/dist/javascript/format.d.ts.map +1 -1
- package/dist/javascript/format.js +7 -2
- package/dist/javascript/format.js.map +1 -1
- package/dist/javascript/parser.d.ts.map +1 -1
- package/dist/javascript/parser.js +4 -2
- package/dist/javascript/parser.js.map +1 -1
- package/dist/javascript/tabs-and-indents-visitor.d.ts.map +1 -1
- package/dist/javascript/tabs-and-indents-visitor.js +32 -4
- package/dist/javascript/tabs-and-indents-visitor.js.map +1 -1
- package/dist/json/parser.js +35 -20
- package/dist/json/parser.js.map +1 -1
- package/dist/run.d.ts +8 -1
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +78 -21
- package/dist/run.js.map +1 -1
- package/dist/version.txt +1 -1
- package/package.json +1 -1
- package/src/cli/cli-utils.ts +28 -9
- package/src/cli/rewrite.ts +244 -85
- package/src/cli/validate-parsing-recipe.ts +114 -0
- package/src/javascript/format.ts +7 -2
- package/src/javascript/parser.ts +6 -2
- package/src/javascript/tabs-and-indents-visitor.ts +33 -3
- package/src/json/parser.ts +35 -20
- package/src/run.ts +61 -25
package/src/cli/rewrite.ts
CHANGED
|
@@ -15,12 +15,13 @@
|
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
17
|
import {Command} from 'commander';
|
|
18
|
+
import * as fs from 'fs';
|
|
18
19
|
import * as fsp from 'fs/promises';
|
|
19
20
|
import * as path from 'path';
|
|
20
21
|
import {spawn} from 'child_process';
|
|
21
|
-
import {RecipeRegistry} from '../recipe';
|
|
22
|
+
import {Recipe, RecipeRegistry} from '../recipe';
|
|
22
23
|
import {ExecutionContext} from '../execution';
|
|
23
|
-
import {scheduleRunStreaming} from '../run';
|
|
24
|
+
import {ProgressCallback, scheduleRunStreaming} from '../run';
|
|
24
25
|
import {TreePrinters} from '../print';
|
|
25
26
|
import {activate} from '../index';
|
|
26
27
|
import {
|
|
@@ -28,11 +29,13 @@ import {
|
|
|
28
29
|
discoverFiles,
|
|
29
30
|
findRecipe,
|
|
30
31
|
installRecipePackage,
|
|
32
|
+
isAcceptedFile,
|
|
31
33
|
loadLocalRecipes,
|
|
32
|
-
|
|
34
|
+
parseFilesStreaming,
|
|
33
35
|
parseRecipeOptions,
|
|
34
36
|
parseRecipeSpec
|
|
35
37
|
} from './cli-utils';
|
|
38
|
+
import {ValidateParsingRecipe} from './validate-parsing-recipe';
|
|
36
39
|
|
|
37
40
|
const isTTY = process.stdout.isTTY ?? false;
|
|
38
41
|
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
@@ -83,21 +86,80 @@ require('v8').setFlagsFromString('--stack-size=8000');
|
|
|
83
86
|
|
|
84
87
|
const DEFAULT_RECIPE_DIR = path.join(process.env.HOME || '', '.rewrite', 'javascript', 'recipes');
|
|
85
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Check if a directory is a git repository root.
|
|
91
|
+
*/
|
|
92
|
+
function isGitRoot(dir: string): boolean {
|
|
93
|
+
return fs.existsSync(path.join(dir, '.git'));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Find the project root by scanning upward for a directory containing package.json.
|
|
98
|
+
* Stops at git repository root or filesystem root.
|
|
99
|
+
* Returns the starting directory if no package.json is found.
|
|
100
|
+
*/
|
|
101
|
+
function findProjectRoot(startDir: string): string {
|
|
102
|
+
let dir = path.resolve(startDir);
|
|
103
|
+
while (true) {
|
|
104
|
+
const packageJsonPath = path.join(dir, 'package.json');
|
|
105
|
+
if (fs.existsSync(packageJsonPath)) {
|
|
106
|
+
return dir;
|
|
107
|
+
}
|
|
108
|
+
// Stop at git root - don't scan beyond the repository
|
|
109
|
+
if (isGitRoot(dir)) {
|
|
110
|
+
return dir;
|
|
111
|
+
}
|
|
112
|
+
const parent = path.dirname(dir);
|
|
113
|
+
if (parent === dir) {
|
|
114
|
+
// Reached filesystem root, return original directory
|
|
115
|
+
return path.resolve(startDir);
|
|
116
|
+
}
|
|
117
|
+
dir = parent;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
86
121
|
interface CliOptions {
|
|
87
|
-
|
|
122
|
+
apply: boolean;
|
|
123
|
+
list: boolean;
|
|
88
124
|
recipeDir?: string;
|
|
89
125
|
option: string[];
|
|
90
126
|
verbose: boolean;
|
|
91
127
|
debug: boolean;
|
|
92
128
|
}
|
|
93
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Convert a project-root-relative path to a CWD-relative path for display.
|
|
132
|
+
*/
|
|
133
|
+
function toCwdRelative(sourcePath: string, projectRoot: string): string {
|
|
134
|
+
const absolutePath = path.join(projectRoot, sourcePath);
|
|
135
|
+
return path.relative(process.cwd(), absolutePath) || '.';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Transform diff output to use CWD-relative paths in headers.
|
|
140
|
+
*/
|
|
141
|
+
function transformDiffPaths(diff: string, projectRoot: string): string {
|
|
142
|
+
return diff.replace(
|
|
143
|
+
/^(---|\+\+\+) (.+)$/gm,
|
|
144
|
+
(_match, prefix, filePath) => {
|
|
145
|
+
if (filePath) {
|
|
146
|
+
const cwdRelative = toCwdRelative(filePath, projectRoot);
|
|
147
|
+
return `${prefix} ${cwdRelative}`;
|
|
148
|
+
}
|
|
149
|
+
return _match;
|
|
150
|
+
}
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
94
154
|
async function main() {
|
|
95
155
|
const program = new Command();
|
|
96
156
|
program
|
|
97
157
|
.name('rewrite')
|
|
98
158
|
.description('Run OpenRewrite recipes on your codebase')
|
|
99
159
|
.argument('<recipe>', 'Recipe to run in format "package:recipe" (e.g., "@openrewrite/recipes-nodejs:replace-deprecated-slice")')
|
|
100
|
-
.
|
|
160
|
+
.argument('[paths...]', 'Files or directories to process (defaults to project root)')
|
|
161
|
+
.option('--apply', 'Apply changes to files (default is dry-run showing diffs)', false)
|
|
162
|
+
.option('-l, --list', 'Only list paths of files that would be changed', false)
|
|
101
163
|
.option('-d, --recipe-dir <dir>', 'Directory for recipe installation', DEFAULT_RECIPE_DIR)
|
|
102
164
|
.option('-o, --option <option...>', 'Recipe options in format "key=value"', [])
|
|
103
165
|
.option('-v, --verbose', 'Enable verbose output', false)
|
|
@@ -105,6 +167,7 @@ async function main() {
|
|
|
105
167
|
.parse();
|
|
106
168
|
|
|
107
169
|
const recipeArg = program.args[0];
|
|
170
|
+
const pathArgs = program.args.slice(1);
|
|
108
171
|
const opts = program.opts() as CliOptions;
|
|
109
172
|
opts.option = opts.option || [];
|
|
110
173
|
|
|
@@ -118,47 +181,59 @@ async function main() {
|
|
|
118
181
|
return;
|
|
119
182
|
}
|
|
120
183
|
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
console.error(`Invalid recipe format: ${recipeArg}`);
|
|
125
|
-
console.error('Expected format: "package:recipe" (e.g., "@openrewrite/recipes-nodejs:replace-deprecated-slice")');
|
|
126
|
-
process.exit(1);
|
|
127
|
-
}
|
|
184
|
+
// Handle special built-in recipes
|
|
185
|
+
let recipe: Recipe;
|
|
186
|
+
let validateParsingRecipe: ValidateParsingRecipe | undefined;
|
|
128
187
|
|
|
129
|
-
if (
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
188
|
+
if (recipeArg === 'validate-parsing') {
|
|
189
|
+
// Special recipe to validate parsing and check idempotence
|
|
190
|
+
validateParsingRecipe = new ValidateParsingRecipe();
|
|
191
|
+
recipe = validateParsingRecipe;
|
|
192
|
+
} else {
|
|
193
|
+
// Parse recipe specification
|
|
194
|
+
const recipeSpec = parseRecipeSpec(recipeArg);
|
|
195
|
+
if (!recipeSpec) {
|
|
196
|
+
console.error(`Invalid recipe format: ${recipeArg}`);
|
|
197
|
+
console.error('Expected format: "package:recipe" (e.g., "@openrewrite/recipes-nodejs:replace-deprecated-slice")');
|
|
198
|
+
console.error('Or use "validate-parsing" to check for parse errors and idempotence.');
|
|
199
|
+
process.exit(1);
|
|
200
|
+
}
|
|
133
201
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
202
|
+
if (opts.verbose) {
|
|
203
|
+
console.log(`Package: ${recipeSpec.packageName}`);
|
|
204
|
+
console.log(`Recipe: ${recipeSpec.recipeName}`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Parse recipe options
|
|
208
|
+
const recipeOptions = parseRecipeOptions(opts.option);
|
|
209
|
+
if (opts.verbose && Object.keys(recipeOptions).length > 0) {
|
|
210
|
+
console.log(`Options: ${JSON.stringify(recipeOptions)}`);
|
|
211
|
+
}
|
|
139
212
|
|
|
140
|
-
|
|
141
|
-
|
|
213
|
+
// Set up recipe registry
|
|
214
|
+
const registry = new RecipeRegistry();
|
|
142
215
|
|
|
143
|
-
|
|
144
|
-
|
|
216
|
+
// Register built-in recipes
|
|
217
|
+
await activate(registry);
|
|
145
218
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
219
|
+
// Load recipes from local path or install from NPM
|
|
220
|
+
try {
|
|
221
|
+
if (recipeSpec.isLocalPath) {
|
|
222
|
+
await loadLocalRecipes(recipeSpec.packageName, registry, opts.verbose);
|
|
223
|
+
} else {
|
|
224
|
+
await installRecipePackage(recipeSpec.packageName, opts.recipeDir || DEFAULT_RECIPE_DIR, registry, opts.verbose);
|
|
225
|
+
}
|
|
226
|
+
} catch (e: any) {
|
|
227
|
+
console.error(`Failed to load recipes: ${e.message}`);
|
|
228
|
+
process.exit(1);
|
|
152
229
|
}
|
|
153
|
-
} catch (e: any) {
|
|
154
|
-
console.error(`Failed to load recipes: ${e.message}`);
|
|
155
|
-
process.exit(1);
|
|
156
|
-
}
|
|
157
230
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
231
|
+
// Find the recipe
|
|
232
|
+
const foundRecipe = findRecipe(registry, recipeSpec.recipeName, recipeOptions);
|
|
233
|
+
if (!foundRecipe) {
|
|
234
|
+
process.exit(1);
|
|
235
|
+
}
|
|
236
|
+
recipe = foundRecipe;
|
|
162
237
|
}
|
|
163
238
|
|
|
164
239
|
if (opts.verbose) {
|
|
@@ -166,63 +241,115 @@ async function main() {
|
|
|
166
241
|
}
|
|
167
242
|
|
|
168
243
|
const spinner = new Spinner();
|
|
169
|
-
const projectRoot = process.cwd();
|
|
170
244
|
|
|
171
|
-
//
|
|
172
|
-
|
|
173
|
-
|
|
245
|
+
// Determine paths to process and project root
|
|
246
|
+
let targetPaths: string[];
|
|
247
|
+
let projectRoot: string;
|
|
248
|
+
|
|
249
|
+
if (pathArgs.length > 0) {
|
|
250
|
+
// Explicit paths provided - find project root and validate they're all in the same project
|
|
251
|
+
targetPaths = pathArgs.map(p => path.resolve(p));
|
|
252
|
+
const projectRoots = new Set<string>();
|
|
253
|
+
for (const targetPath of targetPaths) {
|
|
254
|
+
const pathDir = fs.statSync(targetPath).isDirectory() ? targetPath : path.dirname(targetPath);
|
|
255
|
+
projectRoots.add(findProjectRoot(pathDir));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (projectRoots.size > 1) {
|
|
259
|
+
console.error('Error: Specified paths belong to different projects:');
|
|
260
|
+
for (const root of projectRoots) {
|
|
261
|
+
console.error(` - ${root}`);
|
|
262
|
+
}
|
|
263
|
+
console.error('\nAll paths must be within the same project (share a common package.json root).');
|
|
264
|
+
process.exit(1);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
projectRoot = projectRoots.values().next().value!;
|
|
268
|
+
|
|
269
|
+
if (opts.verbose) {
|
|
270
|
+
if (targetPaths.length > 1) {
|
|
271
|
+
console.log(`Processing ${targetPaths.length} paths`);
|
|
272
|
+
}
|
|
273
|
+
console.log(`Project root: ${projectRoot}`);
|
|
274
|
+
}
|
|
275
|
+
} else {
|
|
276
|
+
// No paths provided - find project root from CWD and process entire project
|
|
277
|
+
projectRoot = findProjectRoot(process.cwd());
|
|
278
|
+
targetPaths = [projectRoot];
|
|
279
|
+
|
|
280
|
+
if (opts.verbose) {
|
|
281
|
+
console.log(`Processing entire project: ${projectRoot}`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Discover source files from all specified paths
|
|
286
|
+
if (!opts.verbose) {
|
|
287
|
+
spinner.start('Discovering source files...');
|
|
288
|
+
}
|
|
289
|
+
const sourceFiles: string[] = [];
|
|
290
|
+
for (const targetPath of targetPaths) {
|
|
291
|
+
const stat = await fsp.stat(targetPath);
|
|
292
|
+
if (stat.isDirectory()) {
|
|
293
|
+
const files = await discoverFiles(targetPath, opts.verbose);
|
|
294
|
+
sourceFiles.push(...files);
|
|
295
|
+
} else if (stat.isFile() && isAcceptedFile(targetPath)) {
|
|
296
|
+
sourceFiles.push(targetPath);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// Remove duplicates (in case of overlapping paths)
|
|
300
|
+
const uniqueSourceFiles = [...new Set(sourceFiles)];
|
|
174
301
|
spinner.stop();
|
|
175
302
|
|
|
176
|
-
if (
|
|
303
|
+
if (uniqueSourceFiles.length === 0) {
|
|
177
304
|
console.log('No source files found to process.');
|
|
178
305
|
return;
|
|
179
306
|
}
|
|
180
307
|
|
|
181
308
|
if (opts.verbose) {
|
|
182
|
-
console.log(`Found ${
|
|
309
|
+
console.log(`Found ${uniqueSourceFiles.length} source files`);
|
|
183
310
|
}
|
|
184
311
|
|
|
185
|
-
//
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
spinner.update(`Parsing [${current}/${total}] ${filePath}`);
|
|
189
|
-
});
|
|
190
|
-
spinner.stop();
|
|
191
|
-
|
|
192
|
-
if (parsedFiles.length === 0) {
|
|
193
|
-
console.log('No files could be parsed.');
|
|
194
|
-
return;
|
|
312
|
+
// Set project root for validate-parsing recipe if active
|
|
313
|
+
if (validateParsingRecipe) {
|
|
314
|
+
validateParsingRecipe.setProjectRoot(projectRoot);
|
|
195
315
|
}
|
|
196
316
|
|
|
197
|
-
//
|
|
317
|
+
// Create streaming parser - files are parsed on-demand as they're consumed
|
|
318
|
+
const totalFiles = uniqueSourceFiles.length;
|
|
319
|
+
const sourceFileStream = parseFilesStreaming(uniqueSourceFiles, projectRoot, {
|
|
320
|
+
verbose: opts.verbose
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// Run the recipe with streaming output - for non-scanning recipes,
|
|
324
|
+
// parsing and processing happen concurrently without collecting all files first
|
|
198
325
|
const ctx = new ExecutionContext();
|
|
199
326
|
let changeCount = 0;
|
|
200
327
|
let processedCount = 0;
|
|
201
|
-
const totalFiles = parsedFiles.length;
|
|
202
328
|
|
|
203
|
-
spinner
|
|
329
|
+
// Progress callback for spinner updates during all phases (disabled in verbose mode)
|
|
330
|
+
const onProgress: ProgressCallback | undefined = opts.verbose ? undefined : (phase, current, total, sourcePath) => {
|
|
331
|
+
const totalStr = total > 0 ? total : totalFiles;
|
|
332
|
+
const phaseLabel = phase.charAt(0).toUpperCase() + phase.slice(1);
|
|
333
|
+
spinner.update(`${phaseLabel} [${current}/${totalStr}] ${sourcePath}`);
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
if (!opts.verbose) {
|
|
337
|
+
spinner.start(`Processing ${totalFiles} files...`);
|
|
338
|
+
}
|
|
204
339
|
|
|
205
|
-
for await (const result of scheduleRunStreaming(recipe,
|
|
340
|
+
for await (const result of scheduleRunStreaming(recipe, sourceFileStream, ctx, onProgress)) {
|
|
206
341
|
processedCount++;
|
|
207
342
|
const currentPath = result.after?.sourcePath ?? result.before?.sourcePath ?? '';
|
|
208
|
-
spinner.update(`Processing [${processedCount}/${totalFiles}] ${currentPath}`);
|
|
209
343
|
|
|
210
344
|
const statusMsg = `Processing [${processedCount}/${totalFiles}] ${currentPath}`;
|
|
211
345
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
if (hasChanges) {
|
|
220
|
-
changeCount++;
|
|
221
|
-
spinner.stop();
|
|
222
|
-
console.log(colorizeDiff(diff));
|
|
223
|
-
spinner.start(statusMsg);
|
|
224
|
-
}
|
|
225
|
-
} else {
|
|
346
|
+
// Skip unchanged files - first check object identity (fast path),
|
|
347
|
+
// then check actual diff content for files where visitor returned new object
|
|
348
|
+
if (result.before === result.after) {
|
|
349
|
+
continue; // Definitely unchanged
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (opts.apply) {
|
|
226
353
|
// Apply changes immediately
|
|
227
354
|
if (result.after) {
|
|
228
355
|
const filePath = path.join(projectRoot, result.after.sourcePath);
|
|
@@ -233,32 +360,64 @@ async function main() {
|
|
|
233
360
|
await fsp.writeFile(filePath, content);
|
|
234
361
|
|
|
235
362
|
changeCount++;
|
|
236
|
-
spinner.stop();
|
|
363
|
+
if (!opts.verbose) spinner.stop();
|
|
364
|
+
const displayPath = toCwdRelative(result.after.sourcePath, projectRoot);
|
|
237
365
|
if (result.before) {
|
|
238
|
-
console.log(` Modified: ${
|
|
366
|
+
console.log(` Modified: ${displayPath}`);
|
|
239
367
|
} else {
|
|
240
|
-
console.log(` Created: ${
|
|
368
|
+
console.log(` Created: ${displayPath}`);
|
|
241
369
|
}
|
|
242
|
-
spinner.start(statusMsg);
|
|
370
|
+
if (!opts.verbose) spinner.start(statusMsg);
|
|
243
371
|
} else if (result.before) {
|
|
244
372
|
const filePath = path.join(projectRoot, result.before.sourcePath);
|
|
245
373
|
await fsp.unlink(filePath);
|
|
246
374
|
changeCount++;
|
|
247
|
-
spinner.stop();
|
|
248
|
-
console.log(` Deleted: ${result.before.sourcePath}`);
|
|
249
|
-
spinner.start(statusMsg);
|
|
375
|
+
if (!opts.verbose) spinner.stop();
|
|
376
|
+
console.log(` Deleted: ${toCwdRelative(result.before.sourcePath, projectRoot)}`);
|
|
377
|
+
if (!opts.verbose) spinner.start(statusMsg);
|
|
378
|
+
}
|
|
379
|
+
} else {
|
|
380
|
+
// Dry-run mode: show diff or just list paths
|
|
381
|
+
const diff = await result.diff();
|
|
382
|
+
const hasChanges = diff.split('\n').some(line =>
|
|
383
|
+
(line.startsWith('+') || line.startsWith('-')) &&
|
|
384
|
+
!line.startsWith('+++') && !line.startsWith('---')
|
|
385
|
+
);
|
|
386
|
+
if (hasChanges) {
|
|
387
|
+
changeCount++;
|
|
388
|
+
if (!opts.verbose) spinner.stop();
|
|
389
|
+
if (opts.list) {
|
|
390
|
+
// Just list the path
|
|
391
|
+
const displayPath = toCwdRelative(
|
|
392
|
+
result.after?.sourcePath ?? result.before?.sourcePath ?? '',
|
|
393
|
+
projectRoot
|
|
394
|
+
);
|
|
395
|
+
console.log(displayPath);
|
|
396
|
+
} else {
|
|
397
|
+
// Show the diff with CWD-relative paths
|
|
398
|
+
console.log(colorizeDiff(transformDiffPaths(diff, projectRoot)));
|
|
399
|
+
}
|
|
400
|
+
if (!opts.verbose) spinner.start(statusMsg);
|
|
250
401
|
}
|
|
251
402
|
}
|
|
252
403
|
}
|
|
253
404
|
|
|
254
|
-
spinner.stop();
|
|
405
|
+
if (!opts.verbose) spinner.stop();
|
|
406
|
+
|
|
407
|
+
// For validate-parsing recipe, check for errors and exit accordingly
|
|
408
|
+
if (validateParsingRecipe) {
|
|
409
|
+
if (!validateParsingRecipe.hasErrors) {
|
|
410
|
+
console.log('All files parsed successfully.');
|
|
411
|
+
}
|
|
412
|
+
process.exit(validateParsingRecipe.hasErrors ? 1 : 0);
|
|
413
|
+
}
|
|
255
414
|
|
|
256
415
|
if (changeCount === 0) {
|
|
257
416
|
console.log('No changes to make.');
|
|
258
|
-
} else if (opts.
|
|
259
|
-
console.log(`\n${changeCount} file(s) would be changed.`);
|
|
260
|
-
} else {
|
|
417
|
+
} else if (opts.apply) {
|
|
261
418
|
console.log(`\n${changeCount} file(s) changed.`);
|
|
419
|
+
} else if (!opts.list) {
|
|
420
|
+
console.log(`\n${changeCount} file(s) would be changed. Run with --apply to apply changes.`);
|
|
262
421
|
}
|
|
263
422
|
}
|
|
264
423
|
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2025 the original author or authors.
|
|
3
|
+
* <p>
|
|
4
|
+
* Licensed under the Moderne Source Available License (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
* <p>
|
|
8
|
+
* https://docs.moderne.io/licensing/moderne-source-available-license
|
|
9
|
+
* <p>
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
import {Recipe} from '../recipe';
|
|
17
|
+
import {TreeVisitor} from '../visitor';
|
|
18
|
+
import {ExecutionContext} from '../execution';
|
|
19
|
+
import {SourceFile, Tree} from '../tree';
|
|
20
|
+
import {isParseError} from '../parse-error';
|
|
21
|
+
import {MarkersKind, ParseExceptionResult} from '../markers';
|
|
22
|
+
import {TreePrinters} from '../print';
|
|
23
|
+
import {createTwoFilesPatch} from 'diff';
|
|
24
|
+
import * as fs from 'fs';
|
|
25
|
+
import * as path from 'path';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A recipe that validates parsing by:
|
|
29
|
+
* 1. Reporting parse errors to stderr
|
|
30
|
+
* 2. Checking parse-to-print idempotence and showing diffs for failures
|
|
31
|
+
*
|
|
32
|
+
* Use this recipe to validate that the parser correctly handles your codebase.
|
|
33
|
+
*/
|
|
34
|
+
export class ValidateParsingRecipe extends Recipe {
|
|
35
|
+
name = 'org.openrewrite.validate-parsing';
|
|
36
|
+
displayName = 'Validate parsing';
|
|
37
|
+
description = 'Validates that all source files parse correctly and that parse-to-print is idempotent. Reports parse errors and shows diffs for idempotence failures.';
|
|
38
|
+
|
|
39
|
+
private projectRoot: string = process.cwd();
|
|
40
|
+
private parseErrorCount = 0;
|
|
41
|
+
private idempotenceFailureCount = 0;
|
|
42
|
+
|
|
43
|
+
setProjectRoot(root: string): this {
|
|
44
|
+
this.projectRoot = root;
|
|
45
|
+
return this;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async editor(): Promise<TreeVisitor<any, ExecutionContext>> {
|
|
49
|
+
const recipe = this;
|
|
50
|
+
return new class extends TreeVisitor<Tree, ExecutionContext> {
|
|
51
|
+
async visit<R extends Tree>(tree: Tree | undefined, ctx: ExecutionContext): Promise<R | undefined> {
|
|
52
|
+
if (!tree) return undefined;
|
|
53
|
+
|
|
54
|
+
const sourceFile = tree as SourceFile;
|
|
55
|
+
|
|
56
|
+
// Check for parse errors
|
|
57
|
+
if (isParseError(sourceFile)) {
|
|
58
|
+
const parseException = sourceFile.markers.markers.find(
|
|
59
|
+
m => m.kind === MarkersKind.ParseExceptionResult
|
|
60
|
+
) as ParseExceptionResult | undefined;
|
|
61
|
+
const message = parseException?.message ?? 'Unknown parse error';
|
|
62
|
+
console.error(`Parse error in ${sourceFile.sourcePath}: ${message}`);
|
|
63
|
+
recipe.parseErrorCount++;
|
|
64
|
+
return tree as R;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Check parse-to-print idempotence
|
|
68
|
+
try {
|
|
69
|
+
const printed = await TreePrinters.print(sourceFile);
|
|
70
|
+
const originalPath = path.join(recipe.projectRoot, sourceFile.sourcePath);
|
|
71
|
+
const original = fs.readFileSync(originalPath, 'utf-8');
|
|
72
|
+
|
|
73
|
+
if (printed !== original) {
|
|
74
|
+
recipe.idempotenceFailureCount++;
|
|
75
|
+
console.error(`Parse-to-print idempotence failure in ${sourceFile.sourcePath}:`);
|
|
76
|
+
|
|
77
|
+
// Generate and print diff
|
|
78
|
+
const diff = createTwoFilesPatch(
|
|
79
|
+
sourceFile.sourcePath,
|
|
80
|
+
sourceFile.sourcePath,
|
|
81
|
+
original,
|
|
82
|
+
printed,
|
|
83
|
+
'original',
|
|
84
|
+
'printed',
|
|
85
|
+
{context: 3}
|
|
86
|
+
);
|
|
87
|
+
console.error(diff);
|
|
88
|
+
}
|
|
89
|
+
} catch (e: any) {
|
|
90
|
+
recipe.idempotenceFailureCount++;
|
|
91
|
+
console.error(`Failed to check idempotence for ${sourceFile.sourcePath}: ${e.message}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return tree as R;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async onComplete(_ctx: ExecutionContext): Promise<void> {
|
|
100
|
+
if (this.parseErrorCount > 0 || this.idempotenceFailureCount > 0) {
|
|
101
|
+
console.error('');
|
|
102
|
+
if (this.parseErrorCount > 0) {
|
|
103
|
+
console.error(`${this.parseErrorCount} file(s) had parse errors.`);
|
|
104
|
+
}
|
|
105
|
+
if (this.idempotenceFailureCount > 0) {
|
|
106
|
+
console.error(`${this.idempotenceFailureCount} file(s) had parse-to-print idempotence failures.`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
get hasErrors(): boolean {
|
|
112
|
+
return this.parseErrorCount > 0 || this.idempotenceFailureCount > 0;
|
|
113
|
+
}
|
|
114
|
+
}
|
package/src/javascript/format.ts
CHANGED
|
@@ -464,8 +464,13 @@ export class SpacesVisitor<P> extends JavaScriptVisitor<P> {
|
|
|
464
464
|
}
|
|
465
465
|
return produceAsync(ret, async draft => {
|
|
466
466
|
if (draft.initializer) {
|
|
467
|
-
|
|
468
|
-
draft.initializer.
|
|
467
|
+
// Preserve newlines - only modify if no newlines present
|
|
468
|
+
if (!draft.initializer.before.whitespace.includes("\n")) {
|
|
469
|
+
draft.initializer.before.whitespace = this.style.aroundOperators.assignment ? " " : "";
|
|
470
|
+
}
|
|
471
|
+
if (!draft.initializer.element.prefix.whitespace.includes("\n")) {
|
|
472
|
+
draft.initializer.element.prefix.whitespace = this.style.aroundOperators.assignment ? " " : "";
|
|
473
|
+
}
|
|
469
474
|
}
|
|
470
475
|
});
|
|
471
476
|
}
|
package/src/javascript/parser.ts
CHANGED
|
@@ -1764,7 +1764,9 @@ export class JavaScriptParserVisitor {
|
|
|
1764
1764
|
prefix: this.prefix(node),
|
|
1765
1765
|
markers: emptyMarkers,
|
|
1766
1766
|
head: this.visit(node.head),
|
|
1767
|
-
|
|
1767
|
+
// Use emptySpace for the last span's after - any trailing whitespace belongs to the outer context
|
|
1768
|
+
spans: node.templateSpans.map((s, i, arr) =>
|
|
1769
|
+
this.rightPadded(this.visit(s), i === arr.length - 1 ? emptySpace : this.suffix(s))),
|
|
1768
1770
|
type: this.mapType(node)
|
|
1769
1771
|
}
|
|
1770
1772
|
}
|
|
@@ -2496,7 +2498,9 @@ export class JavaScriptParserVisitor {
|
|
|
2496
2498
|
prefix: this.prefix(node),
|
|
2497
2499
|
markers: emptyMarkers,
|
|
2498
2500
|
head: this.visit(node.head),
|
|
2499
|
-
|
|
2501
|
+
// Use emptySpace for the last span's after - any trailing whitespace belongs to the outer context
|
|
2502
|
+
spans: node.templateSpans.map((s, i, arr) =>
|
|
2503
|
+
this.rightPadded(this.visit(s), i === arr.length - 1 ? emptySpace : this.suffix(s))),
|
|
2500
2504
|
type: this.mapType(node)
|
|
2501
2505
|
}
|
|
2502
2506
|
}
|
|
@@ -43,7 +43,19 @@ export class TabsAndIndentsVisitor<P> extends JavaScriptVisitor<P> {
|
|
|
43
43
|
const [parentMyIndent, parentIndentKind] = this.getParentIndentContext(cursor);
|
|
44
44
|
const myIndent = this.computeMyIndent(tree, parentMyIndent, parentIndentKind);
|
|
45
45
|
cursor.messages.set("myIndent", myIndent);
|
|
46
|
-
|
|
46
|
+
|
|
47
|
+
// For Binary, behavior depends on whether it's already on a continuation line
|
|
48
|
+
if (tree.kind === J.Kind.Binary) {
|
|
49
|
+
const hasNewline = tree.prefix?.whitespace?.includes("\n") ||
|
|
50
|
+
tree.prefix?.comments?.some(c => c.suffix.includes("\n"));
|
|
51
|
+
// If Binary has newline, children align. Otherwise, children get continuation.
|
|
52
|
+
cursor.messages.set("indentKind", hasNewline ? 'align' : 'continuation');
|
|
53
|
+
// For LeftPadded children (like operator), same rule applies
|
|
54
|
+
cursor.messages.set("leftPaddedContinuation", hasNewline ? 'align' : 'propagate');
|
|
55
|
+
} else {
|
|
56
|
+
cursor.messages.set("indentKind", this.computeIndentKind(tree));
|
|
57
|
+
cursor.messages.set("leftPaddedContinuation", 'propagate');
|
|
58
|
+
}
|
|
47
59
|
}
|
|
48
60
|
|
|
49
61
|
private getParentIndentContext(cursor: Cursor): [string, IndentKind] {
|
|
@@ -167,11 +179,29 @@ export class TabsAndIndentsVisitor<P> extends JavaScriptVisitor<P> {
|
|
|
167
179
|
left: J.LeftPadded<T>,
|
|
168
180
|
p: P
|
|
169
181
|
): Promise<J.LeftPadded<T> | undefined> {
|
|
182
|
+
const parentIndent = this.cursor.messages.get("myIndent") as string ?? "";
|
|
183
|
+
const continuation = this.cursor.messages.get("leftPaddedContinuation") as string ?? 'propagate';
|
|
184
|
+
const hasNewline = left.before.whitespace.includes("\n");
|
|
185
|
+
const shouldPropagate = hasNewline && continuation === 'propagate';
|
|
186
|
+
|
|
187
|
+
// For 'propagate' mode, update myIndent for children so nested structures get proper indent
|
|
188
|
+
const savedMyIndent = this.cursor.messages.get("myIndent");
|
|
189
|
+
if (shouldPropagate) {
|
|
190
|
+
this.cursor.messages.set("myIndent", parentIndent + this.singleIndent);
|
|
191
|
+
}
|
|
192
|
+
|
|
170
193
|
const ret = await super.visitLeftPadded(left, p);
|
|
171
|
-
|
|
194
|
+
|
|
195
|
+
// Restore myIndent
|
|
196
|
+
if (savedMyIndent !== undefined) {
|
|
197
|
+
this.cursor.messages.set("myIndent", savedMyIndent);
|
|
198
|
+
} else if (shouldPropagate) {
|
|
199
|
+
this.cursor.messages.delete("myIndent");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (ret === undefined || !hasNewline) {
|
|
172
203
|
return ret;
|
|
173
204
|
}
|
|
174
|
-
const parentIndent = this.cursor.messages.get("myIndent") as string ?? "";
|
|
175
205
|
return produce(ret, draft => {
|
|
176
206
|
draft.before.whitespace = this.combineIndent(draft.before.whitespace, parentIndent + this.singleIndent);
|
|
177
207
|
});
|