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 := "