Skip to content

Commit 1b1651d

Browse files
authored
Merge pull request #19 from manuel3108/feat/indentation-and-quote-options
feat: add option for quote and indentation styles
2 parents 5471cb4 + d97e56f commit 1b1651d

File tree

9 files changed

+201
-62
lines changed

9 files changed

+201
-62
lines changed

.changeset/swift-impalas-train.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"esrap": minor
3+
---
4+
5+
feat: add option for quote and indentation styles

README.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,29 @@ If the nodes of the input AST have `loc` properties (e.g. the AST was generated
3535

3636
## Options
3737

38-
You can pass information that will be added to the resulting sourcemap (note that the AST is assumed to come from a single file):
38+
You can pass the following options:
3939

4040
```js
4141
const { code, map } = print(ast, {
42+
// Populate the `sources` field of the resulting sourcemap
43+
// (note that the AST is assumed to come from a single file)
4244
sourceMapSource: 'input.js',
43-
sourceMapContent: fs.readFileSync('input.js', 'utf-8')
45+
46+
// Populate the `sourcesContent` field of the resulting sourcemap
47+
sourceMapContent: fs.readFileSync('input.js', 'utf-8'),
48+
49+
// Whether to encode the `mappings` field of the resulting sourcemap
50+
// as a VLQ string, rather than an unencoded array. Defaults to `true`
51+
sourceMapEncodeMappings: false,
52+
53+
// 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'
4461
});
4562
```
4663

src/handlers.js

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,14 @@ function prepend_comments(comments, state, newlines) {
9696
}
9797
}
9898

99+
/**
100+
* @param {string} string
101+
* @param {'\'' | '"'} char
102+
*/
103+
function quote(string, char) {
104+
return char + string.replaceAll(char, '\\' + char) + char;
105+
}
106+
99107
const OPERATOR_PRECEDENCE = {
100108
'||': 2,
101109
'&&': 3,
@@ -1125,9 +1133,9 @@ const handlers = {
11251133
// TODO do we need to handle weird unicode characters somehow?
11261134
// str.replace(/\\u(\d{4})/g, (m, n) => String.fromCharCode(+n))
11271135

1128-
let value = node.raw;
1129-
if (!value)
1130-
value = typeof node.value === 'string' ? JSON.stringify(node.value) : String(node.value);
1136+
const value =
1137+
node.raw ||
1138+
(typeof node.value === 'string' ? quote(node.value, state.quote) : String(node.value));
11311139

11321140
state.commands.push(c(value, node));
11331141
},

src/index.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ export function print(node, opts = {}) {
3737
const state = {
3838
commands: [],
3939
comments: [],
40-
multiline: false
40+
multiline: false,
41+
quote: opts.quotes === 'double' ? '"' : "'"
4142
};
4243

4344
handle(/** @type {TSESTree.Node} */ (node), state);
@@ -69,6 +70,7 @@ export function print(node, opts = {}) {
6970
}
7071

7172
let newline = '\n';
73+
const indent = opts.indent ?? '\t';
7274

7375
/** @param {Command} command */
7476
function run(command) {
@@ -108,11 +110,11 @@ export function print(node, opts = {}) {
108110
break;
109111

110112
case 'Indent':
111-
newline += '\t';
113+
newline += indent;
112114
break;
113115

114116
case 'Dedent':
115-
newline = newline.slice(0, -1);
117+
newline = newline.slice(0, -indent.length);
116118
break;
117119

118120
case 'Sequence':

src/types.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export interface State {
3535
commands: Command[];
3636
comments: TSESTree.Comment[];
3737
multiline: boolean;
38+
quote: "'" | '"';
3839
}
3940

4041
export interface Chunk {
@@ -79,4 +80,6 @@ export interface PrintOptions {
7980
sourceMapSource?: string;
8081
sourceMapContent?: string;
8182
sourceMapEncodeMappings?: boolean; // default true
83+
indent?: string; // default tab
84+
quotes?: 'single' | 'double'; // default single
8285
}

test/common.js

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import * as acorn from 'acorn';
2+
import { tsPlugin } from 'acorn-typescript';
3+
import { walk } from 'zimmerframe';
4+
5+
// @ts-expect-error
6+
export const acornTs = acorn.Parser.extend(tsPlugin({ allowSatisfies: true }));
7+
8+
/** @param {string} input */
9+
export function load(input) {
10+
const comments = [];
11+
12+
const ast = acornTs.parse(input, {
13+
ecmaVersion: 'latest',
14+
sourceType: 'module',
15+
locations: true,
16+
onComment: (block, value, start, end) => {
17+
if (block && /\n/.test(value)) {
18+
let a = start;
19+
while (a > 0 && input[a - 1] !== '\n') a -= 1;
20+
21+
let b = a;
22+
while (/[ \t]/.test(input[b])) b += 1;
23+
24+
const indentation = input.slice(a, b);
25+
value = value.replace(new RegExp(`^${indentation}`, 'gm'), '');
26+
}
27+
28+
comments.push({ type: block ? 'Block' : 'Line', value, start, end });
29+
}
30+
});
31+
32+
walk(ast, null, {
33+
_(node, { next }) {
34+
let comment;
35+
const commentNode = /** @type {NodeWithComments} */ (/** @type {any} */ (node));
36+
37+
while (comments[0] && comments[0].start < node.start) {
38+
comment = comments.shift();
39+
(commentNode.leadingComments ??= []).push(comment);
40+
}
41+
42+
next();
43+
44+
if (comments[0]) {
45+
const slice = input.slice(node.end, comments[0].start);
46+
47+
if (/^[,) \t]*$/.test(slice)) {
48+
commentNode.trailingComments = [comments.shift()];
49+
}
50+
}
51+
}
52+
});
53+
54+
return /** @type {TSESTree.Program} */ (/** @type {any} */ (ast));
55+
}

test/esrap.test.js

Lines changed: 1 addition & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -3,62 +3,9 @@
33
/** @import { NodeWithComments, PrintOptions } from '../src/types' */
44
import fs from 'node:fs';
55
import { expect, test } from 'vitest';
6-
import * as acorn from 'acorn';
7-
import { tsPlugin } from 'acorn-typescript';
86
import { walk } from 'zimmerframe';
97
import { print } from '../src/index.js';
10-
11-
// @ts-expect-error
12-
const acornTs = acorn.Parser.extend(tsPlugin({ allowSatisfies: true }));
13-
14-
/** @param {string} input */
15-
function load(input) {
16-
const comments = [];
17-
18-
const ast = acornTs.parse(input, {
19-
ecmaVersion: 'latest',
20-
sourceType: 'module',
21-
locations: true,
22-
onComment: (block, value, start, end) => {
23-
if (block && /\n/.test(value)) {
24-
let a = start;
25-
while (a > 0 && input[a - 1] !== '\n') a -= 1;
26-
27-
let b = a;
28-
while (/[ \t]/.test(input[b])) b += 1;
29-
30-
const indentation = input.slice(a, b);
31-
value = value.replace(new RegExp(`^${indentation}`, 'gm'), '');
32-
}
33-
34-
comments.push({ type: block ? 'Block' : 'Line', value, start, end });
35-
}
36-
});
37-
38-
walk(ast, null, {
39-
_(node, { next }) {
40-
let comment;
41-
const commentNode = /** @type {NodeWithComments} */ (/** @type {any} */ (node));
42-
43-
while (comments[0] && comments[0].start < node.start) {
44-
comment = comments.shift();
45-
(commentNode.leadingComments ??= []).push(comment);
46-
}
47-
48-
next();
49-
50-
if (comments[0]) {
51-
const slice = input.slice(node.end, comments[0].start);
52-
53-
if (/^[,) \t]*$/.test(slice)) {
54-
commentNode.trailingComments = [comments.shift()];
55-
}
56-
}
57-
}
58-
});
59-
60-
return /** @type {TSESTree.Program} */ (/** @type {any} */ (ast));
61-
}
8+
import { acornTs, load } from './common.js';
629

6310
/** @param {TSESTree.Node} ast */
6411
function clean(ast) {

test/indent.test.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { test } from 'vitest';
2+
import { load } from './common';
3+
import { print } from '../src';
4+
import { expect } from 'vitest';
5+
6+
const test_code = "const foo = () => { const bar = 'baz' }";
7+
8+
test('default indent type is tab', () => {
9+
const ast = load(test_code);
10+
const code = print(ast).code;
11+
12+
expect(code).toMatchInlineSnapshot(`
13+
"const foo = () => {
14+
const bar = 'baz';
15+
};"
16+
`);
17+
});
18+
19+
test('two space indent', () => {
20+
const ast = load(test_code);
21+
const code = print(ast, { indent: ' ' }).code;
22+
23+
expect(code).toMatchInlineSnapshot(`
24+
"const foo = () => {
25+
const bar = 'baz';
26+
};"
27+
`);
28+
});
29+
30+
test('four space indent', () => {
31+
const ast = load(test_code);
32+
const code = print(ast, { indent: ' ' }).code;
33+
34+
expect(code).toMatchInlineSnapshot(`
35+
"const foo = () => {
36+
const bar = 'baz';
37+
};"
38+
`);
39+
});

test/quotes.test.js

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { test } from 'vitest';
2+
import { print } from '../src/index.js';
3+
import { expect } from 'vitest';
4+
import { load } from './common.js';
5+
import { walk } from 'zimmerframe';
6+
import { TSESTree } from '@typescript-eslint/types';
7+
8+
/**
9+
* Removes the `raw` property from all `Literal` nodes, as the printer is prefering it's
10+
* value. Only if the `raw` value is not present it will try to add the prefered quoting
11+
* @param {TSESTree.Program} ast
12+
*/
13+
function clean(ast) {
14+
walk(ast, null, {
15+
Literal(node, { next }) {
16+
delete node.raw;
17+
18+
next();
19+
}
20+
});
21+
}
22+
23+
const test_code = "const foo = 'bar'";
24+
25+
test('default quote type is single', () => {
26+
const ast = load(test_code);
27+
clean(ast);
28+
const code = print(ast).code;
29+
30+
expect(code).toMatchInlineSnapshot(`"const foo = 'bar';"`);
31+
});
32+
33+
test('single quotes used when single quote type provided', () => {
34+
const ast = load(test_code);
35+
clean(ast);
36+
const code = print(ast, { quotes: 'single' }).code;
37+
38+
expect(code).toMatchInlineSnapshot(`"const foo = 'bar';"`);
39+
});
40+
41+
test('double quotes used when double quote type provided', () => {
42+
const ast = load(test_code);
43+
clean(ast);
44+
const code = print(ast, { quotes: 'double' }).code;
45+
46+
expect(code).toMatchInlineSnapshot(`"const foo = "bar";"`);
47+
});
48+
49+
test('escape single quotes if present in string literal', () => {
50+
const ast = load('const foo = "b\'ar"');
51+
clean(ast);
52+
const code = print(ast, { quotes: 'single' }).code;
53+
54+
expect(code).toMatchInlineSnapshot(`"const foo = 'b\\'ar';"`);
55+
});
56+
57+
test('escape double quotes if present in string literal', () => {
58+
const ast = load("const foo = 'b\"ar'");
59+
clean(ast);
60+
const code = print(ast, { quotes: 'double' }).code;
61+
62+
expect(code).toMatchInlineSnapshot(`"const foo = "b\\"ar";"`);
63+
});

0 commit comments

Comments
 (0)