ember-eslint-parser 0.0.1 → 0.1.0
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/package.json +44 -7
- package/src/parser/gjs-gts-parser.js +59 -0
- package/src/parser/transforms.js +519 -0
- package/src/parser/ts-patch.js +117 -0
- package/src/preprocessor/noop.js +34 -0
- package/src/utils/document.js +101 -0
package/package.json
CHANGED
|
@@ -1,12 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ember-eslint-parser",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "",
|
|
5
|
-
"main": "index.js",
|
|
6
|
-
"scripts": {
|
|
7
|
-
"test": "echo \"Error: no test specified\" && exit 1"
|
|
8
|
-
},
|
|
9
5
|
"keywords": [],
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git@github.com:NullVoxPopuli/ember-eslint-parser.git"
|
|
9
|
+
},
|
|
10
|
+
"license": "ISC",
|
|
10
11
|
"author": "",
|
|
11
|
-
"
|
|
12
|
-
|
|
12
|
+
"exports": {
|
|
13
|
+
".": "./src/parser/gjs-gts-parser.js"
|
|
14
|
+
},
|
|
15
|
+
"main": "src/parser/gjs-gts-parser.js",
|
|
16
|
+
"files": [
|
|
17
|
+
"src"
|
|
18
|
+
],
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"concurrently": "^8.2.2",
|
|
21
|
+
"eslint": "^8.0.1",
|
|
22
|
+
"eslint-config-prettier": "^9.1.0",
|
|
23
|
+
"eslint-config-standard": "^17.1.0",
|
|
24
|
+
"eslint-plugin-import": "^2.25.2",
|
|
25
|
+
"eslint-plugin-n": "^15.0.0 || ^16.0.0 ",
|
|
26
|
+
"eslint-plugin-prettier": "^5.0.1",
|
|
27
|
+
"eslint-plugin-promise": "^6.0.0",
|
|
28
|
+
"jest": "^29.7.0",
|
|
29
|
+
"prettier": "^3.1.1",
|
|
30
|
+
"publint": "^0.2.6",
|
|
31
|
+
"release-plan": "^0.6.0"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@babel/eslint-parser": "^7.23.3",
|
|
35
|
+
"@glimmer/syntax": "^0.85.13",
|
|
36
|
+
"@typescript-eslint/scope-manager": "^6.15.0",
|
|
37
|
+
"content-tag": "^1.2.2",
|
|
38
|
+
"eslint-scope": "^7.2.2",
|
|
39
|
+
"html-tags": "^3.3.1"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"test": "echo \"Error: no test specified\" && exit 1",
|
|
43
|
+
"lint": "concurrently \"npm:lint:*(!fix)\" --names \"lint:\"",
|
|
44
|
+
"lint:js": "eslint .",
|
|
45
|
+
"lint:package": "pnpm publint",
|
|
46
|
+
"lint:fix": "concurrently \"npm:lint:*:fix\" --names \"fix:\"",
|
|
47
|
+
"lint:js:fix": "eslint . --fix"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const babelParser = require('@babel/eslint-parser');
|
|
2
|
+
const { registerParsedFile } = require('../preprocessor/noop');
|
|
3
|
+
const {
|
|
4
|
+
patchTs,
|
|
5
|
+
replaceExtensions,
|
|
6
|
+
syncMtsGtsSourceFiles,
|
|
7
|
+
typescriptParser,
|
|
8
|
+
} = require('./ts-patch');
|
|
9
|
+
const { transformForLint, preprocessGlimmerTemplates, convertAst } = require('./transform');
|
|
10
|
+
|
|
11
|
+
patchTs();
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* implements https://eslint.org/docs/latest/extend/custom-parsers
|
|
15
|
+
* 1. transforms gts/gjs files into parseable ts/js without changing the offsets and locations around it
|
|
16
|
+
* 2. parses the transformed code and generates the AST for TS ot JS
|
|
17
|
+
* 3. preprocesses the templates info and prepares the Glimmer AST
|
|
18
|
+
* 4. converts the js/ts AST so that it includes the Glimmer AST at the right locations, replacing the original
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
*
|
|
22
|
+
* @type {import('eslint').ParserModule}
|
|
23
|
+
*/
|
|
24
|
+
module.exports = {
|
|
25
|
+
parseForESLint(code, options) {
|
|
26
|
+
registerParsedFile(options.filePath);
|
|
27
|
+
let jsCode = code;
|
|
28
|
+
const info = transformForLint(code);
|
|
29
|
+
jsCode = info.output;
|
|
30
|
+
|
|
31
|
+
const isTypescript = options.filePath.endsWith('.gts');
|
|
32
|
+
|
|
33
|
+
let result = null;
|
|
34
|
+
const filePath = options.filePath;
|
|
35
|
+
if (options.project) {
|
|
36
|
+
jsCode = replaceExtensions(jsCode);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (isTypescript && !typescriptParser) {
|
|
40
|
+
throw new Error('Please install typescript to process gts');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
result = isTypescript
|
|
44
|
+
? typescriptParser.parseForESLint(jsCode, { ...options, ranges: true, filePath })
|
|
45
|
+
: babelParser.parseForESLint(jsCode, { ...options, ranges: true });
|
|
46
|
+
if (!info.templateInfos?.length) {
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
const preprocessedResult = preprocessGlimmerTemplates(info, code);
|
|
50
|
+
const { templateVisitorKeys } = preprocessedResult;
|
|
51
|
+
const visitorKeys = { ...result.visitorKeys, ...templateVisitorKeys };
|
|
52
|
+
result.isTypescript = isTypescript;
|
|
53
|
+
convertAst(result, preprocessedResult, visitorKeys);
|
|
54
|
+
if (result.services?.program) {
|
|
55
|
+
syncMtsGtsSourceFiles(result.services?.program);
|
|
56
|
+
}
|
|
57
|
+
return { ...result, visitorKeys };
|
|
58
|
+
},
|
|
59
|
+
};
|
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
const ContentTag = require('content-tag');
|
|
2
|
+
const glimmer = require('@glimmer/syntax');
|
|
3
|
+
const DocumentLines = require('../utils/document');
|
|
4
|
+
const { visitorKeys: glimmerVisitorKeys } = require('@glimmer/syntax');
|
|
5
|
+
const TypescriptScope = require('@typescript-eslint/scope-manager');
|
|
6
|
+
const { Reference, Scope, Variable, Definition } = require('eslint-scope');
|
|
7
|
+
const htmlTags = require('html-tags');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* finds the nearest node scope
|
|
11
|
+
* @param scopeManager
|
|
12
|
+
* @param nodePath
|
|
13
|
+
* @return {*|null}
|
|
14
|
+
*/
|
|
15
|
+
function findParentScope(scopeManager, nodePath) {
|
|
16
|
+
let scope = null;
|
|
17
|
+
let path = nodePath;
|
|
18
|
+
while (path) {
|
|
19
|
+
scope = scopeManager.acquire(path.node, true);
|
|
20
|
+
if (scope) {
|
|
21
|
+
return scope;
|
|
22
|
+
}
|
|
23
|
+
path = path.parentPath;
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* tries to find the variable names {name} in any parent scope
|
|
30
|
+
* if the variable is not found it just returns the nearest scope,
|
|
31
|
+
* so that it's usage can be registered.
|
|
32
|
+
* @param scopeManager
|
|
33
|
+
* @param nodePath
|
|
34
|
+
* @param name
|
|
35
|
+
* @return {{scope: null, variable: *}|{scope: (*|null)}}
|
|
36
|
+
*/
|
|
37
|
+
function findVarInParentScopes(scopeManager, nodePath, name) {
|
|
38
|
+
let scope = null;
|
|
39
|
+
let path = nodePath;
|
|
40
|
+
while (path) {
|
|
41
|
+
scope = scopeManager.acquire(path.node, true);
|
|
42
|
+
if (scope && scope.set.has(name)) {
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
path = path.parentPath;
|
|
46
|
+
}
|
|
47
|
+
const currentScope = findParentScope(scopeManager, nodePath);
|
|
48
|
+
if (!scope) {
|
|
49
|
+
return { scope: currentScope };
|
|
50
|
+
}
|
|
51
|
+
return { scope: currentScope, variable: scope.set.get(name) };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* registers a node variable usage in the scope.
|
|
56
|
+
* @param node
|
|
57
|
+
* @param scope
|
|
58
|
+
* @param variable
|
|
59
|
+
*/
|
|
60
|
+
function registerNodeInScope(node, scope, variable) {
|
|
61
|
+
const ref = new Reference(node, scope, Reference.READ);
|
|
62
|
+
if (variable) {
|
|
63
|
+
variable.references.push(ref);
|
|
64
|
+
ref.resolved = variable;
|
|
65
|
+
} else {
|
|
66
|
+
// register missing variable in most upper scope.
|
|
67
|
+
let s = scope;
|
|
68
|
+
while (s.upper) {
|
|
69
|
+
s = s.upper;
|
|
70
|
+
}
|
|
71
|
+
s.through.push(ref);
|
|
72
|
+
}
|
|
73
|
+
scope.references.push(ref);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* traverses all nodes using the {visitorKeys} calling the callback function, visitor
|
|
78
|
+
* @param visitorKeys
|
|
79
|
+
* @param node
|
|
80
|
+
* @param visitor
|
|
81
|
+
*/
|
|
82
|
+
function traverse(visitorKeys, node, visitor) {
|
|
83
|
+
const allVisitorKeys = visitorKeys;
|
|
84
|
+
const queue = [];
|
|
85
|
+
|
|
86
|
+
queue.push({
|
|
87
|
+
node,
|
|
88
|
+
parent: null,
|
|
89
|
+
parentKey: null,
|
|
90
|
+
parentPath: null,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
while (queue.length > 0) {
|
|
94
|
+
const currentPath = queue.pop();
|
|
95
|
+
|
|
96
|
+
visitor(currentPath);
|
|
97
|
+
|
|
98
|
+
const visitorKeys = allVisitorKeys[currentPath.node.type];
|
|
99
|
+
if (!visitorKeys) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
for (const visitorKey of visitorKeys) {
|
|
104
|
+
const child = currentPath.node[visitorKey];
|
|
105
|
+
|
|
106
|
+
if (!child) {
|
|
107
|
+
continue;
|
|
108
|
+
} else if (Array.isArray(child)) {
|
|
109
|
+
for (const item of child) {
|
|
110
|
+
queue.push({
|
|
111
|
+
node: item,
|
|
112
|
+
parent: currentPath.node,
|
|
113
|
+
parentKey: visitorKey,
|
|
114
|
+
parentPath: currentPath,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
queue.push({
|
|
119
|
+
node: child,
|
|
120
|
+
parent: currentPath.node,
|
|
121
|
+
parentKey: visitorKey,
|
|
122
|
+
parentPath: currentPath,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function isUpperCase(char) {
|
|
130
|
+
return char.toUpperCase() === char;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function isAlphaNumeric(code) {
|
|
134
|
+
return !(
|
|
135
|
+
!(code > 47 && code < 58) && // numeric (0-9)
|
|
136
|
+
!(code > 64 && code < 91) && // upper alpha (A-Z)
|
|
137
|
+
!(code > 96 && code < 123)
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function isWhiteSpace(code) {
|
|
142
|
+
return code === ' ' || code === '\t' || code === '\r' || code === '\n' || code === '\v';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* simple tokenizer for templates, just splits it up into words and punctuators
|
|
147
|
+
* @param template {string}
|
|
148
|
+
* @param startOffset {number}
|
|
149
|
+
* @param doc {DocumentLines}
|
|
150
|
+
* @return {Token[]}
|
|
151
|
+
*/
|
|
152
|
+
function tokenize(template, doc, startOffset) {
|
|
153
|
+
const tokens = [];
|
|
154
|
+
let current = '';
|
|
155
|
+
let start = 0;
|
|
156
|
+
function pushToken(value, type, range) {
|
|
157
|
+
const t = {
|
|
158
|
+
type,
|
|
159
|
+
value,
|
|
160
|
+
range,
|
|
161
|
+
start: range[0],
|
|
162
|
+
end: range[1],
|
|
163
|
+
loc: {
|
|
164
|
+
start: { ...doc.offsetToPosition(range[0]), index: range[0] },
|
|
165
|
+
end: { ...doc.offsetToPosition(range[1]), index: range[1] },
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
tokens.push(t);
|
|
169
|
+
}
|
|
170
|
+
for (const [i, c] of [...template].entries()) {
|
|
171
|
+
if (isAlphaNumeric(c.codePointAt(0))) {
|
|
172
|
+
if (current.length === 0) {
|
|
173
|
+
start = i;
|
|
174
|
+
}
|
|
175
|
+
current += c;
|
|
176
|
+
} else {
|
|
177
|
+
let range = [startOffset + start, startOffset + i];
|
|
178
|
+
if (current.length > 0) {
|
|
179
|
+
pushToken(current, 'word', range);
|
|
180
|
+
current = '';
|
|
181
|
+
}
|
|
182
|
+
range = [startOffset + i, startOffset + i + 1];
|
|
183
|
+
if (!isWhiteSpace(c)) {
|
|
184
|
+
pushToken(c, 'Punctuator', range);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return tokens;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Preprocesses the template info, parsing the template content to Glimmer AST,
|
|
193
|
+
* fixing the offsets and locations of all nodes
|
|
194
|
+
* also calculates the block params locations & ranges
|
|
195
|
+
* and adding it to the info
|
|
196
|
+
* @param info
|
|
197
|
+
* @param code
|
|
198
|
+
* @return {{templateVisitorKeys: {}, comments: *[], templateInfos: {templateRange: *, range: *, replacedRange: *}[]}}
|
|
199
|
+
*/
|
|
200
|
+
module.exports.preprocessGlimmerTemplates = function preprocessGlimmerTemplates(info, code) {
|
|
201
|
+
const templateInfos = info.templateInfos.map((r) => ({
|
|
202
|
+
range: [r.contentRange.start, r.contentRange.end],
|
|
203
|
+
templateRange: [r.range.start, r.range.end],
|
|
204
|
+
}));
|
|
205
|
+
const templateVisitorKeys = {};
|
|
206
|
+
const codeLines = new DocumentLines(code);
|
|
207
|
+
const comments = [];
|
|
208
|
+
const textNodes = [];
|
|
209
|
+
for (const tpl of templateInfos) {
|
|
210
|
+
const range = tpl.range;
|
|
211
|
+
const template = code.slice(...range);
|
|
212
|
+
const docLines = new DocumentLines(template);
|
|
213
|
+
const ast = glimmer.preprocess(template, { mode: 'codemod' });
|
|
214
|
+
ast.tokens = tokenize(code.slice(...tpl.templateRange), codeLines, tpl.templateRange[0]);
|
|
215
|
+
const allNodes = [];
|
|
216
|
+
glimmer.traverse(ast, {
|
|
217
|
+
All(node, path) {
|
|
218
|
+
const n = node;
|
|
219
|
+
n.parent = path.parentNode;
|
|
220
|
+
allNodes.push(node);
|
|
221
|
+
if (node.type === 'CommentStatement' || node.type === 'MustacheCommentStatement') {
|
|
222
|
+
comments.push(node);
|
|
223
|
+
}
|
|
224
|
+
if (node.type === 'TextNode') {
|
|
225
|
+
n.value = node.chars;
|
|
226
|
+
textNodes.push(node);
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
ast.content = template;
|
|
231
|
+
const allNodeTypes = new Set();
|
|
232
|
+
for (const n of allNodes) {
|
|
233
|
+
if (n.type === 'PathExpression') {
|
|
234
|
+
n.head.range = [
|
|
235
|
+
range[0] + docLines.positionToOffset(n.head.loc.start),
|
|
236
|
+
range[0] + docLines.positionToOffset(n.head.loc.end),
|
|
237
|
+
];
|
|
238
|
+
n.head.loc = {
|
|
239
|
+
start: codeLines.offsetToPosition(n.head.range[0]),
|
|
240
|
+
end: codeLines.offsetToPosition(n.head.range[1]),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
n.range =
|
|
244
|
+
n.type === 'Template'
|
|
245
|
+
? [tpl.templateRange[0], tpl.templateRange[1]]
|
|
246
|
+
: [
|
|
247
|
+
range[0] + docLines.positionToOffset(n.loc.start),
|
|
248
|
+
range[0] + docLines.positionToOffset(n.loc.end),
|
|
249
|
+
];
|
|
250
|
+
|
|
251
|
+
n.start = n.range[0];
|
|
252
|
+
n.end = n.range[1];
|
|
253
|
+
n.loc = {
|
|
254
|
+
start: codeLines.offsetToPosition(n.range[0]),
|
|
255
|
+
end: codeLines.offsetToPosition(n.range[1]),
|
|
256
|
+
};
|
|
257
|
+
if (n.type === 'Template') {
|
|
258
|
+
n.loc.start = codeLines.offsetToPosition(tpl.templateRange[0]);
|
|
259
|
+
n.loc.end = codeLines.offsetToPosition(tpl.templateRange[1]);
|
|
260
|
+
}
|
|
261
|
+
// split up element node into sub nodes to be able to reference tag name
|
|
262
|
+
// parts <Foo.Bar /> -> nodes for `Foo` and `Bar`
|
|
263
|
+
if (n.type === 'ElementNode') {
|
|
264
|
+
n.name = n.tag;
|
|
265
|
+
n.parts = [];
|
|
266
|
+
let start = n.range[0];
|
|
267
|
+
let codeSlice = code.slice(...n.range);
|
|
268
|
+
for (const part of n.tag.split('.')) {
|
|
269
|
+
const regex = new RegExp(`\\b${part}\\b`);
|
|
270
|
+
const match = codeSlice.match(regex);
|
|
271
|
+
const range = [start + match.index, 0];
|
|
272
|
+
range[1] = range[0] + part.length;
|
|
273
|
+
codeSlice = code.slice(range[1], n.range[1]);
|
|
274
|
+
start = range[1];
|
|
275
|
+
n.parts.push({
|
|
276
|
+
type: 'GlimmerElementNodePart',
|
|
277
|
+
name: part,
|
|
278
|
+
range,
|
|
279
|
+
parent: n,
|
|
280
|
+
loc: {
|
|
281
|
+
start: codeLines.offsetToPosition(range[0]),
|
|
282
|
+
end: codeLines.offsetToPosition(range[1]),
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
// block params do not have location information
|
|
288
|
+
// add our own nodes so we can reference them
|
|
289
|
+
if ('blockParams' in n) {
|
|
290
|
+
n.params = [];
|
|
291
|
+
}
|
|
292
|
+
if ('blockParams' in n && n.parent) {
|
|
293
|
+
let part = code.slice(...n.parent.range);
|
|
294
|
+
let start = n.parent.range[0];
|
|
295
|
+
let idx = part.indexOf('|') + 1;
|
|
296
|
+
start += idx;
|
|
297
|
+
part = part.slice(idx, -1);
|
|
298
|
+
idx = part.indexOf('|');
|
|
299
|
+
part = part.slice(0, idx);
|
|
300
|
+
for (const param of n.blockParams) {
|
|
301
|
+
const regex = new RegExp(`\\b${param}\\b`);
|
|
302
|
+
const match = part.match(regex);
|
|
303
|
+
const range = [start + match.index, 0];
|
|
304
|
+
range[1] = range[0] + param.length;
|
|
305
|
+
n.params.push({
|
|
306
|
+
type: 'BlockParam',
|
|
307
|
+
name: param,
|
|
308
|
+
range,
|
|
309
|
+
parent: n,
|
|
310
|
+
loc: {
|
|
311
|
+
start: codeLines.offsetToPosition(range[0]),
|
|
312
|
+
end: codeLines.offsetToPosition(range[1]),
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
n.type = `Glimmer${n.type}`;
|
|
318
|
+
allNodeTypes.add(n.type);
|
|
319
|
+
}
|
|
320
|
+
// ast should not contain comment nodes
|
|
321
|
+
for (const comment of comments) {
|
|
322
|
+
const parentBody = comment.parent.body || comment.parent.children;
|
|
323
|
+
const idx = parentBody.indexOf(comment);
|
|
324
|
+
parentBody.splice(idx, 1);
|
|
325
|
+
// comment type can be a block comment or a line comment
|
|
326
|
+
// mark comments as always block comment, this works for eslint in all cases
|
|
327
|
+
comment.type = 'Block';
|
|
328
|
+
}
|
|
329
|
+
// tokens should not contain tokens of comments
|
|
330
|
+
ast.tokens = ast.tokens.filter(
|
|
331
|
+
(t) => !comments.some((c) => c.range[0] <= t.range[0] && c.range[1] >= t.range[1])
|
|
332
|
+
);
|
|
333
|
+
// tokens should not contain tokens of text nodes, but represent the whole node
|
|
334
|
+
// remove existing tokens
|
|
335
|
+
ast.tokens = ast.tokens.filter(
|
|
336
|
+
(t) => !textNodes.some((c) => c.range[0] <= t.range[0] && c.range[1] >= t.range[1])
|
|
337
|
+
);
|
|
338
|
+
// merge in text nodes
|
|
339
|
+
let currentTextNode = textNodes.pop();
|
|
340
|
+
for (let i = ast.tokens.length - 1; i >= 0; i--) {
|
|
341
|
+
const t = ast.tokens[i];
|
|
342
|
+
while (currentTextNode && t.range[0] < currentTextNode.range[0]) {
|
|
343
|
+
ast.tokens.splice(i + 1, 0, currentTextNode);
|
|
344
|
+
currentTextNode = textNodes.pop();
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
ast.contents = template;
|
|
348
|
+
tpl.ast = ast;
|
|
349
|
+
}
|
|
350
|
+
for (const [k, v] of Object.entries(glimmerVisitorKeys)) {
|
|
351
|
+
templateVisitorKeys[`Glimmer${k}`] = [...v];
|
|
352
|
+
}
|
|
353
|
+
return {
|
|
354
|
+
templateVisitorKeys,
|
|
355
|
+
templateInfos,
|
|
356
|
+
comments,
|
|
357
|
+
};
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* traverses the AST and replaces the transformed template parts with the Glimmer
|
|
362
|
+
* AST.
|
|
363
|
+
* This also creates the scopes for the Glimmer Blocks and registers the block params
|
|
364
|
+
* in the scope, and also any usages of variables in path expressions
|
|
365
|
+
* this allows the basic eslint rules no-undef and no-unsused to work also for the
|
|
366
|
+
* templates without needing any custom rules
|
|
367
|
+
* @param result
|
|
368
|
+
* @param preprocessedResult
|
|
369
|
+
* @param visitorKeys
|
|
370
|
+
*/
|
|
371
|
+
module.exports.convertAst = function convertAst(result, preprocessedResult, visitorKeys) {
|
|
372
|
+
const templateInfos = preprocessedResult.templateInfos;
|
|
373
|
+
let counter = 0;
|
|
374
|
+
result.ast.comments.push(...preprocessedResult.comments);
|
|
375
|
+
|
|
376
|
+
for (const ti of templateInfos) {
|
|
377
|
+
const firstIdx = result.ast.tokens.findIndex((t) => t.range[0] === ti.templateRange[0]);
|
|
378
|
+
const lastIdx = result.ast.tokens.findIndex((t) => t.range[1] === ti.templateRange[1]);
|
|
379
|
+
result.ast.tokens.splice(firstIdx, lastIdx - firstIdx + 1, ...ti.ast.tokens);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// eslint-disable-next-line complexity
|
|
383
|
+
traverse(visitorKeys, result.ast, (path) => {
|
|
384
|
+
const node = path.node;
|
|
385
|
+
if (
|
|
386
|
+
node.type === 'ExpressionStatement' ||
|
|
387
|
+
node.type === 'StaticBlock' ||
|
|
388
|
+
node.type === 'TaggedTemplateExpression' ||
|
|
389
|
+
node.type === 'ExportDefaultDeclaration'
|
|
390
|
+
) {
|
|
391
|
+
let range = node.range;
|
|
392
|
+
if (node.type === 'ExportDefaultDeclaration') {
|
|
393
|
+
range = [node.declaration.range[0], node.declaration.range[1]];
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const template = templateInfos.find(
|
|
397
|
+
(t) =>
|
|
398
|
+
t.templateRange[0] === range[0] &&
|
|
399
|
+
(t.templateRange[1] === range[1] || t.templateRange[1] === range[1] + 1)
|
|
400
|
+
);
|
|
401
|
+
if (!template) {
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
counter++;
|
|
405
|
+
const ast = template.ast;
|
|
406
|
+
Object.assign(node, ast);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (node.type === 'GlimmerPathExpression' && node.head.type === 'VarHead') {
|
|
410
|
+
const name = node.head.name;
|
|
411
|
+
if (glimmer.isKeyword(name)) {
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
const { scope, variable } = findVarInParentScopes(result.scopeManager, path, name) || {};
|
|
415
|
+
if (scope) {
|
|
416
|
+
node.head.parent = node;
|
|
417
|
+
registerNodeInScope(node.head, scope, variable);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
if (node.type === 'GlimmerElementNode') {
|
|
421
|
+
// always reference first part of tag name, this also has the advantage
|
|
422
|
+
// that errors regarding this tag will only mark the tag name instead of
|
|
423
|
+
// the whole tag + children
|
|
424
|
+
const n = node.parts[0];
|
|
425
|
+
const { scope, variable } = findVarInParentScopes(result.scopeManager, path, n.name) || {};
|
|
426
|
+
if (
|
|
427
|
+
scope &&
|
|
428
|
+
(variable ||
|
|
429
|
+
isUpperCase(n.name[0]) ||
|
|
430
|
+
node.name.includes('.') ||
|
|
431
|
+
!htmlTags.includes(node.name))
|
|
432
|
+
) {
|
|
433
|
+
registerNodeInScope(n, scope, variable);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if ('blockParams' in node) {
|
|
438
|
+
const upperScope = findParentScope(result.scopeManager, path);
|
|
439
|
+
const scope = result.isTypescript
|
|
440
|
+
? new TypescriptScope.BlockScope(result.scopeManager, upperScope, node)
|
|
441
|
+
: new Scope(result.scopeManager, 'block', upperScope, node);
|
|
442
|
+
for (const [i, b] of node.params.entries()) {
|
|
443
|
+
const v = new Variable(b.name, scope);
|
|
444
|
+
v.identifiers.push(b);
|
|
445
|
+
v.defs.push(new Definition('Parameter', b, node, node, i, 'Block Param'));
|
|
446
|
+
scope.variables.push(v);
|
|
447
|
+
scope.set.set(b.name, v);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return null;
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
if (counter !== templateInfos.length) {
|
|
454
|
+
throw new Error('failed to process all templates');
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
const replaceRange = function replaceRange(s, start, end, substitute) {
|
|
459
|
+
return s.slice(0, start) + substitute + s.slice(end);
|
|
460
|
+
};
|
|
461
|
+
module.exports.replaceRange = replaceRange;
|
|
462
|
+
|
|
463
|
+
const processor = new ContentTag.Preprocessor();
|
|
464
|
+
|
|
465
|
+
module.exports.transformForLint = function transformForLint(code) {
|
|
466
|
+
let jsCode = code;
|
|
467
|
+
/**
|
|
468
|
+
*
|
|
469
|
+
* @type {{
|
|
470
|
+
* type: 'expression' | 'class-member';
|
|
471
|
+
* tagName: 'template';
|
|
472
|
+
* contents: string;
|
|
473
|
+
* range: {
|
|
474
|
+
* start: number;
|
|
475
|
+
* end: number;
|
|
476
|
+
* };
|
|
477
|
+
* contentRange: {
|
|
478
|
+
* start: number;
|
|
479
|
+
* end: number;
|
|
480
|
+
* };
|
|
481
|
+
* startRange: {
|
|
482
|
+
* end: number;
|
|
483
|
+
* start: number;
|
|
484
|
+
* };
|
|
485
|
+
* endRange: {
|
|
486
|
+
* start: number;
|
|
487
|
+
* end: number;
|
|
488
|
+
* };
|
|
489
|
+
* }[]}
|
|
490
|
+
*/
|
|
491
|
+
const result = processor.parse(code);
|
|
492
|
+
for (const tplInfo of result.reverse()) {
|
|
493
|
+
const lineBreaks = [...tplInfo.contents].reduce(
|
|
494
|
+
(prev, curr) => prev + (DocumentLines.isLineBreak(curr.codePointAt(0)) ? 1 : 0),
|
|
495
|
+
0
|
|
496
|
+
);
|
|
497
|
+
if (tplInfo.type === 'class-member') {
|
|
498
|
+
const tplLength = tplInfo.range.end - tplInfo.range.start;
|
|
499
|
+
const spaces = tplLength - 'static{`'.length - '`}'.length - lineBreaks;
|
|
500
|
+
const total = ' '.repeat(spaces) + '\n'.repeat(lineBreaks);
|
|
501
|
+
const replacementCode = `static{\`${total}\`}`;
|
|
502
|
+
jsCode = replaceRange(jsCode, tplInfo.range.start, tplInfo.range.end, replacementCode);
|
|
503
|
+
} else {
|
|
504
|
+
const tplLength = tplInfo.range.end - tplInfo.range.start;
|
|
505
|
+
const spaces = tplLength - '""`'.length - '`'.length - lineBreaks;
|
|
506
|
+
const total = ' '.repeat(spaces) + '\n'.repeat(lineBreaks);
|
|
507
|
+
const replacementCode = `""\`${total}\``;
|
|
508
|
+
jsCode = replaceRange(jsCode, tplInfo.range.start, tplInfo.range.end, replacementCode);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
/* istanbul ignore next */
|
|
512
|
+
if (jsCode.length !== code.length) {
|
|
513
|
+
throw new Error('bad transform');
|
|
514
|
+
}
|
|
515
|
+
return {
|
|
516
|
+
templateInfos: result,
|
|
517
|
+
output: jsCode,
|
|
518
|
+
};
|
|
519
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const { transformForLint } = require('./transform');
|
|
3
|
+
const babel = require('@babel/core');
|
|
4
|
+
const { replaceRange } = require('./transform');
|
|
5
|
+
|
|
6
|
+
let patchTs, replaceExtensions, syncMtsGtsSourceFiles, typescriptParser;
|
|
7
|
+
|
|
8
|
+
try {
|
|
9
|
+
const ts = require('typescript');
|
|
10
|
+
typescriptParser = require('@typescript-eslint/parser');
|
|
11
|
+
patchTs = function patchTs() {
|
|
12
|
+
const sys = { ...ts.sys };
|
|
13
|
+
const newSys = {
|
|
14
|
+
...ts.sys,
|
|
15
|
+
readDirectory(...args) {
|
|
16
|
+
const results = sys.readDirectory.call(this, ...args);
|
|
17
|
+
return [
|
|
18
|
+
...results,
|
|
19
|
+
...results.filter((x) => x.endsWith('.gts')).map((f) => f.replace(/\.gts$/, '.mts')),
|
|
20
|
+
];
|
|
21
|
+
},
|
|
22
|
+
fileExists(fileName) {
|
|
23
|
+
return fs.existsSync(fileName.replace(/\.mts$/, '.gts')) || fs.existsSync(fileName);
|
|
24
|
+
},
|
|
25
|
+
readFile(fname) {
|
|
26
|
+
let fileName = fname;
|
|
27
|
+
let content = '';
|
|
28
|
+
try {
|
|
29
|
+
content = fs.readFileSync(fileName).toString();
|
|
30
|
+
} catch {
|
|
31
|
+
fileName = fileName.replace(/\.mts$/, '.gts');
|
|
32
|
+
content = fs.readFileSync(fileName).toString();
|
|
33
|
+
}
|
|
34
|
+
if (fileName.endsWith('.gts')) {
|
|
35
|
+
content = transformForLint(content).output;
|
|
36
|
+
}
|
|
37
|
+
if (
|
|
38
|
+
(!fileName.endsWith('.d.ts') && fileName.endsWith('.ts')) ||
|
|
39
|
+
fileName.endsWith('.gts')
|
|
40
|
+
) {
|
|
41
|
+
content = replaceExtensions(content);
|
|
42
|
+
}
|
|
43
|
+
return content;
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
ts.setSys(newSys);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
replaceExtensions = function replaceExtensions(code) {
|
|
50
|
+
let jsCode = code;
|
|
51
|
+
const babelParseResult = babel.parse(jsCode, {
|
|
52
|
+
parserOpts: { ranges: true, plugins: ['typescript'] },
|
|
53
|
+
});
|
|
54
|
+
const length = jsCode.length;
|
|
55
|
+
for (const b of babelParseResult.program.body) {
|
|
56
|
+
if (b.type === 'ImportDeclaration' && b.source.value.endsWith('.gts')) {
|
|
57
|
+
const value = b.source.value.replace(/\.gts$/, '.mts');
|
|
58
|
+
const strWrapper = jsCode[b.source.start];
|
|
59
|
+
jsCode = replaceRange(
|
|
60
|
+
jsCode,
|
|
61
|
+
b.source.start,
|
|
62
|
+
b.source.end,
|
|
63
|
+
strWrapper + value + strWrapper
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (length !== jsCode.length) {
|
|
68
|
+
throw new Error('bad replacement');
|
|
69
|
+
}
|
|
70
|
+
return jsCode;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
*
|
|
75
|
+
* @param program {ts.Program}
|
|
76
|
+
*/
|
|
77
|
+
syncMtsGtsSourceFiles = function syncMtsGtsSourceFiles(program) {
|
|
78
|
+
const sourceFiles = program.getSourceFiles();
|
|
79
|
+
for (const sourceFile of sourceFiles) {
|
|
80
|
+
// check for deleted gts files, need to remove mts as well
|
|
81
|
+
if (sourceFile.path.endsWith('.mts') && sourceFile.isVirtualGts) {
|
|
82
|
+
const gtsFile = program.getSourceFile(sourceFile.path.replace(/\.mts$/, '.gts'));
|
|
83
|
+
if (!gtsFile) {
|
|
84
|
+
sourceFile.version = null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (sourceFile.path.endsWith('.gts')) {
|
|
88
|
+
/**
|
|
89
|
+
* @type {ts.SourceFile}
|
|
90
|
+
*/
|
|
91
|
+
const mtsSourceFile = program.getSourceFile(sourceFile.path.replace(/\.gts$/, '.mts'));
|
|
92
|
+
if (mtsSourceFile) {
|
|
93
|
+
const keep = {
|
|
94
|
+
fileName: mtsSourceFile.fileName,
|
|
95
|
+
path: mtsSourceFile.path,
|
|
96
|
+
originalFileName: mtsSourceFile.originalFileName,
|
|
97
|
+
resolvedPath: mtsSourceFile.resolvedPath,
|
|
98
|
+
};
|
|
99
|
+
Object.assign(mtsSourceFile, sourceFile, keep);
|
|
100
|
+
mtsSourceFile.isVirtualGts = true;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
} catch /* istanbul ignore next */ {
|
|
106
|
+
// typescript not available
|
|
107
|
+
patchTs = () => null;
|
|
108
|
+
replaceExtensions = (code) => code;
|
|
109
|
+
syncMtsGtsSourceFiles = () => null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
module.exports = {
|
|
113
|
+
patchTs,
|
|
114
|
+
replaceExtensions,
|
|
115
|
+
syncMtsGtsSourceFiles,
|
|
116
|
+
typescriptParser,
|
|
117
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* this preprocessor validates if eslint if configured correctly so that gjs/gts are correctly processed by our parser
|
|
3
|
+
* this preprocessor is setup by our recommended rules for gjs/gts, so it will always be called for it
|
|
4
|
+
* the flow is the following:
|
|
5
|
+
* 1. gjs/gts files goes through our parses, which calls registerParsedFile, if its not correctly setup, registerParsedFile will not be called
|
|
6
|
+
* 2. postprocess checks if the file is registered and if not throws an error (only if parsing failed)
|
|
7
|
+
*
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const parsedFiles = new Set();
|
|
11
|
+
|
|
12
|
+
module.exports = {
|
|
13
|
+
registerParsedFile(f) {
|
|
14
|
+
parsedFiles.add(f);
|
|
15
|
+
},
|
|
16
|
+
preprocess: null,
|
|
17
|
+
postprocess: (messages, fileName) => {
|
|
18
|
+
const msgs = messages.flat();
|
|
19
|
+
if (
|
|
20
|
+
msgs.length === 1 &&
|
|
21
|
+
msgs[0].ruleId === null &&
|
|
22
|
+
msgs[0].message.startsWith('Parsing error: ')
|
|
23
|
+
) {
|
|
24
|
+
if (!parsedFiles.has(fileName)) {
|
|
25
|
+
msgs[0].message += '\n';
|
|
26
|
+
msgs[0].message +=
|
|
27
|
+
'To lint Gjs/Gts files please follow the setup guide at https://github.com/ember-cli/eslint-plugin-ember#gtsgjs';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
parsedFiles.delete(fileName); // required for tests
|
|
31
|
+
return msgs;
|
|
32
|
+
},
|
|
33
|
+
supportsAutofix: true,
|
|
34
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {{ line: number; column: number }} Position
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// Helper class to convert line/column from and to offset
|
|
6
|
+
// taken and adapt from https://github.com/typed-ember/glint/blob/main/packages/core/src/language-server/util/position.ts
|
|
7
|
+
class DocumentLines {
|
|
8
|
+
/**
|
|
9
|
+
* @param {string} contents
|
|
10
|
+
*/
|
|
11
|
+
constructor(contents) {
|
|
12
|
+
this.lineStarts = computeLineStarts(contents);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {Position} position
|
|
17
|
+
* @return {number}
|
|
18
|
+
*/
|
|
19
|
+
positionToOffset(position) {
|
|
20
|
+
const { line, column } = position;
|
|
21
|
+
return this.lineStarts[line - 1] + column;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
*
|
|
26
|
+
* @param {number} position
|
|
27
|
+
* @return {Position}
|
|
28
|
+
*/
|
|
29
|
+
offsetToPosition(position) {
|
|
30
|
+
const lineStarts = this.lineStarts;
|
|
31
|
+
let line = 0;
|
|
32
|
+
while (line + 1 < lineStarts.length && lineStarts[line + 1] <= position) {
|
|
33
|
+
line++;
|
|
34
|
+
}
|
|
35
|
+
const column = position - lineStarts[line];
|
|
36
|
+
return { line: line + 1, column };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @returns {number[]}
|
|
42
|
+
* @param {string} text
|
|
43
|
+
*/
|
|
44
|
+
function computeLineStarts(text) {
|
|
45
|
+
const result = [];
|
|
46
|
+
let pos = 0;
|
|
47
|
+
let lineStart = 0;
|
|
48
|
+
while (pos < text.length) {
|
|
49
|
+
const ch = text.codePointAt(pos);
|
|
50
|
+
pos++;
|
|
51
|
+
switch (ch) {
|
|
52
|
+
case 13 /* carriageReturn */: {
|
|
53
|
+
if (text.codePointAt(pos) === 10 /* lineFeed */) {
|
|
54
|
+
pos++;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// falls through
|
|
58
|
+
case 10 /* lineFeed */: {
|
|
59
|
+
result.push(lineStart);
|
|
60
|
+
lineStart = pos;
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
default: {
|
|
64
|
+
if (ch > 127 /* maxAsciiCharacter */ && isLineBreak(ch)) {
|
|
65
|
+
result.push(lineStart);
|
|
66
|
+
lineStart = pos;
|
|
67
|
+
}
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
result.push(lineStart);
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/* istanbul ignore next */
|
|
77
|
+
/**
|
|
78
|
+
* @param {number} ch
|
|
79
|
+
* @return {boolean}
|
|
80
|
+
*/
|
|
81
|
+
function isLineBreak(ch) {
|
|
82
|
+
// ES5 7.3:
|
|
83
|
+
// The ECMAScript line terminator characters are listed in Table 3.
|
|
84
|
+
// Table 3: Line Terminator Characters
|
|
85
|
+
// Code Unit Value Name Formal Name
|
|
86
|
+
// \u000A Line Feed <LF>
|
|
87
|
+
// \u000D Carriage Return <CR>
|
|
88
|
+
// \u2028 Line separator <LS>
|
|
89
|
+
// \u2029 Paragraph separator <PS>
|
|
90
|
+
// Only the characters in Table 3 are treated as line terminators. Other new line or line
|
|
91
|
+
// breaking characters are treated as white space but not as line terminators.
|
|
92
|
+
return (
|
|
93
|
+
ch === 10 /* lineFeed */ ||
|
|
94
|
+
ch === 13 /* carriageReturn */ ||
|
|
95
|
+
ch === 8232 /* lineSeparator */ ||
|
|
96
|
+
ch === 8233 /* paragraphSeparator */
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = DocumentLines;
|
|
101
|
+
module.exports.isLineBreak = isLineBreak;
|