Skip to content

Commit aeb529b

Browse files
mreidenclaude
andauthored
feat: print PHP 8.4 property hooks (and abstract/final/private(set) properties) (#2516)
* 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> * Print asymmetric set visibility on promoted constructor parameters `public private(set) int $x` as a promoted constructor parameter lost its `private(set)`: the parser exposes the set visibility as `parameter.flagsSet` (same 1/2/4 mapping as `flags`), but the printer only looked at `flags`. `private(set) int $x` on its own (no explicit get visibility) is printed as written. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent a7c6e1e commit aeb529b

6 files changed

Lines changed: 368 additions & 12 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: 85 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1477,6 +1477,38 @@ function printFunction(path, options, print) {
14771477
];
14781478
}
14791479

1480+
// PHP 8.4 property hooks (`{ get => ...; set(...) { ... } }`) on a property or a
1481+
// promoted constructor parameter. PER-CS puts the opening brace on the
1482+
// declaration line and one hook per line, preserving single blank lines.
1483+
function printPropertyHooks(path, options, print) {
1484+
const { node } = path;
1485+
1486+
if (!node.hooks || node.hooks.length === 0) {
1487+
return "";
1488+
}
1489+
1490+
// Interface / abstract declarations (`{ get; set; }`) stay on the property's line.
1491+
if (node.hooks.every((hook) => !hook.body)) {
1492+
return [" { ", join(" ", path.map(print, "hooks")), " }"];
1493+
}
1494+
1495+
const printedHooks = path.map(({ node: hook, isLast }) => {
1496+
const parts = [print()];
1497+
1498+
if (!isLast) {
1499+
parts.push(hardline);
1500+
1501+
if (isNextLineEmpty(options.originalText, locEnd(hook))) {
1502+
parts.push(hardline);
1503+
}
1504+
}
1505+
1506+
return parts;
1507+
}, "hooks");
1508+
1509+
return [" {", indent([hardline, ...printedHooks]), hardline, "}"];
1510+
}
1511+
14801512
function printBodyControlStructure(
14811513
path,
14821514
options,
@@ -1785,14 +1817,13 @@ function printNode(path, options, print) {
17851817
node.parenthesizedExpression ? ")" : "",
17861818
];
17871819
case "parameter": {
1788-
let promoted = "";
1789-
if (node.flags === 1) {
1790-
promoted = "public ";
1791-
} else if (node.flags === 2) {
1792-
promoted = "protected ";
1793-
} else if (node.flags === 4) {
1794-
promoted = "private ";
1795-
}
1820+
const visibilities = { 1: "public", 2: "protected", 4: "private" };
1821+
// promoted constructor property: `public`, and/or the asymmetric set
1822+
// visibility `private(set)` (PHP 8.4)
1823+
const promoted = [
1824+
node.flags ? [visibilities[node.flags], " "] : "",
1825+
node.flagsSet ? [visibilities[node.flagsSet], "(set) "] : "",
1826+
];
17961827
const name = [
17971828
...printAttrs(path, options, print, { inline: true }),
17981829
promoted,
@@ -1804,6 +1835,7 @@ function printNode(path, options, print) {
18041835
"$",
18051836
print("name"),
18061837
];
1838+
const hooks = printPropertyHooks(path, options, print);
18071839

18081840
if (node.value) {
18091841
return group([
@@ -1821,10 +1853,11 @@ function printNode(path, options, print) {
18211853
false,
18221854
options
18231855
),
1856+
hooks,
18241857
]);
18251858
}
18261859

1827-
return name;
1860+
return [name, hooks];
18281861
}
18291862
case "variadic":
18301863
return ["...", print("what")];
@@ -1846,7 +1879,41 @@ function printNode(path, options, print) {
18461879
),
18471880
]
18481881
: "",
1882+
printPropertyHooks(path, options, print),
18491883
]);
1884+
case "propertyhook": {
1885+
const declaration = [
1886+
...printAttrs(path, options, print, { inline: true }),
1887+
node.isFinal ? "final " : "",
1888+
node.byref ? "&" : "",
1889+
node.name,
1890+
node.parameter ? ["(", print("parameter"), ")"] : "",
1891+
];
1892+
1893+
// `get;` / `set;` in an interface or abstract class
1894+
if (!node.body) {
1895+
return [...declaration, ";"];
1896+
}
1897+
1898+
// `get => expr;`
1899+
if (node.body.kind !== "block") {
1900+
return group([
1901+
...declaration,
1902+
" =>",
1903+
printAssignmentRight(node, node.body, print("body"), false, options),
1904+
";",
1905+
]);
1906+
}
1907+
1908+
// `get { ... }` - like a closure, the brace stays on the hook's line
1909+
return [
1910+
...declaration,
1911+
" {",
1912+
indent([hasEmptyBody(path) ? "" : hardline, print("body")]),
1913+
hasEmptyBody(path) ? "" : hardline,
1914+
"}",
1915+
];
1916+
}
18501917
case "propertystatement": {
18511918
const attrs = [];
18521919
path.each(() => {
@@ -1869,10 +1936,18 @@ function printNode(path, options, print) {
18691936

18701937
return group([
18711938
...attrs,
1939+
node.isAbstract ? "abstract " : "",
1940+
node.isFinal ? "final " : "",
18721941
hasVisibility
18731942
? [node.visibility === null ? "var" : node.visibility, ""]
18741943
: "",
1875-
node.isStatic ? [hasVisibility ? " " : "", "static"] : "",
1944+
// asymmetric visibility: `public private(set)`
1945+
node.visibilitySet
1946+
? [hasVisibility ? " " : "", node.visibilitySet, "(set)"]
1947+
: "",
1948+
node.isStatic
1949+
? [hasVisibility || node.visibilitySet ? " " : "", "static"]
1950+
: "",
18761951
firstProperty ? [" ", firstProperty] : "",
18771952
indent(
18781953
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: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
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+
public function __construct(
82+
public private(set) int $promotedAsymmetric,
83+
private(set) int $promotedSetOnly,
84+
protected(set) readonly int $promotedSetOnlyReadonly,
85+
public private(set) string $promotedWithHook { get => strtolower($this->promotedWithHook); },
86+
) {}
87+
}
88+
89+
=====================================output=====================================
90+
<?php
91+
92+
declare(strict_types=1);
93+
94+
interface HasName
95+
{
96+
public string $name { get; set; }
97+
public int $count { get; }
98+
}
99+
100+
abstract class Base
101+
{
102+
abstract public string $abstractHooked { get; }
103+
}
104+
105+
#[Attr]
106+
class Company
107+
{
108+
public ?int $plain = null;
109+
110+
// Collections set $agencyId to null before adding entities, but otherwise should be readonly
111+
public ?int $agencyId {
112+
get => $this->agency?->id;
113+
// a comment between the hooks
114+
set(?int $id) {
115+
if ($id !== null) {
116+
throw new \\Exception(
117+
self::class .
118+
"::agencyId set hook should only be used to clear collections: " .
119+
var_export($id, true),
120+
);
121+
}
122+
}
123+
}
124+
125+
public string $fullName {
126+
get => "$this->firstName $this->lastName";
127+
}
128+
129+
public string $withDefault = "x" {
130+
final get {
131+
return strtoupper($this->withDefault);
132+
}
133+
134+
#[Deprecated] set {
135+
$this->withDefault = $value;
136+
}
137+
}
138+
139+
public array $byRef {
140+
&get => $this->items;
141+
}
142+
143+
public string $longExpr {
144+
get =>
145+
$this->someService->buildTheFullDisplayName(
146+
$this->firstName,
147+
$this->middleName,
148+
$this->lastName,
149+
) ?? "unknown";
150+
set(string $value) => ($this->firstName = trim($value)); // trailing comment on the hook
151+
}
152+
153+
public string $emptyBody {
154+
set {}
155+
}
156+
157+
public function __construct(
158+
public string $promoted {
159+
get => strtolower($this->promoted);
160+
set(string $v) {
161+
$this->promoted = $v;
162+
}
163+
},
164+
private readonly int $normal = 0,
165+
) {}
166+
167+
/* leading block */
168+
public string $lastOne {
169+
get => "a";
170+
// comment after the last hook
171+
}
172+
}
173+
174+
final class Flags
175+
{
176+
final public int $b = 1;
177+
public private(set) int $c;
178+
public private(set) int $d;
179+
public protected(set) static int $e;
180+
public private(set) string $f {
181+
get => $this->f;
182+
}
183+
184+
public function __construct(
185+
public private(set) int $promotedAsymmetric,
186+
private(set) int $promotedSetOnly,
187+
protected(set) readonly int $promotedSetOnlyReadonly,
188+
public private(set) string $promotedWithHook {
189+
get => strtolower($this->promotedWithHook);
190+
},
191+
) {}
192+
}
193+
194+
================================================================================
195+
`;
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)