ember-estree 0.5.0 → 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
@@ -63,14 +63,35 @@ Both `toTree` and `parse` accept an options object as their second argument.
63
63
 
64
64
  All options are optional.
65
65
 
66
- | Option | Type | Description |
67
- | -------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
68
- | `filePath` | `string` | Used for language detection. |
69
- | `templateOnly` | `boolean` | Parse the source as a raw Glimmer template. Use for `.hbs` files. |
70
- | `parser` | `(placeholderJS: string) => { ast, ... }` | Use a custom JS/TS parser instead of the default oxc-parser. See [Custom parser](#custom-parser). |
71
- | `visitors` | `VisitorMap` <br /> or `(outerAst) => VisitorMap` | Callbacks fired on every node during traversal — JS/TS and Glimmer in a single pass. See [Visitors](#visitors). |
72
-
73
- Handler signature is `(node, path) => void`, where `path = { node, parent, parentPath }` — a linked list walking back to the root.
66
+ | Option | Type | Description |
67
+ | -------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
68
+ | `filePath` | `string` | Used for language detection. |
69
+ | `tokens` | `boolean` | Generate a flat `ast.tokens` array. Required by ESLint; skipped by default so codemods and type-checkers pay nothing. |
70
+ | `templateOnly` | `boolean` | Parse the source as a raw Glimmer template. Use for `.hbs` files. |
71
+ | `parser` | `(placeholderJS: string) => { ast, ... }` | Use a custom JS/TS parser instead of the default oxc-parser. See [Custom parser](#custom-parser). |
72
+ | `visitors` | `VisitorMap` <br /> or `(outerAst) => VisitorMap` | Callbacks fired on every node during traversal — JS/TS and Glimmer — in a single pass. See [Visitors](#visitors). |
73
+
74
+ Handler signature is `(node, path) => void`, where `path = { node, parent, parentPath }` — a linked list that walks all the way back through the JS/TS root, so visitors can locate the enclosing scope or class from within a Glimmer subtree.
75
+
76
+ ### Token stream
77
+
78
+ Pass `tokens: true` to populate `ast.tokens` with a flat, position-sorted array of lexemes spanning the full file — including Glimmer tokens spliced in place of each `<template>` region. This is what ESLint's `SourceCode` needs; omit it for codemods or type-checkers that don't use the token stream.
79
+
80
+ ```js
81
+ import { toTree } from "ember-estree";
82
+
83
+ const result = toTree(source, {
84
+ tokens: true,
85
+ parser: myTsParser,
86
+ });
87
+ // result.ast.program.tokens now contains JS + Glimmer tokens in source order
88
+ ```
89
+
90
+ For `.hbs` files via `templateOnly`, pass both flags:
91
+
92
+ ```js
93
+ toTree(hbsSource, { templateOnly: true, tokens: true });
94
+ ```
74
95
 
75
96
  ### Custom parser
76
97
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ember-estree",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "ESTree generator for gjs and gts file used by ember",
5
5
  "keywords": [
6
6
  "AST",
package/src/parse.js CHANGED
@@ -31,6 +31,7 @@ const PLACEHOLDER_TYPES = new Set([
31
31
  * @param {string} source
32
32
  * @param {object} [options]
33
33
  * @param {string} [options.filePath] - File path for language detection
34
+ * @param {boolean} [options.tokens] - Generate a flat token stream on the AST (needed by ESLint; skipped by default)
34
35
  * @param {boolean} [options.templateOnly] - Parse as raw Glimmer template content (for .hbs)
35
36
  * @param {function} [options.parser] - Custom JS/TS parser: (placeholderJS) => { ast, scopeManager?, visitorKeys?, services?, ... }
36
37
  * @param {object|function} [options.visitors] - Either a map of `{ [Type]: (node, path) => void }`
@@ -41,8 +42,13 @@ const PLACEHOLDER_TYPES = new Set([
41
42
  * @return {object}
42
43
  */
43
44
  export function toTree(source, options = {}) {
45
+ const generateTokens = !!options.tokens;
46
+
44
47
  if (options.templateOnly) {
45
- return processTemplate(source, new DocumentLines(source), [0, source.length]);
48
+ return processTemplate(source, new DocumentLines(source), {
49
+ templateRange: [0, source.length],
50
+ tokens: generateTokens,
51
+ });
46
52
  }
47
53
 
48
54
  let parseResults = preprocessor.parse(source);
@@ -115,7 +121,10 @@ export function toTree(source, options = {}) {
115
121
  ];
116
122
  let fullRange = [parseResult.range.startUtf16Codepoint, parseResult.range.endUtf16Codepoint];
117
123
 
118
- const { ast } = processTemplate(templateContent, codeLines, contentRange);
124
+ const { ast } = processTemplate(templateContent, codeLines, {
125
+ templateRange: contentRange,
126
+ tokens: generateTokens,
127
+ });
119
128
 
120
129
  // Fix the Template root to cover the full <template>...</template> range
121
130
  ast.range = fullRange;
@@ -126,27 +135,29 @@ export function toTree(source, options = {}) {
126
135
  end: codeLines.offsetToPosition(fullRange[1]),
127
136
  };
128
137
 
129
- // Add tokens for the <template> and </template> tags
130
- const openEnd = contentRange[0];
131
- const closeStart = contentRange[1];
132
- const openTag = source.slice(fullRange[0], openEnd);
133
- const closeTag = source.slice(closeStart, fullRange[1]);
134
- const makeToken = (value, range) => ({
135
- type: "Punctuator",
136
- value,
137
- range,
138
- start: range[0],
139
- end: range[1],
140
- loc: {
141
- start: codeLines.offsetToPosition(range[0]),
142
- end: codeLines.offsetToPosition(range[1]),
143
- },
144
- });
145
- ast.tokens = [
146
- makeToken(openTag, [fullRange[0], openEnd]),
147
- ...(ast.tokens || []),
148
- makeToken(closeTag, [closeStart, fullRange[1]]),
149
- ];
138
+ if (generateTokens) {
139
+ // Add tokens for the <template> and </template> tags
140
+ const openEnd = contentRange[0];
141
+ const closeStart = contentRange[1];
142
+ const openTag = source.slice(fullRange[0], openEnd);
143
+ const closeTag = source.slice(closeStart, fullRange[1]);
144
+ const makeToken = (value, range) => ({
145
+ type: "Punctuator",
146
+ value,
147
+ range,
148
+ start: range[0],
149
+ end: range[1],
150
+ loc: {
151
+ start: codeLines.offsetToPosition(range[0]),
152
+ end: codeLines.offsetToPosition(range[1]),
153
+ },
154
+ });
155
+ ast.tokens = [
156
+ makeToken(openTag, [fullRange[0], openEnd]),
157
+ ...(ast.tokens || []),
158
+ makeToken(closeTag, [closeStart, fullRange[1]]),
159
+ ];
160
+ }
150
161
 
151
162
  templateInfos.push({ utf16Range: fullRange, ast });
152
163
  return ast;
@@ -182,7 +193,14 @@ export function toTree(source, options = {}) {
182
193
  // configured we re-enter the walk manually via `visit()` to
183
194
  // dispatch them across the Glimmer nodes. With no handlers the
184
195
  // walk would be pure overhead, so just return the subtree.
185
- return hasVisitors ? visit(ast, null) : ast;
196
+ //
197
+ // Pass `state` (the placeholder's inherited parent context) so the
198
+ // Glimmer root's parentPath reflects its true JS parent. The
199
+ // placeholder (TemplateLiteral / StaticBlock) is an internal
200
+ // artifact — the GlimmerTemplate logically lives where the
201
+ // placeholder was, so its parent is e.g. VariableDeclarator or
202
+ // ClassBody, not the placeholder itself.
203
+ return hasVisitors ? visit(ast, state) : ast;
186
204
  }
187
205
  }
188
206
 
package/src/tokens.js ADDED
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Token stream generation for Glimmer templates.
3
+ *
4
+ * Only needed by ESLint consumers — codemods, type-checkers, and formatters
5
+ * do not use the flat token stream and skip this entirely by omitting
6
+ * `{ tokens: true }` from processTemplate options.
7
+ *
8
+ * ESLint-specific note: comment tokens are placed at range[0]+1 rather than
9
+ * range[0] because ESLint's createIndexMap infinite-loops when a token and an
10
+ * ast.comments entry share range[0] — both inner loops fail the strict-less-than
11
+ * guard and the outer loop never advances. Tracked upstream as eslint/eslint#20492.
12
+ */
13
+
14
+ function isAlphaNumeric(code) {
15
+ return (code >= 48 && code <= 57) || (code >= 65 && code <= 90) || (code >= 97 && code <= 122);
16
+ }
17
+
18
+ /**
19
+ * Lex a Glimmer template into a flat token array.
20
+ * Tracks line/column incrementally from a single seed call to
21
+ * doc.offsetToPosition — O(n + log L) instead of O(n log L).
22
+ */
23
+ export function tokenize(template, doc, startOffset) {
24
+ const tokens = [];
25
+ let wordStart = -1;
26
+
27
+ // Seed position from the start offset — one binary search total.
28
+ // Then track line/column incrementally (DocumentLines counts only \n
29
+ // as line separators, so this matches offsetToPosition exactly).
30
+ let { line: curLine, column: curCol } = doc.offsetToPosition(startOffset);
31
+ let wordLine = 0,
32
+ wordCol = 0;
33
+
34
+ for (let i = 0; i < template.length; i++) {
35
+ const code = template.charCodeAt(i);
36
+ if (isAlphaNumeric(code)) {
37
+ if (wordStart < 0) {
38
+ wordStart = i;
39
+ wordLine = curLine;
40
+ wordCol = curCol;
41
+ }
42
+ curCol++;
43
+ } else {
44
+ if (wordStart >= 0) {
45
+ const absStart = startOffset + wordStart;
46
+ const absEnd = startOffset + i;
47
+ tokens.push({
48
+ type: "word",
49
+ value: template.slice(wordStart, i),
50
+ range: [absStart, absEnd],
51
+ start: absStart,
52
+ end: absEnd,
53
+ loc: {
54
+ start: { line: wordLine, column: wordCol, index: absStart },
55
+ end: { line: curLine, column: curCol, index: absEnd },
56
+ },
57
+ });
58
+ wordStart = -1;
59
+ }
60
+ if (code === 10 /* \n */) {
61
+ curLine++;
62
+ curCol = 0;
63
+ } else {
64
+ if (code !== 32 && code !== 9 && code !== 13 && code !== 11 /* non-whitespace */) {
65
+ const absPos = startOffset + i;
66
+ tokens.push({
67
+ type: "Punctuator",
68
+ value: template[i],
69
+ range: [absPos, absPos + 1],
70
+ start: absPos,
71
+ end: absPos + 1,
72
+ loc: {
73
+ start: { line: curLine, column: curCol, index: absPos },
74
+ end: { line: curLine, column: curCol + 1, index: absPos + 1 },
75
+ },
76
+ });
77
+ }
78
+ curCol++;
79
+ }
80
+ }
81
+ }
82
+
83
+ if (wordStart >= 0) {
84
+ const absStart = startOffset + wordStart;
85
+ const absEnd = startOffset + template.length;
86
+ tokens.push({
87
+ type: "word",
88
+ value: template.slice(wordStart),
89
+ range: [absStart, absEnd],
90
+ start: absStart,
91
+ end: absEnd,
92
+ loc: {
93
+ start: { line: wordLine, column: wordCol, index: absStart },
94
+ end: { line: curLine, column: curCol, index: absEnd },
95
+ },
96
+ });
97
+ }
98
+
99
+ return tokens;
100
+ }
101
+
102
+ /**
103
+ * Merge the raw Glimmer token stream with text nodes and comment tokens,
104
+ * dropping raw tokens that fall inside comment or text-node intervals.
105
+ *
106
+ * All inputs are sorted by range[0] (sequential document scan), so the
107
+ * entire operation is a single O(n+m+k) pass with advancing pointers
108
+ * rather than O(n log m) per-token binary searches.
109
+ */
110
+ export function buildTokenStream(rawTokens, comments, textNodes, templateContent, offset) {
111
+ // Comment tokens shifted by 1 to avoid the ESLint createIndexMap conflict
112
+ const commentTokens = comments.map((c) => {
113
+ const start = c.range[0] + 1;
114
+ const end = c.range[1];
115
+ return {
116
+ type: "Block",
117
+ value: templateContent.slice(start - offset, end - offset),
118
+ range: [start, end],
119
+ start,
120
+ end,
121
+ loc: c.loc,
122
+ };
123
+ });
124
+
125
+ const spliceables = linearMerge(textNodes, commentTokens);
126
+
127
+ const result = [];
128
+ let ri = 0; // rawTokens
129
+ let si = 0; // spliceables
130
+ let ci = 0; // comment intervals (for skip detection)
131
+ let ni = 0; // text-node intervals (for skip detection)
132
+
133
+ while (ri < rawTokens.length || si < spliceables.length) {
134
+ if (ri >= rawTokens.length) {
135
+ result.push(spliceables[si++]);
136
+ continue;
137
+ }
138
+
139
+ const tok = rawTokens[ri];
140
+
141
+ // Advance interval pointers past intervals that end before this token
142
+ while (ci < comments.length && comments[ci].range[1] <= tok.range[0]) ci++;
143
+ while (ni < textNodes.length && textNodes[ni].range[1] <= tok.range[0]) ni++;
144
+
145
+ // Skip raw token if it falls inside a comment or text-node interval
146
+ if (
147
+ (ci < comments.length && comments[ci].range[0] <= tok.range[0]) ||
148
+ (ni < textNodes.length && textNodes[ni].range[0] <= tok.range[0])
149
+ ) {
150
+ ri++;
151
+ continue;
152
+ }
153
+
154
+ // Emit the earlier of the next spliceable or this raw token
155
+ if (si < spliceables.length && spliceables[si].range[0] < tok.range[0]) {
156
+ result.push(spliceables[si++]);
157
+ } else {
158
+ result.push(tok);
159
+ ri++;
160
+ }
161
+ }
162
+
163
+ return result;
164
+ }
165
+
166
+ function linearMerge(a, b) {
167
+ const result = Array.from({ length: a.length + b.length });
168
+ let ai = 0,
169
+ bi = 0,
170
+ ri = 0;
171
+ while (ai < a.length && bi < b.length) {
172
+ result[ri++] = a[ai].range[0] <= b[bi].range[0] ? a[ai++] : b[bi++];
173
+ }
174
+ while (ai < a.length) result[ri++] = a[ai++];
175
+ while (bi < b.length) result[ri++] = b[bi++];
176
+ return result;
177
+ }
package/src/transforms.js CHANGED
@@ -7,6 +7,8 @@ import {
7
7
  preprocess as glimmerPreprocess,
8
8
  } from "@glimmer/syntax";
9
9
 
10
+ import { tokenize, buildTokenStream } from "./tokens.js";
11
+
10
12
  /**
11
13
  * Converts between character offsets and line/column positions.
12
14
  * Lines are 1-based, columns are 0-based (matching ESTree & Glimmer conventions).
@@ -82,89 +84,6 @@ function removeFromParent(nodes) {
82
84
  }
83
85
  }
84
86
 
85
- function isAlphaNumeric(code) {
86
- return !(!(code > 47 && code < 58) && !(code > 64 && code < 91) && !(code > 96 && code < 123));
87
- }
88
-
89
- function isWhiteSpaceCode(code) {
90
- return code === 32 || code === 9 || code === 13 || code === 10 || code === 11;
91
- }
92
-
93
- function tokenize(template, doc, startOffset) {
94
- const tokens = [];
95
- let wordStart = -1;
96
- function pushToken(value, type, range) {
97
- tokens.push({
98
- type,
99
- value,
100
- range,
101
- start: range[0],
102
- end: range[1],
103
- loc: {
104
- start: { ...doc.offsetToPosition(range[0]), index: range[0] },
105
- end: { ...doc.offsetToPosition(range[1]), index: range[1] },
106
- },
107
- });
108
- }
109
- for (let i = 0; i < template.length; i++) {
110
- const code = template.charCodeAt(i);
111
- if (isAlphaNumeric(code)) {
112
- if (wordStart < 0) wordStart = i;
113
- } else {
114
- if (wordStart >= 0) {
115
- pushToken(template.slice(wordStart, i), "word", [startOffset + wordStart, startOffset + i]);
116
- wordStart = -1;
117
- }
118
- if (!isWhiteSpaceCode(code)) {
119
- pushToken(template[i], "Punctuator", [startOffset + i, startOffset + i + 1]);
120
- }
121
- }
122
- }
123
- if (wordStart >= 0) {
124
- pushToken(template.slice(wordStart), "word", [
125
- startOffset + wordStart,
126
- startOffset + template.length,
127
- ]);
128
- }
129
- return tokens;
130
- }
131
-
132
- function buildTokenStream(rawTokens, comments, textNodes) {
133
- const commentIntervals = comments.map((c) => c.range).sort((a, b) => a[0] - b[0]);
134
- const textNodeIntervals = textNodes.map((t) => t.range).sort((a, b) => a[0] - b[0]);
135
-
136
- function isCovered(tokenRange, intervals) {
137
- let lo = 0;
138
- let hi = intervals.length - 1;
139
- while (lo <= hi) {
140
- const mid = (lo + hi) >> 1;
141
- const iv = intervals[mid];
142
- if (iv[0] <= tokenRange[0] && iv[1] >= tokenRange[1]) return true;
143
- if (iv[0] > tokenRange[0]) hi = mid - 1;
144
- else lo = mid + 1;
145
- }
146
- return false;
147
- }
148
-
149
- const filteredTokens = rawTokens.filter(
150
- (t) => !isCovered(t.range, commentIntervals) && !isCovered(t.range, textNodeIntervals),
151
- );
152
-
153
- const sortedTextNodes = [...textNodes].sort((a, b) => a.range[0] - b.range[0]);
154
- const result = [];
155
- let ti = 0;
156
- for (const token of filteredTokens) {
157
- while (ti < sortedTextNodes.length && sortedTextNodes[ti].range[0] < token.range[0]) {
158
- result.push(sortedTextNodes[ti++]);
159
- }
160
- result.push(token);
161
- }
162
- while (ti < sortedTextNodes.length) {
163
- result.push(sortedTextNodes[ti++]);
164
- }
165
- return result;
166
- }
167
-
168
87
  /**
169
88
  * Parse and transform a Glimmer template into an ESTree-compatible AST.
170
89
  * Internal — consumed by toTree.
@@ -173,7 +92,8 @@ function buildTokenStream(rawTokens, comments, textNodes) {
173
92
  * positions, create parts/blockParamNodes, nullify empty hashes, and
174
93
  * prefix types. No separate collect-then-transform loop.
175
94
  */
176
- export function processTemplate(templateContent, codeLines, templateRange) {
95
+ export function processTemplate(templateContent, codeLines, options = {}) {
96
+ const { templateRange, tokens: generateTokens = false } = options;
177
97
  const offset = templateRange[0];
178
98
  const docLines = offset === 0 ? codeLines : new DocumentLines(templateContent);
179
99
 
@@ -335,7 +255,15 @@ export function processTemplate(templateContent, codeLines, templateRange) {
335
255
 
336
256
  removeFromParent(emptyTextNodes);
337
257
 
338
- ast.tokens = buildTokenStream(tokenize(templateContent, codeLines, offset), comments, textNodes);
258
+ if (generateTokens) {
259
+ ast.tokens = buildTokenStream(
260
+ tokenize(templateContent, codeLines, offset),
261
+ comments,
262
+ textNodes,
263
+ templateContent,
264
+ offset,
265
+ );
266
+ }
339
267
  ast.contents = templateContent;
340
268
 
341
269
  return { ast, comments };