@ethlete/eslint-plugin 1.0.0-next.15 → 1.0.0-next.16
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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.0-next.16
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#3037](https://github.com/ethlete-io/ethdk/pull/3037) [`19469c2`](https://github.com/ethlete-io/ethdk/commit/19469c21903217e05b974ac69773f222e63ae4e1) Thanks [@github-actions](https://github.com/apps/github-actions)! - Add `no-effect-cleanup-return`: flags a cleanup function returned from `effect()` /
|
|
8
|
+
`afterRenderEffect()`, which Angular ignores — so the teardown silently never runs. Auto-fixes the
|
|
9
|
+
mechanical case to the `onCleanup` parameter; otherwise points at `inject(DestroyRef).onDestroy()`.
|
|
10
|
+
|
|
11
|
+
- [#3037](https://github.com/ethlete-io/ethdk/pull/3037) [`3151b7a`](https://github.com/ethlete-io/ethdk/commit/3151b7a253d14e38e22e20d67bf0191f141c144e) Thanks [@github-actions](https://github.com/apps/github-actions)! - Add `ethlete/no-template-literal-before-inline-template`, and restructure the files it flagged.
|
|
12
|
+
|
|
13
|
+
The Angular VS Code extension decides **client-side** whether the cursor sits inside an inline `template:` before it forwards completion, hover, go-to-definition or signature-help to the language server. That check (`isNotTypescriptOrSupportedDecoratorField`) walks the file with a bare `ts.createScanner()` loop, which cannot re-scan `}` as `TemplateMiddle`/`TemplateTail` — that needs the parser's `reScanTemplateToken()`. So the first template literal containing a `${…}` substitution desynchronises both the token stream and the brace counter, the scanner never recognises `template` `:` again, and every template request below it is dropped. The language server answers those requests correctly; the editor just never asks, so the template silently has no IntelliSense at all.
|
|
14
|
+
|
|
15
|
+
The new rule reproduces that scanner verbatim, so it reports exactly the templates the extension would abandon — no heuristic. Twenty inline templates across `components`, `cdk` and the playground were affected, all of them behind a fixture or helper that happened to use an interpolated template literal. Story fixtures moved into sibling `*-storybook.data.ts` files; spec fixtures and in-class helpers that must stay above their component (because a later `@Component` references the class in `imports`) were rewritten without the interpolation.
|
|
16
|
+
|
|
17
|
+
No public API changed — the `components` and `cdk` bumps are story/spec restructuring plus moving `signalVisibilityChangeClasses` below `RichFilterHostComponent` in the same module.
|
|
18
|
+
|
|
3
19
|
## 1.0.0-next.15
|
|
4
20
|
|
|
5
21
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ethlete/eslint-plugin",
|
|
3
|
-
"version": "1.0.0-next.
|
|
3
|
+
"version": "1.0.0-next.16",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"peerDependencies": {
|
|
8
8
|
"@angular-eslint/eslint-plugin-template": ">=21.0.0",
|
|
9
|
-
"@angular/core": "22.0.
|
|
9
|
+
"@angular/core": "22.0.7",
|
|
10
10
|
"@typescript-eslint/eslint-plugin": ">=8.0.0",
|
|
11
11
|
"eslint": ">=9.0.0",
|
|
12
12
|
"tslib": "2.8.1",
|
|
@@ -253,6 +253,9 @@ const recommendedTs = {
|
|
|
253
253
|
// No .subscribe() inside effect() or computed()
|
|
254
254
|
'ethlete/no-rxjs-in-effect': 'error',
|
|
255
255
|
|
|
256
|
+
// No cleanup function returned from effect() — Angular ignores it, so it never runs
|
|
257
|
+
'ethlete/no-effect-cleanup-return': 'error',
|
|
258
|
+
|
|
256
259
|
// No readonly on reactive class properties (signals, inputs, computed, inject, etc.)
|
|
257
260
|
'ethlete/no-readonly-signal': 'error',
|
|
258
261
|
|
|
@@ -343,6 +346,10 @@ const recommendedTs = {
|
|
|
343
346
|
// Remove standalone metadata — standalone is implicit and should not be declared
|
|
344
347
|
'ethlete/no-standalone-flag': 'error',
|
|
345
348
|
|
|
349
|
+
// No interpolated template literal above an inline template — it silently kills
|
|
350
|
+
// Angular language service completions for the rest of the file
|
|
351
|
+
'ethlete/no-template-literal-before-inline-template': 'error',
|
|
352
|
+
|
|
346
353
|
// ── Angular outputs ─────────────────────────────────────────────────────
|
|
347
354
|
|
|
348
355
|
// No on-prefixed outputs (onSelectDate → selectDate)
|
package/src/index.js
CHANGED
|
@@ -5,6 +5,7 @@ const noInjectChain = require('./rules/no-inject-chain');
|
|
|
5
5
|
const noTrivialReturnType = require('./rules/no-trivial-return-type');
|
|
6
6
|
const noSubscribeInPipe = require('./rules/no-subscribe-in-pipe');
|
|
7
7
|
const noRxjsInEffect = require('./rules/no-rxjs-in-effect');
|
|
8
|
+
const noEffectCleanupReturn = require('./rules/no-effect-cleanup-return');
|
|
8
9
|
const noReadonlySignal = require('./rules/no-readonly-signal');
|
|
9
10
|
const noScreamingCaseLocal = require('./rules/no-screaming-case-local');
|
|
10
11
|
const preferRxjsTimer = require('./rules/prefer-rxjs-timer');
|
|
@@ -40,6 +41,7 @@ const noUnusedClassMember = require('./rules/no-unused-class-member');
|
|
|
40
41
|
const noDomQuery = require('./rules/no-dom-query');
|
|
41
42
|
const noLeadingUnderscoreClassMember = require('./rules/no-leading-underscore-class-member');
|
|
42
43
|
const noEmptyAngularMetadataArrays = require('./rules/no-empty-angular-metadata-arrays');
|
|
44
|
+
const noTemplateLiteralBeforeInlineTemplate = require('./rules/no-template-literal-before-inline-template');
|
|
43
45
|
const noStandaloneFlag = require('./rules/no-standalone-flag');
|
|
44
46
|
const noEmptyNewlinesBetweenImports = require('./rules/no-empty-newlines-between-imports');
|
|
45
47
|
const angularDecoratorPropertyOrder = require('./rules/angular-decorator-property-order');
|
|
@@ -64,6 +66,7 @@ const plugin = {
|
|
|
64
66
|
'no-trivial-return-type': noTrivialReturnType,
|
|
65
67
|
'no-subscribe-in-pipe': noSubscribeInPipe,
|
|
66
68
|
'no-rxjs-in-effect': noRxjsInEffect,
|
|
69
|
+
'no-effect-cleanup-return': noEffectCleanupReturn,
|
|
67
70
|
'no-readonly-signal': noReadonlySignal,
|
|
68
71
|
'no-screaming-case-local': noScreamingCaseLocal,
|
|
69
72
|
'prefer-rxjs-timer': preferRxjsTimer,
|
|
@@ -99,6 +102,7 @@ const plugin = {
|
|
|
99
102
|
'no-dom-query': noDomQuery,
|
|
100
103
|
'no-leading-underscore-class-member': noLeadingUnderscoreClassMember,
|
|
101
104
|
'no-empty-angular-metadata-arrays': noEmptyAngularMetadataArrays,
|
|
105
|
+
'no-template-literal-before-inline-template': noTemplateLiteralBeforeInlineTemplate,
|
|
102
106
|
'no-standalone-flag': noStandaloneFlag,
|
|
103
107
|
'no-empty-newlines-between-imports': noEmptyNewlinesBetweenImports,
|
|
104
108
|
'angular-decorator-property-order': angularDecoratorPropertyOrder,
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Disallows returning a cleanup function from an `effect()` callback.
|
|
6
|
+
*
|
|
7
|
+
* Angular's `effect()` ignores whatever the callback returns — the React/Svelte idiom of returning a
|
|
8
|
+
* teardown function silently does nothing, so the "cleanup" never runs. The bug is invisible: the code
|
|
9
|
+
* reads as if it unregisters, and only the leak shows up later.
|
|
10
|
+
*
|
|
11
|
+
* BAD:
|
|
12
|
+
* effect(() => {
|
|
13
|
+
* this.group?.registerItem(this);
|
|
14
|
+
*
|
|
15
|
+
* return () => this.group?.unregisterItem(this); // ❌ never called
|
|
16
|
+
* });
|
|
17
|
+
*
|
|
18
|
+
* GOOD — teardown tied to each re-run (the registered key is reactive):
|
|
19
|
+
* effect((onCleanup) => {
|
|
20
|
+
* const id = this.ref().id;
|
|
21
|
+
* this.stack?.registerItem(id);
|
|
22
|
+
*
|
|
23
|
+
* onCleanup(() => this.stack?.unregisterItem(id));
|
|
24
|
+
* });
|
|
25
|
+
*
|
|
26
|
+
* GOOD — teardown tied to the lifetime, for a registration that never changes:
|
|
27
|
+
* this.group?.registerItem(this);
|
|
28
|
+
* inject(DestroyRef).onDestroy(() => this.group?.unregisterItem(this));
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** Calls whose first argument is a body that ignores its return value. */
|
|
32
|
+
const EFFECT_CALLEES = new Set(['effect', 'afterRenderEffect']);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {any} node
|
|
36
|
+
* @returns {boolean}
|
|
37
|
+
*/
|
|
38
|
+
const isFunctionExpression = (node) =>
|
|
39
|
+
!!node && (node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression');
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {any} node
|
|
43
|
+
* @returns {boolean}
|
|
44
|
+
*/
|
|
45
|
+
const isFunction = (node) => isFunctionExpression(node) || node?.type === 'FunctionDeclaration';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Every `return` the given function owns — returns inside nested functions belong to those, so the walk
|
|
49
|
+
* stops at each function boundary.
|
|
50
|
+
* @param {any} fn
|
|
51
|
+
* @returns {any[]}
|
|
52
|
+
*/
|
|
53
|
+
const ownReturnStatements = (fn) => {
|
|
54
|
+
/** @type {any[]} */
|
|
55
|
+
const returns = [];
|
|
56
|
+
|
|
57
|
+
/** @param {any} node */
|
|
58
|
+
const visit = (node) => {
|
|
59
|
+
if (!node || typeof node.type !== 'string') {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (node.type === 'ReturnStatement') {
|
|
64
|
+
returns.push(node);
|
|
65
|
+
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (isFunction(node)) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (const key of Object.keys(node)) {
|
|
74
|
+
if (key === 'parent') {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const value = node[key];
|
|
79
|
+
|
|
80
|
+
if (Array.isArray(value)) {
|
|
81
|
+
value.forEach(visit);
|
|
82
|
+
} else if (value && typeof value.type === 'string') {
|
|
83
|
+
visit(value);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
fn.body.body.forEach(visit);
|
|
89
|
+
|
|
90
|
+
return returns;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
94
|
+
const noEffectCleanupReturn = {
|
|
95
|
+
meta: {
|
|
96
|
+
type: 'problem',
|
|
97
|
+
docs: {
|
|
98
|
+
description: 'Disallow returning a cleanup function from an effect() callback — Angular ignores it.',
|
|
99
|
+
recommended: true,
|
|
100
|
+
},
|
|
101
|
+
messages: {
|
|
102
|
+
returnedCleanup:
|
|
103
|
+
'{{callee}}() ignores the value its callback returns, so this cleanup function never runs. ' +
|
|
104
|
+
'Register it via the onCleanup parameter — {{callee}}((onCleanup) => { …; onCleanup(() => …) }) — or, ' +
|
|
105
|
+
'when the teardown belongs to the lifetime rather than to each re-run, drop the effect and use ' +
|
|
106
|
+
'inject(DestroyRef).onDestroy(() => …).',
|
|
107
|
+
},
|
|
108
|
+
schema: [],
|
|
109
|
+
fixable: 'code',
|
|
110
|
+
},
|
|
111
|
+
create(context) {
|
|
112
|
+
const sourceCode = context.sourceCode;
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
CallExpression(node) {
|
|
116
|
+
const { callee } = node;
|
|
117
|
+
|
|
118
|
+
if (callee.type !== 'Identifier' || !EFFECT_CALLEES.has(callee.name)) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const callback = /** @type {any} */ (node.arguments[0]);
|
|
123
|
+
|
|
124
|
+
if (!isFunctionExpression(callback)) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// effect(() => () => cleanup()) — the whole body *is* the ignored cleanup
|
|
129
|
+
if (isFunctionExpression(callback.body)) {
|
|
130
|
+
context.report({
|
|
131
|
+
node: callback.body,
|
|
132
|
+
messageId: 'returnedCleanup',
|
|
133
|
+
data: { callee: callee.name },
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (callback.body.type !== 'BlockStatement') {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const returns = ownReturnStatements(callback);
|
|
144
|
+
const cleanupReturns = returns.filter((statement) => isFunctionExpression(statement.argument));
|
|
145
|
+
|
|
146
|
+
if (cleanupReturns.length === 0) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Only the mechanical shape is auto-fixed: a zero-parameter callback whose single `return` hands
|
|
151
|
+
// back a function literal. With an existing parameter, an early `return`, or a returned reference,
|
|
152
|
+
// the intent needs a human.
|
|
153
|
+
// The parameter list's `(` — the first one before the body, so this works for `() => {}` and
|
|
154
|
+
// `function () {}` alike (their first token differs).
|
|
155
|
+
const openParen = sourceCode
|
|
156
|
+
.getTokens(callback)
|
|
157
|
+
.find((token) => token.value === '(' && token.range[1] <= callback.body.range[0]);
|
|
158
|
+
const fixable = callback.params.length === 0 && returns.length === 1 && cleanupReturns.length === 1;
|
|
159
|
+
|
|
160
|
+
for (const statement of cleanupReturns) {
|
|
161
|
+
context.report({
|
|
162
|
+
node: statement.argument,
|
|
163
|
+
messageId: 'returnedCleanup',
|
|
164
|
+
data: { callee: callee.name },
|
|
165
|
+
fix:
|
|
166
|
+
fixable && openParen
|
|
167
|
+
? (fixer) => [
|
|
168
|
+
fixer.insertTextAfter(openParen, 'onCleanup'),
|
|
169
|
+
fixer.replaceText(statement, `onCleanup(${sourceCode.getText(statement.argument)});`),
|
|
170
|
+
]
|
|
171
|
+
: null,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
module.exports = noEffectCleanupReturn;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const ts = require('typescript');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The Angular VS Code extension decides client-side whether a cursor in a `.ts` file sits inside an
|
|
8
|
+
* inline `template:` before it forwards completion / hover / definition / signature-help to the
|
|
9
|
+
* language server. That check (`isNotTypescriptOrSupportedDecoratorField` in
|
|
10
|
+
* `client/src/embedded_support.js`) walks the file with a bare `ts.createScanner()` loop.
|
|
11
|
+
*
|
|
12
|
+
* A bare scanner cannot re-scan `}` as `TemplateMiddle` / `TemplateTail` — that needs the parser's
|
|
13
|
+
* `reScanTemplateToken()`. So the first template literal containing a substitution desynchronises
|
|
14
|
+
* both the token stream and the brace counter, the scanner never recognises `template` `:` again,
|
|
15
|
+
* and every template request in the rest of the file is silently dropped. The language server
|
|
16
|
+
* answers those requests correctly; the editor just never asks.
|
|
17
|
+
*
|
|
18
|
+
* This rule reproduces that scanner verbatim, so it reports exactly the templates the extension
|
|
19
|
+
* would abandon — no heuristic, no false positives.
|
|
20
|
+
*/
|
|
21
|
+
const ANGULAR_PROPERTY_ASSIGNMENTS = new Set(['template', 'templateUrl', 'styleUrls', 'styleUrl', 'host']);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {import('typescript').SyntaxKind} token
|
|
25
|
+
*/
|
|
26
|
+
const isPropertyAssignmentTerminator = (token) =>
|
|
27
|
+
token === ts.SyntaxKind.EndOfFileToken ||
|
|
28
|
+
token === ts.SyntaxKind.CommaToken ||
|
|
29
|
+
token === ts.SyntaxKind.SemicolonToken ||
|
|
30
|
+
token === ts.SyntaxKind.CloseBraceToken;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Mirrors the extension's `isPropertyAssignmentToStringOrStringInArray`. Returns whether the editor
|
|
34
|
+
* would recognise `offset` as sitting inside a supported decorator field.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} text
|
|
37
|
+
* @param {number} offset
|
|
38
|
+
*/
|
|
39
|
+
const editorSeesDecoratorField = (text, offset) => {
|
|
40
|
+
const scanner = ts.createScanner(ts.ScriptTarget.ESNext, true);
|
|
41
|
+
scanner.setText(text);
|
|
42
|
+
|
|
43
|
+
let token = scanner.scan();
|
|
44
|
+
let lastToken;
|
|
45
|
+
let lastTokenText;
|
|
46
|
+
let unclosedBraces = 0;
|
|
47
|
+
let unclosedBrackets = 0;
|
|
48
|
+
let propertyAssignmentContext = false;
|
|
49
|
+
|
|
50
|
+
while (token !== ts.SyntaxKind.EndOfFileToken && scanner.getTokenFullStart() < offset) {
|
|
51
|
+
if (
|
|
52
|
+
lastToken === ts.SyntaxKind.Identifier &&
|
|
53
|
+
lastTokenText !== undefined &&
|
|
54
|
+
token === ts.SyntaxKind.ColonToken &&
|
|
55
|
+
ANGULAR_PROPERTY_ASSIGNMENTS.has(lastTokenText)
|
|
56
|
+
) {
|
|
57
|
+
propertyAssignmentContext = true;
|
|
58
|
+
token = scanner.scan();
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (unclosedBraces === 0 && unclosedBrackets === 0 && isPropertyAssignmentTerminator(token)) {
|
|
63
|
+
propertyAssignmentContext = false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (token === ts.SyntaxKind.OpenBracketToken) unclosedBrackets++;
|
|
67
|
+
else if (token === ts.SyntaxKind.OpenBraceToken) unclosedBraces++;
|
|
68
|
+
else if (token === ts.SyntaxKind.CloseBracketToken) unclosedBrackets--;
|
|
69
|
+
else if (token === ts.SyntaxKind.CloseBraceToken) unclosedBraces--;
|
|
70
|
+
|
|
71
|
+
const isStringToken =
|
|
72
|
+
token === ts.SyntaxKind.StringLiteral || token === ts.SyntaxKind.NoSubstitutionTemplateLiteral;
|
|
73
|
+
const isCursorInToken =
|
|
74
|
+
scanner.getTokenFullStart() <= offset && scanner.getTokenFullStart() + scanner.getTokenText().length >= offset;
|
|
75
|
+
|
|
76
|
+
if (propertyAssignmentContext && isCursorInToken && isStringToken) return true;
|
|
77
|
+
|
|
78
|
+
lastTokenText = scanner.getTokenText();
|
|
79
|
+
lastToken = token;
|
|
80
|
+
token = scanner.scan();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return false;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {import('estree').Property['key']} key
|
|
88
|
+
*/
|
|
89
|
+
const getPropertyName = (key) => {
|
|
90
|
+
if (key.type === 'Identifier') return key.name;
|
|
91
|
+
if (key.type === 'Literal' && typeof key.value === 'string') return key.value;
|
|
92
|
+
return null;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @param {any} node
|
|
97
|
+
*/
|
|
98
|
+
const getDecoratorName = (node) => {
|
|
99
|
+
const expression = node.expression;
|
|
100
|
+
if (!expression) return null;
|
|
101
|
+
if (expression.type === 'CallExpression') {
|
|
102
|
+
return expression.callee.type === 'Identifier' ? expression.callee.name : null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return expression.type === 'Identifier' ? expression.name : null;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The inline template text, or null when the property is not a plain non-empty string literal.
|
|
110
|
+
*
|
|
111
|
+
* @param {any} value
|
|
112
|
+
*/
|
|
113
|
+
const getInlineTemplateText = (value) => {
|
|
114
|
+
if (value.type === 'Literal') return typeof value.value === 'string' ? value.value : null;
|
|
115
|
+
if (value.type === 'TemplateLiteral' && value.expressions.length === 0) return value.quasis[0]?.value.raw ?? '';
|
|
116
|
+
return null;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
120
|
+
const noTemplateLiteralBeforeInlineTemplate = {
|
|
121
|
+
meta: {
|
|
122
|
+
type: 'problem',
|
|
123
|
+
docs: {
|
|
124
|
+
description:
|
|
125
|
+
'Disallow a substituted template literal above an inline component template, which silently disables Angular language service completions for the rest of the file.',
|
|
126
|
+
},
|
|
127
|
+
schema: [],
|
|
128
|
+
messages: {
|
|
129
|
+
breaksLanguageService:
|
|
130
|
+
'The Angular language service gives no completions, hover or go-to-definition inside this template. Its editor-side scanner desynchronises on the interpolated template literal at line {{line}}, so every template request below it is dropped. Move that code below the component, extract it to a sibling file, or write it without an interpolation.',
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
create(context) {
|
|
134
|
+
const sourceCode = context.sourceCode;
|
|
135
|
+
const text = sourceCode.getText();
|
|
136
|
+
|
|
137
|
+
/** @type {number[]} */
|
|
138
|
+
const interpolatedLiteralStarts = [];
|
|
139
|
+
/** @type {any[]} */
|
|
140
|
+
const inlineTemplates = [];
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
/** @param {any} node */
|
|
144
|
+
TemplateLiteral(node) {
|
|
145
|
+
if (node.expressions.length > 0) interpolatedLiteralStarts.push(node.range[0]);
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
/** @param {import('eslint').Rule.Node} node */
|
|
149
|
+
Decorator(node) {
|
|
150
|
+
const decoratorName = getDecoratorName(node);
|
|
151
|
+
if (decoratorName !== 'Component' && decoratorName !== 'Directive') return;
|
|
152
|
+
|
|
153
|
+
const expression = /** @type {any} */ (node).expression;
|
|
154
|
+
if (expression.type !== 'CallExpression') return;
|
|
155
|
+
|
|
156
|
+
const metadata = expression.arguments[0];
|
|
157
|
+
if (!metadata || metadata.type !== 'ObjectExpression') return;
|
|
158
|
+
|
|
159
|
+
for (const property of metadata.properties) {
|
|
160
|
+
if (property.type !== 'Property') continue;
|
|
161
|
+
if (getPropertyName(property.key) !== 'template') continue;
|
|
162
|
+
|
|
163
|
+
const templateText = getInlineTemplateText(property.value);
|
|
164
|
+
if (templateText === null || templateText.trim().length === 0) continue;
|
|
165
|
+
|
|
166
|
+
inlineTemplates.push(property);
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
'Program:exit'() {
|
|
171
|
+
for (const property of inlineTemplates) {
|
|
172
|
+
// Where the editor would put the cursor: just inside the opening quote or backtick.
|
|
173
|
+
const cursor = property.value.range[0] + 2;
|
|
174
|
+
if (editorSeesDecoratorField(text, cursor)) continue;
|
|
175
|
+
|
|
176
|
+
const culprit = interpolatedLiteralStarts.find((start) => start < property.value.range[0]);
|
|
177
|
+
if (culprit === undefined) continue;
|
|
178
|
+
|
|
179
|
+
context.report({
|
|
180
|
+
node: property.key,
|
|
181
|
+
messageId: 'breaksLanguageService',
|
|
182
|
+
data: { line: String(sourceCode.getLocFromIndex(culprit).line) },
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
module.exports = noTemplateLiteralBeforeInlineTemplate;
|