esrap 1.4.9 → 2.0.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 CHANGED
@@ -6,8 +6,9 @@ Parse in reverse. AST goes in, code comes out.
6
6
 
7
7
  ```js
8
8
  import { print } from 'esrap';
9
+ import ts from 'esrap/languages/ts';
9
10
 
10
- const { code, map } = print({
11
+ const ast = {
11
12
  type: 'Program',
12
13
  body: [
13
14
  {
@@ -26,19 +27,105 @@ const { code, map } = print({
26
27
  }
27
28
  }
28
29
  ]
29
- });
30
+ };
31
+
32
+ const { code, map } = print(ast, ts());
30
33
 
31
34
  console.log(code); // alert('hello world!');
32
35
  ```
33
36
 
34
37
  If the nodes of the input AST have `loc` properties (e.g. the AST was generated with [`acorn`](https://github.com/acornjs/acorn/tree/master/acorn/#interface) with the `locations` option set), sourcemap mappings will be created.
35
38
 
39
+ ## Built-in languages
40
+
41
+ `esrap` ships with two built-in languages — `ts()` and `tsx()` (considered experimental at present!) — which can print ASTs conforming to [`@typescript-eslint/types`](https://www.npmjs.com/package/@typescript-eslint/types) (which extends [ESTree](https://github.com/estree/estree)):
42
+
43
+ ```js
44
+ import ts from 'esrap/languages/ts';
45
+ import tsx from 'esrap/languages/tsx'; // experimental!
46
+ ```
47
+
48
+ Both languages accept an options object:
49
+
50
+ ```js
51
+ const { code, map } = print(
52
+ ast,
53
+ ts({
54
+ // how string literals should be quoted — `single` (the default) or `double`
55
+ quotes: 'single',
56
+
57
+ // an array of `{ type: 'Line' | 'Block', value: string, loc: { start, end } }` objects
58
+ comments: []
59
+ })
60
+ );
61
+ ```
62
+
63
+ You can generate the `comments` array by, for example, using [Acorn's](https://github.com/acornjs/acorn/tree/master/acorn/#interface) `onComment` option.
64
+
65
+ ## Custom languages
66
+
67
+ You can also create your own languages:
68
+
69
+ ```ts
70
+ import { print, type Visitors } from 'esrap';
71
+
72
+ const language: Visitors<MyNodeType> = {
73
+ _(node, context, visit) {
74
+ // the `_` visitor handles any node type
75
+ context.write('[');
76
+ visit(node);
77
+ context.write(']');
78
+ },
79
+ List(node, context) {
80
+ // node.type === 'List'
81
+ for (const child of node.children) {
82
+ context.visit(child);
83
+ }
84
+ },
85
+ Foo(node, context) {
86
+ // node.type === 'Foo'
87
+ context.write('foo');
88
+ },
89
+ Bar(node, context) {
90
+ // node.type === 'Bar'
91
+ context.write('bar');
92
+ }
93
+ };
94
+
95
+ const ast: MyNodeType = {
96
+ type: 'List',
97
+ children: [{ type: 'Foo' }, { type: 'Bar' }]
98
+ };
99
+
100
+ const { code, map } = print(ast, language);
101
+
102
+ code; // `[[foo][bar]]`
103
+ ```
104
+
105
+ The `context` API has several methods:
106
+
107
+ - `context.write(data: string, node?: BaseNode)` — add a string. If `node` is provided and has a standard `loc` property (with `start` and `end` properties each with a `line` and `column`), a sourcemap mapping will be created
108
+ - `context.indent()` — increase the indentation level, typically before adding a newline
109
+ - `context.newline()` — self-explanatory
110
+ - `context.margin()` — causes the next newline to be repeated (consecutive newlines are otherwise merged into one)
111
+ - `context.dedent()` — decrease the indentation level (again, typically before adding a newline)
112
+ - `context.visit(node: BaseNode)` — calls the visitor corresponding to `node.type`
113
+ - `context.location(line: number, column: number)` — insert a sourcemap mapping _without_ calling `context.write(...)`
114
+ - `context.measure()` — returns the number of characters contained in `context`
115
+ - `context.empty()` — returns true if the context has no content
116
+ - `context.new()` — creates a child context
117
+ - `context.append(child)` — appends a child context
118
+
119
+ In addition, `context.multiline` is `true` if the context has multiline content. (This is useful for knowing, for example, when to insert newlines between nodes.)
120
+
121
+ To understand how to wield these methods effectively, read the source code for the built-in languages.
122
+
36
123
  ## Options
37
124
 
38
125
  You can pass the following options:
39
126
 
40
127
  ```js
41
- const { code, map } = print(ast, {
128
+ const { code, map } = print(ast, ts(), {
42
129
  // Populate the `sources` field of the resulting sourcemap
43
130
  // (note that the AST is assumed to come from a single file)
44
131
  sourceMapSource: 'input.js',
@@ -51,20 +138,10 @@ const { code, map } = print(ast, {
51
138
  sourceMapEncodeMappings: false,
52
139
 
53
140
  // String to use for indentation — defaults to '\t'
54
- indent: ' ',
55
-
56
- // Whether to wrap strings in single or double quotes — defaults to 'single'.
57
- // This only applies to string literals with no `raw` value, which generally
58
- // means the AST node was generated programmatically, rather than parsed
59
- // from an original source
60
- quotes: 'single'
141
+ indent: ' '
61
142
  });
62
143
  ```
63
144
 
64
- ## TypeScript
65
-
66
- `esrap` can also print TypeScript nodes, assuming they match the ESTree-like [`@typescript-eslint/types`](https://www.npmjs.com/package/@typescript-eslint/types).
67
-
68
145
  ## Why not just use Prettier?
69
146
 
70
147
  Because it's ginormous.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "esrap",
3
- "version": "1.4.9",
3
+ "version": "2.0.0",
4
4
  "description": "Parse in reverse",
5
5
  "repository": {
6
6
  "type": "git",
@@ -15,6 +15,14 @@
15
15
  ".": {
16
16
  "types": "./types/index.d.ts",
17
17
  "default": "./src/index.js"
18
+ },
19
+ "./languages/ts": {
20
+ "types": "./types/index.d.ts",
21
+ "default": "./src/languages/ts/index.js"
22
+ },
23
+ "./languages/tsx": {
24
+ "types": "./types/index.d.ts",
25
+ "default": "./src/languages/tsx/index.js"
18
26
  }
19
27
  },
20
28
  "types": "./types/index.d.ts",
@@ -23,8 +31,8 @@
23
31
  "@sveltejs/acorn-typescript": "^1.0.5",
24
32
  "@typescript-eslint/types": "^8.2.0",
25
33
  "@vitest/ui": "^2.1.1",
26
- "acorn": "^8.11.3",
27
- "dts-buddy": "^0.5.4",
34
+ "acorn": "^8.15.0",
35
+ "dts-buddy": "^0.6.2",
28
36
  "prettier": "^3.0.3",
29
37
  "typescript": "^5.7.2",
30
38
  "vitest": "^2.1.1",
package/src/context.js ADDED
@@ -0,0 +1,150 @@
1
+ /** @import { TSESTree } from '@typescript-eslint/types' */
2
+ /** @import { BaseNode, Command, Visitors } from './types' */
3
+
4
+ export const margin = 0;
5
+ export const newline = 1;
6
+ export const indent = 2;
7
+ export const dedent = 3;
8
+
9
+ export class Context {
10
+ #visitors;
11
+ #commands;
12
+
13
+ multiline = false;
14
+
15
+ /**
16
+ *
17
+ * @param {Visitors} visitors
18
+ * @param {Command[]} commands
19
+ */
20
+ constructor(visitors, commands = []) {
21
+ this.#visitors = visitors;
22
+ this.#commands = commands;
23
+ }
24
+
25
+ indent() {
26
+ this.#commands.push(indent);
27
+ }
28
+
29
+ dedent() {
30
+ this.#commands.push(dedent);
31
+ }
32
+
33
+ margin() {
34
+ this.#commands.push(margin);
35
+ }
36
+
37
+ newline() {
38
+ this.multiline = true;
39
+ this.#commands.push(newline);
40
+ }
41
+
42
+ /**
43
+ * @param {Context} context
44
+ */
45
+ append(context) {
46
+ this.#commands.push(context.#commands);
47
+ }
48
+
49
+ /**
50
+ *
51
+ * @param {string} content
52
+ * @param {BaseNode} [node]
53
+ */
54
+ write(content, node) {
55
+ if (node?.loc) {
56
+ this.location(node.loc.start.line, node.loc.start.column);
57
+ this.#commands.push(content);
58
+ this.location(node.loc.end.line, node.loc.end.column);
59
+ } else {
60
+ this.#commands.push(content);
61
+ }
62
+ }
63
+
64
+ /**
65
+ *
66
+ * @param {number} line
67
+ * @param {number} column
68
+ */
69
+ location(line, column) {
70
+ this.#commands.push({ type: 'Location', line, column });
71
+ }
72
+
73
+ /**
74
+ * @param {{ type: string }} node
75
+ */
76
+ visit(node) {
77
+ const visitor = this.#visitors[node.type];
78
+
79
+ if (!visitor) {
80
+ let message = `Not implemented: ${node.type}`;
81
+
82
+ if (node.type.includes('TS')) {
83
+ message += ` (consider using 'esrap/languages/ts')`;
84
+ }
85
+
86
+ if (node.type.includes('JSX')) {
87
+ message += ` (consider using 'esrap/languages/tsx')`;
88
+ }
89
+
90
+ throw new Error(message);
91
+ }
92
+
93
+ if (this.#visitors._) {
94
+ // @ts-ignore
95
+ this.#visitors._(node, this, (node) => visitor(node, this));
96
+ } else {
97
+ // @ts-ignore
98
+ visitor(node, this);
99
+ }
100
+ }
101
+
102
+ empty() {
103
+ return !this.#commands.some(has_content);
104
+ }
105
+
106
+ measure() {
107
+ return measure(this.#commands);
108
+ }
109
+
110
+ new() {
111
+ return new Context(this.#visitors);
112
+ }
113
+ }
114
+
115
+ /**
116
+ *
117
+ * @param {Command[]} commands
118
+ * @param {number} [from]
119
+ * @param {number} [to]
120
+ */
121
+ function measure(commands, from = 0, to = commands.length) {
122
+ let total = 0;
123
+
124
+ for (let i = from; i < to; i += 1) {
125
+ const command = commands[i];
126
+
127
+ if (typeof command === 'string') {
128
+ total += command.length;
129
+ } else if (Array.isArray(command)) {
130
+ total += measure(command);
131
+ }
132
+ }
133
+
134
+ return total;
135
+ }
136
+
137
+ /**
138
+ * @param {Command} command
139
+ */
140
+ function has_content(command) {
141
+ if (Array.isArray(command)) {
142
+ return command.some(has_content);
143
+ }
144
+
145
+ if (typeof command === 'string') {
146
+ return command.length > 0;
147
+ }
148
+
149
+ return false;
150
+ }
package/src/index.js CHANGED
@@ -1,7 +1,6 @@
1
- /** @import { TSESTree } from '@typescript-eslint/types' */
2
- /** @import { Command, PrintOptions, State } from './types' */
3
- import { handle } from './handlers.js';
1
+ /** @import { BaseNode, Command, Visitors, PrintOptions } from './types' */
4
2
  import { encode } from '@jridgewell/sourcemap-codec';
3
+ import { Context, dedent, indent, margin, newline } from './context.js';
5
4
 
6
5
  /** @type {(str: string) => string} str */
7
6
  let btoa = () => {
@@ -10,38 +9,56 @@ let btoa = () => {
10
9
 
11
10
  if (typeof window !== 'undefined' && typeof window.btoa === 'function') {
12
11
  btoa = (str) => window.btoa(unescape(encodeURIComponent(str)));
13
- // @ts-expect-error
14
12
  } else if (typeof Buffer === 'function') {
15
- // @ts-expect-error
16
13
  btoa = (str) => Buffer.from(str, 'utf-8').toString('base64');
17
14
  }
18
15
 
16
+ class SourceMap {
17
+ version = 3;
18
+
19
+ /** @type {string[]} */
20
+ names = [];
21
+
22
+ /**
23
+ * @param {[number, number, number, number][][]} mappings
24
+ * @param {PrintOptions} opts
25
+ */
26
+ constructor(mappings, opts) {
27
+ this.sources = [opts.sourceMapSource || null];
28
+ this.sourcesContent = [opts.sourceMapContent || null];
29
+ this.mappings = opts.sourceMapEncodeMappings === false ? mappings : encode(mappings);
30
+ }
31
+
32
+ /**
33
+ * Returns a JSON representation suitable for saving as a `*.map` file
34
+ */
35
+ toString() {
36
+ return JSON.stringify(this);
37
+ }
38
+
39
+ /**
40
+ * Returns a base64-encoded JSON representation suitable for inlining at the bottom of a file with `//# sourceMappingURL={...}`
41
+ */
42
+ toUrl() {
43
+ return 'data:application/json;charset=utf-8;base64,' + btoa(this.toString());
44
+ }
45
+ }
46
+
19
47
  /**
20
- * @param {{ type: string, [key: string]: any }} node
48
+ * @template {BaseNode} [T=BaseNode]
49
+ * @param {T} node
50
+ * @param {Visitors<T>} visitors
21
51
  * @param {PrintOptions} opts
22
52
  * @returns {{ code: string, map: any }} // TODO
23
53
  */
24
- export function print(node, opts = {}) {
25
- if (Array.isArray(node)) {
26
- return print(
27
- {
28
- type: 'Program',
29
- body: node,
30
- sourceType: 'module'
31
- },
32
- opts
33
- );
34
- }
54
+ export function print(node, visitors, opts = {}) {
55
+ /** @type {Command[]} */
56
+ const commands = [];
35
57
 
36
- /** @type {State} */
37
- const state = {
38
- commands: [],
39
- comments: [],
40
- multiline: false,
41
- quote: opts.quotes === 'double' ? '"' : "'"
42
- };
58
+ // @ts-expect-error some nonsense I don't understand
59
+ const context = new Context(visitors, commands);
43
60
 
44
- handle(/** @type {TSESTree.Node} */ (node), state);
61
+ context.visit(node);
45
62
 
46
63
  /** @typedef {[number, number, number, number]} Segment */
47
64
 
@@ -69,16 +86,14 @@ export function print(node, opts = {}) {
69
86
  }
70
87
  }
71
88
 
72
- let newline = '\n';
73
- const indent = opts.indent ?? '\t';
89
+ let current_newline = '\n';
90
+ const indent_str = opts.indent ?? '\t';
91
+
92
+ let needs_newline = false;
93
+ let needs_margin = false;
74
94
 
75
95
  /** @param {Command} command */
76
96
  function run(command) {
77
- if (typeof command === 'string') {
78
- append(command);
79
- return;
80
- }
81
-
82
97
  if (Array.isArray(command)) {
83
98
  for (let i = 0; i < command.length; i += 1) {
84
99
  run(command[i]);
@@ -86,74 +101,60 @@ export function print(node, opts = {}) {
86
101
  return;
87
102
  }
88
103
 
89
- switch (command.type) {
90
- case 'Location':
91
- current_line.push([
92
- current_column,
93
- 0, // source index is always zero
94
- command.line - 1,
95
- command.column
96
- ]);
97
- break;
98
-
99
- case 'Newline':
100
- append(newline);
101
- break;
102
-
103
- case 'Indent':
104
- newline += indent;
105
- break;
106
-
107
- case 'Dedent':
108
- newline = newline.slice(0, -indent.length);
109
- break;
110
-
111
- case 'Comment':
112
- if (command.comment.type === 'Line') {
113
- append(`//${command.comment.value}`);
114
- } else {
115
- append(`/*${command.comment.value.replace(/\n/g, newline)}*/`);
116
- }
117
-
118
- break;
104
+ if (typeof command === 'number') {
105
+ if (command === newline) {
106
+ needs_newline = true;
107
+ } else if (command === margin) {
108
+ needs_margin = true;
109
+ } else if (command === indent) {
110
+ current_newline += indent_str;
111
+ } else if (command === dedent) {
112
+ current_newline = current_newline.slice(0, -indent_str.length);
113
+ }
114
+
115
+ return;
116
+ }
117
+
118
+ if (needs_newline) {
119
+ append(needs_margin ? '\n' + current_newline : current_newline);
120
+ }
121
+
122
+ needs_margin = needs_newline = false;
123
+
124
+ if (typeof command === 'string') {
125
+ append(command);
126
+ return;
127
+ }
128
+
129
+ if (command.type === 'Location') {
130
+ current_line.push([
131
+ current_column,
132
+ 0, // source index is always zero
133
+ command.line - 1,
134
+ command.column
135
+ ]);
119
136
  }
120
137
  }
121
138
 
122
- for (let i = 0; i < state.commands.length; i += 1) {
123
- run(state.commands[i]);
139
+ for (let i = 0; i < commands.length; i += 1) {
140
+ run(commands[i]);
124
141
  }
125
142
 
126
143
  mappings.push(current_line);
127
144
 
128
- const map = {
129
- version: 3,
130
- /** @type {string[]} */
131
- names: [],
132
- sources: [opts.sourceMapSource || null],
133
- sourcesContent: [opts.sourceMapContent || null],
134
- mappings:
135
- opts.sourceMapEncodeMappings == undefined || opts.sourceMapEncodeMappings
136
- ? encode(mappings)
137
- : mappings
138
- };
139
-
140
- Object.defineProperties(map, {
141
- toString: {
142
- enumerable: false,
143
- value: function toString() {
144
- return JSON.stringify(this);
145
- }
146
- },
147
- toUrl: {
148
- enumerable: false,
149
- value: function toUrl() {
150
- return 'data:application/json;charset=utf-8;base64,' + btoa(this.toString());
151
- }
152
- }
153
- });
145
+ /** @type {SourceMap} */
146
+ let map;
154
147
 
155
148
  return {
156
149
  code,
157
- map
150
+ // create sourcemap lazily in case we don't need it
151
+ get map() {
152
+ return (map ??= new SourceMap(mappings, opts));
153
+ }
158
154
  };
159
155
  }
156
+
157
+ // it sucks that we have to export the class rather than just
158
+ // re-exporting it via public.d.ts, but otherwise TypeScript
159
+ // gets confused about private fields because it is really dumb!
160
+ export { Context };