ember-estree 0.3.0 → 0.4.1

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,6 +1,6 @@
1
1
  {
2
2
  "name": "ember-estree",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "ESTree generator for gjs and gts file used by ember",
5
5
  "keywords": [
6
6
  "AST",
@@ -30,14 +30,15 @@
30
30
  }
31
31
  },
32
32
  "dependencies": {
33
+ "@glimmer/env": "^0.1.7",
33
34
  "@glimmer/syntax": "^0.95.0",
34
35
  "content-tag": "^4.1.0",
35
- "ember-template-recast": "^6.1.5",
36
36
  "oxc-parser": "^0.119.0",
37
37
  "zimmerframe": "^1.1.4"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@tsconfig/node-lts": "^22.0.2",
41
+ "mitata": "^1.0.34",
41
42
  "oxfmt": "^0.40.0",
42
43
  "oxlint": "^1.55.0",
43
44
  "publint": "^0.3.18",
@@ -48,6 +49,9 @@
48
49
  "scripts": {
49
50
  "format": "oxfmt",
50
51
  "format:check": "oxfmt --check",
52
+ "bench": "node --expose-gc tests/parser.bench.mjs",
53
+ "bench:compare": "node scripts/bench-compare.mjs",
54
+ "bench:summary": "./scripts/local-bench-summary.sh",
51
55
  "lint": "oxlint && pnpm format:check && publint",
52
56
  "lint:fix": "oxlint --fix && oxfmt",
53
57
  "test": "vitest run"
package/src/index.d.ts CHANGED
@@ -1,23 +1,8 @@
1
- /**
2
- * Options accepted by `parse` and `toTree`.
3
- */
4
- export interface ParseOptions {
5
- /** Path to the file being parsed, used to determine the language (js/ts). */
6
- filePath?: string;
7
- }
8
-
9
- /**
10
- * A 1-based line / 0-based column position, matching ESTree and Glimmer
11
- * conventions.
12
- */
13
1
  export interface Position {
14
2
  line: number;
15
3
  column: number;
16
4
  }
17
5
 
18
- /**
19
- * Minimal shape shared by every AST node (ESTree, TypeScript, and Glimmer).
20
- */
21
6
  export interface ASTNode {
22
7
  type: string;
23
8
  start?: number;
@@ -25,80 +10,55 @@ export interface ASTNode {
25
10
  [key: string]: unknown;
26
11
  }
27
12
 
28
- /**
29
- * The `File`-like wrapper returned by `toTree` and `parse`.
30
- *
31
- * Mirrors the shape produced internally:
32
- * ```
33
- * { type: "File", program: Program, comments: Comment[], start, end }
34
- * ```
35
- */
36
13
  export interface FileNode extends ASTNode {
37
14
  type: "File";
38
15
  program: ASTNode;
39
16
  comments: ASTNode[];
40
17
  }
41
18
 
42
- /**
43
- * Converts between character offsets and line/column positions within a
44
- * source string.
45
- */
19
+ export interface TemplateResult {
20
+ ast: ASTNode;
21
+ comments: ASTNode[];
22
+ }
23
+
24
+ export interface VisitorPath {
25
+ node: ASTNode;
26
+ parent: ASTNode | null;
27
+ parentPath: VisitorPath | null;
28
+ }
29
+
30
+ export interface ParseOptions {
31
+ filePath?: string;
32
+ templateOnly?: boolean;
33
+ /**
34
+ * Include `parent` references on Glimmer AST nodes.
35
+ * Defaults to `true`. Set to `false` for JSON-serializable output.
36
+ */
37
+ includeParentLinks?: boolean;
38
+ /**
39
+ * Custom JS/TS parser. Called with the placeholder JS string
40
+ * (templates replaced with backtick expressions of equal length).
41
+ * Must return at least `{ ast }`.
42
+ */
43
+ parser?: (placeholderJS: string) => { ast: ASTNode; [key: string]: unknown };
44
+ /**
45
+ * Callbacks invoked for Glimmer nodes during the AST splice traversal.
46
+ * Runs in DFS order, so parent nodes are visited before children.
47
+ */
48
+ visitors?: {
49
+ [glimmerNodeType: string]: (node: ASTNode, path: VisitorPath) => void;
50
+ GlimmerBlockParams?: (node: ASTNode, path: VisitorPath) => void;
51
+ };
52
+ }
53
+
46
54
  export class DocumentLines {
47
55
  constructor(source: string);
48
-
49
- /** Converts a `{ line, column }` position to a character offset. */
50
56
  positionToOffset(pos: Position): number;
51
-
52
- /** Converts a character offset to a `{ line, column }` position. */
53
57
  offsetToPosition(offset: number): Position;
54
58
  }
55
59
 
56
- /**
57
- * Parse Ember .gjs/.gts source code and return a File-like ESTree-compatible
58
- * AST with embedded Glimmer template nodes.
59
- *
60
- * @param source The raw source code of the file.
61
- * @param options Optional parse options.
62
- * @returns A `File`-shaped object with a `.program` property.
63
- */
64
- export function toTree(source: string, options?: ParseOptions): FileNode;
65
-
66
- /**
67
- * Parse Ember .gjs/.gts source code into an ESTree-compatible AST with
68
- * embedded Glimmer template nodes.
69
- *
70
- * @param source The source code to parse.
71
- * @param options Optional parse options.
72
- * @returns The ESTree-compatible AST.
73
- */
74
- export function parse(source: string, options?: ParseOptions): FileNode;
75
-
76
- /**
77
- * Recursively print an AST node back to source code.
78
- *
79
- * Handles ESTree, TypeScript, and Glimmer template node types.
80
- * JSX nodes are not supported — Ember uses Glimmer templates instead.
81
- *
82
- * @param node The AST node to print.
83
- * @returns The printed source string.
84
- */
60
+ export function toTree(source: string, options?: ParseOptions): FileNode | TemplateResult;
61
+ export function parse(source: string, options?: ParseOptions): FileNode | TemplateResult;
85
62
  export function print(node: ASTNode): string;
86
63
 
87
- /**
88
- * Build and return the Glimmer visitor keys map with a `"Glimmer"` prefix on
89
- * every key (e.g. `"GlimmerElementNode"`).
90
- *
91
- * The result is cached after the first call.
92
- *
93
- * @returns A map of Glimmer node type names to arrays of child-property names.
94
- */
95
- export function buildGlimmerVisitorKeys(): Record<string, string[]>;
96
-
97
- /**
98
- * Recursively remove all `parent` references from an AST.
99
- * Useful when you need to serialize the tree to JSON,
100
- * since parent back-references create circular structures.
101
- *
102
- * Mutates the tree in place and returns it.
103
- */
104
- export function removeParentReferences(ast: ASTNode): ASTNode;
64
+ export const glimmerVisitorKeys: Record<string, string[]>;
package/src/index.js CHANGED
@@ -1,4 +1,3 @@
1
1
  export { toTree, parse } from "./parse.js";
2
2
  export { print } from "./print.js";
3
- export { buildGlimmerVisitorKeys, DocumentLines } from "./transforms.js";
4
- export { removeParentReferences } from "./utils.js";
3
+ export { glimmerVisitorKeys, DocumentLines } from "./transforms.js";
package/src/parse.js CHANGED
@@ -1,176 +1,340 @@
1
1
  /**
2
2
  * The Strategy:
3
3
  *
4
- * 1. parse out the <template>...</template> regions
5
- * - we haven't shipped "content-tag" through TC39, so for now, gjs and gts are invalid JavaScript
6
- *
7
- * 2. create a new string/contents of the file with a placeholder for the template regisions
8
- * - this will be used later to splice in the Template AST Nodes
9
- * - the placeholder should be the same dimensions as the template region
10
- *
11
- * 3. parse the string/contents as js/ts to generate an ESTree
12
- *
13
- * 4. parse each template region to generate an AST from that
14
- *
15
- * 5. convert the AST from `@glimmer/syntax` to ESTree
16
- * - NOTE: it may already be ESTree
17
- *
18
- * 6. splice in the template ESTrees into the JS/TS ESTree
19
- *
20
- * 7. Done
21
- */
22
-
23
- /**
24
- * Docs for dependencies:
25
- * - https://github.com/embroider-build/content-tag/
4
+ * 1. parse out the <template>...</template> regions (content-tag)
5
+ * 2. create placeholder JS for the template regions (backtick/static-block, same char length)
6
+ * 3. parse as js/ts — default: oxc-parser, or a custom parser via options
7
+ * 4. splice in processed Glimmer ASTs, invoking visitors during traversal
8
+ * 5. Merge Glimmer visitor keys into the result
9
+ * 6. Done
26
10
  */
27
11
 
28
12
  import { parseSync } from "oxc-parser";
29
- import templateRecast from "ember-template-recast";
30
13
  import { Preprocessor } from "content-tag";
31
14
  import { walk } from "zimmerframe";
32
15
 
33
- import { processGlimmerTemplate } from "./transforms.js";
16
+ import { processTemplate, DocumentLines, glimmerVisitorKeys } from "./transforms.js";
34
17
 
35
18
  const preprocessor = new Preprocessor();
36
19
 
20
+ // Node types that placeholders parse into (backtick/static-block format)
21
+ const PLACEHOLDER_TYPES = new Set([
22
+ "ExpressionStatement",
23
+ "StaticBlock",
24
+ "TemplateLiteral",
25
+ "ExportDefaultDeclaration",
26
+ ]);
27
+
37
28
  /**
29
+ * Parse Ember source and return an ESTree-compatible AST.
30
+ *
38
31
  * @param {string} source
39
- * @param {object} options
40
- * @return {object} A File-like AST with a `.program` property
32
+ * @param {object} [options]
33
+ * @param {string} [options.filePath] - File path for language detection
34
+ * @param {boolean} [options.templateOnly] - Parse as raw Glimmer template content (for .hbs)
35
+ * @param {function} [options.parser] - Custom JS/TS parser: (placeholderJS) => { ast, scopeManager?, visitorKeys?, services?, ... }
36
+ * @param {object} [options.visitors] - Callbacks invoked for Glimmer nodes during traversal
37
+ * @return {object}
41
38
  */
42
39
  export function toTree(source, options = {}) {
40
+ const templateOpts = options.includeParentLinks === false ? { includeParentLinks: false } : {};
41
+
42
+ if (options.templateOnly) {
43
+ return processTemplate(source, new DocumentLines(source), [0, source.length], templateOpts);
44
+ }
45
+
43
46
  let parseResults = preprocessor.parse(source);
44
47
  let js = toPlaceholderJS(source, parseResults);
45
48
 
46
- let filename = options.filePath || "input.ts";
47
- let oxcResult = parseSync(filename, js);
48
-
49
- // Wrap in a File-like node to match the expected structure
50
- let outerAST = {
51
- type: "File",
52
- program: oxcResult.program,
53
- comments: oxcResult.comments || [],
54
- start: oxcResult.program.start,
55
- end: oxcResult.program.end,
56
- };
57
-
58
- // content-tag v4 provides UTF-16 codepoint offsets that match
59
- // JavaScript string indices and oxc-parser character offsets directly,
60
- // so no byte-to-character conversion is needed.
61
- outerAST = walk(outerAST, null, {
62
- _(node, { next }) {
63
- if (isExpressionPlaceholder(node) || isClassMemberPlaceholder(node)) {
64
- let parseResult = parseResults.find((r) => {
65
- return (
66
- node.start === r.range.startUtf16Codepoint && node.end === r.range.endUtf16Codepoint
67
- );
68
- });
69
-
70
- let content = parseResult.contents;
71
- let templateAST = templateRecast.parse(content);
72
-
73
- let contentOffset = parseResult.contentRange.startUtf16Codepoint;
74
- let templateRange = [
75
- parseResult.range.startUtf16Codepoint,
76
- parseResult.range.endUtf16Codepoint,
77
- ];
78
-
79
- return processGlimmerTemplate(templateAST, {
80
- contentOffset,
81
- templateRange,
82
- source,
83
- });
49
+ const useCustomParser = !!options.parser;
50
+ const visitors = options.visitors || null;
51
+
52
+ // Parse the placeholder JS use custom parser or default oxc
53
+ let result;
54
+ if (useCustomParser) {
55
+ result = options.parser(js);
56
+ if (!result.ast) {
57
+ result = { ast: result };
58
+ }
59
+ } else {
60
+ let filename = options.filePath || "input.ts";
61
+ let oxcResult = parseSync(filename, js);
62
+ result = {
63
+ ast: {
64
+ type: "File",
65
+ program: oxcResult.program,
66
+ comments: oxcResult.comments || [],
67
+ start: oxcResult.program.start,
68
+ end: oxcResult.program.end,
69
+ },
70
+ };
71
+ }
72
+
73
+ // If no templates, return early
74
+ if (!parseResults.length) {
75
+ if (useCustomParser) {
76
+ result.visitorKeys = { ...result.visitorKeys, ...glimmerVisitorKeys };
77
+ return result;
78
+ }
79
+ result.ast.visitorKeys = glimmerVisitorKeys;
80
+ return result.ast;
81
+ }
82
+
83
+ const codeLines = new DocumentLines(source);
84
+ const allComments = [];
85
+ const templateInfos = [];
86
+
87
+ // Build a map of template ranges for lookup
88
+ const templateRangeByStart = new Map(parseResults.map((r) => [r.range.startUtf16Codepoint, r]));
89
+
90
+ // Process a matched placeholder node: create Glimmer AST and tokens
91
+ function processPlaceholder(parseResult) {
92
+ let templateContent = parseResult.contents;
93
+ let contentRange = [
94
+ parseResult.contentRange.startUtf16Codepoint,
95
+ parseResult.contentRange.endUtf16Codepoint,
96
+ ];
97
+ let fullRange = [parseResult.range.startUtf16Codepoint, parseResult.range.endUtf16Codepoint];
98
+
99
+ const { ast, comments } = processTemplate(
100
+ templateContent,
101
+ codeLines,
102
+ contentRange,
103
+ templateOpts,
104
+ );
105
+
106
+ // Fix the Template root to cover the full <template>...</template> range
107
+ ast.range = fullRange;
108
+ ast.start = fullRange[0];
109
+ ast.end = fullRange[1];
110
+ ast.loc = {
111
+ start: codeLines.offsetToPosition(fullRange[0]),
112
+ end: codeLines.offsetToPosition(fullRange[1]),
113
+ };
114
+
115
+ // Add tokens for the <template> and </template> tags
116
+ const openEnd = contentRange[0];
117
+ const closeStart = contentRange[1];
118
+ const openTag = source.slice(fullRange[0], openEnd);
119
+ const closeTag = source.slice(closeStart, fullRange[1]);
120
+ const makeToken = (value, range) => ({
121
+ type: "Punctuator",
122
+ value,
123
+ range,
124
+ start: range[0],
125
+ end: range[1],
126
+ loc: {
127
+ start: codeLines.offsetToPosition(range[0]),
128
+ end: codeLines.offsetToPosition(range[1]),
129
+ },
130
+ });
131
+ ast.tokens = [
132
+ makeToken(openTag, [fullRange[0], openEnd]),
133
+ ...(ast.tokens || []),
134
+ makeToken(closeTag, [closeStart, fullRange[1]]),
135
+ ];
136
+
137
+ allComments.push(...comments);
138
+ templateInfos.push({ utf16Range: fullRange, ast });
139
+ return ast;
140
+ }
141
+
142
+ // Check if a node matches a template range
143
+ function matchPlaceholder(node) {
144
+ let range = node.range || [node.start, node.end];
145
+ if (node.type === "ExportDefaultDeclaration" && node.declaration) {
146
+ const decl = node.declaration;
147
+ range = decl.range || [decl.start, decl.end];
148
+ }
149
+ const parseResult = templateRangeByStart.get(range[0]);
150
+ if (
151
+ !parseResult ||
152
+ (parseResult.range.endUtf16Codepoint !== range[1] &&
153
+ parseResult.range.endUtf16Codepoint !== range[1] + 1)
154
+ ) {
155
+ return null;
156
+ }
157
+ return parseResult;
158
+ }
159
+
160
+ // Walk Glimmer subtree, invoking visitors with full path context
161
+ function walkGlimmerTree(node, parentPath) {
162
+ if (!node || typeof node !== "object" || !node.type) return;
163
+ const path = { node, parent: parentPath?.node ?? null, parentPath };
164
+
165
+ if (visitors && node.type.startsWith("Glimmer")) {
166
+ const handler = visitors[node.type];
167
+ if (handler) handler(node, path);
168
+ if ("blockParams" in node && visitors.GlimmerBlockParams) {
169
+ visitors.GlimmerBlockParams(node, path);
84
170
  }
85
- next();
86
- },
87
- });
171
+ }
88
172
 
89
- let ast = outerAST;
173
+ const keys = glimmerVisitorKeys[node.type];
174
+ if (!keys) return;
175
+ for (const key of keys) {
176
+ const child = node[key];
177
+ if (!child) continue;
178
+ if (Array.isArray(child)) {
179
+ for (const item of child) {
180
+ walkGlimmerTree(item, path);
181
+ }
182
+ } else if (typeof child === "object" && child.type) {
183
+ walkGlimmerTree(child, path);
184
+ }
185
+ }
186
+ }
90
187
 
91
- return ast;
92
- }
188
+ if (useCustomParser) {
189
+ // Custom parser path: mutate the parser's AST in-place, invoke visitors.
190
+ // Use the parser's visitorKeys to traverse efficiently (avoids Object.keys).
191
+ const parserVisitorKeys = result.visitorKeys || {};
93
192
 
94
- /**
95
- * Parse Ember .gjs/.gts source code into an ESTree-compatible AST
96
- * with embedded Glimmer template nodes.
97
- *
98
- * @param {string} source - The source code to parse
99
- * @param {object} [options] - Parse options
100
- * @return {object} The ESTree-compatible AST
101
- */
102
- export function parse(source, options = {}) {
103
- let ast = toTree(source, options);
193
+ function visitNode(node, parentPath) {
194
+ if (!node || typeof node !== "object" || !node.type) return;
104
195
 
105
- return ast;
106
- }
196
+ const path = { node, parent: parentPath?.node ?? null, parentPath };
107
197
 
108
- //////////////////////////////////////////////////
109
- //
110
- // Helpers
111
- //
112
- //////////////////////////////////////////////////
198
+ if (PLACEHOLDER_TYPES.has(node.type)) {
199
+ const parseResult = matchPlaceholder(node);
200
+ if (parseResult) {
201
+ const ast = processPlaceholder(parseResult);
202
+ for (const key of Object.keys(node)) {
203
+ if (!(key in ast) && key !== "parent") {
204
+ delete node[key];
205
+ }
206
+ }
207
+ Object.assign(node, ast);
208
+ if (visitors) walkGlimmerTree(node, parentPath);
209
+ return;
210
+ }
211
+ }
113
212
 
114
- function isExpressionPlaceholder(node) {
115
- if (node.type !== "CallExpression") return;
213
+ // Use visitorKeys for efficient child traversal
214
+ const keys = parserVisitorKeys[node.type];
215
+ if (!keys) return;
216
+ for (const key of keys) {
217
+ const child = node[key];
218
+ if (!child) continue;
219
+ if (Array.isArray(child)) {
220
+ for (const item of child) {
221
+ if (item && typeof item === "object" && item.type) {
222
+ visitNode(item, path);
223
+ }
224
+ }
225
+ } else if (typeof child === "object" && child.type) {
226
+ visitNode(child, path);
227
+ }
228
+ }
229
+ }
116
230
 
117
- return node.callee.name === "TEMPLATE_TEMPLATE";
118
- }
231
+ visitNode(result.ast, null);
232
+ } else {
233
+ // Default oxc path: use zimmerframe walk (returns new tree)
234
+ result.ast = walk(result.ast, null, {
235
+ _(node, { next }) {
236
+ if (PLACEHOLDER_TYPES.has(node.type)) {
237
+ const parseResult = matchPlaceholder(node);
238
+ if (parseResult) {
239
+ return processPlaceholder(parseResult);
240
+ }
241
+ }
242
+ next();
243
+ },
244
+ });
245
+
246
+ // Walk Glimmer subtrees for visitors (after zimmerframe splicing)
247
+ if (visitors) {
248
+ for (const ti of templateInfos) {
249
+ walkGlimmerTree(ti.ast, null);
250
+ }
251
+ }
252
+ }
119
253
 
120
- function isClassMemberPlaceholder(node) {
121
- if (node.type !== "PropertyDefinition") return;
254
+ // Splice template tokens into the AST token stream.
255
+ // Tokens are sorted by range, so use binary search for O(log n) lookup.
256
+ const astRoot = result.ast.program || result.ast;
257
+ if (astRoot.tokens) {
258
+ for (const ti of templateInfos) {
259
+ const [tStart, tEnd] = ti.utf16Range;
260
+ const tokens = astRoot.tokens;
261
+ // Binary search for first token with range[0] >= tStart
262
+ let lo = 0;
263
+ let hi = tokens.length;
264
+ while (lo < hi) {
265
+ const mid = (lo + hi) >>> 1;
266
+ if (tokens[mid].range[0] < tStart) lo = mid + 1;
267
+ else hi = mid;
268
+ }
269
+ const firstIdx = lo;
270
+ if (firstIdx >= tokens.length || tokens[firstIdx].range[0] >= tEnd) continue;
271
+ let lastIdx = firstIdx;
272
+ while (lastIdx < tokens.length && tokens[lastIdx].range[1] <= tEnd) {
273
+ lastIdx++;
274
+ }
275
+ tokens.splice(firstIdx, lastIdx - firstIdx, ...ti.ast.tokens);
276
+ }
277
+ }
122
278
 
123
- return (
124
- node.computed && node.key?.type === "CallExpression" && node.key.callee?.name === "_TEMPLATE_"
125
- );
279
+ // Merge comments
280
+ if (allComments.length) {
281
+ if (!astRoot.comments) astRoot.comments = [];
282
+ astRoot.comments.push(...allComments);
283
+ }
284
+
285
+ if (useCustomParser) {
286
+ result.visitorKeys = { ...result.visitorKeys, ...glimmerVisitorKeys };
287
+ result.templateInfos = templateInfos;
288
+ return result;
289
+ }
290
+
291
+ // Default path: return bare AST with visitorKeys attached
292
+ result.ast.visitorKeys = glimmerVisitorKeys;
293
+ return result.ast;
126
294
  }
127
295
 
296
+ export const parse = toTree;
297
+
298
+ // ── Placeholder JS ────────────────────────────────────────────────────
299
+
128
300
  /**
129
- * Replaces <template>...</template> regions in source with
130
- * placeholder expressions of the same character length that
131
- * are valid JavaScript, so oxc-parser can parse them.
132
- *
133
- * Expression templates become: TEMPLATE_TEMPLATE(`...`)
134
- * Class member templates become: [_TEMPLATE_(`...`)] = 0;
301
+ * Replaces <template>...</template> regions with placeholder expressions
302
+ * of the same character length that are valid JS/TS.
135
303
  *
136
- * Both placeholder forms use exactly 21 characters for the
137
- * opening + closing wrappers, matching the original
138
- * <template> (10) + </template> (11) = 21 character overhead.
304
+ * Expression templates become: `content ` (backtick, space-padded)
305
+ * Class member templates become: static{`content `} (static block, space-padded)
139
306
  *
140
- * @param {string} source
141
- * @param {Array<object>} parseResults
142
- * @returns {string}
307
+ * This format is compatible with all JS/TS parsers including
308
+ * oxc-parser, @typescript-eslint/parser, and @babel/eslint-parser.
143
309
  */
144
310
  function toPlaceholderJS(source, parseResults) {
145
- let result = source;
146
- let offset = 0;
147
-
148
- for (let pr of parseResults) {
149
- let start = pr.range.startUtf16Codepoint;
150
- let end = pr.range.endUtf16Codepoint;
151
-
152
- let openingTag, closingTag;
153
- switch (pr.type) {
154
- case "expression":
155
- openingTag = "TEMPLATE_TEMPLATE(`";
156
- closingTag = "`)";
157
- break;
158
- case "class-member":
159
- openingTag = "[_TEMPLATE_(`";
160
- closingTag = "`)] = 0;";
161
- break;
162
- }
311
+ // Build result in forward order using parts array (avoids intermediate string allocations)
312
+ const parts = [];
313
+ let cursor = 0;
163
314
 
164
- let content = source.slice(
165
- pr.contentRange.startUtf16Codepoint,
166
- pr.contentRange.endUtf16Codepoint,
167
- );
315
+ for (const pr of parseResults) {
316
+ const start = pr.range.startUtf16Codepoint;
317
+ const end = pr.range.endUtf16Codepoint;
318
+ const tplLength = end - start;
319
+
320
+ parts.push(source.slice(cursor, start));
168
321
 
169
- let replacement = openingTag + content + closingTag;
322
+ const content = source
323
+ .slice(pr.contentRange.startUtf16Codepoint, pr.contentRange.endUtf16Codepoint)
324
+ .replace(/`/g, "\\`")
325
+ .replace(/\$/g, "\\$");
326
+
327
+ if (pr.type === "class-member") {
328
+ const spaces = tplLength - content.length - 10; // "static{`" + "`}" = 10
329
+ parts.push(`static{\`${content}${" ".repeat(Math.max(0, spaces))}\`}`);
330
+ } else {
331
+ const spaces = tplLength - content.length - 2; // "`" + "`" = 2
332
+ parts.push(`\`${content}${" ".repeat(Math.max(0, spaces))}\``);
333
+ }
170
334
 
171
- result = result.slice(0, start + offset) + replacement + result.slice(end + offset);
172
- offset += replacement.length - (end - start);
335
+ cursor = end;
173
336
  }
174
337
 
175
- return result;
338
+ parts.push(source.slice(cursor));
339
+ return parts.join("");
176
340
  }
package/src/transforms.js CHANGED
@@ -1,18 +1,11 @@
1
1
  /**
2
2
  * Glimmer AST → ESTree transform utilities.
3
- *
4
- * Ported from ember-eslint-parser's transforms.js, adapted for
5
- * ember-estree's ESM architecture. Handles:
6
- *
7
- * - Type prefixing (all Glimmer types get a "Glimmer" prefix)
8
- * - Range / loc fixing (converts template-local positions to file-level)
9
- * - ElementNode `parts` and `name` fields
10
- * - blockParams → virtual node creation
11
- * - Empty hash nullification
12
- * - Empty text node removal
13
3
  */
14
4
 
15
- import { traverse, visitorKeys as glimmerVisitorKeys } from "@glimmer/syntax";
5
+ import {
6
+ visitorKeys as rawGlimmerVisitorKeys,
7
+ preprocess as glimmerPreprocess,
8
+ } from "@glimmer/syntax";
16
9
 
17
10
  /**
18
11
  * Converts between character offsets and line/column positions.
@@ -45,38 +38,34 @@ export class DocumentLines {
45
38
  }
46
39
 
47
40
  /**
48
- * Traverse a Glimmer AST, set parent references, and categorize nodes.
41
+ * Glimmer visitor keys map with "Glimmer" prefix.
42
+ * Computed once at module load.
49
43
  */
50
- function collectNodes(ast) {
51
- const allNodes = [];
52
- const comments = [];
53
- const textNodes = [];
54
- const emptyTextNodes = [];
44
+ export const glimmerVisitorKeys = (() => {
45
+ const keys = {};
46
+ for (const [k, v] of Object.entries(rawGlimmerVisitorKeys)) {
47
+ keys[`Glimmer${k}`] = v;
48
+ }
49
+ keys.GlimmerElementNode = [...keys.GlimmerElementNode, "blockParamNodes", "parts"];
50
+ keys.GlimmerProgram = ["body", "blockParamNodes"];
51
+ keys.GlimmerTemplate = ["body"];
52
+ return keys;
53
+ })();
55
54
 
56
- traverse(ast, {
57
- All(node, path) {
58
- node.parent = path.parentNode;
59
- allNodes.push(node);
60
- if (node.type === "CommentStatement" || node.type === "MustacheCommentStatement") {
61
- comments.push(node);
62
- }
63
- if (node.type === "TextNode") {
64
- node.value = node.chars;
65
- if (node.value.trim().length !== 0 || (node.parent && node.parent.type === "AttrNode")) {
66
- textNodes.push(node);
67
- } else {
68
- emptyTextNodes.push(node);
69
- }
70
- }
71
- },
72
- });
55
+ // ── Internal helpers ──────────────────────────────────────────────────
73
56
 
74
- return { allNodes, comments, textNodes, emptyTextNodes };
57
+ // @glimmer/syntax nodes use prototype getters that form circular chains,
58
+ // crashing traversers like esrecurse. We snapshot configurable getters:
59
+ // ElementNode: tag, blockParams, selfClosing
60
+ // PathExpression: original
61
+ // VarHead: name, original
62
+ // Block: blockParams
63
+ const _desc = { value: undefined, configurable: true, enumerable: true, writable: true };
64
+ function defOwn(obj, key) {
65
+ _desc.value = obj[key];
66
+ Object.defineProperty(obj, key, _desc);
75
67
  }
76
68
 
77
- /**
78
- * Remove nodes from their parent's children/body/parts arrays.
79
- */
80
69
  function removeFromParent(nodes) {
81
70
  for (const node of nodes) {
82
71
  const children =
@@ -88,114 +77,223 @@ function removeFromParent(nodes) {
88
77
  }
89
78
  }
90
79
 
91
- /**
92
- * Build the Glimmer visitor keys map with "Glimmer" prefix.
93
- * Uses the visitor keys exported by @glimmer/syntax.
94
- */
95
- let _cachedGlimmerVisitorKeys = null;
96
- export function buildGlimmerVisitorKeys() {
97
- if (_cachedGlimmerVisitorKeys) return _cachedGlimmerVisitorKeys;
98
- const keys = {};
99
- for (const [k, v] of Object.entries(glimmerVisitorKeys)) {
100
- keys[`Glimmer${k}`] = [...v];
80
+ function isAlphaNumeric(code) {
81
+ return !(!(code > 47 && code < 58) && !(code > 64 && code < 91) && !(code > 96 && code < 123));
82
+ }
83
+
84
+ function isWhiteSpaceCode(code) {
85
+ return code === 32 || code === 9 || code === 13 || code === 10 || code === 11;
86
+ }
87
+
88
+ function tokenize(template, doc, startOffset) {
89
+ const tokens = [];
90
+ let wordStart = -1;
91
+ function pushToken(value, type, range) {
92
+ tokens.push({
93
+ type,
94
+ value,
95
+ range,
96
+ start: range[0],
97
+ end: range[1],
98
+ loc: {
99
+ start: { ...doc.offsetToPosition(range[0]), index: range[0] },
100
+ end: { ...doc.offsetToPosition(range[1]), index: range[1] },
101
+ },
102
+ });
103
+ }
104
+ for (let i = 0; i < template.length; i++) {
105
+ const code = template.charCodeAt(i);
106
+ if (isAlphaNumeric(code)) {
107
+ if (wordStart < 0) wordStart = i;
108
+ } else {
109
+ if (wordStart >= 0) {
110
+ pushToken(template.slice(wordStart, i), "word", [startOffset + wordStart, startOffset + i]);
111
+ wordStart = -1;
112
+ }
113
+ if (!isWhiteSpaceCode(code)) {
114
+ pushToken(template[i], "Punctuator", [startOffset + i, startOffset + i + 1]);
115
+ }
116
+ }
101
117
  }
102
- if (!keys.GlimmerElementNode.includes("blockParamNodes")) {
103
- keys.GlimmerElementNode.push("blockParamNodes", "parts");
118
+ if (wordStart >= 0) {
119
+ pushToken(template.slice(wordStart), "word", [
120
+ startOffset + wordStart,
121
+ startOffset + template.length,
122
+ ]);
104
123
  }
105
- keys.GlimmerProgram = ["body", "blockParamNodes"];
106
- keys.GlimmerTemplate = ["body"];
107
- _cachedGlimmerVisitorKeys = keys;
108
- return keys;
124
+ return tokens;
125
+ }
126
+
127
+ function buildTokenStream(rawTokens, comments, textNodes) {
128
+ const commentIntervals = comments.map((c) => c.range).sort((a, b) => a[0] - b[0]);
129
+ const textNodeIntervals = textNodes.map((t) => t.range).sort((a, b) => a[0] - b[0]);
130
+
131
+ function isCovered(tokenRange, intervals) {
132
+ let lo = 0;
133
+ let hi = intervals.length - 1;
134
+ while (lo <= hi) {
135
+ const mid = (lo + hi) >> 1;
136
+ const iv = intervals[mid];
137
+ if (iv[0] <= tokenRange[0] && iv[1] >= tokenRange[1]) return true;
138
+ if (iv[0] > tokenRange[0]) hi = mid - 1;
139
+ else lo = mid + 1;
140
+ }
141
+ return false;
142
+ }
143
+
144
+ const filteredTokens = rawTokens.filter(
145
+ (t) => !isCovered(t.range, commentIntervals) && !isCovered(t.range, textNodeIntervals),
146
+ );
147
+
148
+ const sortedTextNodes = [...textNodes].sort((a, b) => a.range[0] - b.range[0]);
149
+ const result = [];
150
+ let ti = 0;
151
+ for (const token of filteredTokens) {
152
+ while (ti < sortedTextNodes.length && sortedTextNodes[ti].range[0] < token.range[0]) {
153
+ result.push(sortedTextNodes[ti++]);
154
+ }
155
+ result.push(token);
156
+ }
157
+ while (ti < sortedTextNodes.length) {
158
+ result.push(sortedTextNodes[ti++]);
159
+ }
160
+ return result;
109
161
  }
110
162
 
111
163
  /**
112
- * Process a Glimmer AST into an ESTree-compatible form.
164
+ * Parse and transform a Glimmer template into an ESTree-compatible AST.
165
+ * Internal — consumed by toTree.
113
166
  *
114
- * @param {object} templateAST - The Glimmer AST (from ember-template-recast / @glimmer/syntax)
115
- * @param {object} opts
116
- * @param {number} opts.contentOffset - Byte offset where the template content begins in the full source
117
- * @param {[number, number]} opts.templateRange - [start, end] byte range of the full <template>...</template> block
118
- * @param {string} opts.source - The full source code
119
- * @returns {object} The transformed AST
167
+ * Single recursive pass: collect, categorize, snapshot getters, fix
168
+ * positions, create parts/blockParamNodes, nullify empty hashes, and
169
+ * prefix types. No separate collect-then-transform loop.
120
170
  */
121
- export function processGlimmerTemplate(templateAST, { contentOffset, templateRange, source }) {
122
- // The Glimmer AST locs are relative to the inner template content only
123
- const closingTagLen = "</template>".length;
124
- const contentEnd = templateRange[1] - closingTagLen;
125
- const contentStr = source.substring(contentOffset, contentEnd);
126
- const contentDoc = new DocumentLines(contentStr);
127
- const sourceDoc = new DocumentLines(source);
128
-
129
- const toFileRange = (loc) => {
130
- const locObj = loc.toJSON ? loc.toJSON() : loc;
131
- return [
132
- contentOffset + contentDoc.positionToOffset(locObj.start),
133
- contentOffset + contentDoc.positionToOffset(locObj.end),
134
- ];
135
- };
171
+ export function processTemplate(
172
+ templateContent,
173
+ codeLines,
174
+ templateRange,
175
+ { includeParentLinks = true } = {},
176
+ ) {
177
+ const offset = templateRange[0];
178
+ const docLines = offset === 0 ? codeLines : new DocumentLines(templateContent);
136
179
 
180
+ const toFileRange = (loc) => [
181
+ offset + docLines.positionToOffset(loc.start),
182
+ offset + docLines.positionToOffset(loc.end),
183
+ ];
137
184
  const toFileLoc = (range) => ({
138
- start: sourceDoc.offsetToPosition(range[0]),
139
- end: sourceDoc.offsetToPosition(range[1]),
185
+ start: codeLines.offsetToPosition(range[0]),
186
+ end: codeLines.offsetToPosition(range[1]),
140
187
  });
141
188
 
142
- const { allNodes, comments, emptyTextNodes } = collectNodes(templateAST);
189
+ const ast = glimmerPreprocess(templateContent, { mode: "codemod" });
190
+ const allNodes = [];
191
+ const comments = [];
192
+ const textNodes = [];
193
+ const emptyTextNodes = [];
143
194
 
144
- for (const n of allNodes) {
145
- const loc = n.loc.toJSON ? n.loc.toJSON() : n.loc;
195
+ // Single recursive pass over the glimmer AST. Processes each node
196
+ // fully (getters, positions, parts, blockParams) then recurses into
197
+ // children using raw visitor keys. Type prefixing happens inline
198
+ // AFTER recursing (so children see the original type during lookup).
199
+ function visit(n, parent) {
200
+ n.parent = parent;
201
+ allNodes.push(n);
146
202
 
147
- // Fix PathExpression head
148
- if (n.type === "PathExpression") {
149
- const head = n.head;
150
- if (head && head.loc) {
151
- const headLoc = head.loc.toJSON ? head.loc.toJSON() : head.loc;
152
- if (headLoc && headLoc.start) {
153
- head.range = toFileRange(headLoc);
154
- head.start = head.range[0];
155
- head.end = head.range[1];
156
- head.loc = toFileLoc(head.range);
157
- }
203
+ // Categorize
204
+ if (n.type === "CommentStatement" || n.type === "MustacheCommentStatement") {
205
+ comments.push(n);
206
+ }
207
+ if (n.type === "TextNode") {
208
+ n.value = n.chars;
209
+ if (n.value.trim().length !== 0 || (parent && parent.type === "AttrNode")) {
210
+ textNodes.push(n);
211
+ } else {
212
+ emptyTextNodes.push(n);
158
213
  }
159
214
  }
160
215
 
161
- // Set range Template root gets the full <template>...</template> range
162
- n.range = n.type === "Template" ? [...templateRange] : toFileRange(loc);
216
+ // Snapshot configurable prototype getters
217
+ switch (n.type) {
218
+ case "ElementNode":
219
+ defOwn(n, "tag");
220
+ defOwn(n, "blockParams");
221
+ defOwn(n, "selfClosing");
222
+ if (n.path?.head) {
223
+ defOwn(n.path.head, "name");
224
+ defOwn(n.path.head, "original");
225
+ }
226
+ break;
227
+ case "PathExpression":
228
+ defOwn(n, "original");
229
+ if (n.head) {
230
+ defOwn(n.head, "name");
231
+ defOwn(n.head, "original");
232
+ }
233
+ break;
234
+ case "Block":
235
+ defOwn(n, "blockParams");
236
+ break;
237
+ }
238
+
239
+ // Fix positions
240
+ if (n.type === "PathExpression") {
241
+ n.head.range = toFileRange(n.head.loc);
242
+ n.head.start = n.head.range[0];
243
+ n.head.end = n.head.range[1];
244
+ n.head.loc = toFileLoc(n.head.range);
245
+ }
246
+ n.range = n.type === "Template" ? [...templateRange] : toFileRange(n.loc);
163
247
  n.start = n.range[0];
164
248
  n.end = n.range[1];
165
249
  n.loc = toFileLoc(n.range);
166
250
 
167
- // Add parts and name to ElementNode
251
+ // Create parts for ElementNode
168
252
  if (n.type === "ElementNode") {
169
253
  n.name = n.tag;
170
- // Compute the tag name range: starts 1 char after element start (<), length = tag.length
171
- const tagStart = n.range[0] + 1; // skip "<"
172
- const tagEnd = tagStart + n.tag.length;
173
- const tagRange = [tagStart, tagEnd];
254
+ const p = n.path.head;
255
+ const partRange = toFileRange(p.loc);
174
256
  n.parts = [
175
257
  {
176
- original: n.tag,
177
- name: n.tag,
178
258
  type: "GlimmerElementNodePart",
179
- range: tagRange,
180
- start: tagRange[0],
181
- end: tagRange[1],
182
- loc: toFileLoc(tagRange),
259
+ original: p.original,
260
+ name: p.original,
261
+ parent: n,
262
+ range: partRange,
263
+ start: partRange[0],
264
+ end: partRange[1],
265
+ loc: toFileLoc(partRange),
183
266
  },
184
267
  ];
185
268
  }
186
269
 
187
- // Handle blockParams — create virtual nodes from the blockParams string array
270
+ // Create blockParamNodes
188
271
  if ("blockParams" in n && Array.isArray(n.blockParams)) {
189
- n.blockParamNodes = n.blockParams.map((name) => {
190
- return {
272
+ if (n.params && n.params.length === n.blockParams.length) {
273
+ n.blockParamNodes = n.params.map((p) => {
274
+ const range = toFileRange(p.loc);
275
+ return {
276
+ type: "GlimmerBlockParam",
277
+ name: p.original || p.name,
278
+ original: p.original,
279
+ parent: n,
280
+ range,
281
+ start: range[0],
282
+ end: range[1],
283
+ loc: toFileLoc(range),
284
+ };
285
+ });
286
+ } else {
287
+ n.blockParamNodes = n.blockParams.map((bpName) => ({
191
288
  type: "GlimmerBlockParam",
192
- name,
193
- range: [...n.range],
289
+ name: bpName,
290
+ parent: n,
291
+ range: [n.range[0], n.range[1]],
194
292
  start: n.range[0],
195
293
  end: n.range[1],
196
294
  loc: toFileLoc(n.range),
197
- };
198
- });
295
+ }));
296
+ }
199
297
  }
200
298
 
201
299
  // Nullify empty hashes
@@ -203,23 +301,49 @@ export function processGlimmerTemplate(templateAST, { contentOffset, templateRan
203
301
  (n.type === "MustacheStatement" ||
204
302
  n.type === "BlockStatement" ||
205
303
  n.type === "SubExpression") &&
206
- n.hash &&
207
- n.hash.pairs &&
208
- n.hash.pairs.length === 0
304
+ n.hash?.pairs?.length === 0
209
305
  ) {
210
306
  n.hash = null;
211
307
  }
212
308
 
213
- // Prefix type with "Glimmer"
309
+ // Recurse into children BEFORE prefixing type (visitor keys use original type)
310
+ const keys = rawGlimmerVisitorKeys[n.type];
311
+ if (keys) {
312
+ for (const key of keys) {
313
+ const child = n[key];
314
+ if (!child) continue;
315
+ if (Array.isArray(child)) {
316
+ for (const item of child) {
317
+ if (item && typeof item === "object" && item.type) visit(item, n);
318
+ }
319
+ } else if (typeof child === "object" && child.type) {
320
+ visit(child, n);
321
+ }
322
+ }
323
+ }
324
+
325
+ // Prefix type after children are visited
214
326
  n.type = `Glimmer${n.type}`;
215
327
  }
216
328
 
217
- // Clean up AST structure
329
+ visit(ast, null);
330
+
218
331
  removeFromParent(emptyTextNodes);
219
332
  removeFromParent(comments);
220
333
  for (const comment of comments) {
221
334
  comment.type = "Block";
222
335
  }
223
336
 
224
- return templateAST;
337
+ ast.tokens = buildTokenStream(tokenize(templateContent, codeLines, offset), comments, textNodes);
338
+ ast.contents = templateContent;
339
+
340
+ if (!includeParentLinks) {
341
+ for (const n of allNodes) {
342
+ delete n.parent;
343
+ if (n.parts) for (const p of n.parts) delete p.parent;
344
+ if (n.blockParamNodes) for (const p of n.blockParamNodes) delete p.parent;
345
+ }
346
+ }
347
+
348
+ return { ast, comments };
225
349
  }
package/src/utils.js DELETED
@@ -1,22 +0,0 @@
1
- /**
2
- * Optional utility functions for working with ESTree ASTs
3
- * produced by ember-estree.
4
- */
5
-
6
- import { walk } from "zimmerframe";
7
-
8
- /**
9
- * Recursively remove all `parent` references from an AST.
10
- * Useful when you need to serialize the tree to JSON (e.g. for zmod),
11
- * since parent back-references create circular structures.
12
- *
13
- * Mutates the tree in place and returns it.
14
- */
15
- export function removeParentReferences(ast) {
16
- return walk(ast, null, {
17
- _(node, { next }) {
18
- delete node.parent;
19
- next();
20
- },
21
- });
22
- }