Skip to content

Commit a950a9f

Browse files
authored
Merge branch 'master' into post-merge-review/no-this-in-template-only-components
2 parents 7993e48 + c4ac906 commit a950a9f

6 files changed

Lines changed: 1154 additions & 126 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-no-unused-block-params.js

Lines changed: 117 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ function collectChildNodes(n) {
2424
if (n.children) {
2525
children.push(...n.children);
2626
}
27+
if (n.modifiers) {
28+
children.push(...n.modifiers);
29+
}
2730
// GlimmerPathExpression also has 'parts', so make sure we're not treating
2831
// concat'd path string parts as AST nodes.
2932
if (n.type === 'GlimmerConcatStatement' && n.parts) {
@@ -56,6 +59,113 @@ function buildShadowedSet(shadowedParams, innerBlockParams, outerBlockParams) {
5659
return newShadowed;
5760
}
5861

62+
function checkBlockParts(n, blockParams, usedParams, shadowedParams, newShadowed, checkNodeFn) {
63+
// Check the path/params of the block statement itself with current scope
64+
if (n.path) {
65+
checkNodeFn(n.path, shadowedParams);
66+
}
67+
if (n.params) {
68+
for (const param of n.params) {
69+
checkNodeFn(param, shadowedParams);
70+
}
71+
}
72+
if (n.hash?.pairs) {
73+
for (const pair of n.hash.pairs) {
74+
checkNodeFn(pair.value, shadowedParams);
75+
}
76+
}
77+
78+
// Check the program body with the updated shadowed set
79+
if (n.program) {
80+
checkNodeFn(n.program, newShadowed);
81+
}
82+
if (n.inverse) {
83+
checkNodeFn(n.inverse, newShadowed);
84+
}
85+
}
86+
87+
/**
88+
* Scan child nodes for usage of blockParams, then report the first unused
89+
* trailing param. Shared by both GlimmerBlockStatement and GlimmerElementNode.
90+
*/
91+
function checkUnusedBlockParams(context, node, blockParams, startNodes) {
92+
const usedParams = new Set();
93+
94+
function checkNode(n, shadowedParams) {
95+
if (!n) {
96+
return;
97+
}
98+
99+
if (n.type === 'GlimmerPathExpression') {
100+
markParamIfUsed(n.original, blockParams, usedParams, shadowedParams);
101+
}
102+
103+
if (n.type === 'GlimmerElementNode') {
104+
markParamIfUsed(n.tag, blockParams, usedParams, shadowedParams);
105+
}
106+
107+
if (isPartialStatement(n)) {
108+
for (const p of blockParams) {
109+
if (!shadowedParams.has(p)) {
110+
usedParams.add(p);
111+
}
112+
}
113+
}
114+
115+
// Nested block with its own blockParams — shadow them
116+
if (n.type === 'GlimmerBlockStatement' && n.program?.blockParams?.length > 0) {
117+
const newShadowed = buildShadowedSet(shadowedParams, n.program.blockParams, blockParams);
118+
checkBlockParts(n, blockParams, usedParams, shadowedParams, newShadowed, checkNode);
119+
return;
120+
}
121+
122+
// Nested element with block params (e.g. <Component as |x|>) — shadow them
123+
if (n.type === 'GlimmerElementNode' && n.blockParams?.length > 0) {
124+
const newShadowed = buildShadowedSet(shadowedParams, n.blockParams, blockParams);
125+
if (n.attributes) {
126+
for (const attr of n.attributes) {
127+
checkNode(attr.value, shadowedParams);
128+
}
129+
}
130+
if (n.children) {
131+
for (const child of n.children) {
132+
checkNode(child, newShadowed);
133+
}
134+
}
135+
return;
136+
}
137+
138+
for (const child of collectChildNodes(n)) {
139+
checkNode(child, shadowedParams);
140+
}
141+
}
142+
143+
for (const startNode of startNodes) {
144+
checkNode(startNode, new Set());
145+
}
146+
147+
// Find the last index of a used param
148+
let lastUsedIndex = -1;
149+
for (let i = blockParams.length - 1; i >= 0; i--) {
150+
if (usedParams.has(blockParams[i])) {
151+
lastUsedIndex = i;
152+
break;
153+
}
154+
}
155+
156+
// Only report trailing unused params (after the last used one)
157+
const unusedTrailing = blockParams.slice(lastUsedIndex + 1);
158+
const firstUnusedTrailing = unusedTrailing[0];
159+
160+
if (firstUnusedTrailing) {
161+
context.report({
162+
node,
163+
messageId: 'unusedBlockParam',
164+
data: { param: firstUnusedTrailing },
165+
});
166+
}
167+
}
168+
59169
/** @type {import('eslint').Rule.RuleModule} */
60170
module.exports = {
61171
meta: {
@@ -82,98 +192,17 @@ module.exports = {
82192
return {
83193
GlimmerBlockStatement(node) {
84194
const blockParams = node.program?.blockParams || [];
85-
if (blockParams.length === 0) {
86-
return;
195+
if (blockParams.length > 0) {
196+
checkUnusedBlockParams(context, node, blockParams, [node.program]);
87197
}
198+
},
88199

89-
const usedParams = new Set();
90-
91-
function checkNode(n, shadowedParams) {
92-
if (!n) {
93-
return;
94-
}
95-
96-
if (n.type === 'GlimmerPathExpression') {
97-
markParamIfUsed(n.original, blockParams, usedParams, shadowedParams);
98-
}
99-
100-
if (n.type === 'GlimmerElementNode') {
101-
markParamIfUsed(n.tag, blockParams, usedParams, shadowedParams);
102-
}
103-
104-
if (isPartialStatement(n)) {
105-
for (const p of blockParams) {
106-
if (!shadowedParams.has(p)) {
107-
usedParams.add(p);
108-
}
109-
}
110-
}
111-
112-
// When entering a nested block, add its blockParams to the shadowed set
113-
if (n.type === 'GlimmerBlockStatement' && n.program?.blockParams?.length > 0) {
114-
const newShadowed = buildShadowedSet(
115-
shadowedParams,
116-
n.program.blockParams,
117-
blockParams
118-
);
119-
checkBlockParts(n, blockParams, usedParams, shadowedParams, newShadowed, checkNode);
120-
return;
121-
}
122-
123-
// Recursively check children
124-
for (const child of collectChildNodes(n)) {
125-
checkNode(child, shadowedParams);
126-
}
127-
}
128-
129-
checkNode(node.program, new Set());
130-
131-
// Find the last index of a used param
132-
let lastUsedIndex = -1;
133-
for (let i = blockParams.length - 1; i >= 0; i--) {
134-
if (usedParams.has(blockParams[i])) {
135-
lastUsedIndex = i;
136-
break;
137-
}
138-
}
139-
140-
// Only report trailing unused params (after the last used one)
141-
const unusedTrailing = blockParams.slice(lastUsedIndex + 1);
142-
const firstUnusedTrailing = unusedTrailing[0];
143-
144-
if (firstUnusedTrailing) {
145-
context.report({
146-
node,
147-
messageId: 'unusedBlockParam',
148-
data: { param: firstUnusedTrailing },
149-
});
200+
GlimmerElementNode(node) {
201+
const blockParams = node.blockParams || [];
202+
if (blockParams.length > 0) {
203+
checkUnusedBlockParams(context, node, blockParams, node.children || []);
150204
}
151205
},
152206
};
153207
},
154208
};
155-
156-
function checkBlockParts(n, blockParams, usedParams, shadowedParams, newShadowed, checkNodeFn) {
157-
// Check the path/params of the block statement itself with current scope
158-
if (n.path) {
159-
checkNodeFn(n.path, shadowedParams);
160-
}
161-
if (n.params) {
162-
for (const param of n.params) {
163-
checkNodeFn(param, shadowedParams);
164-
}
165-
}
166-
if (n.hash?.pairs) {
167-
for (const pair of n.hash.pairs) {
168-
checkNodeFn(pair.value, shadowedParams);
169-
}
170-
}
171-
172-
// Check the program body with the updated shadowed set
173-
if (n.program) {
174-
checkNodeFn(n.program, newShadowed);
175-
}
176-
if (n.inverse) {
177-
checkNodeFn(n.inverse, newShadowed);
178-
}
179-
}

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)