@angular-eslint/eslint-plugin-template 19.4.1-alpha.2 → 19.4.1-alpha.4

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/README.md CHANGED
@@ -62,6 +62,7 @@ Please see https://github.com/angular-eslint/angular-eslint for full usage instr
62
62
  | [`no-interpolation-in-attributes`](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin-template/docs/rules/no-interpolation-in-attributes.md) | Ensures that property-binding is used instead of interpolation in attributes. | | | | |
63
63
  | [`no-negated-async`](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin-template/docs/rules/no-negated-async.md) | Ensures that async pipe results, as well as values used with the async pipe, are not negated | :white_check_mark: | | :bulb: | |
64
64
  | [`no-positive-tabindex`](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin-template/docs/rules/no-positive-tabindex.md) | Ensures that the `tabindex` attribute is not positive | | | :bulb: | |
65
+ | [`prefer-at-empty`](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin-template/docs/rules/prefer-at-empty.md) | Prefer using `@empty` with `@for` loops instead of a separate `@if` or `@else` block to reduce code and make it easier to read. | | :wrench: | | |
65
66
  | [`prefer-contextual-for-variables`](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin-template/docs/rules/prefer-contextual-for-variables.md) | Ensures that contextual variables are used in @for blocks where possible instead of aliasing them. | | :wrench: | | |
66
67
  | [`prefer-control-flow`](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin-template/docs/rules/prefer-control-flow.md) | Ensures that the built-in control flow is used. | | | | |
67
68
  | [`prefer-ngsrc`](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin-template/docs/rules/prefer-ngsrc.md) | Ensures ngSrc is used instead of src for img elements | | | | |
@@ -25,6 +25,7 @@
25
25
  "@angular-eslint/template/no-negated-async": "error",
26
26
  "@angular-eslint/template/no-nested-tags": "error",
27
27
  "@angular-eslint/template/no-positive-tabindex": "error",
28
+ "@angular-eslint/template/prefer-at-empty": "error",
28
29
  "@angular-eslint/template/prefer-contextual-for-variables": "error",
29
30
  "@angular-eslint/template/prefer-control-flow": "error",
30
31
  "@angular-eslint/template/prefer-ngsrc": "error",
package/dist/index.d.ts CHANGED
@@ -27,6 +27,7 @@ declare const _default: {
27
27
  "@angular-eslint/template/no-negated-async": string;
28
28
  "@angular-eslint/template/no-nested-tags": string;
29
29
  "@angular-eslint/template/no-positive-tabindex": string;
30
+ "@angular-eslint/template/prefer-at-empty": string;
30
31
  "@angular-eslint/template/prefer-contextual-for-variables": string;
31
32
  "@angular-eslint/template/prefer-control-flow": string;
32
33
  "@angular-eslint/template/prefer-ngsrc": string;
@@ -109,6 +110,7 @@ declare const _default: {
109
110
  "no-interpolation-in-attributes": import("@typescript-eslint/utils/ts-eslint").RuleModule<"noInterpolationInAttributes", import("./rules/no-interpolation-in-attributes").Options, import("./utils/create-eslint-rule").RuleDocs, import("@typescript-eslint/utils/ts-eslint").RuleListener>;
110
111
  "no-negated-async": import("@typescript-eslint/utils/ts-eslint").RuleModule<import("./rules/no-negated-async").MessageIds, [], import("./utils/create-eslint-rule").RuleDocs, import("@typescript-eslint/utils/ts-eslint").RuleListener>;
111
112
  "no-positive-tabindex": import("@typescript-eslint/utils/ts-eslint").RuleModule<import("./rules/no-positive-tabindex").MessageIds, [], import("./utils/create-eslint-rule").RuleDocs, import("@typescript-eslint/utils/ts-eslint").RuleListener>;
113
+ "prefer-at-empty": import("@typescript-eslint/utils/ts-eslint").RuleModule<"preferAtEmpty", [], import("./utils/create-eslint-rule").RuleDocs, import("@typescript-eslint/utils/ts-eslint").RuleListener>;
112
114
  "prefer-contextual-for-variables": import("@typescript-eslint/utils/ts-eslint").RuleModule<import("./rules/prefer-contextual-for-variables").MessageIds, import("./rules/prefer-contextual-for-variables").Options, import("./utils/create-eslint-rule").RuleDocs, import("@typescript-eslint/utils/ts-eslint").RuleListener>;
113
115
  "prefer-control-flow": import("@typescript-eslint/utils/ts-eslint").RuleModule<"preferControlFlow", [], import("./utils/create-eslint-rule").RuleDocs, import("@typescript-eslint/utils/ts-eslint").RuleListener>;
114
116
  "prefer-self-closing-tags": import("@typescript-eslint/utils/ts-eslint").RuleModule<"preferSelfClosingTags", [], import("./utils/create-eslint-rule").RuleDocs, import("@typescript-eslint/utils/ts-eslint").RuleListener>;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+FA,kBA2CE"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkGA,kBA4CE"}
package/dist/index.js CHANGED
@@ -64,6 +64,7 @@ const no_negated_async_1 = __importStar(require("./rules/no-negated-async"));
64
64
  const no_nested_tags_1 = __importStar(require("./rules/no-nested-tags"));
65
65
  const no_positive_tabindex_1 = __importStar(require("./rules/no-positive-tabindex"));
66
66
  const prefer_ngsrc_1 = __importStar(require("./rules/prefer-ngsrc"));
67
+ const prefer_at_empty_1 = __importStar(require("./rules/prefer-at-empty"));
67
68
  const prefer_contextual_for_variables_1 = __importStar(require("./rules/prefer-contextual-for-variables"));
68
69
  const prefer_control_flow_1 = __importStar(require("./rules/prefer-control-flow"));
69
70
  const prefer_self_closing_tags_1 = __importStar(require("./rules/prefer-self-closing-tags"));
@@ -105,6 +106,7 @@ module.exports = {
105
106
  [no_interpolation_in_attributes_1.RULE_NAME]: no_interpolation_in_attributes_1.default,
106
107
  [no_negated_async_1.RULE_NAME]: no_negated_async_1.default,
107
108
  [no_positive_tabindex_1.RULE_NAME]: no_positive_tabindex_1.default,
109
+ [prefer_at_empty_1.RULE_NAME]: prefer_at_empty_1.default,
108
110
  [prefer_contextual_for_variables_1.RULE_NAME]: prefer_contextual_for_variables_1.default,
109
111
  [prefer_control_flow_1.RULE_NAME]: prefer_control_flow_1.default,
110
112
  [prefer_self_closing_tags_1.RULE_NAME]: prefer_self_closing_tags_1.default,
@@ -0,0 +1,6 @@
1
+ export type Options = [];
2
+ export type MessageIds = 'preferAtEmpty';
3
+ export declare const RULE_NAME = "prefer-at-empty";
4
+ declare const _default: import("@typescript-eslint/utils/ts-eslint").RuleModule<"preferAtEmpty", [], import("../utils/create-eslint-rule").RuleDocs, import("@typescript-eslint/utils/ts-eslint").RuleListener>;
5
+ export default _default;
6
+ //# sourceMappingURL=prefer-at-empty.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"prefer-at-empty.d.ts","sourceRoot":"","sources":["../../src/rules/prefer-at-empty.ts"],"names":[],"mappings":"AAkBA,MAAM,MAAM,OAAO,GAAG,EAAE,CAAC;AACzB,MAAM,MAAM,UAAU,GAAG,eAAe,CAAC;AACzC,eAAO,MAAM,SAAS,oBAAoB,CAAC;;AAE3C,wBAgaG"}
@@ -0,0 +1,492 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RULE_NAME = void 0;
4
+ const bundled_angular_compiler_1 = require("@angular-eslint/bundled-angular-compiler");
5
+ const utils_1 = require("@angular-eslint/utils");
6
+ const create_eslint_rule_1 = require("../utils/create-eslint-rule");
7
+ const are_equivalent_asts_1 = require("../utils/are-equivalent-asts");
8
+ exports.RULE_NAME = 'prefer-at-empty';
9
+ exports.default = (0, create_eslint_rule_1.createESLintRule)({
10
+ name: exports.RULE_NAME,
11
+ meta: {
12
+ type: 'suggestion',
13
+ fixable: 'code',
14
+ docs: {
15
+ description: 'Prefer using `@empty` with `@for` loops instead of a separate `@if` or `@else` block to reduce code and make it easier to read.',
16
+ },
17
+ schema: [],
18
+ messages: {
19
+ preferAtEmpty: 'Prefer using `@for (...) {...} @empty {...}`.',
20
+ },
21
+ },
22
+ defaultOptions: [],
23
+ create(context) {
24
+ const parserServices = (0, utils_1.getTemplateParserServices)(context);
25
+ const previousNodeStack = [undefined];
26
+ function getOnlyForBlock(node) {
27
+ let forBlock;
28
+ // Find the only `@for` block in the children,
29
+ // ignoring any text nodes that are only whitespace.
30
+ for (const child of node.children) {
31
+ if (child instanceof bundled_angular_compiler_1.TmplAstForLoopBlock) {
32
+ if (forBlock) {
33
+ return undefined;
34
+ }
35
+ forBlock = child;
36
+ }
37
+ else if (child instanceof bundled_angular_compiler_1.TmplAstText) {
38
+ // The `value` property contains the HTML-decoded
39
+ // value, so we need to look at the raw source code
40
+ // to see if the content is only whitespace.
41
+ if (context.sourceCode.text
42
+ .slice(child.sourceSpan.start.offset, child.sourceSpan.end.offset)
43
+ .trim() !== '') {
44
+ return undefined;
45
+ }
46
+ }
47
+ else {
48
+ return undefined;
49
+ }
50
+ }
51
+ return forBlock;
52
+ }
53
+ function checkFor(forInfo, previous) {
54
+ // If the `@for` block is immediately preceded by an "if empty"
55
+ // block for the same collection, then that `@if` block can
56
+ // be moved into the `@empty` block.
57
+ if (previous?.kind === 'if-empty') {
58
+ if ((0, are_equivalent_asts_1.areEquivalentASTs)(forInfo.collection, previous.collection)) {
59
+ const branch = previous.node.branches[0];
60
+ const branchEnd = branch.endSourceSpan;
61
+ context.report({
62
+ loc: parserServices.convertNodeSourceSpanToLoc(previous.node.nameSpan),
63
+ messageId: 'preferAtEmpty',
64
+ fix: branchEnd
65
+ ? function* (fixer) {
66
+ // Remove the entire `@if` block.
67
+ yield fixer.removeRange(toRange(previous.node.sourceSpan));
68
+ if (forInfo.node.empty) {
69
+ // There is already an `@empty` block. The contents of the
70
+ // `@if` block and the contents of the `@empty` block would
71
+ // both be shown in the collection is empty, so we need to
72
+ // combine the two blocks. The `@if` block would be rendered
73
+ // first, so it needs to be inserted before the existing
74
+ // contents of the `@empty` block.
75
+ yield fixer.insertTextAfterRange([
76
+ forInfo.node.empty.nameSpan.end.offset,
77
+ forInfo.node.empty.startSourceSpan.end.offset,
78
+ ], context.sourceCode.text.slice(branch.startSourceSpan.end.offset,
79
+ // The end offset is after the closing `}`, so we
80
+ // need to subtract one to ensure it's not included.
81
+ branchEnd.end.offset - 1));
82
+ }
83
+ else {
84
+ // Take the contents of the `@if` block and move
85
+ // it into an `@empty` block after the `@for` block.
86
+ yield fixer.insertTextAfterRange(toRange(forInfo.node.sourceSpan), ` @empty {${context.sourceCode.text.slice(branch.startSourceSpan.end.offset, branchEnd.end.offset)}`);
87
+ }
88
+ }
89
+ : undefined,
90
+ });
91
+ }
92
+ }
93
+ }
94
+ function checkIfEmpty(ifInfo, previous) {
95
+ if (!previous) {
96
+ return;
97
+ }
98
+ // If the `@if` block is immediately preceded by a `@for`
99
+ // block for the same collection, then that `@if` block
100
+ // can be moved into the `@empty` block.
101
+ switch (previous.kind) {
102
+ case 'for':
103
+ if ((0, are_equivalent_asts_1.areEquivalentASTs)(ifInfo.collection, previous.collection)) {
104
+ // The `@if` block can be moved into the `@for` block,
105
+ // so report the problem on the `@if` block.
106
+ context.report({
107
+ loc: parserServices.convertNodeSourceSpanToLoc(ifInfo.node.nameSpan),
108
+ messageId: 'preferAtEmpty',
109
+ fix: function* (fixer) {
110
+ if (previous.node.empty?.endSourceSpan) {
111
+ // There is already an `@empty` block. The contents of
112
+ // the `@empty` block and the `@if` block would both be
113
+ // rendered. The `@empty` block would appear first, so
114
+ // we need to move the contents of the `@if` block after
115
+ // the existing contents of the `@empty` block. This can
116
+ // easily be achieved by removing the closing brace of the
117
+ // `@empty` block and removing the `@if` statement.
118
+ yield fixer.removeRange(toRange(previous.node.empty.endSourceSpan));
119
+ yield fixer.removeRange(toRange(ifInfo.node.startSourceSpan));
120
+ }
121
+ else {
122
+ // There is not already an `@empty` block, so
123
+ // we can create one by replacing the entire
124
+ // `@if (...) {` segment with `@empty {`.
125
+ yield fixer.replaceTextRange(toRange(ifInfo.node.startSourceSpan), '@empty {');
126
+ }
127
+ },
128
+ });
129
+ }
130
+ break;
131
+ case 'if-not-empty':
132
+ if ((0, are_equivalent_asts_1.areEquivalentASTs)(ifInfo.collection, previous.collection)) {
133
+ const forBlock = getOnlyForBlock(previous.node.branches[0]);
134
+ if (forBlock &&
135
+ (0, are_equivalent_asts_1.areEquivalentASTs)(ifInfo.collection, forBlock.expression.ast)) {
136
+ const previousIfBlockEnd = previous.node.endSourceSpan;
137
+ // The previous `@if` block can be removed and the current `@if`
138
+ // block moved into the `@for` block's `@empty` block, so report
139
+ // the problem on the previous `@if` block.
140
+ context.report({
141
+ loc: parserServices.convertNodeSourceSpanToLoc(previous.node.nameSpan),
142
+ messageId: 'preferAtEmpty',
143
+ fix: previousIfBlockEnd
144
+ ? (fixer) => [
145
+ // Remove the previous `@if` statement.
146
+ fixer.removeRange(toRange(previous.node.startSourceSpan)),
147
+ // Remove the closing brace from the previous `@if` block.
148
+ fixer.removeRange(toRange(previousIfBlockEnd)),
149
+ // Take the contents of the current `@if` block and move
150
+ // it into the `@empty` block of the previous `@for` block.
151
+ fixer.insertTextAfterRange(toRange(forBlock.sourceSpan), ` @empty {${context.sourceCode.text.slice(ifInfo.node.startSourceSpan.end.offset,
152
+ // The end offset includes the closing brace.
153
+ ifInfo.node.sourceSpan.end.offset)}`),
154
+ // Remove the entirety of the current `@if` block.
155
+ fixer.removeRange(toRange(ifInfo.node.sourceSpan)),
156
+ ]
157
+ : undefined,
158
+ });
159
+ }
160
+ }
161
+ }
162
+ }
163
+ function checkIfEmptyElse(info) {
164
+ // Look for an `@for` block in the `@else` branch.
165
+ const forBlock = getOnlyForBlock(info.node.branches[1]);
166
+ if (forBlock &&
167
+ (0, are_equivalent_asts_1.areEquivalentASTs)(info.collection, forBlock.expression.ast)) {
168
+ const ifBranchEnd = info.node.branches[0].endSourceSpan;
169
+ // The contents of the `@if` block can be moved into an
170
+ // `@empty` block, so report the problem on the `@if` block.
171
+ context.report({
172
+ loc: parserServices.convertNodeSourceSpanToLoc(info.node.nameSpan),
173
+ messageId: 'preferAtEmpty',
174
+ fix: ifBranchEnd
175
+ ? function* (fixer) {
176
+ // Remove the entire `@if` branch through to the
177
+ // start of the body of the `@else` block.
178
+ yield fixer.removeRange([
179
+ info.node.sourceSpan.start.offset,
180
+ info.node.branches[1].startSourceSpan.end.offset,
181
+ ]);
182
+ // Take the contents of the `@if` branch and move
183
+ // it into an `@empty` block after the `@for` block.
184
+ const empty = context.sourceCode.text.slice(info.node.startSourceSpan.end.offset, ifBranchEnd.start.offset);
185
+ if (forBlock.empty?.endSourceSpan) {
186
+ // There is already an `@empty` block, but because the `@for`
187
+ // block was inside an `@else` block, the `@empty` block
188
+ // would never have be rendered, so we can replace its contents.
189
+ yield fixer.replaceTextRange([
190
+ forBlock.empty.startSourceSpan.end.offset,
191
+ forBlock.empty.endSourceSpan.start.offset,
192
+ ], empty);
193
+ }
194
+ else {
195
+ // There isn't an existing `@empty` block, so we can create
196
+ // one. We don't need to include a closing brace, because
197
+ // we can reuse the one from the end of the @`if` block.
198
+ yield fixer.insertTextAfterRange(toRange(forBlock.sourceSpan), ` @empty {${empty}`);
199
+ }
200
+ }
201
+ : undefined,
202
+ });
203
+ }
204
+ }
205
+ function checkIfNotEmpty(ifNotInfo, previous) {
206
+ if (previous?.kind === 'if-empty') {
207
+ if ((0, are_equivalent_asts_1.areEquivalentASTs)(ifNotInfo.collection, previous.collection)) {
208
+ const forBlock = getOnlyForBlock(ifNotInfo.node.branches[0]);
209
+ if (forBlock &&
210
+ (0, are_equivalent_asts_1.areEquivalentASTs)(ifNotInfo.collection, forBlock.expression.ast)) {
211
+ // The `@if` block can be removed and the contents of
212
+ // the `@else` block moved into an `@empty` block,
213
+ // so report the problem on the `@if` block.
214
+ context.report({
215
+ loc: parserServices.convertNodeSourceSpanToLoc(ifNotInfo.node.nameSpan),
216
+ messageId: 'preferAtEmpty',
217
+ fix: (fixer) => [
218
+ // Remove the entire previous `@if` block.
219
+ fixer.removeRange(toRange(previous.node.sourceSpan)),
220
+ // Remove the current `@if` statement.
221
+ fixer.removeRange(toRange(ifNotInfo.node.startSourceSpan)),
222
+ // Take the contents of the previous `@if` block and move
223
+ // it into the `@empty` block after the `@for` block.
224
+ fixer.insertTextAfterRange(toRange(forBlock.sourceSpan), ` @empty {${context.sourceCode.text.slice(previous.node.startSourceSpan.end.offset,
225
+ // The end offset is after the closing `}`, so we
226
+ // need to subtract one to ensure it gets removed.
227
+ previous.node.sourceSpan.end.offset - 1)}`),
228
+ ],
229
+ });
230
+ }
231
+ }
232
+ }
233
+ }
234
+ function checkIfNotEmptyElse(info) {
235
+ const forBlock = getOnlyForBlock(info.node.branches[0]);
236
+ if (forBlock &&
237
+ (0, are_equivalent_asts_1.areEquivalentASTs)(info.collection, forBlock.expression.ast)) {
238
+ const ifBranchEnd = info.node.branches[0].endSourceSpan;
239
+ const ifEnd = info.node.endSourceSpan;
240
+ // The `@if` block can be removed and the contents of
241
+ // the `@else` block moved into an `@empty` block,
242
+ // so report the problem on the `@if` block.
243
+ context.report({
244
+ loc: parserServices.convertNodeSourceSpanToLoc(info.node.nameSpan),
245
+ messageId: 'preferAtEmpty',
246
+ fix: ifBranchEnd && ifEnd
247
+ ? function* (fixer) {
248
+ if (forBlock.empty) {
249
+ // Because the `@for` block was inside an `@if`
250
+ // block, the `@empty` block would never be rendered,
251
+ // so we can remove it. We could try to replace it,
252
+ // but it's easier to remove it and create a new one.
253
+ yield fixer.removeRange(toRange(forBlock.empty.sourceSpan));
254
+ }
255
+ // Remove the entire `@if (...) {` segment.
256
+ yield fixer.removeRange(toRange(info.node.startSourceSpan));
257
+ const elseBranch = info.node.branches[1];
258
+ if (elseBranch.expression) {
259
+ // The second branch is an `@else if` branch. We
260
+ // need to turn it into its own `@if` block. Replace
261
+ // the `@else if` text with the start of the `@empty`
262
+ // block and the start of the `@if` block, then put
263
+ // a closing brace after the original `@if` block
264
+ // to close the `@empty` block.
265
+ yield fixer.replaceTextRange([
266
+ ifBranchEnd.end.offset - 1,
267
+ elseBranch.nameSpan.end.offset,
268
+ ], '@empty { @if ');
269
+ yield fixer.insertTextAfterRange(toRange(ifEnd), '}');
270
+ }
271
+ else {
272
+ // The second branch is just an `@else` branch, so we
273
+ // can replace from end of the `@if` branch through to
274
+ // the end of the `@else` statement with `@empty {`.
275
+ // The children of the `@else` branch, and the closing
276
+ // `}`, will become part of the `@empty` block.
277
+ yield fixer.replaceTextRange([
278
+ // The end offset is after the closing `}`, so we
279
+ // need to subtract one to ensure it gets removed.
280
+ ifBranchEnd.end.offset - 1,
281
+ elseBranch.startSourceSpan.end.offset,
282
+ ], '@empty {');
283
+ }
284
+ }
285
+ : undefined,
286
+ });
287
+ }
288
+ }
289
+ return {
290
+ // We need to visit `@for` and `@if` blocks, but we
291
+ // also need to know if there are any nodes immediately
292
+ // before them, so we need to visit all nodes.
293
+ '*'(node) {
294
+ const current = getNodeInfo(node);
295
+ if (current !== undefined) {
296
+ switch (current.kind) {
297
+ case 'for':
298
+ checkFor(current, previousNodeStack.at(-1));
299
+ break;
300
+ case 'if-empty':
301
+ checkIfEmpty(current, previousNodeStack.at(-1));
302
+ break;
303
+ case 'if-empty-else':
304
+ checkIfEmptyElse(current);
305
+ break;
306
+ case 'if-not-empty':
307
+ checkIfNotEmpty(current, previousNodeStack.at(-1));
308
+ break;
309
+ case 'if-not-empty-else':
310
+ checkIfNotEmptyElse(current);
311
+ break;
312
+ }
313
+ }
314
+ // Record this current node as the previous node so that
315
+ // we can get the info when we look at the next sibling.
316
+ previousNodeStack[previousNodeStack.length - 1] = current;
317
+ // We are about to visit the children of this node,
318
+ // so push a new "previous node info" onto the stack.
319
+ // The previous node of the first child is undefined.
320
+ previousNodeStack.push(undefined);
321
+ },
322
+ '*:exit'() {
323
+ // We've finished visiting the children of this node,
324
+ // so pop the "previous node info" off the stack.
325
+ previousNodeStack.pop();
326
+ },
327
+ };
328
+ },
329
+ });
330
+ function getNodeInfo(node) {
331
+ if (node instanceof bundled_angular_compiler_1.TmplAstForLoopBlock) {
332
+ return {
333
+ node,
334
+ kind: 'for',
335
+ collection: node.expression.ast,
336
+ };
337
+ }
338
+ if (node instanceof bundled_angular_compiler_1.TmplAstIfBlock) {
339
+ if (node.branches.length === 0) {
340
+ return undefined;
341
+ }
342
+ if (!node.branches[0].expression) {
343
+ return undefined;
344
+ }
345
+ let collection = getNotEmptyTestCollection(node.branches[0].expression);
346
+ if (collection) {
347
+ // The block is either:
348
+ //
349
+ // @if (collection.length > 0) {
350
+ // }
351
+ //
352
+ // or:
353
+ //
354
+ // @if (collection.length > 0) {
355
+ // } @else {
356
+ // }
357
+ //
358
+ // or:
359
+ //
360
+ // @if (collection.length > 0) {
361
+ // } @else if (condition){
362
+ // }
363
+ //
364
+ // or:
365
+ //
366
+ // @if (collection.length > 0) {
367
+ // } @else if (condition) {
368
+ // } @else {
369
+ // }
370
+ //
371
+ // In any case, we treat this as one of the "if not empty"
372
+ // nodes, because if there is an `@for` block in the `@if`
373
+ // branch, then whatever is in the `@else if` or @else`
374
+ // branches, could be moved into the `@empty` block.
375
+ return {
376
+ node,
377
+ kind: node.branches.length === 1 ? 'if-not-empty' : 'if-not-empty-else',
378
+ collection,
379
+ };
380
+ }
381
+ collection = getEmptyTestCollection(node.branches[0].expression);
382
+ if (collection) {
383
+ // Unlike the "if not empty" cases, there are only two cases
384
+ // that could be considered an "if empty" case:
385
+ //
386
+ // @if (collection.length === 0) {
387
+ // }
388
+ //
389
+ // or:
390
+ //
391
+ // @if (collection.length > 0) {
392
+ // } @else {
393
+ // }
394
+ //
395
+ // If there is an `@else if`, then whatever is in the `@if`
396
+ // branch could not safely be moved into an `@empty` block
397
+ // because of the condition in the `@else if` branch.
398
+ if (node.branches.length === 1) {
399
+ return {
400
+ node,
401
+ kind: 'if-empty',
402
+ collection,
403
+ };
404
+ }
405
+ else if (node.branches.length === 2 && !node.branches[1].expression) {
406
+ return {
407
+ node,
408
+ kind: 'if-empty-else',
409
+ collection,
410
+ };
411
+ }
412
+ }
413
+ }
414
+ return undefined;
415
+ }
416
+ function getNotEmptyTestCollection(node) {
417
+ if (node instanceof bundled_angular_compiler_1.ASTWithSource) {
418
+ node = node.ast;
419
+ }
420
+ if (isLengthRead(node)) {
421
+ // @if (collection.length)
422
+ return node.receiver;
423
+ }
424
+ if (node instanceof bundled_angular_compiler_1.Binary) {
425
+ if (isLengthRead(node.left)) {
426
+ if (node.operation === '!==' ||
427
+ node.operation === '>' ||
428
+ node.operation === '!=') {
429
+ if (isZero(node.right)) {
430
+ // @if (collection.length !== 0)
431
+ // @if (collection.length > 0)
432
+ // @if (collection.length != 0)
433
+ return node.left.receiver;
434
+ }
435
+ }
436
+ }
437
+ else if (isZero(node.left)) {
438
+ if (node.operation === '!==' ||
439
+ node.operation === '<' ||
440
+ node.operation === '!=') {
441
+ if (isLengthRead(node.right)) {
442
+ // @if (0 !== collection.length)
443
+ // @if (0 < collection.length)
444
+ // @if (0 != collection.length)
445
+ return node.right.receiver;
446
+ }
447
+ }
448
+ }
449
+ }
450
+ return undefined;
451
+ }
452
+ function getEmptyTestCollection(node) {
453
+ if (node instanceof bundled_angular_compiler_1.ASTWithSource) {
454
+ node = node.ast;
455
+ }
456
+ if (node instanceof bundled_angular_compiler_1.PrefixNot) {
457
+ if (isLengthRead(node.expression)) {
458
+ // @if (!collection.length)
459
+ return node.expression.receiver;
460
+ }
461
+ }
462
+ else if (node instanceof bundled_angular_compiler_1.Binary) {
463
+ if (isLengthRead(node.left)) {
464
+ if (node.operation === '===' || node.operation === '==') {
465
+ if (isZero(node.right)) {
466
+ // @if (collection.length === 0)
467
+ // @if (collection.length == 0)
468
+ return node.left.receiver;
469
+ }
470
+ }
471
+ }
472
+ else if (isZero(node.left)) {
473
+ if (node.operation === '===' || node.operation === '==') {
474
+ if (isLengthRead(node.right)) {
475
+ // @if (0 === collection.length)
476
+ // @if (0 == collection.length)
477
+ return node.right.receiver;
478
+ }
479
+ }
480
+ }
481
+ }
482
+ return undefined;
483
+ }
484
+ function isLengthRead(node) {
485
+ return node instanceof bundled_angular_compiler_1.PropertyRead && node.name === 'length';
486
+ }
487
+ function isZero(node) {
488
+ return node instanceof bundled_angular_compiler_1.LiteralPrimitive && node.value === 0;
489
+ }
490
+ function toRange(span) {
491
+ return [span.start.offset, span.end.offset];
492
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"prefer-template-literal.d.ts","sourceRoot":"","sources":["../../src/rules/prefer-template-literal.ts"],"names":[],"mappings":"AAeA,QAAA,MAAM,SAAS,0BAA0B,CAAC;AAE1C,MAAM,MAAM,OAAO,GAAG,EAAE,CAAC;AACzB,MAAM,MAAM,UAAU,GAAG,OAAO,SAAS,CAAC;AAC1C,eAAO,MAAM,SAAS,4BAA4B,CAAC;;AAEnD,wBAwFG"}
1
+ {"version":3,"file":"prefer-template-literal.d.ts","sourceRoot":"","sources":["../../src/rules/prefer-template-literal.ts"],"names":[],"mappings":"AAeA,QAAA,MAAM,SAAS,0BAA0B,CAAC;AAE1C,MAAM,MAAM,OAAO,GAAG,EAAE,CAAC;AACzB,MAAM,MAAM,UAAU,GAAG,OAAO,SAAS,CAAC;AAC1C,eAAO,MAAM,SAAS,4BAA4B,CAAC;;AAEnD,wBAiJG"}
@@ -45,6 +45,11 @@ exports.default = (0, create_eslint_rule_1.createESLintRule)({
45
45
  }
46
46
  return '`';
47
47
  }
48
+ function hasParentheses(node) {
49
+ const { start, end } = node.sourceSpan;
50
+ const text = sourceCode.text.slice(start - 1, end + 1);
51
+ return text.startsWith('(') && text.endsWith(')');
52
+ }
48
53
  context.report({
49
54
  loc: {
50
55
  start: sourceCode.getLocFromIndex(start),
@@ -58,13 +63,50 @@ exports.default = (0, create_eslint_rule_1.createESLintRule)({
58
63
  const quote = getQuote();
59
64
  return fixer.replaceTextRange([start, end], `${quote}${(0, literal_primitive_1.getLiteralPrimitiveStringValue)(left, quote)}${(0, literal_primitive_1.getLiteralPrimitiveStringValue)(right, quote)}${quote}`);
60
65
  }
61
- const fixes = new Array();
66
+ const fixes = Array();
67
+ const leftHasParentheses = hasParentheses(left);
68
+ const rightHasParentheses = hasParentheses(right);
69
+ // Remove the left first parenthesis if it exists
70
+ if (leftHasParentheses) {
71
+ fixes.push(fixer.removeRange([
72
+ left.sourceSpan.start - 1,
73
+ left.sourceSpan.start,
74
+ ]));
75
+ }
62
76
  // Fix the left side
63
77
  fixes.push(...getLeftSideFixes(fixer, left));
78
+ // Remove the left last parenthesis if it exists
79
+ if (leftHasParentheses) {
80
+ fixes.push(fixer.removeRange([
81
+ left.sourceSpan.end,
82
+ left.sourceSpan.end + 1,
83
+ ]));
84
+ }
64
85
  // Remove the `+` sign
65
- fixes.push(fixer.removeRange([left.sourceSpan.end, right.sourceSpan.start]));
86
+ fixes.push(fixer.removeRange([
87
+ leftHasParentheses
88
+ ? left.sourceSpan.end + 1
89
+ : left.sourceSpan.end,
90
+ rightHasParentheses
91
+ ? right.sourceSpan.start - 1
92
+ : right.sourceSpan.start,
93
+ ]));
94
+ // Remove the right first parenthesis if it exists
95
+ if (rightHasParentheses) {
96
+ fixes.push(fixer.removeRange([
97
+ right.sourceSpan.start - 1,
98
+ right.sourceSpan.start,
99
+ ]));
100
+ }
66
101
  // Fix the right side
67
102
  fixes.push(...getRightSideFixes(fixer, right));
103
+ // Remove the right last parenthesis if it exists
104
+ if (rightHasParentheses) {
105
+ fixes.push(fixer.removeRange([
106
+ right.sourceSpan.end,
107
+ right.sourceSpan.end + 1,
108
+ ]));
109
+ }
68
110
  return fixes;
69
111
  },
70
112
  });
@@ -78,19 +120,17 @@ function getLeftSideFixes(fixer, left) {
78
120
  // Remove the end ` sign from the left side
79
121
  return [fixer.removeRange([end - 1, end])];
80
122
  }
81
- else if ((0, literal_primitive_1.isLiteralPrimitive)(left)) {
123
+ if ((0, literal_primitive_1.isLiteralPrimitive)(left)) {
82
124
  // Transform left side to template literal
83
125
  return [
84
126
  fixer.replaceTextRange([start, end], `\`${(0, literal_primitive_1.getLiteralPrimitiveStringValue)(left, '`')}`),
85
127
  ];
86
128
  }
87
- else {
88
- // Transform left side to template literal
89
- return [
90
- fixer.insertTextBeforeRange([start, end], '`${'),
91
- fixer.insertTextAfterRange([start, end], '}'),
92
- ];
93
- }
129
+ // Transform left side to template literal
130
+ return [
131
+ fixer.insertTextBeforeRange([start, end], '`${'),
132
+ fixer.insertTextAfterRange([start, end], '}'),
133
+ ];
94
134
  }
95
135
  function getRightSideFixes(fixer, right) {
96
136
  const { start, end } = right.sourceSpan;
@@ -98,17 +138,15 @@ function getRightSideFixes(fixer, right) {
98
138
  // Remove the start ` sign from the right side
99
139
  return [fixer.removeRange([start, start + 1])];
100
140
  }
101
- else if ((0, literal_primitive_1.isLiteralPrimitive)(right)) {
141
+ if ((0, literal_primitive_1.isLiteralPrimitive)(right)) {
102
142
  // Transform right side to template literal if it's a string
103
143
  return [
104
144
  fixer.replaceTextRange([start, end], `${(0, literal_primitive_1.getLiteralPrimitiveStringValue)(right, '`')}\``),
105
145
  ];
106
146
  }
107
- else {
108
- // Transform right side to template literal
109
- return [
110
- fixer.insertTextBeforeRange([start, end], '${'),
111
- fixer.insertTextAfterRange([start, end], '}`'),
112
- ];
113
- }
147
+ // Transform right side to template literal
148
+ return [
149
+ fixer.insertTextBeforeRange([start, end], '${'),
150
+ fixer.insertTextAfterRange([start, end], '}`'),
151
+ ];
114
152
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@angular-eslint/eslint-plugin-template",
3
- "version": "19.4.1-alpha.2",
3
+ "version": "19.4.1-alpha.4",
4
4
  "description": "ESLint plugin for Angular Templates",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -20,13 +20,13 @@
20
20
  "dependencies": {
21
21
  "aria-query": "5.3.2",
22
22
  "axobject-query": "4.1.0",
23
- "@angular-eslint/bundled-angular-compiler": "19.4.1-alpha.2",
24
- "@angular-eslint/utils": "19.4.1-alpha.2"
23
+ "@angular-eslint/bundled-angular-compiler": "19.4.1-alpha.4",
24
+ "@angular-eslint/utils": "19.4.1-alpha.4"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/aria-query": "5.0.4",
28
- "@angular-eslint/template-parser": "19.4.1-alpha.2",
29
- "@angular-eslint/test-utils": "19.4.1-alpha.2"
28
+ "@angular-eslint/template-parser": "19.4.1-alpha.4",
29
+ "@angular-eslint/test-utils": "19.4.1-alpha.4"
30
30
  },
31
31
  "peerDependencies": {
32
32
  "@typescript-eslint/types": "^7.11.0 || ^8.0.0",