ember-eslint-parser 0.5.12 → 0.6.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # ember-eslint-parser
2
2
 
3
- This is the eslint parser for ember's gjs and gts files (using `<template>`).
3
+ This is the eslint parser for ember's gjs and gts files (using `<template>`), and also for Handlebars (`.hbs`) template files.
4
4
 
5
5
  It is meant to be used with [eslint-plugin-ember](https://github.com/ember-cli/eslint-plugin-ember), which provides nice defaults for all the different file types in ember projects.
6
6
 
@@ -31,6 +31,26 @@ It's recommended to only use _overrides_ when defining your eslint config, so us
31
31
  if we detect a typescript parser, it will also be used for all files, otherwise babel parser will be used.
32
32
  If we cannot find a typescript parser when linting gts we throw an error.
33
33
 
34
+ ## HBS (Handlebars) support
35
+
36
+ For `.hbs` template files, use the `ember-eslint-parser/hbs` parser. In ESLint's flat config format (ESLint 9+):
37
+
38
+ ```js
39
+ // eslint.config.mjs
40
+ import hbsParser from 'ember-eslint-parser/hbs';
41
+
42
+ export default [
43
+ {
44
+ files: ['**/*.hbs'],
45
+ languageOptions: {
46
+ parser: hbsParser,
47
+ },
48
+ },
49
+ ];
50
+ ```
51
+
52
+ > **Note:** In `.hbs` files, all locals not defined in the template are assumed to be defined at runtime. This avoids false-positive `no-undef` errors for template identifiers. This is a known limitation of the classic HBS format — use `.gjs`/`.gts` for full static analysis.
53
+
34
54
  ## Support
35
55
 
36
56
  eslint-plugin-ember is the primary consumer of this parser library, so SemVer _may_ not be respected for other consumers.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ember-eslint-parser",
3
- "version": "0.5.12",
3
+ "version": "0.6.0",
4
4
  "description": "",
5
5
  "keywords": [],
6
6
  "repository": {
@@ -11,6 +11,7 @@
11
11
  "author": "",
12
12
  "exports": {
13
13
  ".": "./src/parser/gjs-gts-parser.js",
14
+ "./hbs": "./src/parser/hbs-parser.js",
14
15
  "./noop": "./src/preprocessor/noop.js"
15
16
  },
16
17
  "main": "src/parser/gjs-gts-parser.js",
@@ -44,7 +45,7 @@
44
45
  "fs-extra": "^11.2.0",
45
46
  "prettier": "^3.2.5",
46
47
  "publint": "^0.2.6",
47
- "release-plan": "^0.16.0",
48
+ "release-plan": "^0.17.4",
48
49
  "typescript": "^5.3.3",
49
50
  "vite": "^5.0.12",
50
51
  "vitest": "^1.2.2"
@@ -1,5 +1,5 @@
1
1
  const tsconfigUtils = require('@typescript-eslint/tsconfig-utils');
2
- const babelParser = require('@babel/eslint-parser');
2
+ const babelParser = require('@babel/eslint-parser/experimental-worker');
3
3
  const { registerParsedFile } = require('../preprocessor/noop');
4
4
  const {
5
5
  patchTs,
@@ -0,0 +1,89 @@
1
+ const eslintScope = require('eslint-scope');
2
+ const DocumentLines = require('../utils/document');
3
+ const { processGlimmerTemplate, buildGlimmerVisitorKeys } = require('./transforms');
4
+
5
+ /**
6
+ * implements https://eslint.org/docs/latest/extend/custom-parsers
7
+ * for Handlebars (.hbs) template files.
8
+ *
9
+ * The entire file is treated as a Glimmer template.
10
+ * All locals not defined in the template are assumed to be defined
11
+ * (no no-undef errors for template identifiers).
12
+ */
13
+
14
+ /**
15
+ * @type {import('eslint').ParserModule}
16
+ */
17
+ module.exports = {
18
+ meta: {
19
+ name: 'ember-eslint-parser/hbs',
20
+ version: '*',
21
+ },
22
+
23
+ parseForESLint(code, options) {
24
+ const filePath = (options && options.filePath) || '<hbs>';
25
+ const codeLines = new DocumentLines(code);
26
+
27
+ let result;
28
+ try {
29
+ result = processGlimmerTemplate({
30
+ templateContent: code,
31
+ codeLines,
32
+ templateRange: [0, code.length],
33
+ });
34
+ } catch (e) {
35
+ // Transform glimmer parse error to ESLint-compatible error
36
+ const loc = e.location || (e.hash && e.hash.loc);
37
+ if (loc && loc.start) {
38
+ const err = Object.assign(new SyntaxError(e.message), {
39
+ lineNumber: loc.start.line,
40
+ column: loc.start.column,
41
+ index: codeLines.positionToOffset(loc.start),
42
+ fileName: filePath,
43
+ });
44
+ throw err;
45
+ }
46
+ throw e;
47
+ }
48
+
49
+ const { ast: templateNode, comments } = result;
50
+
51
+ // Wrap in a synthetic Program node (required by ESLint)
52
+ const program = {
53
+ type: 'Program',
54
+ body: [templateNode],
55
+ tokens: templateNode.tokens,
56
+ comments,
57
+ range: [0, code.length],
58
+ start: 0,
59
+ end: code.length,
60
+ loc: {
61
+ start: { line: 1, column: 0 },
62
+ end: codeLines.offsetToPosition(code.length),
63
+ },
64
+ };
65
+
66
+ // Build visitor keys: Program + all Glimmer node types
67
+ const visitorKeys = { Program: ['body'], ...buildGlimmerVisitorKeys() };
68
+
69
+ // Create an empty scope manager.
70
+ // For HBS, all locals are assumed to be defined at runtime,
71
+ // so we don't track variable references (no no-undef errors).
72
+ const scopeManager = eslintScope.analyze(
73
+ {
74
+ type: 'Program',
75
+ body: [],
76
+ range: [0, code.length],
77
+ loc: program.loc,
78
+ },
79
+ { range: true }
80
+ );
81
+
82
+ return {
83
+ ast: program,
84
+ scopeManager,
85
+ visitorKeys,
86
+ services: {},
87
+ };
88
+ },
89
+ };
@@ -1,7 +1,7 @@
1
1
  const ContentTag = require('content-tag');
2
2
  const glimmer = require('@glimmer/syntax');
3
+ const { visitorKeys: glimmerVisitorKeys } = glimmer;
3
4
  const DocumentLines = require('../utils/document');
4
- const { visitorKeys: glimmerVisitorKeys } = require('@glimmer/syntax');
5
5
  const { Reference, Scope, Variable, Definition } = require('eslint-scope');
6
6
  const htmlTags = require('html-tags');
7
7
  const svgTags = require('svg-tags');
@@ -112,6 +112,24 @@ function registerNodeInScope(node, scope, variable) {
112
112
  scope.references.push(ref);
113
113
  }
114
114
 
115
+ /**
116
+ * Builds the complete Glimmer visitor keys map with "Glimmer" prefix and
117
+ * additional keys needed for traversal (blockParamNodes, parts, etc).
118
+ * @return {object}
119
+ */
120
+ function buildGlimmerVisitorKeys() {
121
+ const keys = {};
122
+ for (const [k, v] of Object.entries(glimmerVisitorKeys)) {
123
+ keys[`Glimmer${k}`] = [...v];
124
+ }
125
+ if (!keys.GlimmerElementNode.includes('blockParamNodes')) {
126
+ keys.GlimmerElementNode.push('blockParamNodes', 'parts');
127
+ }
128
+ keys.GlimmerProgram = ['body', 'blockParamNodes'];
129
+ keys.GlimmerTemplate = ['body'];
130
+ return keys;
131
+ }
132
+
115
133
  /**
116
134
  * traverses all nodes using the {visitorKeys} calling the callback function, visitor
117
135
  * @param visitorKeys
@@ -119,7 +137,7 @@ function registerNodeInScope(node, scope, variable) {
119
137
  * @param visitor
120
138
  */
121
139
  function traverse(visitorKeys, node, visitor) {
122
- const allVisitorKeys = visitorKeys;
140
+ const allVisitorKeys = { ...visitorKeys, ...buildGlimmerVisitorKeys() };
123
141
  const queue = [];
124
142
 
125
143
  queue.push({
@@ -137,17 +155,7 @@ function traverse(visitorKeys, node, visitor) {
137
155
 
138
156
  if (!currentPath.node) continue;
139
157
 
140
- const visitorKeys = [...(allVisitorKeys[currentPath.node.type] || [])];
141
- if (currentPath.node.type === 'GlimmerElementNode') {
142
- if (!visitorKeys.includes('blockParamNodes')) {
143
- visitorKeys.push('blockParamNodes', 'parts');
144
- }
145
- }
146
- if (currentPath.node.type === 'GlimmerProgram') {
147
- if (!visitorKeys.includes('blockParamNodes')) {
148
- visitorKeys.push('blockParamNodes');
149
- }
150
- }
158
+ const visitorKeys = allVisitorKeys[currentPath.node.type];
151
159
  if (!visitorKeys) {
152
160
  continue;
153
161
  }
@@ -242,6 +250,187 @@ function tokenize(template, doc, startOffset) {
242
250
  return tokens;
243
251
  }
244
252
 
253
+ /**
254
+ * Traverses a Glimmer AST, sets parent references, and categorizes nodes.
255
+ * @param {object} ast
256
+ * @return {{ allNodes: object[], comments: object[], textNodes: object[], emptyTextNodes: object[] }}
257
+ */
258
+ function collectNodes(ast) {
259
+ const allNodes = [];
260
+ const comments = [];
261
+ const textNodes = [];
262
+ const emptyTextNodes = [];
263
+
264
+ glimmer.traverse(ast, {
265
+ All(node, path) {
266
+ node.parent = path.parentNode;
267
+ allNodes.push(node);
268
+ if (node.type === 'CommentStatement' || node.type === 'MustacheCommentStatement') {
269
+ comments.push(node);
270
+ }
271
+ if (node.type === 'TextNode') {
272
+ node.value = node.chars;
273
+ if (node.value.trim().length !== 0 || (node.parent && node.parent.type === 'AttrNode')) {
274
+ textNodes.push(node);
275
+ } else {
276
+ emptyTextNodes.push(node);
277
+ }
278
+ }
279
+ },
280
+ });
281
+
282
+ return { allNodes, comments, textNodes, emptyTextNodes };
283
+ }
284
+
285
+ /**
286
+ * Removes nodes from their parent's children/body/parts arrays.
287
+ * @param {object[]} nodes
288
+ */
289
+ function removeFromParent(nodes) {
290
+ for (const node of nodes) {
291
+ const children =
292
+ (node.parent && (node.parent.children || node.parent.body || node.parent.parts)) || [];
293
+ const idx = children.indexOf(node);
294
+ if (idx >= 0) {
295
+ children.splice(idx, 1);
296
+ }
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Builds the final token stream by filtering out tokens covered by comments
302
+ * or text nodes, then merging text nodes back in sorted order.
303
+ * @param {object[]} rawTokens
304
+ * @param {object[]} comments
305
+ * @param {object[]} textNodes
306
+ * @return {object[]}
307
+ */
308
+ function buildTokenStream(rawTokens, comments, textNodes) {
309
+ let tokens = rawTokens.filter(
310
+ (t) => !comments.some((c) => c.range[0] <= t.range[0] && c.range[1] >= t.range[1])
311
+ );
312
+ tokens = tokens.filter(
313
+ (t) => !textNodes.some((c) => c.range[0] <= t.range[0] && c.range[1] >= t.range[1])
314
+ );
315
+
316
+ const sortedTextNodes = [...textNodes];
317
+ let currentTextNode = sortedTextNodes.pop();
318
+ for (let i = tokens.length - 1; i >= 0; i--) {
319
+ const t = tokens[i];
320
+ while (currentTextNode && t.range[0] < currentTextNode.range[0]) {
321
+ tokens.splice(i + 1, 0, currentTextNode);
322
+ currentTextNode = sortedTextNodes.pop();
323
+ }
324
+ }
325
+ if (currentTextNode) {
326
+ tokens.unshift(currentTextNode);
327
+ }
328
+
329
+ return tokens;
330
+ }
331
+
332
+ /**
333
+ * Parses a Glimmer template and produces a processed AST ready for ESLint.
334
+ * Shared between hbs-parser (standalone .hbs files) and gjs/gts parser (embedded templates).
335
+ *
336
+ * @param {object} options
337
+ * @param {string} options.templateContent - The template string to parse with glimmer
338
+ * @param {DocumentLines} options.codeLines - DocumentLines for the full source file
339
+ * @param {[number, number]} options.templateRange - Range [start, end] for the Template root node
340
+ * @param {string} [options.tokenSource] - String to tokenize (defaults to templateContent)
341
+ * @return {{ ast: object, comments: object[] }}
342
+ */
343
+ function processGlimmerTemplate({ templateContent, codeLines, templateRange, tokenSource }) {
344
+ const offset = templateRange[0];
345
+ const docLines = new DocumentLines(templateContent);
346
+
347
+ /** Convert a Glimmer loc to a file-level [start, end] range */
348
+ const toFileRange = (loc) => [
349
+ offset + docLines.positionToOffset(loc.start),
350
+ offset + docLines.positionToOffset(loc.end),
351
+ ];
352
+ /** Convert a file-level range to a file-level loc */
353
+ const toFileLoc = (range) => ({
354
+ start: codeLines.offsetToPosition(range[0]),
355
+ end: codeLines.offsetToPosition(range[1]),
356
+ });
357
+
358
+ const ast = glimmer.preprocess(templateContent, { mode: 'codemod' });
359
+ const { allNodes, comments, textNodes, emptyTextNodes } = collectNodes(ast);
360
+
361
+ // Fix ranges, locs, and prefix types with "Glimmer"
362
+ for (const n of allNodes) {
363
+ if (n.type === 'PathExpression') {
364
+ n.head.range = toFileRange(n.head.loc);
365
+ n.head.loc = toFileLoc(n.head.range);
366
+ }
367
+
368
+ n.range = n.type === 'Template' ? [...templateRange] : toFileRange(n.loc);
369
+ n.start = n.range[0];
370
+ n.end = n.range[1];
371
+ n.loc = toFileLoc(n.range);
372
+
373
+ if (n.type === 'ElementNode') {
374
+ n.name = n.tag;
375
+ n.parts = [n.path.head].map((p) => {
376
+ const range = toFileRange(p.loc);
377
+ return {
378
+ ...p,
379
+ name: p.original,
380
+ parent: n,
381
+ type: 'GlimmerElementNodePart',
382
+ range,
383
+ loc: toFileLoc(range),
384
+ };
385
+ });
386
+ }
387
+
388
+ if ('blockParams' in n) {
389
+ n.params = (n.params || []).map((p) => {
390
+ const range = toFileRange(p.loc);
391
+ return {
392
+ ...p,
393
+ type: 'BlockParam',
394
+ name: p.original,
395
+ parent: n,
396
+ range,
397
+ loc: toFileLoc(range),
398
+ };
399
+ });
400
+ }
401
+
402
+ n.type = `Glimmer${n.type}`;
403
+ }
404
+
405
+ // Clean up AST structure
406
+ removeFromParent(emptyTextNodes);
407
+ removeFromParent(comments);
408
+ for (const comment of comments) {
409
+ comment.type = 'Block';
410
+ }
411
+
412
+ // Nullify empty hashes
413
+ for (const n of allNodes) {
414
+ if (
415
+ ['GlimmerMustacheStatement', 'GlimmerBlockStatement', 'GlimmerSubExpression'].includes(n.type)
416
+ ) {
417
+ if (n.hash && n.hash.pairs && n.hash.pairs.length === 0) {
418
+ n.hash = null;
419
+ }
420
+ }
421
+ }
422
+
423
+ // Build final token stream
424
+ ast.tokens = buildTokenStream(
425
+ tokenize(tokenSource || templateContent, codeLines, offset),
426
+ comments,
427
+ textNodes
428
+ );
429
+ ast.contents = templateContent;
430
+
431
+ return { ast, comments };
432
+ }
433
+
245
434
  /**
246
435
  * Preprocesses the template info, parsing the template content to Glimmer AST,
247
436
  * fixing the offsets and locations of all nodes
@@ -257,164 +446,34 @@ module.exports.preprocessGlimmerTemplates = function preprocessGlimmerTemplates(
257
446
  templateRange: [r.range.start, r.range.end],
258
447
  utf16Range: [byteToCharIndex(code, r.range.start), byteToCharIndex(code, r.range.end)],
259
448
  }));
260
- const templateVisitorKeys = {};
261
449
  const codeLines = new DocumentLines(code);
262
- const comments = [];
450
+ const allComments = [];
451
+ const templateVisitorKeys = {};
452
+
263
453
  for (const tpl of templateInfos) {
264
- const currentComments = [];
265
- const textNodes = [];
266
- const emptyTextNodes = [];
267
454
  const range = tpl.utf16Range;
268
- const offset = range[0];
269
455
  const template = code.slice(...range);
270
- const docLines = new DocumentLines(template);
271
- const ast = glimmer.preprocess(template, { mode: 'codemod' });
272
- ast.tokens = tokenize(sliceByteRange(code, ...tpl.templateRange), codeLines, tpl.utf16Range[0]);
273
- const allNodes = [];
274
- glimmer.traverse(ast, {
275
- All(node, path) {
276
- const n = node;
277
- n.parent = path.parentNode;
278
- allNodes.push(node);
279
- if (node.type === 'CommentStatement' || node.type === 'MustacheCommentStatement') {
280
- comments.push(node);
281
- currentComments.push(node);
282
- }
283
- if (node.type === 'TextNode') {
284
- n.value = node.chars;
285
- // empty text nodes are not allowed, it's empty if its only whitespace or line terminators
286
- // it's okay for AttrNodes
287
- if (n.value.trim().length !== 0 || n.parent.type === 'AttrNode') {
288
- textNodes.push(node);
289
- } else {
290
- emptyTextNodes.push(node);
291
- }
292
- }
293
- },
294
- });
295
- ast.content = template;
296
- const allNodeTypes = new Set();
297
- for (const n of allNodes) {
298
- if (n.type === 'PathExpression') {
299
- n.head.range = [
300
- offset + docLines.positionToOffset(n.head.loc.start),
301
- offset + docLines.positionToOffset(n.head.loc.end),
302
- ];
303
- n.head.loc = {
304
- start: codeLines.offsetToPosition(n.head.range[0]),
305
- end: codeLines.offsetToPosition(n.head.range[1]),
306
- };
307
- }
308
- n.range =
309
- n.type === 'Template'
310
- ? [...tpl.utf16Range]
311
- : [
312
- offset + docLines.positionToOffset(n.loc.start),
313
- offset + docLines.positionToOffset(n.loc.end),
314
- ];
315
- n.start = n.range[0];
316
- n.end = n.range[1];
317
- n.loc = {
318
- start: codeLines.offsetToPosition(n.range[0]),
319
- end: codeLines.offsetToPosition(n.range[1]),
320
- };
321
- if (n.type === 'Template') {
322
- n.loc.start = codeLines.offsetToPosition(tpl.utf16Range[0]);
323
- n.loc.end = codeLines.offsetToPosition(tpl.utf16Range[1]);
324
- }
325
-
326
- if (n.type === 'ElementNode') {
327
- n.name = n.tag;
328
- n.parts = [n.path.head].map((p) => {
329
- const range = [
330
- offset + docLines.positionToOffset(p.loc.start),
331
- offset + docLines.positionToOffset(p.loc.end),
332
- ];
333
- const loc = {
334
- start: codeLines.offsetToPosition(range[0]),
335
- end: codeLines.offsetToPosition(range[1]),
336
- };
337
- return {
338
- ...p,
339
- name: p.original,
340
- parent: n,
341
- type: 'GlimmerElementNodePart',
342
- range,
343
- loc,
344
- };
345
- });
346
- }
347
456
 
348
- if ('blockParams' in n) {
349
- n.params = (n.params || []).map((p) => {
350
- const range = [
351
- offset + docLines.positionToOffset(p.loc.start),
352
- offset + docLines.positionToOffset(p.loc.end),
353
- ];
354
- const loc = {
355
- start: codeLines.offsetToPosition(range[0]),
356
- end: codeLines.offsetToPosition(range[1]),
357
- };
358
- return {
359
- ...p,
360
- type: 'BlockParam',
361
- name: p.original,
362
- parent: n,
363
- range,
364
- loc,
365
- };
366
- });
367
- }
368
- n.type = `Glimmer${n.type}`;
369
- allNodeTypes.add(n.type);
370
- }
371
-
372
- // ast should not contains empty text nodes
373
- for (const node of emptyTextNodes) {
374
- const children = node.parent.children || node.parent.body || node.parent.parts;
375
- const idx = children.indexOf(node);
376
- children.splice(idx, 1);
377
- }
457
+ const { ast, comments } = processGlimmerTemplate({
458
+ templateContent: template,
459
+ codeLines,
460
+ templateRange: [...tpl.utf16Range],
461
+ tokenSource: sliceByteRange(code, ...tpl.templateRange),
462
+ });
378
463
 
379
- // ast should not contain comment nodes
380
- for (const comment of currentComments) {
381
- const parentBody = comment.parent.body || comment.parent.children;
382
- const idx = parentBody.indexOf(comment);
383
- if (idx >= 0) {
384
- parentBody.splice(idx, 1);
385
- }
386
- // comment type can be a block comment or a line comment
387
- // mark comments as always block comment, this works for eslint in all cases
388
- comment.type = 'Block';
389
- }
390
- // tokens should not contain tokens of comments
391
- ast.tokens = ast.tokens.filter(
392
- (t) => !comments.some((c) => c.range[0] <= t.range[0] && c.range[1] >= t.range[1])
393
- );
394
- // tokens should not contain tokens of text nodes, but represent the whole node
395
- // remove existing tokens
396
- ast.tokens = ast.tokens.filter(
397
- (t) => !textNodes.some((c) => c.range[0] <= t.range[0] && c.range[1] >= t.range[1])
398
- );
399
- // merge in text nodes
400
- let currentTextNode = textNodes.pop();
401
- for (let i = ast.tokens.length - 1; i >= 0; i--) {
402
- const t = ast.tokens[i];
403
- while (currentTextNode && t.range[0] < currentTextNode.range[0]) {
404
- ast.tokens.splice(i + 1, 0, currentTextNode);
405
- currentTextNode = textNodes.pop();
406
- }
407
- }
408
- ast.contents = template;
464
+ ast.content = template;
465
+ allComments.push(...comments);
409
466
  tpl.ast = ast;
410
467
  }
468
+
411
469
  for (const [k, v] of Object.entries(glimmerVisitorKeys)) {
412
470
  templateVisitorKeys[`Glimmer${k}`] = [...v];
413
471
  }
472
+
414
473
  return {
415
474
  templateVisitorKeys,
416
475
  templateInfos,
417
- comments,
476
+ comments: allComments,
418
477
  };
419
478
  };
420
479
 
@@ -557,21 +616,6 @@ module.exports.convertAst = function convertAst(result, preprocessedResult, visi
557
616
  return null;
558
617
  });
559
618
 
560
- traverse(visitorKeys, result.ast, (path) => {
561
- const node = path.node;
562
- if (!node) return null;
563
-
564
- if (
565
- ['GlimmerMustacheStatement', 'GlimmerBlockStatement', 'GlimmerSubExpression'].includes(
566
- node.type
567
- )
568
- ) {
569
- if (node.hash && node.hash.pairs.length === 0) {
570
- node.hash = null;
571
- }
572
- }
573
- });
574
-
575
619
  if (counter !== templateInfos.length) {
576
620
  throw new Error('failed to process all templates');
577
621
  }
@@ -690,3 +734,5 @@ module.exports.transformForLint = function transformForLint(code, fileName) {
690
734
 
691
735
  module.exports.traverse = traverse;
692
736
  module.exports.tokenize = tokenize;
737
+ module.exports.processGlimmerTemplate = processGlimmerTemplate;
738
+ module.exports.buildGlimmerVisitorKeys = buildGlimmerVisitorKeys;