Skip to content
Draft
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
21 changes: 13 additions & 8 deletions src/embed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { getText } from './lib/getText';
import { snippedTagContentAttribute } from './lib/snipTagContent';
import { isBracketSameLine, ParserOptions } from './options';
import { PrintFn } from './print';
import { isLine, removeParentheses, trimRight } from './print/doc-helpers';
import { isLine, removeParentheses, removeLeadingSemicolon, trimRight } from './print/doc-helpers';
import { isASTNode, printWithPrependedAttributeLine } from './print/helpers';
import {
assignCommentsToNodes,
Expand Down Expand Up @@ -157,13 +157,16 @@ export function embed(path: AstPath, _options: Options) {
): Promise<Doc> => {
try {
const embeddedOptions = {
// Prettier only allows string references as parsers from v3 onwards,
// so we need to have another public parser and defer to that
parser: options._svelte_ts
? 'svelteTSExpressionParser'
: 'svelteExpressionParser',
// Use babel-ts/babel directly for Prettier 3.7.0+ compatibility.
// This fixes generic type parameters being stripped. The custom parsers
// (svelteTSExpressionParser/svelteExpressionParser) manipulate the AST
// in a way that's incompatible with Prettier 3.7.0+.
// Note: Using babel-ts directly may affect parentheses formatting compared
// to previous versions, as Prettier removes "unnecessary" parentheses.
parser: options._svelte_ts ? 'babel-ts' : 'babel',
singleQuote: node.forceSingleQuote ? true : options.singleQuote,
_svelte_asFunction: node.asFunction,
// Don't add semicolons to expressions
semi: false,
};

// If we have snipped content, it was done wrongly and we need to unsnip it.
Expand All @@ -176,7 +179,9 @@ export function embed(path: AstPath, _options: Options) {
if (node.forceSingleLine) {
docs = removeLines(docs);
}
if (node.removeParentheses) {
// For non-function expressions, always remove the wrapping parentheses
// and protective semicolons added by forceIntoExpression and Prettier
if (!node.asFunction) {
docs = removeParentheses(docs);
}
if (node.asFunction) {
Expand Down
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ export const parsers: Record<string, Parser> = {
parse: (text: string, options: any) => {
const ast = babelParser.parse(text, options);

// Extract just the expression from the wrapper statement for non-functions
// This preserves the expression structure including necessary parentheses
let program = ast.program.body[0];
if (!options._svelte_asFunction) {
program = program.expression;
Expand All @@ -80,6 +82,8 @@ export const parsers: Record<string, Parser> = {
parse: (text: string, options: any) => {
const ast = typescriptParser.parse(text, options);

// Extract just the expression from the wrapper statement for non-functions
// This preserves the expression structure including necessary parentheses
let program = ast.program.body[0];
if (!options._svelte_asFunction) {
program = program.expression;
Expand Down
88 changes: 87 additions & 1 deletion src/print/doc-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,9 +153,95 @@ function getParts(doc: Doc): Doc[] | undefined {
}
}

/**
* Remove leading semicolons added by Prettier for ASI protection
*/
export function removeLeadingSemicolon(doc: Doc): Doc {
// Handle string docs
if (typeof doc === 'string') {
let str = doc;
// Remove leading semicolon (ASI protection added by Prettier)
while (str.startsWith(';')) {
str = str.slice(1);
}
return str;
}

// Handle array docs
if (Array.isArray(doc)) {
const result = [...doc];
// Remove leading semicolons from the first element
while (result.length > 0 && typeof result[0] === 'string' && result[0].startsWith(';')) {
result[0] = result[0].slice(1);
if (result[0] === '') {
result.shift();
}
}
return result;
}

// For other doc types, return as-is
return doc;
}

/**
* `(foo = bar)` => `foo = bar`
* Also removes leading semicolons added by Prettier for ASI protection
*/
export function removeParentheses(doc: Doc): Doc {
return trim([doc], (_doc: Doc) => _doc === '(' || _doc === ')')[0];
// Handle string docs that have semicolons and/or parentheses
if (typeof doc === 'string') {
let str = doc;
// Remove leading semicolon (ASI protection added by Prettier)
while (str.startsWith(';')) {
str = str.slice(1);
}
// For simple expressions, Prettier keeps wrapping parentheses even with semi:false
// e.g., ('foo') stays as ('foo'). We need to remove these for single expressions.
// We use a very conservative heuristic: only remove if it starts with '(', ends with ')',
// and has no other parentheses anywhere inside. This ensures we only remove wrapping
// from simple literals and avoid touching expressions like function calls or grouping.
// Note: This is intentionally conservative to avoid removing semantically important
// parentheses. It means some unnecessary wrapping may remain, which is acceptable.
if (str.startsWith('(') && str.endsWith(')')) {
const hasNoInnerParens = str.indexOf('(', 1) === -1 && str.indexOf(')', str.length - 2) === str.length - 1;
if (hasNoInnerParens) {
// Only one set of parentheses wrapping the whole thing
str = str.slice(1, -1);
}
}
return str;
}

// Handle array docs
if (Array.isArray(doc)) {
const result = [...doc];
// Remove leading semicolons from the first element
while (result.length > 0 && typeof result[0] === 'string' && result[0].startsWith(';')) {
result[0] = result[0].slice(1);
if (result[0] === '') {
result.shift();
}
}

// If the result is a single string, handle it recursively
// Only recurse if we actually made a change (removed semicolons) to avoid infinite recursion
if (result.length === 1 && typeof result[0] === 'string') {
const originalFirstElement = Array.isArray(doc) ? doc[0] : doc;
const wasChanged = result[0] !== originalFirstElement;
if (wasChanged) {
return removeParentheses(result[0]);
}
return result[0];
}

// For multi-element arrays, just return after removing leading semicolons
// Don't try to remove parentheses as they might be part of the expression structure
// (e.g., function call parentheses, not wrapping from forceIntoExpression)
return result;
}

// For other doc types (objects/groups), don't modify them
// as they're complex structures that shouldn't have wrapping removed
return doc;
}
6 changes: 6 additions & 0 deletions test/formatting/samples/generic-type-parameters/input.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<script lang="ts">
import { page } from '$app/state';
import { resolve } from '$app/paths';
</script>

<a href={resolve('/[foo]/[bar]/[baz]', { ...(page.params as Required<typeof page.params>), baz: 2 })}>Go</a>
11 changes: 11 additions & 0 deletions test/formatting/samples/generic-type-parameters/output.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<script lang="ts">
import { page } from "$app/state";
import { resolve } from "$app/paths";
</script>

<a
href={resolve("/[foo]/[bar]/[baz]", {
...(page.params as Required<typeof page.params>),
baz: 2,
})}>Go</a
>