Skip to content

Commit 2f23ec6

Browse files
mreidenclaude
andcommitted
feat: print PHP 8.4 property hooks and property modifiers
php-parser 3.7 parses property hooks into `property.hooks` (kind `propertyhook`), but the printer never printed them, so formatting a hooked property silently dropped the hooks and left a plain property behind (exit code 0). A comment inside a hook made it fail with "Comment was not printed" instead. Print hooks following PER-CS 4.9: - the opening brace stays on the property line, one hook per line, a single blank line between hooks is preserved; - `get => expr;` breaks like an assignment, `get { ... }` keeps the brace on the hook line, `get;` / `set;` declarations (interfaces, abstract classes) stay on one line: `public string $x { get; set; }`; - `final`, `&get`, `set(Type $value)` and attributes on hooks; - hooks on promoted constructor parameters. A hooked property statement ends with its closing brace, so it no longer gets a trailing semicolon. Property statements also lost `abstract`, `final` and the asymmetric set visibility (`public private(set)`), which the parser exposes as `isAbstract`, `isFinal` and `visibilitySet`; print those too. Guard `handleFunctionParameter` against a missing preceding node, which a comment right after `set(` reaches. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 269374b commit 2f23ec6

6 files changed

Lines changed: 338 additions & 4 deletions

File tree

‎src/comments.mjs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -592,8 +592,8 @@ function handleFunctionParameter(
592592
return false;
593593
}
594594
if (
595-
precedingNode.kind === "typereference" &&
596-
followingNode.kind === "identifier"
595+
precedingNode?.kind === "typereference" &&
596+
followingNode?.kind === "identifier"
597597
) {
598598
addTrailingComment(precedingNode, comment);
599599
return true;

‎src/printer.mjs‎

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1466,6 +1466,38 @@ function printFunction(path, options, print) {
14661466
];
14671467
}
14681468

1469+
// PHP 8.4 property hooks (`{ get => ...; set(...) { ... } }`) on a property or a
1470+
// promoted constructor parameter. PER-CS puts the opening brace on the
1471+
// declaration line and one hook per line, preserving single blank lines.
1472+
function printPropertyHooks(path, options, print) {
1473+
const { node } = path;
1474+
1475+
if (!node.hooks || node.hooks.length === 0) {
1476+
return "";
1477+
}
1478+
1479+
// Interface / abstract declarations (`{ get; set; }`) stay on the property's line.
1480+
if (node.hooks.every((hook) => !hook.body)) {
1481+
return [" { ", join(" ", path.map(print, "hooks")), " }"];
1482+
}
1483+
1484+
const printedHooks = path.map(({ node: hook, isLast }) => {
1485+
const parts = [print()];
1486+
1487+
if (!isLast) {
1488+
parts.push(hardline);
1489+
1490+
if (isNextLineEmpty(options.originalText, locEnd(hook))) {
1491+
parts.push(hardline);
1492+
}
1493+
}
1494+
1495+
return parts;
1496+
}, "hooks");
1497+
1498+
return [" {", indent([hardline, ...printedHooks]), hardline, "}"];
1499+
}
1500+
14691501
function printBodyControlStructure(
14701502
path,
14711503
options,
@@ -1793,6 +1825,7 @@ function printNode(path, options, print) {
17931825
"$",
17941826
print("name"),
17951827
];
1828+
const hooks = printPropertyHooks(path, options, print);
17961829

17971830
if (node.value) {
17981831
return group([
@@ -1810,10 +1843,11 @@ function printNode(path, options, print) {
18101843
false,
18111844
options
18121845
),
1846+
hooks,
18131847
]);
18141848
}
18151849

1816-
return name;
1850+
return [name, hooks];
18171851
}
18181852
case "variadic":
18191853
return ["...", print("what")];
@@ -1835,7 +1869,41 @@ function printNode(path, options, print) {
18351869
),
18361870
]
18371871
: "",
1872+
printPropertyHooks(path, options, print),
18381873
]);
1874+
case "propertyhook": {
1875+
const declaration = [
1876+
...printAttrs(path, options, print, { inline: true }),
1877+
node.isFinal ? "final " : "",
1878+
node.byref ? "&" : "",
1879+
node.name,
1880+
node.parameter ? ["(", print("parameter"), ")"] : "",
1881+
];
1882+
1883+
// `get;` / `set;` in an interface or abstract class
1884+
if (!node.body) {
1885+
return [...declaration, ";"];
1886+
}
1887+
1888+
// `get => expr;`
1889+
if (node.body.kind !== "block") {
1890+
return group([
1891+
...declaration,
1892+
" =>",
1893+
printAssignmentRight(node, node.body, print("body"), false, options),
1894+
";",
1895+
]);
1896+
}
1897+
1898+
// `get { ... }` - like a closure, the brace stays on the hook's line
1899+
return [
1900+
...declaration,
1901+
" {",
1902+
indent([hasEmptyBody(path) ? "" : hardline, print("body")]),
1903+
hasEmptyBody(path) ? "" : hardline,
1904+
"}",
1905+
];
1906+
}
18391907
case "propertystatement": {
18401908
const attrs = [];
18411909
path.each(() => {
@@ -1858,10 +1926,18 @@ function printNode(path, options, print) {
18581926

18591927
return group([
18601928
...attrs,
1929+
node.isAbstract ? "abstract " : "",
1930+
node.isFinal ? "final " : "",
18611931
hasVisibility
18621932
? [node.visibility === null ? "var" : node.visibility, ""]
18631933
: "",
1864-
node.isStatic ? [hasVisibility ? " " : "", "static"] : "",
1934+
// asymmetric visibility: `public private(set)`
1935+
node.visibilitySet
1936+
? [hasVisibility ? " " : "", node.visibilitySet, "(set)"]
1937+
: "",
1938+
node.isStatic
1939+
? [hasVisibility || node.visibilitySet ? " " : "", "static"]
1940+
: "",
18651941
firstProperty ? [" ", firstProperty] : "",
18661942
indent(
18671943
printed.slice(1).map((p) => [",", hasValue ? hardline : line, p])

‎src/util.mjs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,13 @@ function lineShouldEndWithSemicolon(path) {
354354
if (node.kind === "method" && node.isAbstract) {
355355
return true;
356356
}
357+
// a hooked property ends with its closing brace, not a semicolon
358+
if (
359+
node.kind === "propertystatement" &&
360+
node.properties.some((property) => property.hooks?.length > 0)
361+
) {
362+
return false;
363+
}
357364
if (node.kind === "method") {
358365
const { parent } = path;
359366
if (parent && parent.kind === "interface") {
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
2+
3+
exports[`property-hooks.php 1`] = `
4+
====================================options=====================================
5+
parsers: ["php"]
6+
phpVersion: "8.4"
7+
printWidth: 80
8+
| printWidth
9+
=====================================input======================================
10+
<?php
11+
12+
declare(strict_types=1);
13+
14+
interface HasName
15+
{
16+
public string $name { get; set; }
17+
public int $count { get; }
18+
}
19+
20+
abstract class Base
21+
{
22+
abstract public string $abstractHooked { get; }
23+
}
24+
25+
#[Attr]
26+
class Company
27+
{
28+
public ?int $plain = null;
29+
30+
// Collections set $agencyId to null before adding entities, but otherwise should be readonly
31+
public ?int $agencyId {
32+
get => $this->agency?->id;
33+
// a comment between the hooks
34+
set (?int $id) {
35+
if ($id !== null) {
36+
throw new \\Exception(self::class . "::agencyId set hook should only be used to clear collections: ". var_export($id, true));
37+
}
38+
}
39+
}
40+
41+
public string $fullName {
42+
get => "$this->firstName $this->lastName";
43+
}
44+
45+
public string $withDefault = 'x' {
46+
final get { return strtoupper($this->withDefault); }
47+
48+
#[Deprecated] set { $this->withDefault = $value; }
49+
}
50+
51+
public array $byRef { &get => $this->items; }
52+
53+
public string $longExpr {
54+
get => $this->someService->buildTheFullDisplayName($this->firstName, $this->middleName, $this->lastName) ?? 'unknown';
55+
set(string $value) => $this->firstName = trim($value); // trailing comment on the hook
56+
}
57+
58+
public string $emptyBody { set { } }
59+
60+
public function __construct(
61+
public string $promoted { get => strtolower($this->promoted); set(string $v) { $this->promoted = $v; } },
62+
private readonly int $normal = 0,
63+
) {
64+
}
65+
66+
/* leading block */
67+
public string $lastOne {
68+
get => 'a';
69+
// comment after the last hook
70+
}
71+
}
72+
73+
final class Flags
74+
{
75+
final public int $b = 1;
76+
public private(set) int $c;
77+
private(set) int $d;
78+
protected(set) static int $e;
79+
public private(set) string $f { get => $this->f; }
80+
}
81+
82+
=====================================output=====================================
83+
<?php
84+
85+
declare(strict_types=1);
86+
87+
interface HasName
88+
{
89+
public string $name { get; set; }
90+
public int $count { get; }
91+
}
92+
93+
abstract class Base
94+
{
95+
abstract public string $abstractHooked { get; }
96+
}
97+
98+
#[Attr]
99+
class Company
100+
{
101+
public ?int $plain = null;
102+
103+
// Collections set $agencyId to null before adding entities, but otherwise should be readonly
104+
public ?int $agencyId {
105+
get => $this->agency?->id;
106+
// a comment between the hooks
107+
set(?int $id) {
108+
if ($id !== null) {
109+
throw new \\Exception(
110+
self::class .
111+
"::agencyId set hook should only be used to clear collections: " .
112+
var_export($id, true),
113+
);
114+
}
115+
}
116+
}
117+
118+
public string $fullName {
119+
get => "$this->firstName $this->lastName";
120+
}
121+
122+
public string $withDefault = "x" {
123+
final get {
124+
return strtoupper($this->withDefault);
125+
}
126+
127+
#[Deprecated] set {
128+
$this->withDefault = $value;
129+
}
130+
}
131+
132+
public array $byRef {
133+
&get => $this->items;
134+
}
135+
136+
public string $longExpr {
137+
get =>
138+
$this->someService->buildTheFullDisplayName(
139+
$this->firstName,
140+
$this->middleName,
141+
$this->lastName,
142+
) ?? "unknown";
143+
set(string $value) => ($this->firstName = trim($value)); // trailing comment on the hook
144+
}
145+
146+
public string $emptyBody {
147+
set {}
148+
}
149+
150+
public function __construct(
151+
public string $promoted {
152+
get => strtolower($this->promoted);
153+
set(string $v) {
154+
$this->promoted = $v;
155+
}
156+
},
157+
private readonly int $normal = 0,
158+
) {}
159+
160+
/* leading block */
161+
public string $lastOne {
162+
get => "a";
163+
// comment after the last hook
164+
}
165+
}
166+
167+
final class Flags
168+
{
169+
final public int $b = 1;
170+
public private(set) int $c;
171+
public private(set) int $d;
172+
public protected(set) static int $e;
173+
public private(set) string $f {
174+
get => $this->f;
175+
}
176+
}
177+
178+
================================================================================
179+
`;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
run_spec(import.meta, ["php"], { phpVersion: "8.4" });

0 commit comments

Comments
 (0)