diff --git a/.changeset/pretty-spiders-hear.md b/.changeset/pretty-spiders-hear.md new file mode 100644 index 0000000..5778e9f --- /dev/null +++ b/.changeset/pretty-spiders-hear.md @@ -0,0 +1,5 @@ +--- +'esrap': minor +--- + +feat: add `getLeadingComments` & `getTrailingComments` option for programmatic comment insertion diff --git a/README.md b/README.md index 3c7f62f..0fa7c29 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,12 @@ const { code, map } = print( quotes: 'single', // an array of `{ type: 'Line' | 'Block', value: string, loc: { start, end } }` objects - comments: [] + comments: [], + + // a pair of functions for inserting additional comments before or after a given node. + // returns `Array<{ type: 'Line' | 'Block', value: string }>` or `undefined` + getLeadingComments: (node) => [{ type: 'Line', value: ' a comment before the node' }], + getTrailingComments: (node) => [{ type: 'Block', value: ' a comment after the node' }] }) ); ``` diff --git a/src/languages/ts/index.js b/src/languages/ts/index.js index ce1a686..34df1e0 100644 --- a/src/languages/ts/index.js +++ b/src/languages/ts/index.js @@ -1,6 +1,6 @@ /** @import { TSESTree } from '@typescript-eslint/types' */ /** @import { Visitors } from '../../types.js' */ -/** @import { TSOptions, Comment } from '../types.js' */ +/** @import { TSOptions, BaseComment } from '../types.js' */ import { Context } from 'esrap'; /** @typedef {TSESTree.Node} Node */ @@ -74,7 +74,7 @@ const OPERATOR_PRECEDENCE = { }; /** - * @param {Comment} comment + * @param {BaseComment} comment * @param {Context} context */ function write_comment(comment, context) { @@ -90,6 +90,7 @@ function write_comment(comment, context) { } context.write('*/'); + if (lines.length > 1) context.newline(); } } @@ -104,6 +105,36 @@ export default (options = {}) => { let comment_index = 0; + /** + * Write additional comments for a node + * @param {Context} context + * @param {BaseComment[] | undefined} comments + * @param {('leading' | 'trailing')} position + */ + function write_additional_comments(context, comments, position) { + if (!comments) { + return; + } + + for (let i = 0; i < comments.length; i += 1) { + const comment = comments[i]; + + if (position === 'trailing' && i === 0) { + context.write(' '); + } + + write_comment(comment, context); + + if (position === 'leading') { + if (comment.type === 'Line') { + context.newline(); + } else if (comment.type === 'Block' && !comment.value.includes('\n')) { + context.write(' '); + } + } + } + } + /** * Set `comment_index` to be the first comment after `start`. * Most of the time this is already correct, but if nodes @@ -775,11 +806,15 @@ export default (options = {}) => { return { _(node, context, visit) { + write_additional_comments(context, options.getLeadingComments?.(node), 'leading'); + if (node.loc) { flush_comments_until(context, null, node.loc.start, true); } visit(node); + + write_additional_comments(context, options.getTrailingComments?.(node), 'trailing'); }, AccessorProperty: diff --git a/src/languages/ts/public.d.ts b/src/languages/ts/public.d.ts index 06fa86b..4009459 100644 --- a/src/languages/ts/public.d.ts +++ b/src/languages/ts/public.d.ts @@ -1,2 +1,2 @@ export * from './index'; -export { Comment } from '../types'; +export { BaseComment, Comment } from '../types'; diff --git a/src/languages/tsx/public.d.ts b/src/languages/tsx/public.d.ts index 06fa86b..4009459 100644 --- a/src/languages/tsx/public.d.ts +++ b/src/languages/tsx/public.d.ts @@ -1,2 +1,2 @@ export * from './index'; -export { Comment } from '../types'; +export { BaseComment, Comment } from '../types'; diff --git a/src/languages/types.d.ts b/src/languages/types.d.ts index 1229d88..7e60a07 100644 --- a/src/languages/types.d.ts +++ b/src/languages/types.d.ts @@ -1,6 +1,10 @@ +import { TSESTree } from '@typescript-eslint/types'; + export type TSOptions = { quotes?: 'double' | 'single'; comments?: Comment[]; + getLeadingComments?: (node: TSESTree.Node) => BaseComment[] | undefined; + getTrailingComments?: (node: TSESTree.Node) => BaseComment[] | undefined; }; interface Position { @@ -10,11 +14,14 @@ interface Position { // this exists in TSESTree but because of the inanity around enums // it's easier to do this ourselves -export interface Comment { +export interface BaseComment { type: 'Line' | 'Block'; value: string; start?: number; end?: number; +} + +export interface Comment extends BaseComment { loc: { start: Position; end: Position; diff --git a/test/additional-comments.test.js b/test/additional-comments.test.js new file mode 100644 index 0000000..1b3d32f --- /dev/null +++ b/test/additional-comments.test.js @@ -0,0 +1,122 @@ +// @ts-check +/** @import { TSESTree } from '@typescript-eslint/types' */ +/** @import { BaseComment } from '../src/languages/types.js' */ +import { expect, test } from 'vitest'; +import { print } from '../src/index.js'; +import { acornParse } from './common.js'; +import ts from '../src/languages/ts/index.js'; + +/** + * @param {string} value + * @returns {BaseComment} + */ +function line(value) { + return { type: 'Line', value }; +} + +/** + * @param {string} value + * @returns {BaseComment} + */ +function block(value) { + return { type: 'Block', value }; +} + +/** + * Helper to get return statement from a simple function + * @param {TSESTree.Program} ast - Parsed AST + * @returns {TSESTree.Node} The return statement + */ +function get_return_statement(ast) { + const functionDecl = ast.body[0]; + // @ts-expect-error accessing function body + const statements = functionDecl.body.body; + // Find the return statement (could be first or second depending on function structure) + return statements.find(/** @param {any} stmt */ (stmt) => stmt.type === 'ReturnStatement'); +} + +test('additional comments are inserted correctly', () => { + const input = `function example() { + const x = 1; + return x; +}`; + + const { ast } = acornParse(input); + const returnStatement = get_return_statement(ast); + expect(returnStatement.type).toBe('ReturnStatement'); + + const { code } = print( + ast, + ts({ + getLeadingComments: (n) => + n === returnStatement ? [line(' This is a leading comment')] : undefined, + getTrailingComments: (n) => + n === returnStatement ? [block(' This is a trailing comment ')] : undefined + }) + ); + + expect(code).toContain('// This is a leading comment'); + expect(code).toContain('/* This is a trailing comment */'); +}); + +test('only leading comments are inserted when specified', () => { + const input = `function test() { return 42; }`; + const { ast } = acornParse(input); + const returnStatement = get_return_statement(ast); + + const { code } = print( + ast, + ts({ + getLeadingComments: (n) => (n === returnStatement ? [line(' Leading only ')] : undefined) + }) + ); + + expect(code).toContain('// Leading only'); + expect(code).not.toContain('trailing'); +}); + +test('only trailing comments are inserted when specified', () => { + const input = `function test() { return 42; }`; + const { ast } = acornParse(input); + const returnStatement = get_return_statement(ast); + + const { code } = print( + ast, + ts({ + getTrailingComments: (n) => (n === returnStatement ? [block(' Trailing only ')] : undefined) + }) + ); + + expect(code).toContain('/* Trailing only */'); + expect(code).not.toContain('//'); +}); + +test('additional comments multi-line comments have new line', () => { + const input = `function example() { + const x = 1; + return x; +}`; + + const { ast } = acornParse(input); + const returnStatement = get_return_statement(ast); + expect(returnStatement.type).toBe('ReturnStatement'); + + const { code } = print( + ast, + ts({ + getLeadingComments: (n) => + n === returnStatement ? [block('*\n * This is a leading comment\n ')] : undefined + }) + ); + + expect(code).toMatchInlineSnapshot(` + "function example() { + const x = 1; + + /** + * This is a leading comment + */ + return x; + }" + `); +});