Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-jsdoc-cast-param-bindings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'esrap': patch
---

fix: don't treat JSDoc `@type` comments re-anchored to parameter defaults or named function ids as type casts
36 changes: 28 additions & 8 deletions src/languages/ts/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,24 @@ function track_bindings(nodes) {
if (nodes) for (const node of nodes) track_binding(node);
}

/**
* Tracks function parameters as binding positions. A param with a default value
* is an `AssignmentPattern`; the binding is its `left`, not the pattern itself —
* the comment flush that decides whether a JSDoc `@type` cast gets wrapped in
* parentheses happens on the identifier's own visit.
* @param {(object | null | undefined)[] | null | undefined} params
* @returns {void}
*/
function track_param_bindings(params) {
if (!params) return;
track_bindings(params);
for (const param of params) {
if (/** @type {any} */ (param)?.type === 'AssignmentPattern') {
track_binding(/** @type {any} */ (param).left);
}
}
Comment on lines +115 to +119
}

/**
* Writes `keyword` bounded by source map locations for the exact character span,
* so breakpoints line up on keywords (not only identifiers and braces).
Expand Down Expand Up @@ -844,13 +862,15 @@ export default (options = {}) => {
}
}

if (node.id) track_binding(node.id);

if (node.id) context.visit(node.id);

if (node.typeParameters) {
context.visit(node.typeParameters);
}

track_bindings(node.params);
track_param_bindings(node.params);
context.write('(');
sequence(context, node.params, (node.returnType ?? node.body).loc?.start ?? null, false);
context.write(')');
Expand Down Expand Up @@ -912,7 +932,7 @@ export default (options = {}) => {
// @ts-expect-error `typeParameters` lives on the method node, not its value
if (node.typeParameters) context.visit(node.typeParameters);

track_bindings(node.value.params);
track_param_bindings(node.value.params);
context.write('(');
sequence(
context,
Expand Down Expand Up @@ -1036,7 +1056,7 @@ export default (options = {}) => {
}

// @ts-expect-error `acorn-typescript` and `@typescript-eslint/types` have slightly different type definitions
track_bindings(node.parameters ?? node.params);
track_param_bindings(node.parameters ?? node.params);
context.write('(');
sequence(
context,
Expand Down Expand Up @@ -1067,7 +1087,7 @@ export default (options = {}) => {
if (node.typeParameters) context.visit(node.typeParameters);

// @ts-expect-error `acorn-typescript` and `@typescript-eslint/types` have slightly different type definitions
track_bindings(node.parameters ?? node.params);
track_param_bindings(node.parameters ?? node.params);
context.write('(');
sequence(
context,
Expand Down Expand Up @@ -1139,7 +1159,7 @@ export default (options = {}) => {
context.visit(node.typeParameters);
}

track_bindings(node.params);
track_param_bindings(node.params);
context.write('(');
sequence(context, node.params, (node.returnType ?? node.body).loc?.start ?? null, false);
context.write(')');
Expand Down Expand Up @@ -1656,7 +1676,7 @@ export default (options = {}) => {
if (node.computed) context.write('[', token_before(node.key.loc?.start));
context.visit(node.key);
if (node.computed) context.write(']', token_at(node.key.loc?.end));
track_bindings(node.value.params);
track_param_bindings(node.value.params);
context.write('(');
sequence(
context,
Expand Down Expand Up @@ -1951,7 +1971,7 @@ export default (options = {}) => {
context.visit(node.typeParameters);
}

track_bindings(node.params);
track_param_bindings(node.params);
context.write('(');
sequence(context, node.params, node.returnType?.loc?.start ?? node.loc?.end ?? null, false);
context.write(')');
Expand Down Expand Up @@ -2279,7 +2299,7 @@ export default (options = {}) => {
}

// @ts-expect-error `acorn-typescript` and `@typescript-eslint/types` have slightly different type definitions
track_bindings(node.parameters ?? node.params);
track_param_bindings(node.parameters ?? node.params);
context.write('(');
sequence(
context,
Expand Down
49 changes: 49 additions & 0 deletions test/comment-jsdoc-type-cast-bindings.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// @ts-check
/** @import { TSESTree } from '@typescript-eslint/types' */
import { expect, test } from 'vitest';
import { print } from '../src/index.js';
import { acornParse } from './common.js';
import ts from '../src/languages/ts/index.js';

// Regression test for https://github.com/sveltejs/esrap/issues/181:
// a JSDoc `@type` comment re-anchored to a binding position (function parameter
// with a default value) must not be treated as a type cast — wrapping the
// binding target in parentheses produces invalid JS (`(row) = $.noop`).
test('JSDoc @type comment on a parameter with a default value is not wrapped', () => {
const input = `const row_template = ($$anchor, row = $.noop) => {
row;
};`;

const { ast } = acornParse(input);
const arrow = /** @type {TSESTree.ArrowFunctionExpression} */ (
/** @type {any} */ (ast.body[0]).declarations[0].init
);
const param = /** @type {TSESTree.AssignmentPattern} */ (arrow.params[1]);

// svelte re-anchors template comments onto freshly generated AST nodes whose
// synthetic `loc` starts before the binding identifier itself
/** @type {any} */ (param).start = /** @type {any} */ (param.left).start - 4;
param.loc = {
start: { line: 1, column: /** @type {any} */ (param.left).start - 4 },
end: /** @type {any} */ (param.loc).end
};

const anchor = /** @type {any} */ (param.left).start - 3;
const comments = [
{
type: /** @type {const} */ ('Block'),
value: '* @type {any} ',
start: anchor,
end: anchor,
loc: { start: { line: 1, column: anchor }, end: { line: 1, column: anchor } }
}
];

const { code } = print(ast, ts({ comments }), {});

expect(code).toContain('/** @type {any} */ row = $.noop');
expect(code).not.toContain('(row)');

// output must be valid JavaScript
expect(() => new Function(code)).not.toThrow();
});
11 changes: 11 additions & 0 deletions test/samples/comment-jsdoc-type-cast-param-default/expected.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
const row_template = ($$anchor, row = $.noop) => {
row;
};

const f = ({ a } = {}) => a;

function g(x = 1) {
return x;
}

const value = /** @type {number} */ (computed);
11 changes: 11 additions & 0 deletions test/samples/comment-jsdoc-type-cast-param-default/expected.js.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions test/samples/comment-jsdoc-type-cast-param-default/input.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
const row_template = ($$anchor, row = $.noop) => {
row;
};

const f = ({ a } = {}) => a;

function g(x = 1) {
return x;
}

const value = /** @type {number} */ (computed);