From ba665b5791d2289542c019437819d7099bd794f9 Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Fri, 30 Oct 2026 23:35:27 +0100 Subject: [PATCH] Do not reflow eval/exit/die argument lists in blink MethodArgumentSpace eval, exit and die are language constructs, not function calls: PHP tokenizes them separately, so PHP-CS-Fixer's MethodArgumentSpaceFixer never reflows their parentheses. blink lexed them as identifiers and treated "eval(" as a call, so a single heredoc argument was pushed onto its own line and diverged from ECS (e.g. Symfony's ClassExistsMock, ClockMock, DnsMock). Exclude the three constructs from call detection unless used as a member call ($obj->eval(...)). Adds a local parity fixture and a unit test. --- .../fixer/rules/method_argument_space.go | 22 +++++++++++++++++++ .../fixer/rules/method_argument_space_test.go | 16 ++++++++++++++ .../eval_heredoc_single_arg/expected.php | 19 ++++++++++++++++ .../cases/eval_heredoc_single_arg/input.php | 19 ++++++++++++++++ 4 files changed, 76 insertions(+) create mode 100644 blink/internal/parityfixture/testdata/cases/eval_heredoc_single_arg/expected.php create mode 100644 blink/internal/parityfixture/testdata/cases/eval_heredoc_single_arg/input.php diff --git a/blink/internal/fixer/rules/method_argument_space.go b/blink/internal/fixer/rules/method_argument_space.go index 023e9c5678..6b309bf28f 100644 --- a/blink/internal/fixer/rules/method_argument_space.go +++ b/blink/internal/fixer/rules/method_argument_space.go @@ -710,6 +710,25 @@ func argListIsMultiline(s *tokens.Stream, open, closeIdx int) bool { // isCallOrDeclParen reports whether the "(" at open opens a function/method call // or a function/closure declaration (not a control structure, array or grouping). +// isLanguageConstructBareword reports whether the identifier at p is a language +// construct (eval, exit, die) used as a bareword rather than a member call. PHP +// tokenizes these as their own constructs, not T_STRING, so MethodArgumentSpace +// never reflows their parentheses as a function argument list. +func isLanguageConstructBareword(s *tokens.Stream, p int) bool { + switch strings.ToLower(s.At(p).Value) { + case "eval", "exit", "die": + default: + return false + } + if pp := sigPrev(s, p); pp >= 0 && s.At(pp).Kind == token.Punct { + switch s.At(pp).Value { + case "->", "?->", "::": + return false // "$obj->eval(...)" is a real method call + } + } + return true +} + func isCallOrDeclParen(s *tokens.Stream, open int) bool { p := sigPrev(s, open) if p < 0 { @@ -718,6 +737,9 @@ func isCallOrDeclParen(s *tokens.Stream, open int) bool { t := s.At(p) switch t.Kind { case token.Ident, token.Variable: + if t.Kind == token.Ident && isLanguageConstructBareword(s, p) { + return false // eval()/exit()/die() are language constructs, not function calls + } return true case token.Punct: // ")" and "]" end a prior call/subscript; "}" ends a dynamic name like diff --git a/blink/internal/fixer/rules/method_argument_space_test.go b/blink/internal/fixer/rules/method_argument_space_test.go index ac811d36fc..017c156b89 100644 --- a/blink/internal/fixer/rules/method_argument_space_test.go +++ b/blink/internal/fixer/rules/method_argument_space_test.go @@ -54,3 +54,19 @@ func TestMethodArgumentSpaceSourceURL(t *testing.T) { t.Fatalf("SourceURL mismatch: derived=%q declared=%q", got, r.SourceURL()) } } + +// A single-argument call to the eval/exit/die language constructs is not a +// function call, so a heredoc argument must not be reflowed onto its own line. +func TestMethodArgumentSpaceEvalHeredocNotReflowed(t *testing.T) { + t.Parallel() + src := "eval(<<eval() method call must still be processed; changed=%v got=%q", changed, got) + } +} diff --git a/blink/internal/parityfixture/testdata/cases/eval_heredoc_single_arg/expected.php b/blink/internal/parityfixture/testdata/cases/eval_heredoc_single_arg/expected.php new file mode 100644 index 0000000000..86559cdc58 --- /dev/null +++ b/blink/internal/parityfixture/testdata/cases/eval_heredoc_single_arg/expected.php @@ -0,0 +1,19 @@ +