Skip to content

Commit 144e4a0

Browse files
Merge pull request #140 from fortephp/bugfix/php-echo-wrappers
Improve PHP formatting wrappers
2 parents f1d3db4 + d3ef6fb commit 144e4a0

File tree

7 files changed

+298
-276
lines changed

7 files changed

+298
-276
lines changed

src/print/embed/php.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -633,7 +633,11 @@ function getDirectivePhpFormatAttempts(
633633
}
634634

635635
function wrapEchoExpression(expression: string): string {
636-
return `<?php $__prettier_blade_echo__ = (${START_MARKER_COMMENT}${expression}${END_MARKER_COMMENT});`;
636+
return `<?php ${START_MARKER_COMMENT} echo ${expression}; ${END_MARKER_COMMENT}`;
637+
}
638+
639+
function stripLeadingEchoKeyword(text: string): string {
640+
return text.replace(/^\s*echo\b\s*/u, "");
637641
}
638642

639643
function rebuildDirectiveWithFormattedArgs(
@@ -866,7 +870,7 @@ export async function formatEchoNode(node: WrappedNode, options: Options): Promi
866870
const extracted = getTextBetweenMarkers(formatted);
867871
if (extracted === null) return null;
868872

869-
const expression = normalizePayload(extracted);
873+
const expression = normalizePayload(stripLeadingEchoKeyword(extracted));
870874
const normalizedExpression = normalizePayload(stripTrailingSemicolon(expression));
871875
if (!normalizedExpression) return null;
872876

tests/helpers.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ export async function formatEqual(
135135
expected: string,
136136
options: prettier.Options = {},
137137
passes = 5,
138-
) {
138+
): Promise<string> {
139139
const opts = {
140140
parser: "blade" as const,
141141
plugins: [plugin],
@@ -151,6 +151,8 @@ export async function formatEqual(
151151
expect(next, `not idempotent on pass ${i}`).toBe(prev);
152152
prev = next;
153153
}
154+
155+
return prev;
154156
}
155157

156158
export {

tests/html/php-embedding.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,13 @@ describe("html/php-embedding", () => {
9999

100100
it("compensates inline echo wrapper width inside html text contexts", async () => {
101101
const input = "<title>{{ $title ?? 'Formatter Playground - Forte' }}</title>\n";
102-
const expected = '<title>{{ $title ?? "Formatter Playground - Forte" }}</title>\n';
102+
const expected = `<title>
103+
{{
104+
$title ??
105+
"Formatter Playground - Forte"
106+
}}
107+
</title>
108+
`;
103109

104110
await formatEqual(input, expected, {
105111
...withPhp,

tests/php/general.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,43 @@ describe("php/general", () => {
8383
});
8484
});
8585

86+
it("formats nullsafe operators in all echo delimiter variants without leaking wrapper syntax", async () => {
87+
await formatEqual("{{ user()?->name }}\n", "{{ user()?->name }}\n", withPhp);
88+
await formatEqual("{!! user()?->name !!}\n", "{!! user()?->name !!}\n", withPhp);
89+
await formatEqual("{{{ user()?->name }}}\n", "{{{ user()?->name }}}\n", withPhp);
90+
91+
await formatEqual(
92+
"{{ user()?->profile()?->displayName() }}\n",
93+
`{{
94+
user()
95+
?->profile()
96+
?->displayName()
97+
}}
98+
`,
99+
{
100+
...withPhp,
101+
printWidth: 20,
102+
},
103+
);
104+
});
105+
106+
it("preserves leading echo-like string literals when unwrapping delegated echo formatting", async () => {
107+
await formatEqual("{{ ' echo' }}\n", '{{ " echo" }}\n', withPhp);
108+
await formatEqual("{!! 'echo' !!}\n", '{!! "echo" !!}\n', withPhp);
109+
await formatEqual(
110+
"{{{ 'echo' . $suffix }}}\n",
111+
`{{{
112+
"echo" .
113+
$suffix
114+
}}}
115+
`,
116+
{
117+
...withPhp,
118+
printWidth: 14,
119+
},
120+
);
121+
});
122+
86123
it("keeps invalid php fragments unchanged as fallback", async () => {
87124
const input = "{{$a + }}\n@blaze($x + )\n";
88125
const expected = "{{$a + }}\n@blaze ($x + )\n";

tests/php/nullsafe-echoes.test.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { describe, expect, it } from "vitest";
2+
import bladePlugin from "../../src/index.js";
3+
import * as phpPlugin from "@prettier/plugin-php";
4+
import { formatEqual } from "../helpers.js";
5+
6+
const withPhp = {
7+
plugins: [bladePlugin, phpPlugin],
8+
bladePhpFormatting: "safe" as const,
9+
};
10+
11+
function expectNoEchoWrapperArtifacts(output: string): void {
12+
expect(output).not.toContain("__prettier_blade_echo__");
13+
expect(output).not.toContain("/*__BLADE_PHP_FMT_START__*/");
14+
expect(output).not.toContain("/*__BLADE_PHP_FMT_END__*/");
15+
expect(output).not.toMatch(/\{\{[\s\r\n]*=/u);
16+
expect(output).not.toMatch(/\{!![\s\r\n]*=/u);
17+
expect(output).not.toMatch(/\{\{\{[\s\r\n]*=/u);
18+
}
19+
20+
describe("php/nullsafe-echoes", () => {
21+
it("formats tight nullsafe echoes without leaking wrapper syntax", async () => {
22+
const output = await formatEqual(
23+
"{{ $user?->name }}\n",
24+
"{{$user?->name}}\n",
25+
{
26+
...withPhp,
27+
bladeEchoSpacing: "tight",
28+
},
29+
);
30+
expectNoEchoWrapperArtifacts(output);
31+
});
32+
33+
it("formats raw and triple nullsafe chains across line breaks", async () => {
34+
const raw = await formatEqual(
35+
"{!! $user?->teams()?->first()?->name !!}\n",
36+
`{!!\n $user\n ?->teams()\n ?->first()\n ?->name\n!!}\n`,
37+
{
38+
...withPhp,
39+
printWidth: 20,
40+
},
41+
);
42+
const triple = await formatEqual(
43+
"{{{ $user?->teams()?->first()?->name }}}\n",
44+
`{{{\n $user\n ?->teams()\n ?->first()\n ?->name\n}}}\n`,
45+
{
46+
...withPhp,
47+
printWidth: 20,
48+
},
49+
);
50+
expectNoEchoWrapperArtifacts(raw);
51+
expectNoEchoWrapperArtifacts(triple);
52+
});
53+
54+
it("formats nullsafe chains mixed with array access and coalescing", async () => {
55+
const output = await formatEqual(
56+
"{{ $user?->teams()[0]?->owner?->name ?? 'guest' }}\n",
57+
`{{\n $user?->teams()[0]\n ?->owner?->name ??\n "guest"\n}}\n`,
58+
{
59+
...withPhp,
60+
printWidth: 28,
61+
},
62+
);
63+
expectNoEchoWrapperArtifacts(output);
64+
});
65+
66+
it("formats nullsafe chains inside inline text runs", async () => {
67+
const output = await formatEqual(
68+
"<p>Hello {{ auth()->user()?->profile()?->displayName() }} world</p>\n",
69+
"<p>\n Hello {{\n auth()\n ->user()\n ?->profile()\n ?->displayName()\n }} world\n</p>\n",
70+
{
71+
...withPhp,
72+
printWidth: 36,
73+
},
74+
);
75+
expectNoEchoWrapperArtifacts(output);
76+
});
77+
78+
it("formats title-only nullsafe echoes", async () => {
79+
const output = await formatEqual(
80+
"<title>{{ auth()->user()?->profile()?->displayName() }}</title>\n",
81+
"<title>\n {{\n auth()\n ->user()\n ?->profile()\n ?->displayName()\n }}\n</title>\n",
82+
{
83+
...withPhp,
84+
printWidth: 36,
85+
},
86+
);
87+
expectNoEchoWrapperArtifacts(output);
88+
});
89+
90+
it("formats nullsafe echoes in aggressive mode and echo-only target mode", async () => {
91+
const aggressive = await formatEqual(
92+
"{{ auth()->user()?->profile()?->displayName() }}\n",
93+
"{{\n auth()\n ->user()\n ?->profile()\n ?->displayName()\n}}\n",
94+
{
95+
plugins: [bladePlugin, phpPlugin],
96+
bladePhpFormatting: "aggressive",
97+
printWidth: 24,
98+
},
99+
);
100+
const echoOnly = await formatEqual(
101+
"{{ auth()->user()?->name }}\n",
102+
"{{\n auth()->user()\n ?->name\n}}\n",
103+
{
104+
...withPhp,
105+
bladePhpFormattingTargets: ["echo"],
106+
printWidth: 24,
107+
},
108+
);
109+
expectNoEchoWrapperArtifacts(aggressive);
110+
expectNoEchoWrapperArtifacts(echoOnly);
111+
});
112+
});

tests/validation/conformance/__snapshots__/format-cases.test.ts.snap

Lines changed: 35 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,10 @@ exports[`validation/conformance/format-cases > formats inline_echo_block__008 1`
4747
href="#"
4848
target="_blank"
4949
{{
50-
$attributes->merge(
51-
[
52-
'class' =>
53-
'rounded transition focus-visible:outline-none focus-visible:ring focus-visible:ring-red-600',
54-
],
55-
)
50+
$attributes->merge([
51+
'class' =>
52+
'rounded transition focus-visible:outline-none focus-visible:ring focus-visible:ring-red-600',
53+
])
5654
}}
5755
>
5856
Some link
@@ -69,11 +67,9 @@ exports[`validation/conformance/format-cases > formats inline_echo_block__009 1`
6967

7068
exports[`validation/conformance/format-cases > formats inline_echo_block__010 1`] = `
7169
"{{
72-
$attributes->class(
73-
[
74-
'text-gray-900 border-gray-300 invalid:text-gray-400 block w-full h-9 py-1 transition duration-75 rounded-lg shadow-sm outline-none focus:border-primary-500 focus:ring-1 focus:ring-inset focus:ring-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-200 dark:focus:border-primary-500',
75-
],
76-
)
70+
$attributes->class([
71+
'text-gray-900 border-gray-300 invalid:text-gray-400 block w-full h-9 py-1 transition duration-75 rounded-lg shadow-sm outline-none focus:border-primary-500 focus:ring-1 focus:ring-inset focus:ring-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-200 dark:focus:border-primary-500',
72+
])
7773
}}
7874
"
7975
`;
@@ -118,15 +114,13 @@ exports[`validation/conformance/format-cases > formats inline_echo_block__013 1`
118114
exports[`validation/conformance/format-cases > formats inline_echo_block__014 1`] = `
119115
"<div
120116
{{
121-
$attributes->class(
122-
[
123-
'some classes',
124-
match ($someCondition) {
125-
true => 'more classes foo bar baz',
126-
default => 'even more classes foo bar baz',
127-
},
128-
],
129-
)
117+
$attributes->class([
118+
'some classes',
119+
match ($someCondition) {
120+
true => 'more classes foo bar baz',
121+
default => 'even more classes foo bar baz',
122+
},
123+
])
130124
}}
131125
></div>
132126
"
@@ -135,15 +129,13 @@ exports[`validation/conformance/format-cases > formats inline_echo_block__014 1`
135129
exports[`validation/conformance/format-cases > formats inline_echo_block__015 1`] = `
136130
"<div
137131
{{
138-
$attributes->class(
139-
[
140-
'some classes',
141-
match ($someCondition) {
142-
true => 'more classes foo bar baz',
143-
default => 'even more classes foo bar baz',
144-
},
145-
],
146-
)
132+
$attributes->class([
133+
'some classes',
134+
match ($someCondition) {
135+
true => 'more classes foo bar baz',
136+
default => 'even more classes foo bar baz',
137+
},
138+
])
147139
}}
148140
></div>
149141
"
@@ -152,15 +144,13 @@ exports[`validation/conformance/format-cases > formats inline_echo_block__015 1`
152144
exports[`validation/conformance/format-cases > formats inline_echo_block__016 1`] = `
153145
"<div
154146
{{
155-
$attributes->class(
156-
[
157-
'some classes',
158-
match ($someCondition) {
159-
true => 'more classes foo bar baz',
160-
default => 'even more classes foo bar baz',
161-
},
162-
],
163-
)
147+
$attributes->class([
148+
'some classes',
149+
match ($someCondition) {
150+
true => 'more classes foo bar baz',
151+
default => 'even more classes foo bar baz',
152+
},
153+
])
164154
}}
165155
more
166156
attributes
@@ -173,15 +163,13 @@ exports[`validation/conformance/format-cases > formats inline_echo_block__016 1`
173163
exports[`validation/conformance/format-cases > formats inline_echo_block__017 1`] = `
174164
"<div
175165
{{
176-
$attributes->class(
177-
[
178-
'some classes',
179-
match ($someCondition) {
180-
true => 'more classes foo bar baz',
181-
default => 'even more classes foo bar baz',
182-
},
183-
],
184-
)
166+
$attributes->class([
167+
'some classes',
168+
match ($someCondition) {
169+
true => 'more classes foo bar baz',
170+
default => 'even more classes foo bar baz',
171+
},
172+
])
185173
}}
186174
more
187175
attributes

0 commit comments

Comments
 (0)