@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 CHANGED
@@ -1,3 +1,139 @@
1
- # teasel
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
- Placeholder release. The parser lives at https://github.com/Nic-Polumeyv/teasel.
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
+ }