Skip to content

Commit 96e57e1

Browse files
committed
Port autofix for template-sort-invocations
ember-template-lint's sort-invocations rebuilds the AST via ember-template-recast to produce a fully-sorted result. The autofix was lost when the rule was extracted. Restores the autofix using a bubble-sort single-swap pattern: each fixer call swaps just one out-of-order adjacent pair, and ESLint's fix loop reruns until convergence (up to 10 passes). This is simpler and produces better output than upstream's full-rebuild approach: - Whitespace and indentation are preserved (upstream collapses multi-line attributes to one line via the recast rebuild) - Glint comments stay in place (upstream displaces them to the end of the attribute list) createSwapFix strips trailing whitespace from each side before swapping, which is necessary because Glimmer attribute and modifier nodes can have trailing whitespace absorbed into their range — a naive `textB + interNodeText + textA` reconstruction fails 7 of the 50 tests. The trailing whitespace is re-appended after the swap.
1 parent c97528a commit 96e57e1

4 files changed

Lines changed: 1014 additions & 38 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,7 @@ rules in templates can be disabled with eslint directives with mustache or html
284284
| [template-self-closing-void-elements](docs/rules/template-self-closing-void-elements.md) | require self-closing on void elements | | 🔧 | |
285285
| [template-simple-modifiers](docs/rules/template-simple-modifiers.md) | require simple modifier syntax | | | |
286286
| [template-simple-unless](docs/rules/template-simple-unless.md) | require simple conditions in unless blocks | | | |
287-
| [template-sort-invocations](docs/rules/template-sort-invocations.md) | require sorted attributes and modifiers | | | |
287+
| [template-sort-invocations](docs/rules/template-sort-invocations.md) | require sorted attributes and modifiers | | 🔧 | |
288288
| [template-splat-attributes-only](docs/rules/template-splat-attributes-only.md) | disallow ...spread other than ...attributes | | | |
289289
| [template-style-concatenation](docs/rules/template-style-concatenation.md) | disallow string concatenation in inline styles | | | |
290290

docs/rules/template-sort-invocations.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# ember/template-sort-invocations
22

3+
🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
4+
35
<!-- end auto-generated rule header -->
46

57
## Why use it?

lib/rules/template-sort-invocations.js

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ module.exports = {
99
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-sort-invocations.md',
1010
templateMode: 'both',
1111
},
12-
fixable: null,
12+
fixable: 'code',
1313
schema: [],
1414
messages: {
1515
attributeOrder: '`{{attributeName}}` must appear after `{{expectedAfter}}`',
@@ -26,6 +26,24 @@ module.exports = {
2626
},
2727

2828
create(context) {
29+
const sourceCode = context.sourceCode;
30+
31+
// Glimmer attribute and modifier nodes can have trailing whitespace
32+
// absorbed into their range, so we strip the trailing whitespace from
33+
// each side and re-append it at the end of the swapped output.
34+
function createSwapFix(fixer, nodeA, nodeB) {
35+
const rawA = sourceCode.getText(nodeA);
36+
const rawB = sourceCode.getText(nodeB);
37+
const contentA = rawA.trimEnd();
38+
const contentB = rawB.trimEnd();
39+
const separator = sourceCode.text.slice(nodeA.range[0] + contentA.length, nodeB.range[0]);
40+
const trailing = rawB.slice(contentB.length);
41+
return fixer.replaceTextRange(
42+
[nodeA.range[0], nodeB.range[1]],
43+
contentB + separator + contentA + trailing
44+
);
45+
}
46+
2947
function getAttributeName(node) {
3048
return node.name;
3149
}
@@ -171,6 +189,9 @@ module.exports = {
171189
attributeName: getAttributeName(attributes[index]),
172190
expectedAfter: getAttributeName(attributes[index + 1]),
173191
},
192+
fix(fixer) {
193+
return createSwapFix(fixer, attributes[index], attributes[index + 1]);
194+
},
174195
});
175196
}
176197
}
@@ -186,13 +207,28 @@ module.exports = {
186207
modifierName: getModifierName(modifiers[index]),
187208
expectedAfter: getModifierName(modifiers[index + 1]),
188209
},
210+
fix(fixer) {
211+
return createSwapFix(fixer, modifiers[index], modifiers[index + 1]);
212+
},
189213
});
190214
}
191215
}
192216

193217
if (!canSkipSplattributesLast(node)) {
194218
const splattributes = attributes.at(-1);
195219

220+
// Swap ...attributes past the first modifier that appears after it;
221+
// ESLint's fix loop continues until splattributes is fully sorted.
222+
// canSkipSplattributesLast guarantees at least one such modifier exists.
223+
const firstModifierAfter = modifiers.find(
224+
(mod) =>
225+
mod.loc.start.line > splattributes.loc.start.line ||
226+
(mod.loc.start.line === splattributes.loc.start.line &&
227+
mod.loc.start.column > splattributes.loc.start.column)
228+
);
229+
230+
const splatFixFn = (fixer) => createSwapFix(fixer, splattributes, firstModifierAfter);
231+
196232
// When ...attributes is the only attribute, report as attributeOrder
197233
// (the ordering issue is that ...attributes should appear after modifiers)
198234
if (attributes.length === 1) {
@@ -203,11 +239,13 @@ module.exports = {
203239
attributeName: '...attributes',
204240
expectedAfter: 'modifiers',
205241
},
242+
fix: splatFixFn,
206243
});
207244
} else {
208245
context.report({
209246
node: splattributes,
210247
messageId: 'splattributesOrder',
248+
fix: splatFixFn,
211249
});
212250
}
213251
}
@@ -225,6 +263,9 @@ module.exports = {
225263
hashPairName: getHashPairName(node.hash.pairs[index]),
226264
expectedAfter: getHashPairName(node.hash.pairs[index + 1]),
227265
},
266+
fix(fixer) {
267+
return createSwapFix(fixer, node.hash.pairs[index], node.hash.pairs[index + 1]);
268+
},
228269
});
229270
}
230271
}
@@ -244,6 +285,10 @@ module.exports = {
244285
node.params.length > 0 &&
245286
node.params[0].type === 'GlimmerStringLiteral';
246287

288+
const fixFn = function (fixer) {
289+
return createSwapFix(fixer, node.hash.pairs[index], node.hash.pairs[index + 1]);
290+
};
291+
247292
if (isComponentInvocation) {
248293
context.report({
249294
node: node.hash.pairs[index],
@@ -252,6 +297,7 @@ module.exports = {
252297
attributeName: getHashPairName(node.hash.pairs[index]),
253298
expectedAfter: getHashPairName(node.hash.pairs[index + 1]),
254299
},
300+
fix: fixFn,
255301
});
256302
} else {
257303
context.report({
@@ -261,6 +307,7 @@ module.exports = {
261307
hashPairName: getHashPairName(node.hash.pairs[index]),
262308
expectedAfter: getHashPairName(node.hash.pairs[index + 1]),
263309
},
310+
fix: fixFn,
264311
});
265312
}
266313
}
@@ -279,6 +326,9 @@ module.exports = {
279326
hashPairName: getHashPairName(node.hash.pairs[index]),
280327
expectedAfter: getHashPairName(node.hash.pairs[index + 1]),
281328
},
329+
fix(fixer) {
330+
return createSwapFix(fixer, node.hash.pairs[index], node.hash.pairs[index + 1]);
331+
},
282332
});
283333
}
284334
}

0 commit comments

Comments
 (0)