Skip to content

spacing.anonymousFunction: the space after the function of a closure - #6

Closed
kaskader201 wants to merge 16 commits into
dg:masterfrom
kaskader201:closure-spacing
Closed

kaskader201 wants to merge 16 commits into
dg:masterfrom
kaskader201:closure-spacing

Conversation

@kaskader201

@kaskader201 kaskader201 commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

spacing.anonymousFunction: the space after the function of a closure

spacing.keyword covers every keyword, including the function of a closure, and it only takes 'return $x' or keep. So a project that writes its closures as function($x) can't require the PSR-12 space after if, foreach, catch, match and } else {. Its only choice is keep, and with keep nothing checks keyword spacing at all.

The closure is the only one of its siblings without its own decision:

  • spacing.arrowFunction covers the arrow function ('fn($x) => $x' / 'fn ($x) => $x').
  • spacing.anonymousClass covers the anonymous class ('new class($a)' / 'new class ($a)').
  • PHP CS Fixer has the same split: function_declaration takes closure_function_spacing beside closure_fn_spacing. Before this PR, closure_fn_spacing already mapped to spacing.arrowFunction, but closure_function_spacing: none mapped to nothing and only produced a warning.

What changes

  • New decision spacing.anonymousFunction: 'function($x)', 'function ($x)' or keep. It is declared and enforced by ConstructSpacingRule, next to spacing.arrowFunction.
  • Name. anonymousFunction is the word for this place in the closed list of places in docs/internals.md. types.declaration.anonymousFunction already uses it.
  • Scope. The decision rules on the gap after the function of a closure, whatever follows it. That covers function (, static function ( and a closure returning by reference, which becomes function &( or function&( (this matches what closure_function_spacing does). The function of a declaration and the use of a closure stay under spacing.keyword.
  • keep leaves the closure alone. It does not fall back to spacing.keyword, which is how spacing.arrowFunction behaves.
  • The standards. PSR-12 sets 'function ($x)'. PER CS, Nette, Symfony (function_declaration with its default closure_function_spacing: one) and Laravel Pint inherit it. None of the standards changes what it enforces: the reference shows 'function ($x)' for all five.
  • Interop:
    • PHP CS Fixer function_declaration: closure_function_spacing maps to spacing.anonymousFunction, and spacing.keyword: spaced is now set for both of its values. The warning for none is gone.
    • PHP_CodeSniffer Squiz.Functions.MultiLineFunctionDeclaration: the PEAR sniff it extends requires one space after the function of a closure (SpaceAfterFunction), so it maps to 'function ($x)'.

Note on existing configurations

Before this PR, a configuration that set spacing.keyword: 'return $x' without a standard preset also spaced its closures. With this PR, it has to add spacing.anonymousFunction: 'function ($x)' to keep that behaviour, because the new decision defaults to keep like spacing.arrowFunction does. Configurations built on a standard preset, or translated from PHP CS Fixer or PHP_CodeSniffer, are not affected. If you would rather keep the old behaviour for such configurations, the alternative is to have keep fall back to spacing.keyword, but then the two siblings would behave differently.

Tests

  • New fixtures in constructSpacing:
    • both values on a plain, a static and a by-reference closure with use (…)
    • 'function($x)' together with spacing.keyword, where if( and return $x are fixed and function foo follows the keyword decision
    • keep
    • a closure inside an arrow function and the other way round, each following its own decision
  • Translator.phpt covers closure_function_spacing: none and the default.
  • composer reference was regenerated. composer api produced no change.
  • composer tester, composer phpstan and bin/dresscode check give the same results as on master:
    • the same 3 failing tests: forbiddenPhpdocLines, a Translator.phpt case that needs simplexml (missing from my local PHP), and the import case in Application.phpt
    • the same 4 PHPStan errors
    • check is clean

🤖 Generated with Claude Code

dg and others added 15 commits October 29, 2026 02:16
…ich init proposes for a project formatted by Pint

PER Coding Style with what Pint 1.32 decides otherwise and without what it leaves to the author: the concatenation
without spaces, `! $x`, `new Foo`, `fn ($x) =>`, the trailing comma in arrays alone, a blank line before `return`
and `continue`, the enum cases kept together, the names in doc comments imported, and no construct spread for its
width. A standard of the core beside the others, so the reference, explain and init list it with them; init writes
it where the project requires laravel/pint, the standard of the preset a pint.json names, laravel without one.
dresscode init measures the code of a project and writes the configuration that fits it, each decision with its description.
… in the style the project keeps

dresscode upgrade lays the set deprecations, with --modernize also modernizations, over the presets the configuration
uses and beneath its own decisions and overrides, so that what the project keeps stays kept, lets in the plugins of
the packages of upgrading data, adds the types of the code and fixes. Without a configuration file it takes the
directory as a whole and no style. With one it keeps the scope, the target, the types, fixRisky, the baseline and the
maps, and of the style only the decisions the code keeps: a check without the types and the sets runs first, every
decision it finds violated anywhere is left out, and the run is narrowed by only to the rest, the sets and the packages
whose data reach the project, which a worker gets through --only. It takes --only, --fix-risky and --ask-risky, and
the summary names upgrade --ask-risky for the risky fixes left. The upgrade goes to the lowest versions of PHP and of
the libraries the project allows; the console says which libraries it takes, which packages are installed newer than
that, whose declarations then say nothing of what it deprecates, advising composer update --prefer-lowest, and,
where it rewrote a file, to run it again.
…beside DressCode

A package of upgrading data says which packages it is about under extra.dresscode.packages, and
UpgradingTester::collectPackageProblems() refuses a list that is not exactly the packages its upgrading files are
about. upgrade learns of the packages from Packagist, asked once a day and remembered in the cache directory, for now
of the vendor dresscode alone, and PackageDiscovery finds those installed beside DressCode by their type
dresscode-plugin, not only those of the project. A project requiring one of their libraries itself, not one where it
comes with another library, gets a missing package installed first: composer require into the installation of
DressCode, or require --dev into the project where its lock holds DressCode, in a terminal after asking and without
one only with --yes, otherwise it refuses with the command to run. The command then runs anew in a new process, whose
autoloader knows the installed packages and the plugin dresscode/upgrade-nette brings.
The configurations and the suppressions of PHP CS Fixer, PHP_CodeSniffer and Slevomat translated to the decisions of DressCode.
… the workers included

`--profile <file>` writes as JSON where the time of a run went, in nanoseconds: the phases (the start of the process,
the start of the container of PHPStan or of a derived one and its scopes, the parse, the passes, the gaps, the print,
the cycle collection, the run as a whole), every rule by the time of its callbacks, how often they ran and how often
they changed the tree, every gap rule by the time of its claims, every file with its size, rounds and passes, and the
peak memory of every process. A worker measures its own files and hands that over with every result and the parent
adds it up, so the totals do not depend on the number of workers. A run with the option leaves the result cache out,
a file served from the cache having nothing to measure.

It says where the time of a run goes, not only how much of it: a comparison of two revisions then names the rule or
the claim that moved. Without the option the engine only asks whether there is a profiler, and wraps a claim in
a measurement only when there is one.
…tinessTest

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, the naming messages of ValidVariableName, CamelCapsMethodName and UpperCaseConstantName getting one

A message without a row still stands for nothing. A message excluded while the configuration turns its sniff on by name says it cannot leave out what that sniff sets as well.

Co-Authored-By: Claude Code
Claude-Session: https://claude.ai/code/session_01D1mfZTuCXTAaFC39NM1xdk
… allows narrows it to that value, DisallowGroupUse with MultipleUsesPerLine giving separated imports

Co-Authored-By: Claude Code
Claude-Session: https://claude.ai/code/session_01D1mfZTuCXTAaFC39NM1xdk
…g.anonymousFunction

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dg

dg commented Oct 29, 2026

Copy link
Copy Markdown
Owner

Thank you! Merged into master. On top of it I made the ampersand of a closure or an arrow function returning a reference hug its parenthesis, function &($x) and fn&($x), so that function& ($x) no longer ends up as function & ($x).

@dg dg closed this Oct 29, 2026
dg pushed a commit that referenced this pull request Oct 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants