Skip to content

Commit 89b919a

Browse files
committed
Add autofix for template-no-obscure-array-access
Implements automatic fixing for numeric array access patterns in templates: - MustacheStatement paths: {{this.list.[0].name}} -> {{get this.list "0.name"}} - Params: {{fn this.func @foo.0.bar}} -> {{fn this.func (get @foo "0.bar")}} - Hash values: {{foo bar=this.list.[0]}} -> {{foo bar=(get this.list "0")}} Handles both dot notation (foo.0.bar) and bracket notation (foo.[0]). Does not autofix @each or [] patterns as those are structural.
1 parent 131cd12 commit 89b919a

4 files changed

Lines changed: 90 additions & 16 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ rules in templates can be disabled with eslint directives with mustache or html
239239
| [template-no-negated-comparison](docs/rules/template-no-negated-comparison.md) | disallow negated comparisons in templates | | | |
240240
| [template-no-negated-condition](docs/rules/template-no-negated-condition.md) | disallow negated conditions in if/unless | | | |
241241
| [template-no-nested-splattributes](docs/rules/template-no-nested-splattributes.md) | disallow nested ...attributes usage | | | |
242-
| [template-no-obscure-array-access](docs/rules/template-no-obscure-array-access.md) | disallow obscure array access patterns like `[email protected]` | | | |
242+
| [template-no-obscure-array-access](docs/rules/template-no-obscure-array-access.md) | disallow obscure array access patterns like `[email protected]` | | 🔧 | |
243243
| [template-no-obsolete-elements](docs/rules/template-no-obsolete-elements.md) | disallow obsolete HTML elements | | | |
244244
| [template-no-outlet-outside-routes](docs/rules/template-no-outlet-outside-routes.md) | disallow {{outlet}} outside of route templates | | | |
245245
| [template-no-page-title-component](docs/rules/template-no-page-title-component.md) | disallow usage of ember-page-title component | | | |

docs/rules/template-no-obscure-array-access.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# ember/template-no-obscure-array-access
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
Disallow obscure array access patterns in templates.

lib/rules/template-no-obscure-array-access.js

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,30 @@
1+
const DIGIT_REGEXP = /^\[?\d+]?$/;
2+
3+
/**
4+
* Extract the target path and string key from a path with numeric segments.
5+
* E.g. "this.list.0.name" → ["this.list", "0.name"]
6+
* E.g. "this.list.[0]" → ["this.list", "0"]
7+
* E.g. "@foo.0.bar" → ["@foo", "0.bar"]
8+
*
9+
* @param {string} original The original path string
10+
* @returns {[string, string]} A tuple of [targetPath, stringKey]
11+
*/
12+
function getHelperParams(original) {
13+
const parts = original.split('.');
14+
const firstDigitIndex = parts.findIndex((part) => DIGIT_REGEXP.test(part));
15+
16+
if (firstDigitIndex === -1) {
17+
return null;
18+
}
19+
20+
const targetPath = parts.slice(0, firstDigitIndex).join('.');
21+
// Strip brackets from digit segments: [0] → 0
22+
const keyParts = parts.slice(firstDigitIndex).map((part) => part.replace(/^\[(\d+)]$/, '$1'));
23+
const stringKey = keyParts.join('.');
24+
25+
return [targetPath, stringKey];
26+
}
27+
128
/** @type {import('eslint').Rule.RuleModule} */
229
module.exports = {
330
meta: {
@@ -8,6 +35,7 @@ module.exports = {
835
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-obscure-array-access.md',
936
templateMode: 'both',
1037
},
38+
fixable: 'code',
1139
schema: [],
1240
messages: {
1341
noObscureArrayAccess:
@@ -26,7 +54,7 @@ module.exports = {
2654
GlimmerPathExpression(node) {
2755
const path = node.original;
2856
const sourcePath = context.sourceCode.getText(node);
29-
// Check for @each or [] in paths
57+
// Check for @each or [] in paths — these are structural and not autofixable
3058
if (path && (path.includes('.@each.') || path.includes('.[].'))) {
3159
context.report({
3260
node,
@@ -37,13 +65,57 @@ module.exports = {
3765
}
3866
// Check for numeric path segments (e.g., foo.0.bar) or bracket notation (e.g., foo.[0])
3967
if (node.tail && node.tail.some((segment) => /^\d+$/.test(segment))) {
68+
const params = getHelperParams(path);
4069
context.report({
4170
node,
4271
messageId: 'noObscureArrayAccess',
4372
data: { path: sourcePath },
73+
fix: params ? buildFix(context, node, params) : undefined,
4474
});
4575
}
4676
},
4777
};
4878
},
4979
};
80+
81+
/**
82+
* Build a fix function for a numeric path expression.
83+
*
84+
* @param {import('eslint').Rule.RuleContext} context
85+
* @param {import('estree').Node} node The GlimmerPathExpression node
86+
* @param {[string, string]} params The [targetPath, stringKey] tuple
87+
* @returns {(fixer: import('eslint').Rule.RuleFixer) => import('eslint').Rule.Fix}
88+
*/
89+
function buildFix(context, node, params) {
90+
const [target, key] = params;
91+
const parent = node.parent;
92+
93+
// Case 1: PathExpression is the path of a MustacheStatement (e.g., {{this.list.0.name}})
94+
// Replace the entire mustache inner content with: get target "key"
95+
if (
96+
parent &&
97+
parent.type === 'GlimmerMustacheStatement' &&
98+
parent.path === node &&
99+
parent.params.length === 0 &&
100+
(!parent.hash || parent.hash.pairs.length === 0)
101+
) {
102+
return (fixer) => {
103+
// The mustache is {{path}} — replace just the path portion
104+
// The source text of the MustacheStatement includes {{ and }}
105+
const mustacheSource = context.sourceCode.getText(parent);
106+
const isTriple = mustacheSource.startsWith('{{{');
107+
const openLen = isTriple ? 3 : 2;
108+
const closeLen = isTriple ? 3 : 2;
109+
const innerStart = parent.range[0] + openLen;
110+
const innerEnd = parent.range[1] - closeLen;
111+
112+
return fixer.replaceTextRange([innerStart, innerEnd], `get ${target} "${key}"`);
113+
};
114+
}
115+
116+
// Case 2: PathExpression is a param or hash value or any other context
117+
// Wrap with (get target "key")
118+
return (fixer) => {
119+
return fixer.replaceText(node, `(get ${target} "${key}")`);
120+
};
121+
}

tests/lib/rules/template-no-obscure-array-access.js

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -40,37 +40,37 @@ ruleTester.run('template-no-obscure-array-access', rule, {
4040

4141
{
4242
code: '<template><Foo @onClick={{fn this.func @foo.0.bar}} /></template>',
43-
output: null,
43+
output: '<template><Foo @onClick={{fn this.func (get @foo "0.bar")}} /></template>',
4444
errors: [{ messageId: 'noObscureArrayAccess' }],
4545
},
4646
{
4747
code: '<template>{{foo bar=this.list.[0]}}</template>',
48-
output: null,
48+
output: '<template>{{foo bar=(get this.list "0")}}</template>',
4949
errors: [{ messageId: 'noObscureArrayAccess' }],
5050
},
5151
{
5252
code: '<template>{{foo bar=@list.[1]}}</template>',
53-
output: null,
53+
output: '<template>{{foo bar=(get @list "1")}}</template>',
5454
errors: [{ messageId: 'noObscureArrayAccess' }],
5555
},
5656
{
5757
code: '<template>{{this.list.[0]}}</template>',
58-
output: null,
58+
output: '<template>{{get this.list "0"}}</template>',
5959
errors: [{ messageId: 'noObscureArrayAccess' }],
6060
},
6161
{
6262
code: '<template>{{this.list.[0].name}}</template>',
63-
output: null,
63+
output: '<template>{{get this.list "0.name"}}</template>',
6464
errors: [{ messageId: 'noObscureArrayAccess' }],
6565
},
6666
{
6767
code: '<template><Foo @bar={{this.list.[0]}} /></template>',
68-
output: null,
68+
output: '<template><Foo @bar={{get this.list "0"}} /></template>',
6969
errors: [{ messageId: 'noObscureArrayAccess' }],
7070
},
7171
{
7272
code: '<template><Foo @bar={{this.list.[0].name.[1].foo}} /></template>',
73-
output: null,
73+
output: '<template><Foo @bar={{get this.list "0.name.1.foo"}} /></template>',
7474
errors: [{ messageId: 'noObscureArrayAccess' }],
7575
},
7676
],
@@ -102,7 +102,7 @@ hbsRuleTester.run('template-no-obscure-array-access', rule, {
102102
invalid: [
103103
{
104104
code: '<Foo @onClick={{fn this.func @foo.0.bar}} />',
105-
output: null,
105+
output: '<Foo @onClick={{fn this.func (get @foo "0.bar")}} />',
106106
errors: [
107107
{
108108
message:
@@ -112,7 +112,7 @@ hbsRuleTester.run('template-no-obscure-array-access', rule, {
112112
},
113113
{
114114
code: '{{foo bar=this.list.[0]}}',
115-
output: null,
115+
output: '{{foo bar=(get this.list "0")}}',
116116
errors: [
117117
{
118118
message:
@@ -122,7 +122,7 @@ hbsRuleTester.run('template-no-obscure-array-access', rule, {
122122
},
123123
{
124124
code: '{{foo bar=@list.[1]}}',
125-
output: null,
125+
output: '{{foo bar=(get @list "1")}}',
126126
errors: [
127127
{
128128
message:
@@ -132,7 +132,7 @@ hbsRuleTester.run('template-no-obscure-array-access', rule, {
132132
},
133133
{
134134
code: '{{this.list.[0]}}',
135-
output: null,
135+
output: '{{get this.list "0"}}',
136136
errors: [
137137
{
138138
message:
@@ -142,7 +142,7 @@ hbsRuleTester.run('template-no-obscure-array-access', rule, {
142142
},
143143
{
144144
code: '{{this.list.[0].name}}',
145-
output: null,
145+
output: '{{get this.list "0.name"}}',
146146
errors: [
147147
{
148148
message:
@@ -152,7 +152,7 @@ hbsRuleTester.run('template-no-obscure-array-access', rule, {
152152
},
153153
{
154154
code: '<Foo @bar={{this.list.[0]}} />',
155-
output: null,
155+
output: '<Foo @bar={{get this.list "0"}} />',
156156
errors: [
157157
{
158158
message:
@@ -162,7 +162,7 @@ hbsRuleTester.run('template-no-obscure-array-access', rule, {
162162
},
163163
{
164164
code: '<Foo @bar={{this.list.[0].name.[1].foo}} />',
165-
output: null,
165+
output: '<Foo @bar={{get this.list "0.name.1.foo"}} />',
166166
errors: [
167167
{
168168
message:

0 commit comments

Comments
 (0)