ember-estree 0.4.3 → 0.5.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/README.md CHANGED
@@ -57,6 +57,109 @@ print({
57
57
  // => "<template>Hello</template>"
58
58
  ```
59
59
 
60
+ ## Options
61
+
62
+ Both `toTree` and `parse` accept an options object as their second argument.
63
+
64
+ All options are optional.
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.
74
+
75
+ ### Custom parser
76
+
77
+ Pass any JS/TS parser that returns an ESTree-compatible AST. ember-estree handles template splicing and Glimmer traversal on top of it.
78
+
79
+ ```js
80
+ import { parseSync } from "oxc-parser";
81
+ import { toTree } from "ember-estree";
82
+
83
+ const result = toTree(source, {
84
+ parser: (js) => ({
85
+ ast: parseSync("input.ts", js).program,
86
+ visitorKeys: {
87
+ /* ...parser's visitor keys... */
88
+ },
89
+ }),
90
+ });
91
+ ```
92
+
93
+ The parser receives a placeholder-JS string (templates replaced with backtick expressions of equal length) and must return at least `{ ast }`. Additional fields like `scopeManager`, `visitorKeys`, or `services` are preserved on the returned result.
94
+
95
+ ### Visitors
96
+
97
+ Pass `visitors` to observe or rewrite the tree in a single traversal. Handlers fire on both outer JS/TS nodes and spliced Glimmer subtrees, and a single node is never dispatched twice — safe to relocate nodes mid-walk.
98
+
99
+ The pseudo-type `GlimmerBlockParams` fires on any node that carries a `blockParams` array.
100
+
101
+ **Plain-object form** — use when you only need the type → handler map:
102
+
103
+ ```js
104
+ import { toTree } from "ember-estree";
105
+
106
+ const identifiers = [];
107
+ toTree(source, {
108
+ visitors: {
109
+ Identifier: (node) => identifiers.push(node.name),
110
+ GlimmerPathExpression: (node) => identifiers.push(node.original),
111
+ },
112
+ });
113
+ ```
114
+
115
+ **Factory form** — use when you need the outer JS/TS AST up front (for example, to attach state to it before the walk):
116
+
117
+ ```js
118
+ import { toTree, print } from "ember-estree";
119
+
120
+ const ast = toTree(`const world = "🌍"; const X = <template>{{world}}</template>;`, {
121
+ visitors: () => ({
122
+ Identifier: (node) => (node.name = node.name.toUpperCase()),
123
+ GlimmerPathExpression(node) {
124
+ node.original = node.original.toUpperCase();
125
+ if (node.head) node.head.name = node.original;
126
+ },
127
+ }),
128
+ });
129
+
130
+ print(ast.program);
131
+ // => 'const WORLD = "🌍";\nconst X = <template>{{WORLD}}</template>;'
132
+ ```
133
+
134
+ **Collecting Glimmer comments into `program.comments`** — useful when adapting the AST for ESLint, which reads comments from the Program node:
135
+
136
+ ```js
137
+ const ast = toTree(source, {
138
+ visitors: (outerAst) => {
139
+ outerAst.program.comments = [...(outerAst.comments ?? [])];
140
+ const push = (node) => outerAst.program.comments.push(node);
141
+ return {
142
+ GlimmerCommentStatement: push,
143
+ GlimmerMustacheCommentStatement: push,
144
+ };
145
+ },
146
+ });
147
+ ```
148
+
149
+ **Removing nodes mid-traversal** — siblings are splice-safe:
150
+
151
+ ```js
152
+ toTree(source, {
153
+ visitors: () => ({
154
+ GlimmerMustacheCommentStatement(node, path) {
155
+ const siblings = path.parent?.body ?? path.parent?.children;
156
+ const idx = siblings?.indexOf(node) ?? -1;
157
+ if (idx >= 0) siblings.splice(idx, 1);
158
+ },
159
+ }),
160
+ });
161
+ ```
162
+
60
163
  ## Examples
61
164
 
62
165
  The [`examples/`](./examples) directory contains ready-to-run integrations:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ember-estree",
3
- "version": "0.4.3",
3
+ "version": "0.5.1",
4
4
  "description": "ESTree generator for gjs and gts file used by ember",
5
5
  "keywords": [
6
6
  "AST",
package/src/index.d.ts CHANGED
@@ -30,11 +30,6 @@ export interface VisitorPath {
30
30
  export interface ParseOptions {
31
31
  filePath?: string;
32
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
33
  /**
39
34
  * Custom JS/TS parser. Called with the placeholder JS string
40
35
  * (templates replaced with backtick expressions of equal length).
@@ -42,15 +37,24 @@ export interface ParseOptions {
42
37
  */
43
38
  parser?: (placeholderJS: string) => { ast: ASTNode; [key: string]: unknown };
44
39
  /**
45
- * Callbacks invoked for Glimmer nodes during the AST splice traversal.
46
- * Runs in DFS order, so parent nodes are visited before children.
40
+ * Callbacks fired on each node during traversal outer JS/TS nodes AND
41
+ * spliced Glimmer subtrees so callers can gather information or mutate
42
+ * the tree in a single pass.
43
+ *
44
+ * Pass either a plain handler map, or a factory `(outerAst) => handlers`
45
+ * that's called once after parsing (before template splicing) when you
46
+ * need a view of the raw JS/TS tree up front.
47
+ *
48
+ * The pseudo-type `GlimmerBlockParams` fires on any node that carries
49
+ * a `blockParams` array.
47
50
  */
48
- visitors?: {
49
- [glimmerNodeType: string]: (node: ASTNode, path: VisitorPath) => void;
50
- GlimmerBlockParams?: (node: ASTNode, path: VisitorPath) => void;
51
- };
51
+ visitors?: VisitorMap | ((outerAst: ASTNode) => VisitorMap | null | undefined);
52
52
  }
53
53
 
54
+ export type VisitorMap = {
55
+ [nodeType: string]: (node: ASTNode, path: VisitorPath) => void;
56
+ };
57
+
54
58
  export class DocumentLines {
55
59
  constructor(source: string);
56
60
  positionToOffset(pos: Position): number;
package/src/parse.js CHANGED
@@ -33,21 +33,22 @@ const PLACEHOLDER_TYPES = new Set([
33
33
  * @param {string} [options.filePath] - File path for language detection
34
34
  * @param {boolean} [options.templateOnly] - Parse as raw Glimmer template content (for .hbs)
35
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
36
+ * @param {object|function} [options.visitors] - Either a map of `{ [Type]: (node, path) => void }`
37
+ * handlers, or a factory `(outerAst) => handlers` invoked once after parsing (before any
38
+ * template splicing) to give callers a view of the raw JS/TS tree. Handlers fire on every
39
+ * node during traversal — outer JS/TS nodes AND spliced Glimmer subtrees — in a single pass.
40
+ * The pseudo-type `GlimmerBlockParams` fires on any node that carries `blockParams`.
37
41
  * @return {object}
38
42
  */
39
43
  export function toTree(source, options = {}) {
40
- const templateOpts = options.includeParentLinks === false ? { includeParentLinks: false } : {};
41
-
42
44
  if (options.templateOnly) {
43
- return processTemplate(source, new DocumentLines(source), [0, source.length], templateOpts);
45
+ return processTemplate(source, new DocumentLines(source), [0, source.length]);
44
46
  }
45
47
 
46
48
  let parseResults = preprocessor.parse(source);
47
49
  let js = toPlaceholderJS(source, parseResults);
48
50
 
49
51
  const useCustomParser = !!options.parser;
50
- const visitors = options.visitors || null;
51
52
 
52
53
  // Parse the placeholder JS — use custom parser or default oxc
53
54
  let result;
@@ -73,8 +74,24 @@ export function toTree(source, options = {}) {
73
74
  };
74
75
  }
75
76
 
76
- // If no templates, return early
77
- if (!parseResults.length) {
77
+ // Resolve user visitors against the outer AST. A plain object is used
78
+ // as-is; a factory is called once so callers can introspect the raw
79
+ // JS/TS tree before any template splicing. Default to `{}` so downstream
80
+ // dispatch can be a bare `visitors[type]` lookup without null-guards.
81
+ const visitors =
82
+ typeof options.visitors === "function"
83
+ ? (options.visitors(result.ast) ?? {})
84
+ : (options.visitors ?? {});
85
+ const hasVisitors = Object.keys(visitors).length > 0;
86
+ // Guard against dispatching a handler twice on the same node.
87
+ // Visitors that relocate nodes (e.g. moving Glimmer comments into
88
+ // `program.comments`) would otherwise fire a second time when the walk
89
+ // reaches the new location.
90
+ const seen = new WeakSet();
91
+ const hasTemplates = parseResults.length > 0;
92
+
93
+ // Nothing to walk — attach visitor keys and return.
94
+ if (!hasTemplates && !hasVisitors) {
78
95
  if (useCustomParser) {
79
96
  result.visitorKeys = { ...result.visitorKeys, ...glimmerVisitorKeys };
80
97
  return result;
@@ -83,11 +100,11 @@ export function toTree(source, options = {}) {
83
100
  return result.ast;
84
101
  }
85
102
 
86
- const codeLines = new DocumentLines(source);
103
+ const codeLines = hasTemplates ? new DocumentLines(source) : null;
87
104
  const templateInfos = [];
88
-
89
- // Build a map of template ranges for lookup
90
- const templateRangeByStart = new Map(parseResults.map((r) => [r.range.startUtf16Codepoint, r]));
105
+ const templateRangeByStart = hasTemplates
106
+ ? new Map(parseResults.map((r) => [r.range.startUtf16Codepoint, r]))
107
+ : null;
91
108
 
92
109
  // Process a matched placeholder node: create Glimmer AST and tokens
93
110
  function processPlaceholder(parseResult) {
@@ -98,7 +115,7 @@ export function toTree(source, options = {}) {
98
115
  ];
99
116
  let fullRange = [parseResult.range.startUtf16Codepoint, parseResult.range.endUtf16Codepoint];
100
117
 
101
- const { ast } = processTemplate(templateContent, codeLines, contentRange, templateOpts);
118
+ const { ast } = processTemplate(templateContent, codeLines, contentRange);
102
119
 
103
120
  // Fix the Template root to cover the full <template>...</template> range
104
121
  ast.range = fullRange;
@@ -153,101 +170,64 @@ export function toTree(source, options = {}) {
153
170
  return parseResult;
154
171
  }
155
172
 
156
- // Walk Glimmer subtree, invoking visitors with full path context
157
- function walkGlimmerTree(node, parentPath) {
158
- if (!node || typeof node !== "object" || !node.type) return;
159
- const path = { node, parent: parentPath?.node ?? null, parentPath };
160
-
161
- if (visitors && node.type.startsWith("Glimmer")) {
162
- const handler = visitors[node.type];
163
- if (handler) handler(node, path);
164
- if ("blockParams" in node && visitors.GlimmerBlockParams) {
165
- visitors.GlimmerBlockParams(node, path);
166
- }
167
- }
168
-
169
- const keys = glimmerVisitorKeys[node.type];
170
- if (!keys) return;
171
- for (const key of keys) {
172
- const child = node[key];
173
- if (!child) continue;
174
- if (Array.isArray(child)) {
175
- for (const item of child) {
176
- walkGlimmerTree(item, path);
177
- }
178
- } else if (typeof child === "object" && child.type) {
179
- walkGlimmerTree(child, path);
180
- }
181
- }
182
- }
183
-
184
- if (useCustomParser) {
185
- // Custom parser path: mutate the parser's AST in-place, invoke visitors.
186
- // Use the parser's visitorKeys to traverse efficiently (avoids Object.keys).
187
- const parserVisitorKeys = result.visitorKeys || {};
188
-
189
- function visitNode(node, parentPath) {
190
- if (!node || typeof node !== "object" || !node.type) return;
191
-
192
- const path = { node, parent: parentPath?.node ?? null, parentPath };
193
-
194
- if (PLACEHOLDER_TYPES.has(node.type)) {
173
+ result.ast = walk(result.ast, null, {
174
+ _(node, { next, visit, state }) {
175
+ if (hasTemplates && PLACEHOLDER_TYPES.has(node.type)) {
195
176
  const parseResult = matchPlaceholder(node);
196
177
  if (parseResult) {
197
178
  const ast = processPlaceholder(parseResult);
198
- for (const key of Object.keys(node)) {
199
- if (!(key in ast) && key !== "parent") {
200
- delete node[key];
201
- }
202
- }
203
- Object.assign(node, ast);
204
- if (visitors) walkGlimmerTree(node, parentPath);
205
- return;
179
+ // Zimmerframe treats a visitor that returns a node as having
180
+ // taken responsibility for the subtree it splices the result
181
+ // in but does NOT descend into it. So when any handlers are
182
+ // configured we re-enter the walk manually via `visit()` to
183
+ // dispatch them across the Glimmer nodes. With no handlers the
184
+ // walk would be pure overhead, so just return the subtree.
185
+ //
186
+ // Pass `state` (the placeholder's inherited parent context) so the
187
+ // Glimmer root's parentPath reflects its true JS parent. The
188
+ // placeholder (TemplateLiteral / StaticBlock) is an internal
189
+ // artifact — the GlimmerTemplate logically lives where the
190
+ // placeholder was, so its parent is e.g. VariableDeclarator or
191
+ // ClassBody, not the placeholder itself.
192
+ return hasVisitors ? visit(ast, state) : ast;
206
193
  }
207
194
  }
208
195
 
209
- // Use visitorKeys for efficient child traversal
210
- const keys = parserVisitorKeys[node.type];
211
- if (!keys) return;
212
- for (const key of keys) {
213
- const child = node[key];
214
- if (!child) continue;
215
- if (Array.isArray(child)) {
216
- for (const item of child) {
217
- if (item && typeof item === "object" && item.type) {
218
- visitNode(item, path);
219
- }
220
- }
221
- } else if (typeof child === "object" && child.type) {
222
- visitNode(child, path);
196
+ const path = {
197
+ node,
198
+ parent: state?.parentPath?.node ?? null,
199
+ parentPath: state?.parentPath ?? null,
200
+ };
201
+
202
+ if (hasVisitors && !seen.has(node)) {
203
+ seen.add(node);
204
+ const handler = visitors[node.type];
205
+ if (handler) handler(node, path);
206
+ if ("blockParams" in node && visitors.GlimmerBlockParams) {
207
+ visitors.GlimmerBlockParams(node, path);
223
208
  }
224
209
  }
225
- }
226
-
227
- visitNode(result.ast, null);
228
- } else {
229
- // Default oxc path: use zimmerframe walk (returns new tree)
230
- result.ast = walk(result.ast, null, {
231
- _(node, { next }) {
232
- if (PLACEHOLDER_TYPES.has(node.type)) {
233
- const parseResult = matchPlaceholder(node);
234
- if (parseResult) {
235
- return processPlaceholder(parseResult);
236
- }
237
- }
238
- next();
239
- },
240
- });
241
210
 
242
- // Walk Glimmer subtrees for visitors (after zimmerframe splicing)
243
- if (visitors) {
244
- for (const ti of templateInfos) {
245
- walkGlimmerTree(ti.ast, null);
246
- }
247
- }
248
- }
211
+ next({ parentPath: path });
212
+ },
213
+ });
249
214
 
250
215
  // Splice template tokens into the AST token stream.
216
+ //
217
+ // `tokens` is the flat lexed stream (keywords, punctuators, identifiers,
218
+ // literals) that ESLint, formatters, and source-map tooling consume —
219
+ // `SourceCode.getTokens()` reads it directly.
220
+ //
221
+ // We replaced each <template>...</template> region with a backtick
222
+ // placeholder before handing the source to the JS/TS parser, so the
223
+ // parser's tokens for those ranges describe the placeholder, not the
224
+ // real source. Here we swap them out for the real lexemes:
225
+ // 1. a fabricated `<template>` Punctuator (added in processPlaceholder)
226
+ // 2. the Glimmer AST's own tokens (from transforms.js)
227
+ // 3. a fabricated `</template>` Punctuator
228
+ // so consumers see a position-accurate token stream matching the
229
+ // original source byte-for-byte across JS and Glimmer regions.
230
+ //
251
231
  // Tokens are sorted by range, so use binary search for O(log n) lookup.
252
232
  const astRoot = result.ast.program || result.ast;
253
233
  if (astRoot.tokens) {
package/src/transforms.js CHANGED
@@ -61,6 +61,11 @@ export const glimmerVisitorKeys = (() => {
61
61
  // VarHead: name, original
62
62
  // Block: blockParams
63
63
  const _desc = { value: undefined, configurable: true, enumerable: true, writable: true };
64
+ const _parentDesc = { value: null, configurable: true, enumerable: false, writable: true };
65
+ function setParent(node, parent) {
66
+ _parentDesc.value = parent;
67
+ Object.defineProperty(node, "parent", _parentDesc);
68
+ }
64
69
  function defOwn(obj, key) {
65
70
  _desc.value = obj[key];
66
71
  Object.defineProperty(obj, key, _desc);
@@ -168,12 +173,7 @@ function buildTokenStream(rawTokens, comments, textNodes) {
168
173
  * positions, create parts/blockParamNodes, nullify empty hashes, and
169
174
  * prefix types. No separate collect-then-transform loop.
170
175
  */
171
- export function processTemplate(
172
- templateContent,
173
- codeLines,
174
- templateRange,
175
- { includeParentLinks = true } = {},
176
- ) {
176
+ export function processTemplate(templateContent, codeLines, templateRange) {
177
177
  const offset = templateRange[0];
178
178
  const docLines = offset === 0 ? codeLines : new DocumentLines(templateContent);
179
179
 
@@ -187,7 +187,6 @@ export function processTemplate(
187
187
  });
188
188
 
189
189
  const ast = glimmerPreprocess(templateContent, { mode: "codemod" });
190
- const allNodes = [];
191
190
  const comments = [];
192
191
  const textNodes = [];
193
192
  const emptyTextNodes = [];
@@ -197,8 +196,7 @@ export function processTemplate(
197
196
  // children using raw visitor keys. Type prefixing happens inline
198
197
  // AFTER recursing (so children see the original type during lookup).
199
198
  function visit(n, parent) {
200
- n.parent = parent;
201
- allNodes.push(n);
199
+ setParent(n, parent);
202
200
 
203
201
  // Categorize
204
202
  if (n.type === "CommentStatement" || n.type === "MustacheCommentStatement") {
@@ -257,18 +255,17 @@ export function processTemplate(
257
255
  n.name = n.tag;
258
256
  const p = n.path.head;
259
257
  const partRange = toFileRange(p.loc);
260
- n.parts = [
261
- {
262
- type: "GlimmerElementNodePart",
263
- original: p.original,
264
- name: p.original,
265
- parent: n,
266
- range: partRange,
267
- start: partRange[0],
268
- end: partRange[1],
269
- loc: toFileLoc(partRange),
270
- },
271
- ];
258
+ const part = {
259
+ type: "GlimmerElementNodePart",
260
+ original: p.original,
261
+ name: p.original,
262
+ range: partRange,
263
+ start: partRange[0],
264
+ end: partRange[1],
265
+ loc: toFileLoc(partRange),
266
+ };
267
+ setParent(part, n);
268
+ n.parts = [part];
272
269
  }
273
270
 
274
271
  // Create blockParamNodes
@@ -276,27 +273,31 @@ export function processTemplate(
276
273
  if (n.params && n.params.length === n.blockParams.length) {
277
274
  n.blockParamNodes = n.params.map((p) => {
278
275
  const range = toFileRange(p.loc);
279
- return {
276
+ const bp = {
280
277
  type: "GlimmerBlockParam",
281
278
  name: p.original || p.name,
282
279
  original: p.original,
283
- parent: n,
284
280
  range,
285
281
  start: range[0],
286
282
  end: range[1],
287
283
  loc: toFileLoc(range),
288
284
  };
285
+ setParent(bp, n);
286
+ return bp;
289
287
  });
290
288
  } else {
291
- n.blockParamNodes = n.blockParams.map((bpName) => ({
292
- type: "GlimmerBlockParam",
293
- name: bpName,
294
- parent: n,
295
- range: [n.range[0], n.range[1]],
296
- start: n.range[0],
297
- end: n.range[1],
298
- loc: toFileLoc(n.range),
299
- }));
289
+ n.blockParamNodes = n.blockParams.map((bpName) => {
290
+ const bp = {
291
+ type: "GlimmerBlockParam",
292
+ name: bpName,
293
+ range: [n.range[0], n.range[1]],
294
+ start: n.range[0],
295
+ end: n.range[1],
296
+ loc: toFileLoc(n.range),
297
+ };
298
+ setParent(bp, n);
299
+ return bp;
300
+ });
300
301
  }
301
302
  }
302
303
 
@@ -337,13 +338,5 @@ export function processTemplate(
337
338
  ast.tokens = buildTokenStream(tokenize(templateContent, codeLines, offset), comments, textNodes);
338
339
  ast.contents = templateContent;
339
340
 
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
341
  return { ast, comments };
349
342
  }