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 CHANGED
@@ -1,12 +1,49 @@
1
1
  {
2
2
  "name": "ember-eslint-parser",
3
- "version": "0.0.1",
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
- "license": "ISC"
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;