ember-estree 0.5.1 → 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 +29 -8
- package/package.json +1 -1
- package/src/parse.js +34 -23
- package/src/tokens.js +177 -0
- package/src/transforms.js +13 -85
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
|
-
| `
|
|
70
|
-
| `
|
|
71
|
-
| `
|
|
72
|
-
|
|
73
|
-
|
|
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
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),
|
|
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,
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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;
|
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,
|
|
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
|
-
|
|
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 };
|