diff --git a/.changeset/swift-impalas-train.md b/.changeset/swift-impalas-train.md new file mode 100644 index 0000000..3880b25 --- /dev/null +++ b/.changeset/swift-impalas-train.md @@ -0,0 +1,5 @@ +--- +"esrap": minor +--- + +feat: add option for quote and indentation styles diff --git a/README.md b/README.md index d790def..2fdb668 100644 --- a/README.md +++ b/README.md @@ -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' }); ``` diff --git a/src/handlers.js b/src/handlers.js index ac7a83a..f7b7cf7 100644 --- a/src/handlers.js +++ b/src/handlers.js @@ -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, @@ -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)); }, diff --git a/src/index.js b/src/index.js index d59aaad..62283de 100644 --- a/src/index.js +++ b/src/index.js @@ -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); @@ -69,6 +70,7 @@ export function print(node, opts = {}) { } let newline = '\n'; + const indent = opts.indent ?? '\t'; /** @param {Command} command */ function run(command) { @@ -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': diff --git a/src/types.d.ts b/src/types.d.ts index e9a0412..a28f94e 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -35,6 +35,7 @@ export interface State { commands: Command[]; comments: TSESTree.Comment[]; multiline: boolean; + quote: "'" | '"'; } export interface Chunk { @@ -79,4 +80,6 @@ export interface PrintOptions { sourceMapSource?: string; sourceMapContent?: string; sourceMapEncodeMappings?: boolean; // default true + indent?: string; // default tab + quotes?: 'single' | 'double'; // default single } diff --git a/test/common.js b/test/common.js new file mode 100644 index 0000000..4f10c99 --- /dev/null +++ b/test/common.js @@ -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)); +} diff --git a/test/esrap.test.js b/test/esrap.test.js index 0a9396f..380ec65 100644 --- a/test/esrap.test.js +++ b/test/esrap.test.js @@ -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) { diff --git a/test/indent.test.js b/test/indent.test.js new file mode 100644 index 0000000..f57bab7 --- /dev/null +++ b/test/indent.test.js @@ -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'; + };" + `); +}); diff --git a/test/quotes.test.js b/test/quotes.test.js new file mode 100644 index 0000000..a8c8590 --- /dev/null +++ b/test/quotes.test.js @@ -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";"`); +});