@openrewrite/rewrite 8.67.0-20251111-082857 → 8.67.0-20251111-123004
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 +77 -1
- package/dist/javascript/comparator.d.ts.map +1 -1
- package/dist/javascript/comparator.js +327 -10
- package/dist/javascript/comparator.js.map +1 -1
- package/dist/javascript/templating/engine.d.ts.map +1 -1
- package/dist/javascript/templating/engine.js +18 -9
- package/dist/javascript/templating/engine.js.map +1 -1
- package/dist/version.txt +1 -1
- package/package.json +1 -1
- package/src/javascript/comparator.ts +336 -3
- package/src/javascript/templating/engine.ts +18 -8
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
import {JavaScriptVisitor} from './visitor';
|
|
17
|
-
import {J, Type} from '../java';
|
|
17
|
+
import {J, Type, Expression, Statement, isIdentifier} from '../java';
|
|
18
18
|
import {JS, JSX} from './tree';
|
|
19
19
|
import {Cursor, Tree} from "../tree";
|
|
20
20
|
|
|
@@ -73,7 +73,6 @@ export class JavaScriptComparatorVisitor extends JavaScriptVisitor<J> {
|
|
|
73
73
|
return t;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
|
|
77
76
|
/**
|
|
78
77
|
* Generic method to visit a property value using the appropriate visitor method.
|
|
79
78
|
* This ensures wrappers (RightPadded, LeftPadded, Container) are properly tracked on the cursor.
|
|
@@ -1838,6 +1837,273 @@ export class JavaScriptSemanticComparatorVisitor extends JavaScriptComparatorVis
|
|
|
1838
1837
|
this.lenientTypeMatching = lenientTypeMatching;
|
|
1839
1838
|
}
|
|
1840
1839
|
|
|
1840
|
+
/**
|
|
1841
|
+
* Unwraps parentheses from a tree node recursively.
|
|
1842
|
+
* This allows comparing expressions with and without redundant parentheses.
|
|
1843
|
+
*
|
|
1844
|
+
* @param tree The tree to unwrap
|
|
1845
|
+
* @returns The unwrapped tree
|
|
1846
|
+
*/
|
|
1847
|
+
protected unwrap(tree: Tree | undefined): Tree | undefined {
|
|
1848
|
+
if (!tree) {
|
|
1849
|
+
return tree;
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
// Unwrap J.Parentheses nodes recursively
|
|
1853
|
+
if ((tree as any).kind === J.Kind.Parentheses) {
|
|
1854
|
+
const parens = tree as J.Parentheses<any>;
|
|
1855
|
+
return this.unwrap(parens.tree.element as Tree);
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
// Unwrap J.ControlParentheses nodes recursively
|
|
1859
|
+
if ((tree as any).kind === J.Kind.ControlParentheses) {
|
|
1860
|
+
const controlParens = tree as J.ControlParentheses<any>;
|
|
1861
|
+
return this.unwrap(controlParens.tree.element as Tree);
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
return tree;
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
override async visit<R extends J>(j: Tree, p: J, parent?: Cursor): Promise<R | undefined> {
|
|
1868
|
+
// If we've already found a mismatch, abort further processing
|
|
1869
|
+
if (!this.match) {
|
|
1870
|
+
return j as R;
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
// Unwrap parentheses from both trees before comparing
|
|
1874
|
+
const unwrappedJ = this.unwrap(j) || j;
|
|
1875
|
+
const unwrappedP = this.unwrap(p) || p;
|
|
1876
|
+
|
|
1877
|
+
// Skip the kind check that the base class does - semantic matching allows different kinds
|
|
1878
|
+
// (e.g., undefined identifier matching void expression)
|
|
1879
|
+
// Update targetCursor to track the target node in parallel with the pattern cursor
|
|
1880
|
+
const savedTargetCursor = this.targetCursor;
|
|
1881
|
+
this.targetCursor = new Cursor(unwrappedP, this.targetCursor);
|
|
1882
|
+
try {
|
|
1883
|
+
// Call the grandparent's visit to do actual visitation without the kind check
|
|
1884
|
+
return await JavaScriptVisitor.prototype.visit.call(this, unwrappedJ, unwrappedP) as R | undefined;
|
|
1885
|
+
} finally {
|
|
1886
|
+
this.targetCursor = savedTargetCursor;
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
/**
|
|
1891
|
+
* Override visitArrowFunction to allow semantic equivalence between expression body
|
|
1892
|
+
* and block with single return statement forms.
|
|
1893
|
+
*
|
|
1894
|
+
* Examples:
|
|
1895
|
+
* - `x => x + 1` matches `x => { return x + 1; }`
|
|
1896
|
+
* - `(x, y) => x + y` matches `(x, y) => { return x + y; }`
|
|
1897
|
+
*/
|
|
1898
|
+
override async visitArrowFunction(arrowFunction: JS.ArrowFunction, other: J): Promise<J | undefined> {
|
|
1899
|
+
if (!this.match) {
|
|
1900
|
+
return arrowFunction;
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
if (other.kind !== JS.Kind.ArrowFunction) {
|
|
1904
|
+
return this.abort(arrowFunction);
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
const otherArrow = other as JS.ArrowFunction;
|
|
1908
|
+
|
|
1909
|
+
// Compare all properties reflectively except lambda (handled specially below)
|
|
1910
|
+
for (const key of Object.keys(arrowFunction)) {
|
|
1911
|
+
if (key.startsWith('_') || key === 'id' || key === 'markers' || key === 'lambda') {
|
|
1912
|
+
continue;
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
const jValue = (arrowFunction as any)[key];
|
|
1916
|
+
const otherValue = (otherArrow as any)[key];
|
|
1917
|
+
|
|
1918
|
+
// Handle arrays
|
|
1919
|
+
if (Array.isArray(jValue)) {
|
|
1920
|
+
if (!Array.isArray(otherValue) || jValue.length !== otherValue.length) {
|
|
1921
|
+
return this.abort(arrowFunction);
|
|
1922
|
+
}
|
|
1923
|
+
for (let i = 0; i < jValue.length; i++) {
|
|
1924
|
+
await this.visitProperty(jValue[i], otherValue[i]);
|
|
1925
|
+
if (!this.match) return arrowFunction;
|
|
1926
|
+
}
|
|
1927
|
+
} else {
|
|
1928
|
+
await this.visitProperty(jValue, otherValue);
|
|
1929
|
+
if (!this.match) return arrowFunction;
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
// Compare lambda parameters
|
|
1934
|
+
const params1 = arrowFunction.lambda.parameters.parameters;
|
|
1935
|
+
const params2 = otherArrow.lambda.parameters.parameters;
|
|
1936
|
+
if (params1.length !== params2.length) {
|
|
1937
|
+
return this.abort(arrowFunction);
|
|
1938
|
+
}
|
|
1939
|
+
for (let i = 0; i < params1.length; i++) {
|
|
1940
|
+
await this.visitProperty(params1[i], params2[i]);
|
|
1941
|
+
if (!this.match) return arrowFunction;
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
// Handle semantic equivalence for lambda bodies
|
|
1945
|
+
const body1 = arrowFunction.lambda.body;
|
|
1946
|
+
const body2 = otherArrow.lambda.body;
|
|
1947
|
+
|
|
1948
|
+
// Try to extract the expression from each body
|
|
1949
|
+
const expr1 = this.extractExpression(body1);
|
|
1950
|
+
const expr2 = this.extractExpression(body2);
|
|
1951
|
+
|
|
1952
|
+
if (expr1 && expr2) {
|
|
1953
|
+
// Both have extractable expressions - compare them
|
|
1954
|
+
await this.visit(expr1, expr2);
|
|
1955
|
+
} else {
|
|
1956
|
+
// At least one is not a simple expression or block-with-return
|
|
1957
|
+
// Fall back to exact comparison
|
|
1958
|
+
await this.visit(body1, body2);
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
return arrowFunction;
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
/**
|
|
1965
|
+
* Override visitLambdaParameters to allow semantic equivalence between
|
|
1966
|
+
* arrow functions with and without parentheses around single parameters.
|
|
1967
|
+
*
|
|
1968
|
+
* Examples:
|
|
1969
|
+
* - `x => x + 1` matches `(x) => x + 1`
|
|
1970
|
+
*/
|
|
1971
|
+
override async visitLambdaParameters(parameters: J.Lambda.Parameters, other: J): Promise<J | undefined> {
|
|
1972
|
+
if (!this.match) {
|
|
1973
|
+
return parameters;
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
if (other.kind !== J.Kind.LambdaParameters) {
|
|
1977
|
+
return this.abort(parameters);
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
const otherParams = other as J.Lambda.Parameters;
|
|
1981
|
+
|
|
1982
|
+
// Compare all properties except 'parenthesized' using reflection
|
|
1983
|
+
for (const key of Object.keys(parameters)) {
|
|
1984
|
+
if (key.startsWith('_') || key === 'id' || key === 'markers' || key === 'parenthesized') {
|
|
1985
|
+
continue;
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
const jValue = (parameters as any)[key];
|
|
1989
|
+
const otherValue = (otherParams as any)[key];
|
|
1990
|
+
|
|
1991
|
+
// Handle arrays
|
|
1992
|
+
if (Array.isArray(jValue)) {
|
|
1993
|
+
if (!Array.isArray(otherValue) || jValue.length !== otherValue.length) {
|
|
1994
|
+
return this.abort(parameters);
|
|
1995
|
+
}
|
|
1996
|
+
for (let i = 0; i < jValue.length; i++) {
|
|
1997
|
+
await this.visitProperty(jValue[i], otherValue[i]);
|
|
1998
|
+
if (!this.match) return parameters;
|
|
1999
|
+
}
|
|
2000
|
+
} else {
|
|
2001
|
+
await this.visitProperty(jValue, otherValue);
|
|
2002
|
+
if (!this.match) return parameters;
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
return parameters;
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
/**
|
|
2010
|
+
* Override visitPropertyAssignment to allow semantic equivalence between
|
|
2011
|
+
* object property shorthand and longhand forms.
|
|
2012
|
+
*
|
|
2013
|
+
* Examples:
|
|
2014
|
+
* - `{ x }` matches `{ x: x }`
|
|
2015
|
+
* - `{ x: x, y: y }` matches `{ x, y }`
|
|
2016
|
+
*/
|
|
2017
|
+
override async visitPropertyAssignment(propertyAssignment: JS.PropertyAssignment, other: J): Promise<J | undefined> {
|
|
2018
|
+
if (!this.match) {
|
|
2019
|
+
return propertyAssignment;
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
if (other.kind !== JS.Kind.PropertyAssignment) {
|
|
2023
|
+
return this.abort(propertyAssignment);
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
const otherProp = other as JS.PropertyAssignment;
|
|
2027
|
+
|
|
2028
|
+
// Extract property names for semantic comparison
|
|
2029
|
+
const propName = this.getPropertyName(propertyAssignment);
|
|
2030
|
+
const otherPropName = this.getPropertyName(otherProp);
|
|
2031
|
+
|
|
2032
|
+
// Names must match
|
|
2033
|
+
if (!propName || !otherPropName || propName !== otherPropName) {
|
|
2034
|
+
// Can't do semantic comparison without identifiers, fall back to exact comparison
|
|
2035
|
+
return await super.visitPropertyAssignment(propertyAssignment, other);
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
// Detect shorthand (no initializer) vs longhand (has initializer)
|
|
2039
|
+
const isShorthand1 = !propertyAssignment.initializer;
|
|
2040
|
+
const isShorthand2 = !otherProp.initializer;
|
|
2041
|
+
|
|
2042
|
+
if (isShorthand1 === isShorthand2) {
|
|
2043
|
+
// Both shorthand or both longhand - use base comparison
|
|
2044
|
+
return await super.visitPropertyAssignment(propertyAssignment, other);
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
// One is shorthand, one is longhand - check semantic equivalence
|
|
2048
|
+
const longhandProp = isShorthand1 ? otherProp : propertyAssignment;
|
|
2049
|
+
|
|
2050
|
+
// Check if the longhand's initializer is an identifier with the same name as the property
|
|
2051
|
+
if (this.isIdentifierWithName(longhandProp.initializer, propName)) {
|
|
2052
|
+
// Semantically equivalent!
|
|
2053
|
+
return propertyAssignment;
|
|
2054
|
+
} else {
|
|
2055
|
+
// Not equivalent (e.g., { x: y })
|
|
2056
|
+
return this.abort(propertyAssignment);
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
/**
|
|
2061
|
+
* Extracts the property name from a PropertyAssignment.
|
|
2062
|
+
* Returns the simple name if the property is an identifier, undefined otherwise.
|
|
2063
|
+
*/
|
|
2064
|
+
private getPropertyName(prop: JS.PropertyAssignment): string | undefined {
|
|
2065
|
+
const nameExpr = prop.name.element;
|
|
2066
|
+
return isIdentifier(nameExpr) ? nameExpr.simpleName : undefined;
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
/**
|
|
2070
|
+
* Checks if an expression is an identifier with the given name.
|
|
2071
|
+
*/
|
|
2072
|
+
private isIdentifierWithName(expr: Expression | undefined, name: string): boolean | undefined {
|
|
2073
|
+
return expr && isIdentifier(expr) && expr.simpleName === name;
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
/**
|
|
2077
|
+
* Extracts the expression from an arrow function body.
|
|
2078
|
+
* Returns the expression if:
|
|
2079
|
+
* - body is already an Expression, OR
|
|
2080
|
+
* - body is a Block with exactly one Return statement
|
|
2081
|
+
* Otherwise returns undefined.
|
|
2082
|
+
*/
|
|
2083
|
+
private extractExpression(body: Statement | Expression): Expression | undefined {
|
|
2084
|
+
// If it's already an expression, return it
|
|
2085
|
+
if ((body as any).kind !== J.Kind.Block) {
|
|
2086
|
+
return body as Expression;
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
// It's a block - check if it contains exactly one return statement
|
|
2090
|
+
const block = body as J.Block;
|
|
2091
|
+
if (block.statements.length !== 1) {
|
|
2092
|
+
return undefined;
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
// Unwrap the RightPadded wrapper from the statement
|
|
2096
|
+
const stmtWrapper = block.statements[0];
|
|
2097
|
+
const stmt = stmtWrapper.element;
|
|
2098
|
+
|
|
2099
|
+
if ((stmt as any).kind !== J.Kind.Return) {
|
|
2100
|
+
return undefined;
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
const returnStmt = stmt as J.Return;
|
|
2104
|
+
return returnStmt.expression;
|
|
2105
|
+
}
|
|
2106
|
+
|
|
1841
2107
|
/**
|
|
1842
2108
|
* Override visitProperty to allow lenient type matching.
|
|
1843
2109
|
* When lenientTypeMatching is enabled, null vs Type comparisons are allowed
|
|
@@ -2084,9 +2350,17 @@ export class JavaScriptSemanticComparatorVisitor extends JavaScriptComparatorVis
|
|
|
2084
2350
|
}
|
|
2085
2351
|
|
|
2086
2352
|
/**
|
|
2087
|
-
* Override identifier comparison to include
|
|
2353
|
+
* Override identifier comparison to include:
|
|
2354
|
+
* 1. Type checking for field access
|
|
2355
|
+
* 2. Semantic equivalence between `undefined` identifier and void expressions
|
|
2088
2356
|
*/
|
|
2089
2357
|
override async visitIdentifier(identifier: J.Identifier, other: J): Promise<J | undefined> {
|
|
2358
|
+
// Check if this identifier is "undefined" and the other is a void expression
|
|
2359
|
+
if (identifier.simpleName === 'undefined' && (other as any).kind === JS.Kind.Void) {
|
|
2360
|
+
// Both evaluate to undefined, so they match
|
|
2361
|
+
return identifier;
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2090
2364
|
if (other.kind !== J.Kind.Identifier) {
|
|
2091
2365
|
return this.abort(identifier);
|
|
2092
2366
|
}
|
|
@@ -2267,4 +2541,63 @@ export class JavaScriptSemanticComparatorVisitor extends JavaScriptComparatorVis
|
|
|
2267
2541
|
|
|
2268
2542
|
return methodDeclaration;
|
|
2269
2543
|
}
|
|
2544
|
+
|
|
2545
|
+
/**
|
|
2546
|
+
* Override visitVoid to allow semantic equivalence with undefined identifier.
|
|
2547
|
+
* This handles the reverse case where the pattern is a void expression
|
|
2548
|
+
* and the source is the undefined identifier.
|
|
2549
|
+
*
|
|
2550
|
+
* Examples:
|
|
2551
|
+
* - `void 0` matches `undefined`
|
|
2552
|
+
* - `void(0)` matches `undefined`
|
|
2553
|
+
* - `void 1` matches `undefined`
|
|
2554
|
+
*/
|
|
2555
|
+
override async visitVoid(voidExpr: JS.Void, other: J): Promise<J | undefined> {
|
|
2556
|
+
if (!this.match) {
|
|
2557
|
+
return voidExpr;
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
// Check if the other is an undefined identifier
|
|
2561
|
+
if ((other as any).kind === J.Kind.Identifier) {
|
|
2562
|
+
const identifier = other as J.Identifier;
|
|
2563
|
+
if (identifier.simpleName === 'undefined') {
|
|
2564
|
+
// Both evaluate to undefined, so they match
|
|
2565
|
+
return voidExpr;
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
// Otherwise delegate to parent
|
|
2570
|
+
return super.visitVoid(voidExpr, other as any);
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2573
|
+
/**
|
|
2574
|
+
* Override visitLiteral to allow semantic equivalence between
|
|
2575
|
+
* different numeric literal formats.
|
|
2576
|
+
*
|
|
2577
|
+
* Examples:
|
|
2578
|
+
* - `255` matches `0xFF`
|
|
2579
|
+
* - `255` matches `0o377`
|
|
2580
|
+
* - `255` matches `0b11111111`
|
|
2581
|
+
* - `1000` matches `1e3`
|
|
2582
|
+
*/
|
|
2583
|
+
override async visitLiteral(literal: J.Literal, other: J): Promise<J | undefined> {
|
|
2584
|
+
if (!this.match) {
|
|
2585
|
+
return literal;
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
if ((other as any).kind !== J.Kind.Literal) {
|
|
2589
|
+
return await super.visitLiteral(literal, other);
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
const otherLiteral = other as J.Literal;
|
|
2593
|
+
|
|
2594
|
+
// Only compare value and type, ignoring valueSource (text representation) and unicodeEscapes
|
|
2595
|
+
await this.visitProperty(literal.value, otherLiteral.value);
|
|
2596
|
+
if (!this.match) return literal;
|
|
2597
|
+
|
|
2598
|
+
await this.visitProperty(literal.type, otherLiteral.type);
|
|
2599
|
+
if (!this.match) return literal;
|
|
2600
|
+
|
|
2601
|
+
return literal;
|
|
2602
|
+
}
|
|
2270
2603
|
}
|
|
@@ -293,7 +293,10 @@ export class TemplateEngine {
|
|
|
293
293
|
const typeString = typeof captureType === 'string'
|
|
294
294
|
? captureType
|
|
295
295
|
: this.typeToString(captureType);
|
|
296
|
-
preamble
|
|
296
|
+
// Only add preamble if we have a concrete type (not 'any')
|
|
297
|
+
if (typeString !== 'any') {
|
|
298
|
+
preamble.push(`let ${placeholder}: ${typeString};`);
|
|
299
|
+
}
|
|
297
300
|
}
|
|
298
301
|
} else if (isCaptureValue) {
|
|
299
302
|
// For CaptureValue, check if the root capture has a type
|
|
@@ -304,7 +307,10 @@ export class TemplateEngine {
|
|
|
304
307
|
const typeString = typeof captureType === 'string'
|
|
305
308
|
? captureType
|
|
306
309
|
: this.typeToString(captureType);
|
|
307
|
-
preamble
|
|
310
|
+
// Only add preamble if we have a concrete type (not 'any')
|
|
311
|
+
if (typeString !== 'any') {
|
|
312
|
+
preamble.push(`let ${placeholder}: ${typeString};`);
|
|
313
|
+
}
|
|
308
314
|
}
|
|
309
315
|
}
|
|
310
316
|
} else if (isTree(param) && !isTreeArray) {
|
|
@@ -312,7 +318,10 @@ export class TemplateEngine {
|
|
|
312
318
|
const jElement = param as J;
|
|
313
319
|
if ((jElement as any).type) {
|
|
314
320
|
const typeString = this.typeToString((jElement as any).type);
|
|
315
|
-
preamble
|
|
321
|
+
// Only add preamble if we have a concrete type (not 'any')
|
|
322
|
+
if (typeString !== 'any') {
|
|
323
|
+
preamble.push(`let ${placeholder}: ${typeString};`);
|
|
324
|
+
}
|
|
316
325
|
}
|
|
317
326
|
}
|
|
318
327
|
}
|
|
@@ -435,12 +444,13 @@ export class TemplateEngine {
|
|
|
435
444
|
const typeString = typeof captureType === 'string'
|
|
436
445
|
? captureType
|
|
437
446
|
: this.typeToString(captureType);
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
447
|
+
// Only add preamble if we have a concrete type (not 'any')
|
|
448
|
+
if (typeString !== 'any') {
|
|
449
|
+
const placeholder = PlaceholderUtils.createCapture(captureName, undefined);
|
|
450
|
+
preamble.push(`let ${placeholder}: ${typeString};`);
|
|
451
|
+
}
|
|
443
452
|
}
|
|
453
|
+
// Don't add preamble declarations without types - they don't provide type attribution
|
|
444
454
|
}
|
|
445
455
|
|
|
446
456
|
// Build the template string with placeholders for captures and raw code
|