Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add option for quote and indentation styles #19

Merged
5 changes: 5 additions & 0 deletions .changeset/swift-impalas-train.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"esrap": minor
---

feat: add option for quote and indentation styles
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,29 @@ If the nodes of the input AST have `loc` properties (e.g. the AST was generated

## Options

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

```js
const { code, map } = print(ast, {
// Populate the `sources` field of the resulting sourcemap
// (note that the AST is assumed to come from a single file)
sourceMapSource: 'input.js',
sourceMapContent: fs.readFileSync('input.js', 'utf-8')

// Populate the `sourcesContent` field of the resulting sourcemap
sourceMapContent: fs.readFileSync('input.js', 'utf-8'),

// Whether to encode the `mappings` field of the resulting sourcemap
// as a VLQ string, rather than an unencoded array. Defaults to `true`
sourceMapEncodeMappings: false,

// String to use for indentation — defaults to '\t'
indent: ' ',

// Whether to wrap strings in single or double quotes — defaults to 'single'.
// This only applies to string literals with no `raw` value, which generally
// means the AST node was generated programmatically, rather than parsed
// from an original source
quotes: 'single'
});
```

Expand Down
14 changes: 11 additions & 3 deletions src/handlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,14 @@ function prepend_comments(comments, state, newlines) {
}
}

/**
* @param {string} string
* @param {'\'' | '"'} char
*/
function quote(string, char) {
return char + string.replaceAll(char, '\\' + char) + char;
}

const OPERATOR_PRECEDENCE = {
'||': 2,
'&&': 3,
Expand Down Expand Up @@ -1125,9 +1133,9 @@ const handlers = {
// TODO do we need to handle weird unicode characters somehow?
// str.replace(/\\u(\d{4})/g, (m, n) => String.fromCharCode(+n))

let value = node.raw;
if (!value)
value = typeof node.value === 'string' ? JSON.stringify(node.value) : String(node.value);
const value =
node.raw ||
(typeof node.value === 'string' ? quote(node.value, state.quote) : String(node.value));

state.commands.push(c(value, node));
},
Expand Down
8 changes: 5 additions & 3 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ export function print(node, opts = {}) {
const state = {
commands: [],
comments: [],
multiline: false
multiline: false,
quote: opts.quotes === 'double' ? '"' : "'"
};

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

let newline = '\n';
const indent = opts.indent ?? '\t';

/** @param {Command} command */
function run(command) {
Expand Down Expand Up @@ -108,11 +110,11 @@ export function print(node, opts = {}) {
break;

case 'Indent':
newline += '\t';
newline += indent;
break;

case 'Dedent':
newline = newline.slice(0, -1);
newline = newline.slice(0, -indent.length);
break;

case 'Sequence':
Expand Down
3 changes: 3 additions & 0 deletions src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export interface State {
commands: Command[];
comments: TSESTree.Comment[];
multiline: boolean;
quote: "'" | '"';
}

export interface Chunk {
Expand Down Expand Up @@ -79,4 +80,6 @@ export interface PrintOptions {
sourceMapSource?: string;
sourceMapContent?: string;
sourceMapEncodeMappings?: boolean; // default true
indent?: string; // default tab
quotes?: 'single' | 'double'; // default single
}
55 changes: 55 additions & 0 deletions test/common.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import * as acorn from 'acorn';
import { tsPlugin } from 'acorn-typescript';
import { walk } from 'zimmerframe';

// @ts-expect-error
export const acornTs = acorn.Parser.extend(tsPlugin({ allowSatisfies: true }));

/** @param {string} input */
export function load(input) {
const comments = [];

const ast = acornTs.parse(input, {
ecmaVersion: 'latest',
sourceType: 'module',
locations: true,
onComment: (block, value, start, end) => {
if (block && /\n/.test(value)) {
let a = start;
while (a > 0 && input[a - 1] !== '\n') a -= 1;

let b = a;
while (/[ \t]/.test(input[b])) b += 1;

const indentation = input.slice(a, b);
value = value.replace(new RegExp(`^${indentation}`, 'gm'), '');
}

comments.push({ type: block ? 'Block' : 'Line', value, start, end });
}
});

walk(ast, null, {
_(node, { next }) {
let comment;
const commentNode = /** @type {NodeWithComments} */ (/** @type {any} */ (node));

while (comments[0] && comments[0].start < node.start) {
comment = comments.shift();
(commentNode.leadingComments ??= []).push(comment);
}

next();

if (comments[0]) {
const slice = input.slice(node.end, comments[0].start);

if (/^[,) \t]*$/.test(slice)) {
commentNode.trailingComments = [comments.shift()];
}
}
}
});

return /** @type {TSESTree.Program} */ (/** @type {any} */ (ast));
}
55 changes: 1 addition & 54 deletions test/esrap.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,62 +3,9 @@
/** @import { NodeWithComments, PrintOptions } from '../src/types' */
import fs from 'node:fs';
import { expect, test } from 'vitest';
import * as acorn from 'acorn';
import { tsPlugin } from 'acorn-typescript';
import { walk } from 'zimmerframe';
import { print } from '../src/index.js';

// @ts-expect-error
const acornTs = acorn.Parser.extend(tsPlugin({ allowSatisfies: true }));

/** @param {string} input */
function load(input) {
const comments = [];

const ast = acornTs.parse(input, {
ecmaVersion: 'latest',
sourceType: 'module',
locations: true,
onComment: (block, value, start, end) => {
if (block && /\n/.test(value)) {
let a = start;
while (a > 0 && input[a - 1] !== '\n') a -= 1;

let b = a;
while (/[ \t]/.test(input[b])) b += 1;

const indentation = input.slice(a, b);
value = value.replace(new RegExp(`^${indentation}`, 'gm'), '');
}

comments.push({ type: block ? 'Block' : 'Line', value, start, end });
}
});

walk(ast, null, {
_(node, { next }) {
let comment;
const commentNode = /** @type {NodeWithComments} */ (/** @type {any} */ (node));

while (comments[0] && comments[0].start < node.start) {
comment = comments.shift();
(commentNode.leadingComments ??= []).push(comment);
}

next();

if (comments[0]) {
const slice = input.slice(node.end, comments[0].start);

if (/^[,) \t]*$/.test(slice)) {
commentNode.trailingComments = [comments.shift()];
}
}
}
});

return /** @type {TSESTree.Program} */ (/** @type {any} */ (ast));
}
import { acornTs, load } from './common.js';

/** @param {TSESTree.Node} ast */
function clean(ast) {
Expand Down
39 changes: 39 additions & 0 deletions test/indent.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { test } from 'vitest';
import { load } from './common';
import { print } from '../src';
import { expect } from 'vitest';

const test_code = "const foo = () => { const bar = 'baz' }";

test('default indent type is tab', () => {
const ast = load(test_code);
const code = print(ast).code;

expect(code).toMatchInlineSnapshot(`
"const foo = () => {
const bar = 'baz';
};"
`);
});

test('two space indent', () => {
const ast = load(test_code);
const code = print(ast, { indent: ' ' }).code;

expect(code).toMatchInlineSnapshot(`
"const foo = () => {
const bar = 'baz';
};"
`);
});

test('four space indent', () => {
const ast = load(test_code);
const code = print(ast, { indent: ' ' }).code;

expect(code).toMatchInlineSnapshot(`
"const foo = () => {
const bar = 'baz';
};"
`);
});
63 changes: 63 additions & 0 deletions test/quotes.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { test } from 'vitest';
import { print } from '../src/index.js';
import { expect } from 'vitest';
import { load } from './common.js';
import { walk } from 'zimmerframe';
import { TSESTree } from '@typescript-eslint/types';

/**
* Removes the `raw` property from all `Literal` nodes, as the printer is prefering it's
* value. Only if the `raw` value is not present it will try to add the prefered quoting
* @param {TSESTree.Program} ast
*/
function clean(ast) {
walk(ast, null, {
Literal(node, { next }) {
delete node.raw;

next();
}
});
}

const test_code = "const foo = 'bar'";

test('default quote type is single', () => {
const ast = load(test_code);
clean(ast);
const code = print(ast).code;

expect(code).toMatchInlineSnapshot(`"const foo = 'bar';"`);
});

test('single quotes used when single quote type provided', () => {
const ast = load(test_code);
clean(ast);
const code = print(ast, { quotes: 'single' }).code;

expect(code).toMatchInlineSnapshot(`"const foo = 'bar';"`);
});

test('double quotes used when double quote type provided', () => {
const ast = load(test_code);
clean(ast);
const code = print(ast, { quotes: 'double' }).code;

expect(code).toMatchInlineSnapshot(`"const foo = "bar";"`);
});

test('escape single quotes if present in string literal', () => {
const ast = load('const foo = "b\'ar"');
clean(ast);
const code = print(ast, { quotes: 'single' }).code;

expect(code).toMatchInlineSnapshot(`"const foo = 'b\\'ar';"`);
});

test('escape double quotes if present in string literal', () => {
const ast = load("const foo = 'b\"ar'");
clean(ast);
const code = print(ast, { quotes: 'double' }).code;

expect(code).toMatchInlineSnapshot(`"const foo = "b\\"ar";"`);
});
Loading