diff --git a/blink/internal/fixer/rules/braces_position.go b/blink/internal/fixer/rules/braces_position.go
index 66d0cbbc56..3da1e6ac1c 100644
--- a/blink/internal/fixer/rules/braces_position.go
+++ b/blink/internal/fixer/rules/braces_position.go
@@ -1,7 +1,8 @@
package rules
import (
- "strings"
+ "regexp"
+ "slices"
"github.com/ecsphp/ecs-src/blink/internal/fixer"
"github.com/ecsphp/ecs-src/blink/internal/token"
@@ -84,14 +85,8 @@ func (f BracesPosition) Fix(s *tokens.Stream) bool {
switch kind {
case braceClassLike:
category = "classes"
- // an anonymous class ("new class ... {", also "new #[Attr] class ... {")
- // is configured separately; step back over any leading attribute
- if kw >= 0 {
- if p := prevMeaningfulSkippingAttributes(s, kw); p >= 0 {
- if prev := s.At(p); prev.Kind == token.Keyword && strings.EqualFold(prev.Value, "new") {
- category = "anon_classes"
- }
- }
+ if taIsAnonymousClass(s, kw) {
+ category = "anon_classes"
}
case braceFunctionDecl:
category = "functions"
@@ -117,17 +112,23 @@ func (f BracesPosition) Fix(s *tokens.Stream) bool {
}
if nextLine, handle := f.wantNextLine(category, s, i); handle {
+ if target := bpBraceTarget(s, i, nextLine); target >= 0 {
+ i = bpMoveBrace(s, i, target)
+ changed = true
+ }
want := " "
if nextLine {
// php-cs-fixer aligns the brace with the line holding the keyword
want = "\n" + lineIndent(s, kw)
}
- if i > 0 && s.At(i-1).Kind == token.Whitespace {
+ switch {
+ case !nextLine && bpFollowsLineComment(s, i):
+ case i > 0 && s.At(i-1).Kind == token.Whitespace:
if s.At(i-1).Value != want {
s.SetValue(i-1, want)
changed = true
}
- } else {
+ default:
s.InsertAt(i, token.Token{Kind: token.Whitespace, Value: want})
i++
changed = true
@@ -141,6 +142,117 @@ func (f BracesPosition) Fix(s *tokens.Stream) bool {
return changed
}
+// bpBraceTarget is PHP-CS-Fixer's $moveBraceToIndex: where the brace goes so that a
+// comment next to it stays out of the way, or -1. On the same line the brace goes
+// before the comments, as written after "// c" it would be part of the comment. On the
+// next line it goes after a "//" or "#" comment that follows it.
+func bpBraceTarget(s *tokens.Stream, open int, nextLine bool) int {
+ if nextLine {
+ return bpTargetAfterComment(s, open)
+ }
+ target := -1
+ previous := prevMeaningfulIndex(s, open)
+ for i := open - 1; i > previous; i-- {
+ if !isComment(s.At(i)) {
+ continue
+ }
+ i--
+ if s.At(i).Kind == token.Whitespace {
+ target = i
+ } else if i == previous {
+ target = previous + 1
+ }
+ }
+ // PHP-CS-Fixer moves it here too and writes a parse error
+ if target < 0 || isLineComment(s.At(target-1)) || s.MatchForward(open) < 0 {
+ return -1
+ }
+ return target
+}
+
+func bpTargetAfterComment(s *tokens.Stream, open int) int {
+ if before := s.At(open - 1); before.Kind == token.Whitespace && hasNewline(before.Value) {
+ return -1
+ }
+ closing := s.MatchForward(open)
+ if closing < 0 {
+ return -1
+ }
+ target := -1
+ for i := open + 1; i < closing; i++ {
+ t := s.At(i)
+ // PHP-CS-Fixer has already put a one-line body on its own line at this point;
+ // blink has not, so stop at code or the brace would jump over it
+ if t.Kind == token.Whitespace && hasNewline(t.Value) || t.Kind != token.Whitespace && !isComment(t) {
+ break
+ }
+ // blink's "// c ?>" comment keeps the space, so the close tag can follow directly
+ if isLineComment(t) && s.At(i+1).Kind == token.Whitespace {
+ target = i + 1
+ }
+ }
+ return target
+}
+
+// bpFollowsLineComment reports a brace whose line starts after a "//" or "#" comment:
+// on the same line as that comment it would be part of it.
+func bpFollowsLineComment(s *tokens.Stream, open int) bool {
+ before := open - 1
+ if s.At(before).Kind == token.Whitespace {
+ before--
+ }
+ return isLineComment(s.At(before))
+}
+
+var bpLeadingBreakRe = regexp.MustCompile(`^(?:[ \t]*?(?:\r\n|\n|\r))?[ \t]*`)
+
+// bpMoveBrace moves the brace at open to target like PHP-CS-Fixer: the tokens between
+// shift by one, and the whitespace left on either side is cleared or merged. It returns
+// the new index of the brace.
+func bpMoveBrace(s *tokens.Stream, open, target int) int {
+ first, last := min(open-1, target), max(open+1, target)
+ work := slices.Clone(s.Tokens()[first : last+1])
+ at := func(i int) *token.Token { return &work[i-first] }
+ clearAt := func(i int) { *at(i) = token.Token{Kind: token.Whitespace} }
+ isSpace := func(i int) bool { return at(i).Kind == token.Whitespace }
+
+ brace := *at(open)
+ delta := 1
+ if open > target {
+ delta = -1
+ }
+ if isSpace(open + delta) {
+ if delta == -1 && hasNewline(at(open-1).Value) {
+ if isSpace(open + 1) {
+ at(open + 1).Value = bpLeadingBreakRe.ReplaceAllString(at(open+1).Value, "")
+ }
+ } else if isSpace(open - 1) {
+ clearAt(open - 1)
+ }
+ }
+ for i := open; i != target; i += delta {
+ *at(i) = *at(i + delta)
+ }
+ *at(target) = brace
+ if isSpace(open) && isSpace(open+1) {
+ at(open).Value += at(open + 1).Value
+ clearAt(open + 1)
+ }
+
+ moved := make([]token.Token, 0, len(work))
+ index := -1
+ for i, t := range work {
+ if i+first == target {
+ index = first + len(moved)
+ }
+ if t.Value != "" {
+ moved = append(moved, t)
+ }
+ }
+ s.ReplaceRange(first, last, moved)
+ return index
+}
+
// closureBodyNeedsNewlines reports whether the body of the anonymous function
// whose "{" is at open goes on lines of its own: always when
// allow_single_line_anonymous_functions is false, and otherwise only when
diff --git a/blink/internal/fixer/rules/braces_position_comment_guard_test.go b/blink/internal/fixer/rules/braces_position_comment_guard_test.go
new file mode 100644
index 0000000000..55455c6e2f
--- /dev/null
+++ b/blink/internal/fixer/rules/braces_position_comment_guard_test.go
@@ -0,0 +1,63 @@
+package rules
+
+import (
+ "strings"
+ "testing"
+)
+
+// PHP-CS-Fixer first puts the body on its own line, which blink does not do, so its output
+// is no reference. The brace must not jump over the code to reach the comment.
+func TestBracesPositionBraceStaysBeforeCode(t *testing.T) {
+ t.Parallel()
+ got, _ := apply(t, BracesPosition{}, "= 0 && strings.Contains(line[comment:], "{") {
+ t.Errorf("%s got=%q", src, got)
+ }
+ if comment := strings.Index(line, "#"); comment >= 0 && strings.Contains(line[comment:], "{") {
+ t.Errorf("%s got=%q", src, got)
+ }
+ }
+ }
+}
+
+// PHP-CS-Fixer ends the comment before the space, so its output is no reference: the brace
+// must not go past the close tag, where it would be inline HTML.
+func TestBracesPositionBraceStaysBeforeCloseTag(t *testing.T) {
+ t.Parallel()
+ got, _ := apply(t, BracesPosition{}, "\nx\n\nx\n= 0 {
if s.At(ws).Value != " " {
s.SetValue(ws, " ")
diff --git a/blink/internal/fixer/rules/method_argument_space_comment_gap_test.go b/blink/internal/fixer/rules/method_argument_space_comment_gap_test.go
new file mode 100644
index 0000000000..799a4873a8
--- /dev/null
+++ b/blink/internal/fixer/rules/method_argument_space_comment_gap_test.go
@@ -0,0 +1,25 @@
+package rules
+
+import "testing"
+
+// The expected output of every case comes from the PHP engine.
+func TestMethodArgumentSpaceCommentGap(t *testing.T) {
+ t.Parallel()
+ cases := []struct{ src, want string }{
+ {"function f(\n string $a, // first\n ?int $b = null, // second\n bool $c = true,\n): void {\n}", "function f(\n string $a, // first\n ?int $b = null, // second\n bool $c = true,\n): void {\n}"},
+ {"function f(\n ?int $a = null, /* third */\n bool $b = true,\n): void {\n}", "function f(\n ?int $a = null, /* third */\n bool $b = true,\n): void {\n}"},
+ {"foo(\n $a, // aligned\n $bb, // aligned\n $ccc\n);", "foo(\n $a, // aligned\n $bb, // aligned\n $ccc\n);"},
+ {"foo(\n $a, // one space\n $b\n);", "foo(\n $a, // one space\n $b\n);"},
+ {"foo(\n $a,// no space\n $b\n);", "foo(\n $a,// no space\n $b\n);"},
+ {"foo(\n $a, /* block */ $b\n);", "foo(\n $a, /* block */\n $b\n);"},
+ {"foo($a, $b);", "foo($a, $b);"},
+ {"foo(\n $a, \n $b\n);", "foo(\n $a,\n $b\n);"},
+ {"foo($a, // c\n $b);", "foo($a, // c\n $b);"},
+ }
+ for _, c := range cases {
+ got, changed := apply(t, MethodArgumentSpace{}, " [...]".
+// A nested target is not checked, so it gets a comma: only these forms are certain.
+func trailingCommaIsPlainDestructuring(s *tokens.Stream, open, closeIdx int) bool {
+ prev := sigPrev(s, open)
+ if prev >= 0 && arrayNotationIsAs(s.At(prev)) {
+ return true
+ }
+ if prev >= 0 && isPunctVal(s, prev, "=>") {
+ key := sigPrev(s, prev)
+ if key < 0 || s.At(key).Kind != token.Variable {
+ return false
+ }
+ before := sigPrev(s, key)
+ return before >= 0 && arrayNotationIsAs(s.At(before))
+ }
+ next := sigNext(s, closeIdx)
+ return next >= 0 && isPunctVal(s, next, "=")
+}
+
func (f TrailingCommaInMultiline) included(s *tokens.Stream, sc *arrayNotationScan, open, closeIdx int) bool {
closer := s.At(closeIdx).Value
if f.Elements == nil {
- return closer == "]" && !isOffsetOpen(s, open)
+ return closer == "]" && !isOffsetOpen(s, open) && !trailingCommaIsPlainDestructuring(s, open, closeIdx)
}
switch trailingCommaCategory(s, sc, open) {
case "arrays":
diff --git a/blink/internal/fixer/rules/trailing_comma_destructuring_test.go b/blink/internal/fixer/rules/trailing_comma_destructuring_test.go
new file mode 100644
index 0000000000..74fc74287d
--- /dev/null
+++ b/blink/internal/fixer/rules/trailing_comma_destructuring_test.go
@@ -0,0 +1,38 @@
+package rules
+
+import "testing"
+
+// The expected output of every case comes from the PHP engine.
+func TestTrailingCommaDestructuring(t *testing.T) {
+ t.Parallel()
+ cases := []struct{ src, want string }{
+ {"[\n $a,\n $b\n] = foo();", "[\n $a,\n $b\n] = foo();"},
+ {"[\n $a,\n $b,\n] = foo();", "[\n $a,\n $b,\n] = foo();"},
+ {"['x' => $a, 'y' => $b] = foo();", "['x' => $a, 'y' => $b] = foo();"},
+ {"[\n 'x' => $a,\n 'y' => $b\n] = foo();", "[\n 'x' => $a,\n 'y' => $b\n] = foo();"},
+ {"list(\n $a,\n $b\n) = foo();", "list(\n $a,\n $b\n) = foo();"},
+ {"foreach ($g as [\n $a,\n $b\n]) {\n}", "foreach ($g as [\n $a,\n $b\n]) {\n}"},
+ {"[\n [$a, $b],\n $c\n] = foo();", "[\n [$a, $b],\n $c\n] = foo();"},
+ {"$x = [\n 1,\n 2\n];", "$x = [\n 1,\n 2,\n];"},
+ {"foo([\n 1,\n 2\n]);", "foo([\n 1,\n 2,\n]);"},
+ {"$a[\n 0\n] = 1;", "$a[\n 0\n] = 1;"},
+ {"foo(\n 1,\n 2\n);", "foo(\n 1,\n 2\n);"},
+ {"[\n 'k' => [\n $a,\n $b\n ],\n 'l' => $c\n] = foo();", "[\n 'k' => [\n $a,\n $b,\n ],\n 'l' => $c\n] = foo();"},
+ {"foreach ($g as ['a' => [\n $b,\n $c\n]]) {\n}", "foreach ($g as ['a' => [\n $b,\n $c,\n]]) {\n}"},
+ {"$f = fn ($x) => [\n $a,\n $b\n] = $x;", "$f = fn ($x) => [\n $a,\n $b,\n] = $x;"},
+ {"foreach ($g as $k => [\n $a,\n $b\n]) {\n}", "foreach ($g as $k => [\n $a,\n $b\n]) {\n}"},
+ {"$x = ['k' => [\n 1,\n 2\n]];", "$x = ['k' => [\n 1,\n 2,\n]];"},
+ {"['k' => [[\n $a\n]]] = foo();", "['k' => [[\n $a,\n]]] = foo();"},
+ {"$f = fn ($x) => [\n $a,\n [\n $b\n ],\n] = $x;", "$f = fn ($x) => [\n $a,\n [\n $b,\n ],\n] = $x;"},
+ {"foreach ($g as $this->k => [\n $a\n]) {\n}", "foreach ($g as $this->k => [\n $a,\n]) {\n}"},
+ {"foreach ($g as $m['x'] => [\n $a\n]) {\n}", "foreach ($g as $m['x'] => [\n $a,\n]) {\n}"},
+ {"[$x . [\n 1\n][0] => $a] = foo();", "[$x . [\n 1,\n][0] => $a] = foo();"},
+ {"$x = ['k' => [[\n $a\n]] = foo()];", "$x = ['k' => [[\n $a,\n]] = foo()];"},
+ }
+ for _, c := range cases {
+ got, changed := apply(t, TrailingCommaInMultiline{}, "") {
+ for p < len(src) && src[p] != '\n' && src[p] != '\r' && !strings.HasPrefix(src[p:], "?>") {
p++
}
default:
@@ -486,7 +486,7 @@ func (l *lexer) matchOperator() int {
}
func (l *lexer) lexLineComment(start int) {
- for l.pos < len(l.src) && l.src[l.pos] != '\n' {
+ for l.pos < len(l.src) && l.src[l.pos] != '\n' && l.src[l.pos] != '\r' {
// a ?> ends a line comment in PHP; stop before it
if l.hasPrefix("?>") {
break
diff --git a/blink/internal/lexer/line_comment_test.go b/blink/internal/lexer/line_comment_test.go
new file mode 100644
index 0000000000..ca070c086a
--- /dev/null
+++ b/blink/internal/lexer/line_comment_test.go
@@ -0,0 +1,43 @@
+package lexer
+
+import (
+ "testing"
+
+ "github.com/ecsphp/ecs-src/blink/internal/token"
+)
+
+// The expected comments come from PHP's token_get_all.
+func TestLineCommentEnds(t *testing.T) {
+ cases := []struct{ src, comment string }{
+ {"", "// c "},
+ }
+ for _, c := range cases {
+ found := false
+ for _, tk := range Lex(c.src) {
+ if tk.Kind == token.Comment {
+ found = true
+ if tk.Value != c.comment {
+ t.Errorf("%q comment=%q want %q", c.src, tk.Value, c.comment)
+ }
+ }
+ }
+ if !found {
+ t.Errorf("%q has no comment", c.src)
+ }
+ }
+}
+
+// PHP's token_get_all gives one T_YIELD_FROM here, comment included.
+func TestYieldFromAcrossCommentEndingAtCarriageReturn(t *testing.T) {
+ src := "