Skip to content

Commit d8f8de7

Browse files
committed
feat: add option for quote and indentation styles
1 parent a9bbc6a commit d8f8de7

File tree

8 files changed

+192
-62
lines changed

8 files changed

+192
-62
lines changed

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,15 +35,19 @@ 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 optionally pass information that will be used while generating the output (note that the AST is assumed to come from a single file):
3939

4040
```js
4141
const { code, map } = print(ast, {
4242
sourceMapSource: 'input.js',
4343
sourceMapContent: fs.readFileSync('input.js', 'utf-8')
44+
indent: ' ' // default '\t'
45+
quotes: 'single' // or 'double', default 'single'
4446
});
4547
```
4648

49+
The `quotes` option is only used for string literals where no raw value was provided. In most cases this means that the ast node was added by manipulating the ast. This avoid's unnecessarily transforming the provided source code.
50+
4751
## TypeScript
4852

4953
`esrap` can also print TypeScript nodes, assuming they match the ESTree-like [`@typescript-eslint/types`](https://www.npmjs.com/package/@typescript-eslint/types).

src/handlers.js

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,14 @@ export function handle(node, state) {
6969
/**
7070
* @param {string} content
7171
* @param {TSESTree.Node} node
72+
* @param {boolean} quote
7273
* @returns {Chunk}
7374
*/
74-
function c(content, node) {
75+
function c(content, node, quote = false) {
7576
return {
7677
type: 'Chunk',
7778
content,
79+
quote,
7880
loc: node?.loc ?? null
7981
};
8082
}
@@ -1115,10 +1117,16 @@ const handlers = {
11151117
// str.replace(/\\u(\d{4})/g, (m, n) => String.fromCharCode(+n))
11161118

11171119
let value = node.raw;
1118-
if (!value)
1119-
value = typeof node.value === 'string' ? JSON.stringify(node.value) : String(node.value);
1120+
if (value) {
1121+
state.commands.push(c(value, node));
1122+
return;
1123+
}
1124+
1125+
const isString = typeof node.value === 'string';
1126+
const addQuotes = isString;
1127+
value = isString ? node.value : String(node.value);
11201128

1121-
state.commands.push(c(value, node));
1129+
state.commands.push(c(value, node, addQuotes));
11221130
},
11231131

11241132
LogicalExpression: shared['BinaryExpression|LogicalExpression'],

src/index.js

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ export function print(node, opts = {}) {
6969
}
7070

7171
let newline = '\n';
72+
const indent = opts.indent ?? '\t';
73+
const quote_type = opts.quote ?? 'single';
74+
const quote = quote_type === 'single' ? "'" : '"';
7275

7376
/** @param {Command} command */
7477
function run(command) {
@@ -90,7 +93,15 @@ export function print(node, opts = {}) {
9093
]);
9194
}
9295

93-
append(command.content);
96+
let content = command.content;
97+
98+
if (command.quote) {
99+
if (content.includes(quote)) content = content.replaceAll(quote, `\\${quote}`);
100+
101+
content = `${quote}${content}${quote}`;
102+
}
103+
104+
append(content);
94105

95106
if (loc) {
96107
current_line.push([
@@ -108,11 +119,11 @@ export function print(node, opts = {}) {
108119
break;
109120

110121
case 'Indent':
111-
newline += '\t';
122+
newline += indent;
112123
break;
113124

114125
case 'Dedent':
115-
newline = newline.slice(0, -1);
126+
newline = newline.slice(0, -indent.length);
116127
break;
117128

118129
case 'Sequence':

src/types.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export interface State {
4040
export interface Chunk {
4141
type: 'Chunk';
4242
content: string;
43+
quote: boolean;
4344
loc: null | {
4445
start: { line: number; column: number };
4546
end: { line: number; column: number };
@@ -79,4 +80,6 @@ export interface PrintOptions {
7980
sourceMapSource?: string;
8081
sourceMapContent?: string;
8182
sourceMapEncodeMappings?: boolean; // default true
83+
indent?: string; // default tab
84+
quote?: '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+
_(node, { next }) {
16+
if (node.type === 'Literal') 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, { quote: '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, { quote: '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, { quote: '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, { quote: 'double' }).code;
61+
62+
expect(code).toMatchInlineSnapshot(`"const foo = "b\\"ar";"`);
63+
});

0 commit comments

Comments
 (0)