diff --git a/blink/internal/fixer/rules/array_notation.go b/blink/internal/fixer/rules/array_notation.go index 28ba76f4c3..5f897bb29e 100644 --- a/blink/internal/fixer/rules/array_notation.go +++ b/blink/internal/fixer/rules/array_notation.go @@ -85,7 +85,7 @@ func arrayNotationEnclosing(s *tokens.Stream, i int) int { } func arrayNotationIsAs(t token.Token) bool { - return (t.Kind == token.Keyword || t.Kind == token.Ident) && strings.EqualFold(t.Value, "as") + return (t.Kind == token.Keyword || t.Kind == token.Ident) && len(t.Value) == 2 && strings.EqualFold(t.Value, "as") } // arrayNotationInForeachHead reports whether the token at i sits inside the @@ -119,9 +119,204 @@ func arrayNotationInForeachHead(s *tokens.Stream, i int) bool { return false } +// arrayNotationForeachHeadAt is the end of arrayNotationInForeachHead's scan: +// enc is the innermost unclosed opener before the token, and seenAs whether an +// "as" sits between them at that depth. +func arrayNotationForeachHeadAt(s *tokens.Stream, enc int, seenAs bool) bool { + if enc < 0 || s.At(enc).Value != "(" || !seenAs { + return false + } + p := sigPrev(s, enc) + return p >= 0 && strings.EqualFold(s.At(p).Value, "foreach") +} + +// arrayNotationTracker follows the bracket nesting of a forward walk over a +// stream, so the walk learns what arrayNotationEnclosing and +// arrayNotationInForeachHead would find by scanning backwards, without the +// scan. Those scans cost the distance to the enclosing bracket, so asking them +// for every "[" of a long array or method body is quadratic. +// +// Step every token in order. A query describes the position after the last +// stepped token, and matches the backward scan as long as the walk has not +// changed a stepped token's kind or bracket/"as" value, nor moved it. +type arrayNotationTracker struct { + frames []arrayNotationFrame +} + +type arrayNotationFrame struct { + open int // index of the unclosed opener + seenAs bool // an "as" was stepped directly inside it (not in a nested bracket) +} + +// step accounts for t, the token at index i. Like the backward scans, it pairs +// any closer with the innermost opener, whatever their kinds. +func (tr *arrayNotationTracker) step(t token.Token, i int) { + if t.Kind != token.Punct { + if len(tr.frames) > 0 && arrayNotationIsAs(t) { + tr.frames[len(tr.frames)-1].seenAs = true + } + return + } + switch t.Value { + case "(", "[", "{": + tr.frames = append(tr.frames, arrayNotationFrame{open: i}) + case ")", "]", "}": + if len(tr.frames) > 0 { + tr.frames = tr.frames[:len(tr.frames)-1] + } + } +} + +// top returns the innermost unclosed opener and whether an "as" sits directly +// in it so far, or -1. +func (tr *arrayNotationTracker) top() (int, bool) { + if len(tr.frames) == 0 { + return -1, false + } + f := tr.frames[len(tr.frames)-1] + return f.open, f.seenAs +} + +// inForeachHead is arrayNotationInForeachHead for the next token to step. +func (tr *arrayNotationTracker) inForeachHead(s *tokens.Stream) bool { + enc, seenAs := tr.top() + return arrayNotationForeachHeadAt(s, enc, seenAs) +} + +// arrayNotationScan answers arrayNotationEnclosing, arrayNotationInForeachHead +// and arrayNotationIsDestructuring for any index of a stream. Near answers come +// from a bounded backward scan; once one would look further back than near +// tokens, a forward pass (an arrayNotationTracker run over the whole stream) +// records the answer at every index, and destructuring answers are remembered. +// +// It describes the stream as it was when that pass ran. A rule that edits the +// stream can keep asking it only about indices below every edit made since, +// and only when those edits cannot change an answer: TrailingCommaInMultiline +// and arrayNotationToLong walk backwards, edit at or after the current token, +// and what they write does not change how earlier brackets classify. +type arrayNotationScan struct { + s *tokens.Stream + near int // longest backward scan before the forward pass + info []uint32 // per index, once built: enclosing+1 | seenAs | memo bits +} + +const ( + arrayNotationEnc = 1<<29 - 1 // innermost unclosed opener before i, plus one (0: none) + arrayNotationSeenAs = 1 << 29 // an "as" sits between that opener and i, at its depth + arrayNotationKnown = 1 << 30 // isDestructuring(i) is remembered... + arrayNotationDestr = 1 << 31 // ...and is true +) + +// newArrayNotationScan returns a scan of s, or nil for a stream too long to +// index, which the callers take as "scan backwards". +func newArrayNotationScan(s *tokens.Stream) *arrayNotationScan { + if s.Len() > arrayNotationEnc { + return nil + } + // most brackets sit close to their enclosing one, and a short scan back is + // cheaper than a pass over the whole file + return &arrayNotationScan{s: s, near: 256} +} + +// arrayNotationEnclosingNear scans back from i, over at most limit tokens, for +// what arrayNotationEnclosing and arrayNotationInForeachHead look for: the +// innermost unclosed opener (-1 for none) and whether an "as" sits between it +// and i at that depth. ok is false when it gave up. +func arrayNotationEnclosingNear(s *tokens.Stream, i, limit int) (enc int, seenAs, ok bool) { + depth := 0 + for j := i - 1; j >= 0; j-- { + if i-j > limit { + return 0, false, false + } + t := s.At(j) + if t.Kind != token.Punct { + if depth == 0 && arrayNotationIsAs(t) { + seenAs = true + } + continue + } + switch t.Value { + case ")", "]", "}": + depth++ + case "(", "[", "{": + if depth == 0 { + return j, seenAs, true + } + depth-- + } + } + return -1, seenAs, true +} + +func (sc *arrayNotationScan) build() { + n := sc.s.Len() + sc.info = make([]uint32, n) + var tr arrayNotationTracker + for i := range n { + enc, seenAs := tr.top() + v := uint32(enc + 1) + if seenAs { + v |= arrayNotationSeenAs + } + sc.info[i] = v + // step ignores every other kind + switch sc.s.Kind(i) { + case token.Punct, token.Keyword, token.Ident: + tr.step(sc.s.At(i), i) + } + } +} + +// lookup returns the innermost unclosed opener before i (or -1) and whether an +// "as" sits between them at that depth. +func (sc *arrayNotationScan) lookup(i int) (int, bool) { + if sc.info == nil { + if enc, seenAs, ok := arrayNotationEnclosingNear(sc.s, i, sc.near); ok { + return enc, seenAs + } + sc.build() + } + v := sc.info[i] + return int(v&arrayNotationEnc) - 1, v&arrayNotationSeenAs != 0 +} + +// enclosing is arrayNotationEnclosing. +func (sc *arrayNotationScan) enclosing(i int) int { + enc, _ := sc.lookup(i) + return enc +} + +// inForeachHead is arrayNotationInForeachHead. +func (sc *arrayNotationScan) inForeachHead(i int) bool { + enc, seenAs := sc.lookup(i) + return arrayNotationForeachHeadAt(sc.s, enc, seenAs) +} + +// isDestructuring is arrayNotationIsDestructuring. Answers are remembered once +// the forward pass has run, which is when asking again gets expensive. +func (sc *arrayNotationScan) isDestructuring(open int) bool { + if sc.info != nil && sc.info[open]&arrayNotationKnown != 0 { + return sc.info[open]&arrayNotationDestr != 0 + } + d := arrayNotationDestructuring(sc.s, open, sc) + if sc.info != nil { + sc.info[open] |= arrayNotationKnown + if d { + sc.info[open] |= arrayNotationDestr + } + } + return d +} + // arrayNotationIsDestructuring reports whether the short "[" at open is an // array destructuring target rather than an array literal. func arrayNotationIsDestructuring(s *tokens.Stream, open int) bool { + return arrayNotationDestructuring(s, open, nil) +} + +// arrayNotationDestructuring is arrayNotationIsDestructuring, asking sc (when +// not nil) instead of scanning backwards. +func arrayNotationDestructuring(s *tokens.Stream, open int, sc *arrayNotationScan) bool { if closeIdx := s.MatchForward(open); closeIdx >= 0 { if n := sigNext(s, closeIdx); n >= 0 && s.At(n).Kind == token.Punct && s.At(n).Value == "=" { return true @@ -131,17 +326,34 @@ func arrayNotationIsDestructuring(s *tokens.Stream, open int) bool { if arrayNotationIsAs(s.At(p)) { return true } - if s.At(p).Kind == token.Punct && s.At(p).Value == "=>" && arrayNotationInForeachHead(s, open) { - return true + if s.At(p).Kind == token.Punct && s.At(p).Value == "=>" { + if sc != nil { + if sc.inForeachHead(open) { + return true + } + } else if arrayNotationInForeachHead(s, open) { + return true + } } } - enc := arrayNotationEnclosing(s, open) + var enc int + if sc != nil { + enc = sc.enclosing(open) + } else { + enc = arrayNotationEnclosing(s, open) + } if enc < 0 { return false } switch s.At(enc).Value { case "[": - return !isOffsetOpen(s, enc) && arrayNotationIsDestructuring(s, enc) + if isOffsetOpen(s, enc) { + return false + } + if sc != nil { + return sc.isDestructuring(enc) + } + return arrayNotationIsDestructuring(s, enc) case "(": q := sigPrev(s, enc) return q >= 0 && strings.EqualFold(s.At(q).Value, "list") @@ -152,13 +364,21 @@ func arrayNotationIsDestructuring(s *tokens.Stream, open int) bool { // arrayNotationToLong rewrites short "[...]" to "array(...)" (literals) or // "list(...)" (destructuring targets), depending on destructuring. func arrayNotationToLong(s *tokens.Stream, destructuring bool) bool { + // Each rewrite is at or after i and turns a matched "[...]" into "name(...)", + // so the scan stays right for the brackets before i (see arrayNotationScan). + return arrayNotationToLongWith(s, destructuring, newArrayNotationScan(s)) +} + +// arrayNotationToLongWith is arrayNotationToLong; a nil sc scans backwards +// instead, as a reference for tests. +func arrayNotationToLongWith(s *tokens.Stream, destructuring bool, sc *arrayNotationScan) bool { changed := false for i := s.Len() - 1; i >= 0; i-- { t := s.At(i) if t.Kind != token.Punct || t.Value != "[" || isOffsetOpen(s, i) { continue } - if arrayNotationIsDestructuring(s, i) != destructuring { + if arrayNotationDestructuring(s, i, sc) != destructuring { continue } closeIdx := s.MatchForward(i) @@ -246,6 +466,12 @@ func topBracketIsArray(stack []string) bool { // destructuring target - its matching "]" is directly followed by a single "=", // or it sits after "as" in a foreach head ("foreach ($x as ['k' => $v])"). func isDestructuringAssignOpen(s *tokens.Stream, open int) bool { + return isDestructuringAssignOpenIn(s, open, nil) +} + +// isDestructuringAssignOpenIn is isDestructuringAssignOpen, asking tr (when not +// nil, and stepped up to open) whether open sits in a foreach head. +func isDestructuringAssignOpenIn(s *tokens.Stream, open int, tr *arrayNotationTracker) bool { c := s.MatchForward(open) if c < 0 { return false @@ -254,6 +480,9 @@ func isDestructuringAssignOpen(s *tokens.Stream, open int) bool { if n >= 0 && s.At(n).Kind == token.Punct && s.At(n).Value == "=" { return true } + if tr != nil { + return tr.inForeachHead(s) + } return arrayNotationInForeachHead(s, open) } @@ -346,8 +575,14 @@ func arrayNotationIsHorizontal(t token.Token) bool { func (f WhitespaceAfterCommaInArray) Fix(s *tokens.Stream) bool { changed := false var stack []string + // the edits below only write whitespace after the current comma (the space + // they insert is skipped, not stepped), and the tracker ignores whitespace + var tr arrayNotationTracker for i := 0; i < s.Len(); i++ { t := s.At(i) + destructuring := t.Kind == token.Punct && t.Value == "[" && + ((len(stack) > 0 && stack[len(stack)-1] == "d[") || isDestructuringAssignOpenIn(s, i, &tr)) + tr.step(t, i) if t.Kind != token.Punct { continue } @@ -355,7 +590,7 @@ func (f WhitespaceAfterCommaInArray) Fix(s *tokens.Stream) bool { case "[": // a destructuring target ("[$a,$b] = ...", or nested in one) is not an // array literal, so php-cs-fixer does not space its commas - if (len(stack) > 0 && stack[len(stack)-1] == "d[") || isDestructuringAssignOpen(s, i) { + if destructuring { stack = append(stack, "d[") } else { stack = append(stack, "[") diff --git a/blink/internal/fixer/rules/array_notation_scan_test.go b/blink/internal/fixer/rules/array_notation_scan_test.go new file mode 100644 index 0000000000..568595600f --- /dev/null +++ b/blink/internal/fixer/rules/array_notation_scan_test.go @@ -0,0 +1,157 @@ +package rules + +import ( + "math/rand/v2" + "strings" + "testing" + + "github.com/ecsphp/ecs-src/blink/internal/lexer" + "github.com/ecsphp/ecs-src/blink/internal/token" + "github.com/ecsphp/ecs-src/blink/internal/tokens" +) + +// arrayNotationScanAlphabet is what the random streams below are made of: +// brackets (balanced or not), the tokens the destructuring checks look for, +// and the filler they skip. +var arrayNotationScanAlphabet = []token.Token{ + {Kind: token.Punct, Value: "("}, {Kind: token.Punct, Value: ")"}, + {Kind: token.Punct, Value: "["}, {Kind: token.Punct, Value: "]"}, + {Kind: token.Punct, Value: "{"}, {Kind: token.Punct, Value: "}"}, + {Kind: token.Punct, Value: "["}, {Kind: token.Punct, Value: "]"}, + {Kind: token.Punct, Value: "="}, {Kind: token.Punct, Value: "=>"}, + {Kind: token.Punct, Value: ","}, {Kind: token.Punct, Value: ";"}, + {Kind: token.Punct, Value: "..."}, + {Kind: token.Keyword, Value: "as"}, {Kind: token.Ident, Value: "AS"}, + {Kind: token.Keyword, Value: "foreach"}, {Kind: token.Keyword, Value: "list"}, + {Kind: token.Keyword, Value: "array"}, {Kind: token.Keyword, Value: "function"}, + {Kind: token.Keyword, Value: "match"}, {Kind: token.Keyword, Value: "object"}, + {Kind: token.Ident, Value: "foo"}, {Kind: token.Variable, Value: "$a"}, + {Kind: token.String, Value: "'s'"}, {Kind: token.Number, Value: "1"}, + {Kind: token.Whitespace, Value: " "}, {Kind: token.Whitespace, Value: "\n"}, + {Kind: token.Whitespace, Value: "\n"}, {Kind: token.Comment, Value: "/* c */"}, + {Kind: token.AttributeStart, Value: "#["}, {Kind: token.AttributeEnd, Value: "]"}, +} + +func randomArrayNotationStream(r *rand.Rand) *tokens.Stream { + n := 1 + r.IntN(60) + toks := make([]token.Token, n) + for i := range toks { + toks[i] = arrayNotationScanAlphabet[r.IntN(len(arrayNotationScanAlphabet))] + } + return tokens.New(toks) +} + +var arrayNotationScanSources = []string{ + " $id, 'tags' => [$a, $b]]) { $x = [$id => [$a]]; }", + " [$a, [$b, $c]]) {}", + " $v) { [$a, $b] = $v; }", + " [1]]; f()[0] = [[2]];", + " [1, 2], default => [[3]] }; fn ($a) => [$a];", + " ['x', 'y'], 'b' => ['z']]; function f(array $a = []) { foreach ($a as [, $b]) {} } }", + " 0 { + tr.step(s.At(index-1), index-1) + } t := s.At(index) cur := len(scopes) - 1 @@ -94,7 +100,7 @@ func (f StatementIndentation) Fix(s *tokens.Stream) bool { cur++ } - if _, isCase := caseBlockStarts[index]; stmtIsBlockFirst(s, index) || isCase { + if _, isCase := caseBlockStarts[index]; stmtIsBlockFirst(s, &tr, index) || isCase { ei := -1 eiInclusive := true @@ -140,7 +146,7 @@ func (f StatementIndentation) Fix(s *tokens.Stream) bool { continue } - if stmtIsArrayOpen(s, index) { + if stmtIsArrayOpen(s, &tr, index) { scopes = append(scopes, stmtScope{ kind: "statement", skip: true, @@ -403,8 +409,9 @@ func stmtIsDynamicMemberBrace(s *tokens.Stream, index int) bool { } // stmtIsBlockFirst reports whether the token at index opens a block scope: "{", -// a destructuring "[", or a "(" that is not an array(...) opener. -func stmtIsBlockFirst(s *tokens.Stream, index int) bool { +// a destructuring "[", or a "(" that is not an array(...) opener. tr has +// stepped every token before index. +func stmtIsBlockFirst(s *tokens.Stream, tr *arrayNotationTracker, index int) bool { t := s.At(index) if t.Kind != token.Punct { return false @@ -414,7 +421,7 @@ func stmtIsBlockFirst(s *tokens.Stream, index int) bool { // a dynamic member access ("$a->{$k}", "Foo::{$k}") is not a block scope return !stmtIsDynamicMemberBrace(s, index) case "[": - return isDestructuringAssignOpen(s, index) + return isDestructuringAssignOpenIn(s, index, tr) case "(": p := prevMeaningfulIndex(s, index) if p >= 0 && s.At(p).Kind == token.Keyword && strings.EqualFold(s.At(p).Value, "array") { @@ -426,8 +433,9 @@ func stmtIsBlockFirst(s *tokens.Stream, index int) bool { } // stmtIsArrayOpen reports whether the token at index opens an array literal that -// forms a statement scope: "array(" or an array-literal "[". -func stmtIsArrayOpen(s *tokens.Stream, index int) bool { +// forms a statement scope: "array(" or an array-literal "[". tr has stepped +// every token before index. +func stmtIsArrayOpen(s *tokens.Stream, tr *arrayNotationTracker, index int) bool { t := s.At(index) if t.Kind != token.Punct { return false @@ -437,7 +445,7 @@ func stmtIsArrayOpen(s *tokens.Stream, index int) bool { return p >= 0 && s.At(p).Kind == token.Keyword && strings.EqualFold(s.At(p).Value, "array") } if t.Value == "[" { - return isArrayLiteralOpen(s, index) && !isDestructuringAssignOpen(s, index) + return isArrayLiteralOpen(s, index) && !isDestructuringAssignOpenIn(s, index, tr) } return false } diff --git a/blink/internal/fixer/rules/trailing_comma.go b/blink/internal/fixer/rules/trailing_comma.go index d47f8d6fbc..9eb0d3c5f6 100644 --- a/blink/internal/fixer/rules/trailing_comma.go +++ b/blink/internal/fixer/rules/trailing_comma.go @@ -10,14 +10,15 @@ import ( // trailingCommaCategory classifies the block opened at open as one of // "arrays", "array_destructuring", "arguments", "parameters", "match", -// "group_import", or "" when it is not a comma-list block. -func trailingCommaCategory(s *tokens.Stream, open int) string { +// "group_import", or "" when it is not a comma-list block. sc, when not nil, +// answers for arrayNotationIsDestructuring. +func trailingCommaCategory(s *tokens.Stream, sc *arrayNotationScan, open int) string { switch s.At(open).Value { case "[": if isOffsetOpen(s, open) { return "" } - if arrayNotationIsDestructuring(s, open) { + if arrayNotationDestructuring(s, open, sc) { return "array_destructuring" } return "arrays" @@ -99,12 +100,12 @@ func (f TrailingCommaInMultiline) WithConfig(config map[string]any) fixer.Fixer return f } -func (f TrailingCommaInMultiline) included(s *tokens.Stream, open, closeIdx int) bool { +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) } - switch trailingCommaCategory(s, open) { + switch trailingCommaCategory(s, sc, open) { case "arrays": return arrayNotationContains(f.Elements, "arrays") case "array_destructuring": @@ -121,6 +122,15 @@ func (f TrailingCommaInMultiline) included(s *tokens.Stream, open, closeIdx int) } func (f TrailingCommaInMultiline) Fix(s *tokens.Stream) bool { + // The only edit is a "," right after the last element of the block closed at + // i, so it lands after every bracket still to be classified, and a "," + // changes neither the nesting nor what follows a closer (only a "=" would): + // the scan stays right (see arrayNotationScan). + return f.fix(s, newArrayNotationScan(s)) +} + +// fix is Fix; a nil sc scans backwards instead, as a reference for tests. +func (f TrailingCommaInMultiline) fix(s *tokens.Stream, sc *arrayNotationScan) bool { changed := false for i := s.Len() - 1; i >= 0; i-- { t := s.At(i) @@ -131,7 +141,7 @@ func (f TrailingCommaInMultiline) Fix(s *tokens.Stream) bool { continue } open := s.MatchBackward(i) - if open < 0 || !f.included(s, open, i) { + if open < 0 || !f.included(s, sc, open, i) { continue } @@ -223,7 +233,7 @@ func (f NoTrailingCommaInSingleline) Fix(s *tokens.Stream) bool { } func (f NoTrailingCommaInSingleline) shouldClear(s *tokens.Stream, open int) bool { - switch trailingCommaCategory(s, open) { + switch trailingCommaCategory(s, nil, open) { case "arrays": return arrayNotationContains(f.Elements, "array") case "array_destructuring":