@openrewrite/rewrite 8.66.0-SNAPSHOT → 8.67.0-20251103-160333
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/javascript/comparator.d.ts +24 -1
- package/dist/javascript/comparator.d.ts.map +1 -1
- package/dist/javascript/comparator.js +532 -673
- package/dist/javascript/comparator.js.map +1 -1
- package/dist/javascript/parser.d.ts.map +1 -1
- package/dist/javascript/parser.js +4 -5
- package/dist/javascript/parser.js.map +1 -1
- package/dist/javascript/print.d.ts +2 -2
- package/dist/javascript/print.d.ts.map +1 -1
- package/dist/javascript/print.js +4 -4
- package/dist/javascript/print.js.map +1 -1
- package/dist/javascript/templating.d.ts.map +1 -1
- package/dist/javascript/templating.js +111 -153
- package/dist/javascript/templating.js.map +1 -1
- package/dist/test/rewrite-test.d.ts.map +1 -1
- package/dist/test/rewrite-test.js +65 -9
- package/dist/test/rewrite-test.js.map +1 -1
- package/dist/version.txt +1 -1
- package/package.json +1 -2
- package/src/javascript/comparator.ts +553 -670
- package/src/javascript/parser.ts +4 -5
- package/src/javascript/print.ts +6 -6
- package/src/javascript/templating.ts +110 -161
- package/src/test/rewrite-test.ts +65 -1
package/src/javascript/parser.ts
CHANGED
|
@@ -2637,11 +2637,10 @@ export class JavaScriptParserVisitor {
|
|
|
2637
2637
|
}
|
|
2638
2638
|
|
|
2639
2639
|
visitVariableStatement(node: ts.VariableStatement): JS.ScopedVariableDeclarations | J.VariableDeclarations {
|
|
2640
|
+
const prefix = this.prefix(node);
|
|
2640
2641
|
return produce(this.visitVariableDeclarationList(node.declarationList), draft => {
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
}
|
|
2644
|
-
draft.prefix = this.prefix(node);
|
|
2642
|
+
draft.prefix = prefix;
|
|
2643
|
+
draft.modifiers = this.mapModifiers(node).concat(draft.modifiers);
|
|
2645
2644
|
});
|
|
2646
2645
|
}
|
|
2647
2646
|
|
|
@@ -3064,7 +3063,7 @@ export class JavaScriptParserVisitor {
|
|
|
3064
3063
|
modifiers.push({
|
|
3065
3064
|
kind: J.Kind.Modifier,
|
|
3066
3065
|
id: randomId(),
|
|
3067
|
-
prefix: this.prefix(kind),
|
|
3066
|
+
prefix: modifiers.length === 0 ? this.prefix(kind) : this.prefix(kind),
|
|
3068
3067
|
markers: emptyMarkers,
|
|
3069
3068
|
annotations: [],
|
|
3070
3069
|
keyword: kind.kind === ts.SyntaxKind.VarKeyword ? 'var' :
|
package/src/javascript/print.ts
CHANGED
|
@@ -75,16 +75,16 @@ export class JavaScriptPrinter extends JavaScriptVisitor<PrintOutputCapture> {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
override async visitExpressionStatement(statement: JS.ExpressionStatement, p: PrintOutputCapture): Promise<J | undefined> {
|
|
78
|
-
await this.
|
|
79
|
-
await this.visitMarkers(statement.markers, p);
|
|
78
|
+
await this.beforeSyntax(statement, p);
|
|
80
79
|
await this.visit(statement.expression, p);
|
|
80
|
+
await this.afterSyntax(statement, p);
|
|
81
81
|
return statement;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
override async visitStatementExpression(statementExpression: JS.StatementExpression, p: PrintOutputCapture): Promise<J | undefined> {
|
|
85
|
-
await this.
|
|
86
|
-
await this.visitMarkers(statementExpression.markers, p);
|
|
85
|
+
await this.beforeSyntax(statementExpression, p);
|
|
87
86
|
await this.visit(statementExpression.statement, p);
|
|
87
|
+
await this.afterSyntax(statementExpression, p);
|
|
88
88
|
return statementExpression;
|
|
89
89
|
}
|
|
90
90
|
|
|
@@ -1767,7 +1767,7 @@ export class JavaScriptPrinter extends JavaScriptVisitor<PrintOutputCapture> {
|
|
|
1767
1767
|
return cursor;
|
|
1768
1768
|
}
|
|
1769
1769
|
|
|
1770
|
-
|
|
1770
|
+
protected async afterSyntax(j: J, p: PrintOutputCapture) {
|
|
1771
1771
|
await this.afterSyntaxMarkers(j.markers, p);
|
|
1772
1772
|
}
|
|
1773
1773
|
|
|
@@ -1777,7 +1777,7 @@ export class JavaScriptPrinter extends JavaScriptVisitor<PrintOutputCapture> {
|
|
|
1777
1777
|
}
|
|
1778
1778
|
}
|
|
1779
1779
|
|
|
1780
|
-
|
|
1780
|
+
protected async beforeSyntax(j: J, p: PrintOutputCapture) {
|
|
1781
1781
|
await this.beforeSyntaxExt(j.prefix, j.markers, p);
|
|
1782
1782
|
}
|
|
1783
1783
|
|
|
@@ -17,11 +17,11 @@ import {JS} from '.';
|
|
|
17
17
|
import {JavaScriptParser} from './parser';
|
|
18
18
|
import {JavaScriptVisitor} from './visitor';
|
|
19
19
|
import {Cursor, isTree, Tree} from '..';
|
|
20
|
-
import {
|
|
20
|
+
import {emptySpace, J} from '../java';
|
|
21
21
|
import {produce} from "immer";
|
|
22
|
-
import {
|
|
22
|
+
import {JavaScriptSemanticComparatorVisitor} from "./comparator";
|
|
23
23
|
import {DependencyWorkspace} from './dependency-workspace';
|
|
24
|
-
import {Marker} from '../markers';
|
|
24
|
+
import {emptyMarkers, Marker} from '../markers';
|
|
25
25
|
import {randomId} from '../uuid';
|
|
26
26
|
|
|
27
27
|
/**
|
|
@@ -118,6 +118,108 @@ class CaptureMarker implements Marker {
|
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* A comparator for pattern matching that is lenient about optional properties.
|
|
123
|
+
* Allows patterns without type annotations to match actual code with type annotations.
|
|
124
|
+
* Uses semantic comparison to match semantically equivalent code (e.g., isDate() and util.isDate()).
|
|
125
|
+
*/
|
|
126
|
+
class PatternMatchingComparator extends JavaScriptSemanticComparatorVisitor {
|
|
127
|
+
constructor(private readonly matcher: { handleCapture: (pattern: J, target: J) => boolean }) {
|
|
128
|
+
super();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Creates a wildcard identifier that will match any AST node during comparison.
|
|
133
|
+
* The identifier has a CaptureMarker which causes it to match anything without storing the result.
|
|
134
|
+
*
|
|
135
|
+
* @param captureName The name for the capture marker (for debugging purposes)
|
|
136
|
+
* @returns A wildcard identifier
|
|
137
|
+
*/
|
|
138
|
+
private createWildcardIdentifier(captureName: string): J.Identifier {
|
|
139
|
+
return {
|
|
140
|
+
id: randomId(),
|
|
141
|
+
kind: J.Kind.Identifier,
|
|
142
|
+
prefix: emptySpace,
|
|
143
|
+
markers: {
|
|
144
|
+
...emptyMarkers,
|
|
145
|
+
markers: [new CaptureMarker(captureName)]
|
|
146
|
+
},
|
|
147
|
+
annotations: [],
|
|
148
|
+
simpleName: '__wildcard__',
|
|
149
|
+
type: undefined,
|
|
150
|
+
fieldType: undefined
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
override async visit<R extends J>(j: Tree, p: J, parent?: Cursor): Promise<R | undefined> {
|
|
155
|
+
// Check if the pattern node is a capture - this handles captures anywhere in the tree
|
|
156
|
+
if (PlaceholderUtils.isCapture(j as J)) {
|
|
157
|
+
const success = this.matcher.handleCapture(j as J, p);
|
|
158
|
+
if (!success) {
|
|
159
|
+
return this.abort(j) as R;
|
|
160
|
+
}
|
|
161
|
+
return j as R;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (!this.match) {
|
|
165
|
+
return j as R;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return super.visit(j, p, parent);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
override async visitVariableDeclarations(variableDeclarations: J.VariableDeclarations, other: J): Promise<J | undefined> {
|
|
172
|
+
if (!this.match || other.kind !== J.Kind.VariableDeclarations) {
|
|
173
|
+
return this.abort(variableDeclarations);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const otherVariableDeclarations = other as J.VariableDeclarations;
|
|
177
|
+
|
|
178
|
+
// LENIENT: If pattern lacks typeExpression but target has one, add a wildcard capture to pattern
|
|
179
|
+
// This allows the pattern to match without requiring us to modify the target (which would corrupt captures)
|
|
180
|
+
if (!variableDeclarations.typeExpression && otherVariableDeclarations.typeExpression) {
|
|
181
|
+
variableDeclarations = produce(variableDeclarations, draft => {
|
|
182
|
+
draft.typeExpression = this.createWildcardIdentifier('__wildcard_type__') as J.Identifier;
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Delegate to super implementation
|
|
187
|
+
return super.visitVariableDeclarations(variableDeclarations, otherVariableDeclarations);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
override async visitMethodDeclaration(methodDeclaration: J.MethodDeclaration, other: J): Promise<J | undefined> {
|
|
191
|
+
if (!this.match || other.kind !== J.Kind.MethodDeclaration) {
|
|
192
|
+
return this.abort(methodDeclaration);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const otherMethodDeclaration = other as J.MethodDeclaration;
|
|
196
|
+
|
|
197
|
+
// LENIENT: If pattern lacks returnTypeExpression but target has one, add a wildcard capture to pattern
|
|
198
|
+
// This allows the pattern to match without requiring us to modify the target (which would corrupt captures)
|
|
199
|
+
if (!methodDeclaration.returnTypeExpression && otherMethodDeclaration.returnTypeExpression) {
|
|
200
|
+
methodDeclaration = produce(methodDeclaration, draft => {
|
|
201
|
+
draft.returnTypeExpression = this.createWildcardIdentifier('__wildcard_return_type__') as J.Identifier;
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Delegate to super implementation
|
|
206
|
+
return super.visitMethodDeclaration(methodDeclaration, otherMethodDeclaration);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
protected hasSameKind(j: J, other: J): boolean {
|
|
210
|
+
return super.hasSameKind(j, other) ||
|
|
211
|
+
(j.kind == J.Kind.Identifier && PlaceholderUtils.isCapture(j as J.Identifier));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
override async visitIdentifier(identifier: J.Identifier, other: J): Promise<J | undefined> {
|
|
215
|
+
if (PlaceholderUtils.isCapture(identifier)) {
|
|
216
|
+
const success = this.matcher.handleCapture(identifier, other);
|
|
217
|
+
return success ? identifier : this.abort(identifier);
|
|
218
|
+
}
|
|
219
|
+
return super.visitIdentifier(identifier, other);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
121
223
|
/**
|
|
122
224
|
* Capture specification for pattern matching.
|
|
123
225
|
* Represents a placeholder in a template pattern that can capture a part of the AST.
|
|
@@ -264,150 +366,6 @@ export class MatchResult implements Pick<Map<string, J>, "get"> {
|
|
|
264
366
|
}
|
|
265
367
|
}
|
|
266
368
|
|
|
267
|
-
/**
|
|
268
|
-
* A comparator visitor that checks semantic equality including type attribution.
|
|
269
|
-
* This ensures that patterns only match code with compatible types, not just
|
|
270
|
-
* structurally similar code.
|
|
271
|
-
*/
|
|
272
|
-
class JavaScriptTemplateSemanticallyEqualVisitor extends JavaScriptComparatorVisitor {
|
|
273
|
-
/**
|
|
274
|
-
* Checks if two types are semantically equal.
|
|
275
|
-
* For method types, this checks that the declaring type and method name match.
|
|
276
|
-
*/
|
|
277
|
-
private isOfType(target?: Type, source?: Type): boolean {
|
|
278
|
-
if (!target || !source) {
|
|
279
|
-
return target === source;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
if (target.kind !== source.kind) {
|
|
283
|
-
return false;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
// For method types, check declaring type
|
|
287
|
-
// Note: We don't check the name field because it might not be fully resolved in patterns
|
|
288
|
-
// The method invocation visitor already checks that simple names match
|
|
289
|
-
if (target.kind === Type.Kind.Method && source.kind === Type.Kind.Method) {
|
|
290
|
-
const targetMethod = target as Type.Method;
|
|
291
|
-
const sourceMethod = source as Type.Method;
|
|
292
|
-
|
|
293
|
-
// Only check that declaring types match
|
|
294
|
-
return this.isOfType(targetMethod.declaringType, sourceMethod.declaringType);
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// For fully qualified types, check the fully qualified name
|
|
298
|
-
if (Type.isFullyQualified(target) && Type.isFullyQualified(source)) {
|
|
299
|
-
return Type.FullyQualified.getFullyQualifiedName(target) ===
|
|
300
|
-
Type.FullyQualified.getFullyQualifiedName(source);
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
// Default: types are equal if they're the same kind
|
|
304
|
-
return true;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
/**
|
|
308
|
-
* Override method invocation comparison to include type attribution checking.
|
|
309
|
-
* When types match semantically, we allow matching even if one has a receiver
|
|
310
|
-
* and the other doesn't (e.g., `isDate(x)` vs `util.isDate(x)`).
|
|
311
|
-
*/
|
|
312
|
-
override async visitMethodInvocation(method: J.MethodInvocation, other: J): Promise<J | undefined> {
|
|
313
|
-
if (other.kind !== J.Kind.MethodInvocation) {
|
|
314
|
-
return method;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
const otherMethod = other as J.MethodInvocation;
|
|
318
|
-
|
|
319
|
-
// Check basic structural equality first
|
|
320
|
-
if (method.name.simpleName !== otherMethod.name.simpleName ||
|
|
321
|
-
method.arguments.elements.length !== otherMethod.arguments.elements.length) {
|
|
322
|
-
this.abort();
|
|
323
|
-
return method;
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
// Check type attribution
|
|
327
|
-
// Both must have method types for semantic equality
|
|
328
|
-
if (!method.methodType || !otherMethod.methodType) {
|
|
329
|
-
// If template has type but target doesn't, they don't match
|
|
330
|
-
if (method.methodType || otherMethod.methodType) {
|
|
331
|
-
this.abort();
|
|
332
|
-
return method;
|
|
333
|
-
}
|
|
334
|
-
// If neither has type, fall through to structural comparison
|
|
335
|
-
return super.visitMethodInvocation(method, other);
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
// Both have types - check they match semantically
|
|
339
|
-
const typesMatch = this.isOfType(method.methodType, otherMethod.methodType);
|
|
340
|
-
if (!typesMatch) {
|
|
341
|
-
// Types don't match - abort comparison
|
|
342
|
-
this.abort();
|
|
343
|
-
return method;
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
// Types match! Now we can ignore receiver differences and just compare arguments.
|
|
347
|
-
// This allows pattern `isDate(x)` to match both `isDate(x)` and `util.isDate(x)`
|
|
348
|
-
// when they have the same type attribution.
|
|
349
|
-
|
|
350
|
-
// Compare type parameters
|
|
351
|
-
if ((method.typeParameters === undefined) !== (otherMethod.typeParameters === undefined)) {
|
|
352
|
-
this.abort();
|
|
353
|
-
return method;
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
if (method.typeParameters && otherMethod.typeParameters) {
|
|
357
|
-
if (method.typeParameters.elements.length !== otherMethod.typeParameters.elements.length) {
|
|
358
|
-
this.abort();
|
|
359
|
-
return method;
|
|
360
|
-
}
|
|
361
|
-
for (let i = 0; i < method.typeParameters.elements.length; i++) {
|
|
362
|
-
await this.visit(method.typeParameters.elements[i].element, otherMethod.typeParameters.elements[i].element);
|
|
363
|
-
if (!this.match) return method;
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
// Compare name (already checked simpleName above, but visit for markers/prefix)
|
|
368
|
-
await this.visit(method.name, otherMethod.name);
|
|
369
|
-
if (!this.match) return method;
|
|
370
|
-
|
|
371
|
-
// Compare arguments
|
|
372
|
-
for (let i = 0; i < method.arguments.elements.length; i++) {
|
|
373
|
-
await this.visit(method.arguments.elements[i].element, otherMethod.arguments.elements[i].element);
|
|
374
|
-
if (!this.match) return method;
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
return method;
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
/**
|
|
381
|
-
* Override identifier comparison to include type checking for field access.
|
|
382
|
-
*/
|
|
383
|
-
override async visitIdentifier(identifier: J.Identifier, other: J): Promise<J | undefined> {
|
|
384
|
-
if (other.kind !== J.Kind.Identifier) {
|
|
385
|
-
return identifier;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
const otherIdentifier = other as J.Identifier;
|
|
389
|
-
|
|
390
|
-
// Check name matches
|
|
391
|
-
if (identifier.simpleName !== otherIdentifier.simpleName) {
|
|
392
|
-
return identifier;
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
// For identifiers with field types, check type attribution
|
|
396
|
-
if (identifier.fieldType && otherIdentifier.fieldType) {
|
|
397
|
-
if (!this.isOfType(identifier.fieldType, otherIdentifier.fieldType)) {
|
|
398
|
-
this.abort();
|
|
399
|
-
return identifier;
|
|
400
|
-
}
|
|
401
|
-
} else if (identifier.fieldType || otherIdentifier.fieldType) {
|
|
402
|
-
// If only one has a type, they don't match
|
|
403
|
-
this.abort();
|
|
404
|
-
return identifier;
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
return super.visitIdentifier(identifier, other);
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
|
|
411
369
|
/**
|
|
412
370
|
* Matcher for checking if a pattern matches an AST node and extracting captured nodes.
|
|
413
371
|
*/
|
|
@@ -473,20 +431,11 @@ class Matcher {
|
|
|
473
431
|
return false;
|
|
474
432
|
}
|
|
475
433
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
override async visitIdentifier(identifier: J.Identifier, other: J): Promise<J | undefined> {
|
|
483
|
-
return this.matchesParameter(identifier, other) ? identifier : await super.visitIdentifier(identifier, other);
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
private matchesParameter(identifier: J.Identifier, other: J): boolean {
|
|
487
|
-
return PlaceholderUtils.isCapture(identifier) && matcher.handleCapture(identifier, other);
|
|
488
|
-
}
|
|
489
|
-
}).compare(pattern, target));
|
|
434
|
+
// Use the pattern matching comparator which is lenient about optional properties
|
|
435
|
+
const comparator = new PatternMatchingComparator({
|
|
436
|
+
handleCapture: (p, t) => this.handleCapture(p, t)
|
|
437
|
+
});
|
|
438
|
+
return await comparator.compare(pattern, target);
|
|
490
439
|
}
|
|
491
440
|
|
|
492
441
|
/**
|
package/src/test/rewrite-test.ts
CHANGED
|
@@ -19,7 +19,6 @@ import {noopVisitor, TreeVisitor} from "../visitor";
|
|
|
19
19
|
import {Parser} from "../parser";
|
|
20
20
|
import {TreePrinters} from "../print";
|
|
21
21
|
import {SourceFile} from "../tree";
|
|
22
|
-
import dedent from "dedent";
|
|
23
22
|
import {Result, scheduleRun} from "../run";
|
|
24
23
|
import {SnowflakeId} from "@akashrajpurohit/snowflake-id";
|
|
25
24
|
import {mapAsync, trimIndent} from "../util";
|
|
@@ -242,6 +241,71 @@ class NoopRecipe extends Recipe {
|
|
|
242
241
|
|
|
243
242
|
export type AfterRecipeText = string | ((actual: string) => string | undefined) | undefined | null;
|
|
244
243
|
|
|
244
|
+
/**
|
|
245
|
+
* Simple dedent implementation that removes common leading whitespace from each line.
|
|
246
|
+
*
|
|
247
|
+
* Behavior:
|
|
248
|
+
* - Removes ONE leading newline if present (for template string ergonomics)
|
|
249
|
+
* - Removes trailing newline + whitespace (for template string ergonomics)
|
|
250
|
+
* - Preserves additional leading/trailing empty lines beyond the first
|
|
251
|
+
* - For lines with content: removes common indentation
|
|
252
|
+
* - For lines with only whitespace: removes common indentation, preserving remaining spaces
|
|
253
|
+
*
|
|
254
|
+
* Examples:
|
|
255
|
+
* - `\n code` → `code` (single leading newline removed)
|
|
256
|
+
* - `\n\n code` → `\ncode` (first newline removed, second preserved)
|
|
257
|
+
* - ` code\n` → `code` (trailing newline removed)
|
|
258
|
+
* - ` code\n\n` → `code\n` (first trailing newline removed, second preserved)
|
|
259
|
+
*/
|
|
260
|
+
function dedent(s: string): string {
|
|
261
|
+
if (!s) return s;
|
|
262
|
+
|
|
263
|
+
// Remove single leading newline for ergonomics
|
|
264
|
+
let start = s.charCodeAt(0) === 10 ? 1 : 0; // 10 = '\n'
|
|
265
|
+
|
|
266
|
+
// Remove trailing newline + any trailing whitespace
|
|
267
|
+
let end = s.length;
|
|
268
|
+
for (let i = s.length - 1; i >= start; i--) {
|
|
269
|
+
const ch = s.charCodeAt(i);
|
|
270
|
+
if (ch === 10) { // '\n'
|
|
271
|
+
end = i;
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
if (ch !== 32 && ch !== 9) break; // not ' ' or '\t'
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (start >= end) return '';
|
|
278
|
+
|
|
279
|
+
const str = start > 0 || end < s.length ? s.slice(start, end) : s;
|
|
280
|
+
const lines = str.split('\n');
|
|
281
|
+
|
|
282
|
+
// Find minimum indentation (avoid regex for performance)
|
|
283
|
+
let minIndent = Infinity;
|
|
284
|
+
for (const line of lines) {
|
|
285
|
+
let indent = 0;
|
|
286
|
+
for (let j = 0; j < line.length; j++) {
|
|
287
|
+
const ch = line.charCodeAt(j);
|
|
288
|
+
if (ch === 32 || ch === 9) { // ' ' or '\t'
|
|
289
|
+
indent++;
|
|
290
|
+
} else {
|
|
291
|
+
// Found non-whitespace, update minIndent
|
|
292
|
+
if (indent < minIndent) minIndent = indent;
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// If all lines are empty or no indentation
|
|
299
|
+
if (minIndent === Infinity || minIndent === 0) {
|
|
300
|
+
return lines.map(line => line.trim() || '').join('\n');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Remove common indentation from each line
|
|
304
|
+
return lines.map(line =>
|
|
305
|
+
line.length >= minIndent ? line.slice(minIndent) : ''
|
|
306
|
+
).join('\n');
|
|
307
|
+
}
|
|
308
|
+
|
|
245
309
|
export function dedentAfter(s?: AfterRecipeText): AfterRecipeText {
|
|
246
310
|
if (s !== null) {
|
|
247
311
|
if (s === undefined) {
|