@teasel/parser 0.0.0 → 0.0.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 +138 -2
- package/api.js +185 -0
- package/binding.cjs +705 -0
- package/binding.d.ts +10 -0
- package/decode.js +282 -0
- package/identifier.js +218 -0
- package/index.d.ts +250 -0
- package/index.js +25 -0
- package/package.json +67 -10
- package/teasel.wasm +0 -0
- package/wasm.d.ts +1 -0
- package/wasm.js +62 -0
package/README.md
CHANGED
|
@@ -1,3 +1,139 @@
|
|
|
1
|
-
|
|
1
|
+
<picture>
|
|
2
|
+
<source media="(prefers-color-scheme: dark)" srcset="banner-dark.svg">
|
|
3
|
+
<img src="banner.svg" width="100%" alt="">
|
|
4
|
+
</picture>
|
|
2
5
|
|
|
3
|
-
|
|
6
|
+
<h1 align="center">teasel</h1>
|
|
7
|
+
<p align="center">A JavaScript and TypeScript parser in Rust. It answers in ESTree.</p>
|
|
8
|
+
|
|
9
|
+
<br>
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
import { Source } from '@teasel/parser';
|
|
13
|
+
|
|
14
|
+
const source = new Source(text, { typescript: true, scopes: true });
|
|
15
|
+
const { node } = source.parse();
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Every parse is `source.parse(entry, offset, { end, stopAt })`. Every answer is `{ node, end }` and what the options add.
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
source.parse() Program
|
|
22
|
+
source.parse('expression', 7) the expression that starts at 7
|
|
23
|
+
source.parse('pattern', 7) an assignment target: a name or a destructuring
|
|
24
|
+
source.parse('params', 7) the patterns of a (a, b = 1)
|
|
25
|
+
source.parse('statement', 7) one statement
|
|
26
|
+
source.parse('typeParameters', 7) a <T extends U>
|
|
27
|
+
source.parse('program', 12, { end: 40 }) the program inside 12..40, positions of the whole
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Inside a larger syntax
|
|
31
|
+
|
|
32
|
+
A host that embeds JavaScript in its own reads one piece at a time, from an offset, and gets back where its own syntax resumes.
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
{{ items as item, index }}
|
|
36
|
+
▲ ▲
|
|
37
|
+
3 8
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
const { node, end } = source.parse('expression', 3, { stopAt: ['as', ','] });
|
|
42
|
+
// node Identifier items
|
|
43
|
+
// end 8
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`stopAt` lists the host's own tokens. One read outside every bracket the parse opened, where the expression could end, ends the parse: `,` does not start a sequence, `/>` is not a division. A `then` after `.` is a property name. A TypeScript `as` is the host's unless another `as` follows the assertion, so `xs as T[] as item` ends after the type.
|
|
47
|
+
|
|
48
|
+
## What the options add
|
|
49
|
+
|
|
50
|
+
| option | on the tree | on the answer |
|
|
51
|
+
| --- | --- | --- |
|
|
52
|
+
| `locations` | `loc` with line and column on every node | |
|
|
53
|
+
| `comments` | `leadingComments`, `trailingComments`, `innerComments` | `comments`, every comment read |
|
|
54
|
+
| `scopes` | | `scopes`, `bindings`, `references`, and the four questions below |
|
|
55
|
+
| `parenthesized` | `parenthesized: true` on a node the source wraps in parens | |
|
|
56
|
+
| `errorRecovery` | an `Identifier` named `''` of no width where something is missing | `errors`, instead of a throw |
|
|
57
|
+
| `typescript` | TypeScript nodes | |
|
|
58
|
+
| `decorators: 'legacy'` or `'proposal'` | restricts decorator syntax; unset reads both | |
|
|
59
|
+
| `typescript: 'erase'` | JavaScript, the types gone | `typescript`, what could not be erased: enums, namespaces with values, parameter properties, `export =`, `import =`, decorators, accessor fields |
|
|
60
|
+
| `sourceType: 'module'` | strict code, `import`, `export`, top-level `await` | |
|
|
61
|
+
|
|
62
|
+
A key is on the answer exactly when its option is on. `allowReturnOutsideFunction`, `allowAwaitOutsideFunction`, `allowSuperOutsideMethod` and `allowUndeclaredExports` loosen the early errors as their names say.
|
|
63
|
+
|
|
64
|
+
## Scopes
|
|
65
|
+
|
|
66
|
+
The tree stays plain ESTree. The facts hang beside it, reached from a node.
|
|
67
|
+
|
|
68
|
+
```js
|
|
69
|
+
import { Source, scopeOf, bindingOf, referenceOf, parentOf } from '@teasel/parser';
|
|
70
|
+
|
|
71
|
+
const { node } = new Source('let x = 1; function f(y) { x = y; }', { scopes: true }).parse();
|
|
72
|
+
const [declaration, fn] = node.body;
|
|
73
|
+
const assignment = fn.body.body[0].expression;
|
|
74
|
+
|
|
75
|
+
bindingOf(declaration.declarations[0].id) // { name: 'x', kind: 'let', scope, node, declaration }
|
|
76
|
+
referenceOf(assignment.left) // { scope, binding, write: true, read: false, mutate: false, node, writeExpr }
|
|
77
|
+
scopeOf(fn) // { kind: 'function', parent, node, topLevelAwait: false }
|
|
78
|
+
parentOf(assignment.left) // the assignment
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
Program ──────────────────── scope: module
|
|
83
|
+
├─ let x = 1 x binding let
|
|
84
|
+
└─ function f(y) { scope: function
|
|
85
|
+
│ y binding param
|
|
86
|
+
└─ x = y x reference write y reference read
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
A reference to a name no scope declares has `binding: null`. A copy of a node carries no facts.
|
|
90
|
+
|
|
91
|
+
## Errors
|
|
92
|
+
|
|
93
|
+
```js
|
|
94
|
+
try {
|
|
95
|
+
new Source('x = ;').parse();
|
|
96
|
+
} catch (e) {
|
|
97
|
+
e.code; // 'unexpected_token'
|
|
98
|
+
e.pos; // 4
|
|
99
|
+
e.end; // 5
|
|
100
|
+
e.loc; // { line: 1, column: 4 }
|
|
101
|
+
e.message; // 'Unexpected token'
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
With `errorRecovery`, the parse comes back and the errors come with it.
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
{ f(a, }
|
|
109
|
+
▲
|
|
110
|
+
errors [{ code: 'unexpected_token', pos: 7, end: 7, loc }]
|
|
111
|
+
node Identifier '' at 7..7
|
|
112
|
+
end 7
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Rust and the command line
|
|
116
|
+
|
|
117
|
+
```rust
|
|
118
|
+
let (ast, roots, end) = teasel::parse_at(source, 0, None, Entry::Program, options, "")?;
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
teasel --typescript --scopes file.ts
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Build
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
cargo build --release
|
|
129
|
+
cd package
|
|
130
|
+
bun run build # the Node addon
|
|
131
|
+
bun run build:wasm # the WebAssembly module
|
|
132
|
+
bun test.js
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Node resolves `@teasel/parser` to the addon and everything else to the WebAssembly module, with the same API.
|
|
136
|
+
|
|
137
|
+
<br>
|
|
138
|
+
|
|
139
|
+
<p align="center"><sub>Named after the plant whose dried heads were used to tease apart wool fibres and raise the nap on cloth.</sub></p>
|
package/api.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { decode, facts } from './decode.js';
|
|
2
|
+
import { isIdentifierStart, isIdentifierChar } from './identifier.js';
|
|
3
|
+
|
|
4
|
+
// acorn's option names, which `Request::set` of json.rs takes as they are
|
|
5
|
+
const OPTIONS = new Set(['sourceType', 'typescript', 'decorators', 'comments', 'scopes', 'locations', 'parenthesized', 'allowReturnOutsideFunction', 'allowAwaitOutsideFunction', 'allowSuperOutsideMethod', 'allowUndeclaredExports', 'errorRecovery']);
|
|
6
|
+
|
|
7
|
+
// the engine takes the options that are on as their names
|
|
8
|
+
export function names(options) {
|
|
9
|
+
if (options === undefined) return '';
|
|
10
|
+
const on = [];
|
|
11
|
+
for (const key in options) {
|
|
12
|
+
const value = options[key];
|
|
13
|
+
if (key === 'host') {
|
|
14
|
+
if (value !== undefined && typeof value !== 'string') throw new TypeError('host must be the grammar as a string');
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
if (!OPTIONS.has(key)) throw new TypeError(`${key} is not an option`);
|
|
18
|
+
if (value === undefined || value === false) continue;
|
|
19
|
+
if (key === 'decorators') {
|
|
20
|
+
if (value !== 'legacy' && value !== 'proposal') throw new TypeError(`decorators must be "legacy" or "proposal", not ${JSON.stringify(value)}`);
|
|
21
|
+
on.push(`${value}Decorators`);
|
|
22
|
+
} else if (key === 'sourceType') {
|
|
23
|
+
if (value !== 'script' && value !== 'module') throw new TypeError(`sourceType must be "script" or "module", not ${JSON.stringify(value)}`);
|
|
24
|
+
if (value === 'module') on.push('module');
|
|
25
|
+
} else if (value === true) on.push(key);
|
|
26
|
+
else if (key === 'typescript' && value === 'erase') on.push('typescript', 'erase');
|
|
27
|
+
else throw new TypeError(`${key} must be a boolean, not ${JSON.stringify(value)}`);
|
|
28
|
+
}
|
|
29
|
+
return on.join(' ');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// `Entry` of parser/mod.rs by index
|
|
33
|
+
export const ENTRY = { program: 0, expression: 1, pattern: 2, params: 3, statement: 4, typeParameters: 5 };
|
|
34
|
+
|
|
35
|
+
// the engine takes the stop tokens as one string
|
|
36
|
+
function stops(list) {
|
|
37
|
+
if (list === undefined) return '';
|
|
38
|
+
if (!Array.isArray(list) || !list.every((stop) => typeof stop === 'string' && stop !== '' && !/\s/.test(stop))) {
|
|
39
|
+
throw new TypeError('stopAt must be a list of words and punctuators');
|
|
40
|
+
}
|
|
41
|
+
return list.join(' ');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @typedef {ArrayBuffer | Uint32Array | string} Answer
|
|
46
|
+
* @typedef {object} Engine
|
|
47
|
+
* @property {(source: string, names: string, host: string) => any} create
|
|
48
|
+
* @property {(held: any, entry: number, offset: number, end: number | undefined, stop: string) => Answer} parse
|
|
49
|
+
* @property {(held: any) => void} [free]
|
|
50
|
+
* @property {() => string[]} constants
|
|
51
|
+
* @property {() => ArrayLike<number>} shapes
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
// words the engine has to judge: keywords, the strict-mode reserved words and the contextual ones
|
|
55
|
+
const KEYWORD = new Set('await break case catch class const continue debugger default delete do else enum export extends false finally for function if implements import in instanceof interface let new null package private protected public return static super switch this throw true try typeof var void while with yield'.split(' '));
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The offset after an identifier the host's syntax follows directly, so the answer needs no
|
|
59
|
+
* engine: a name, then optional space, then an unmatched closer, the cut, or one of `stopAt`.
|
|
60
|
+
* Anything the grammar could continue with, a dot or an operator or a TypeScript `as`, and any
|
|
61
|
+
* word the engine has to judge, is left to it.
|
|
62
|
+
* @param {string} source @param {number} at @param {number} end @param {string[] | undefined} stopAt
|
|
63
|
+
* @returns {[number, number] | null} the identifier's end and where the parse ends
|
|
64
|
+
*/
|
|
65
|
+
function bare(source, at, end, stopAt, typescript) {
|
|
66
|
+
let i = at;
|
|
67
|
+
const first = source.codePointAt(i);
|
|
68
|
+
if (first === undefined || !isIdentifierStart(first) || first === 0x5c) return null;
|
|
69
|
+
i += first > 0xffff ? 2 : 1;
|
|
70
|
+
while (i < end) {
|
|
71
|
+
const code = /** @type {number} */ (source.codePointAt(i));
|
|
72
|
+
if (code === 0x5c) return null;
|
|
73
|
+
if (!isIdentifierChar(code)) break;
|
|
74
|
+
i += code > 0xffff ? 2 : 1;
|
|
75
|
+
}
|
|
76
|
+
const name_end = i;
|
|
77
|
+
if (KEYWORD.has(source.slice(at, name_end))) return null;
|
|
78
|
+
while (i < end && /\s/.test(source[i])) i++;
|
|
79
|
+
if (i === end) return [name_end, name_end];
|
|
80
|
+
const c = source[i];
|
|
81
|
+
if (c === '}' || c === ')' || c === ']') return [name_end, name_end];
|
|
82
|
+
if (stopAt !== undefined) {
|
|
83
|
+
for (const stop of stopAt) {
|
|
84
|
+
if (!source.startsWith(stop, i)) continue;
|
|
85
|
+
const after = source.codePointAt(i + stop.length);
|
|
86
|
+
if (isIdentifierStart(/** @type {number} */ (stop.codePointAt(0))) && after !== undefined && isIdentifierChar(after)) continue;
|
|
87
|
+
// a stop TypeScript reads as its own is the engine's to judge
|
|
88
|
+
if (typescript && (stop === 'as' || stop === 'satisfies')) return null;
|
|
89
|
+
return [name_end, name_end];
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** @param {Engine} engine */
|
|
96
|
+
export function bind(engine) {
|
|
97
|
+
const registry = engine.free && typeof FinalizationRegistry !== 'undefined' ? new FinalizationRegistry(engine.free) : null;
|
|
98
|
+
|
|
99
|
+
function result(answer, source) {
|
|
100
|
+
if (typeof answer !== 'string') return decode(answer, source, engine);
|
|
101
|
+
const { message, ...error } = JSON.parse(answer).error;
|
|
102
|
+
throw Object.assign(new SyntaxError(message), error);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return class Source {
|
|
106
|
+
#held;
|
|
107
|
+
#source;
|
|
108
|
+
#options;
|
|
109
|
+
/** @type {number[] | undefined} */
|
|
110
|
+
#lines;
|
|
111
|
+
|
|
112
|
+
constructor(source, options) {
|
|
113
|
+
this.#held = engine.create(source, names(options), options?.host ?? '');
|
|
114
|
+
this.#source = source;
|
|
115
|
+
this.#options = options ?? {};
|
|
116
|
+
registry?.register(this, this.#held, this);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @param {keyof typeof ENTRY} [entry] what to read
|
|
121
|
+
* @param {number} [offset] where it starts
|
|
122
|
+
* @param {{ end?: number, stopAt?: string[] }} [at] where the source is cut, and the host's tokens that end the parse
|
|
123
|
+
*/
|
|
124
|
+
parse(entry = 'program', offset = 0, { end, stopAt } = {}) {
|
|
125
|
+
if (this.#held === undefined) throw new TypeError('the source is freed');
|
|
126
|
+
const index = ENTRY[entry];
|
|
127
|
+
if (index === undefined) throw new TypeError(`${JSON.stringify(entry)} is not an entry`);
|
|
128
|
+
const stop = stops(stopAt);
|
|
129
|
+
if (this.#options.host !== undefined && index === ENTRY.program) return result(engine.parse(this.#held, index, 0, undefined, ''), this.#source);
|
|
130
|
+
if ((index === ENTRY.expression || index === ENTRY.pattern) && Number.isInteger(offset) && offset >= 0) {
|
|
131
|
+
const cut = end === undefined ? this.#source.length : end;
|
|
132
|
+
const found = Number.isInteger(cut) && cut <= this.#source.length && offset <= cut ? bare(this.#source, offset, cut, index === ENTRY.pattern ? [',', '(', ':', '='] : stopAt, !!this.#options.typescript) : null;
|
|
133
|
+
if (found !== null && (index === ENTRY.expression || !this.#source.startsWith(':', found[0]))) return this.#identifier(offset, found[0], index === ENTRY.pattern);
|
|
134
|
+
}
|
|
135
|
+
return result(engine.parse(this.#held, index, offset, end, stop), this.#source);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The answer the engine would give for a bare identifier, built here. */
|
|
139
|
+
#identifier(start, end, pattern) {
|
|
140
|
+
const o = this.#options;
|
|
141
|
+
/** @type {any} */
|
|
142
|
+
const node = { type: 'Identifier', start, end, name: this.#source.slice(start, end) };
|
|
143
|
+
if (o.locations) node.loc = { start: this.#position(start), end: this.#position(end) };
|
|
144
|
+
/** @type {any} */
|
|
145
|
+
const answer = { node, end };
|
|
146
|
+
if (o.comments) answer.comments = [];
|
|
147
|
+
if (o.errorRecovery) answer.errors = [];
|
|
148
|
+
if (o.typescript === 'erase') answer.typescript = [];
|
|
149
|
+
if (o.scopes) {
|
|
150
|
+
const scope = { kind: 'fragment', parent: null, topLevelAwait: false, node };
|
|
151
|
+
const binding = pattern ? { name: node.name, kind: 'pattern', scope, node, declaration: null } : null;
|
|
152
|
+
const reference = pattern ? null : { scope, binding: null, write: false, read: true, mutate: false, node, writeExpr: null };
|
|
153
|
+
answer.scopes = [scope];
|
|
154
|
+
answer.bindings = binding === null ? [] : [binding];
|
|
155
|
+
answer.references = reference === null ? [] : [reference];
|
|
156
|
+
facts(node, scope, binding, reference);
|
|
157
|
+
} else {
|
|
158
|
+
facts(node, undefined, undefined, undefined);
|
|
159
|
+
}
|
|
160
|
+
return answer;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Line and column of an offset, from a line table built on first use. */
|
|
164
|
+
#position(offset) {
|
|
165
|
+
if (this.#lines === undefined) {
|
|
166
|
+
this.#lines = [0];
|
|
167
|
+
for (let i = this.#source.indexOf('\n'); i !== -1; i = this.#source.indexOf('\n', i + 1)) this.#lines.push(i + 1);
|
|
168
|
+
}
|
|
169
|
+
let lo = 0, hi = this.#lines.length - 1;
|
|
170
|
+
while (lo < hi) {
|
|
171
|
+
const mid = (lo + hi + 1) >> 1;
|
|
172
|
+
if (this.#lines[mid] <= offset) lo = mid;
|
|
173
|
+
else hi = mid - 1;
|
|
174
|
+
}
|
|
175
|
+
return { line: lo + 1, column: offset - this.#lines[lo] };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
free() {
|
|
179
|
+
if (this.#held === undefined) return;
|
|
180
|
+
registry?.unregister(this);
|
|
181
|
+
engine.free?.(this.#held);
|
|
182
|
+
this.#held = undefined;
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
}
|