Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions blink/internal/fixer/rules/attribute.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package rules

import (
"strings"

"blink/internal/lexer"
"blink/internal/token"
"blink/internal/tokens"
)

// Attributes lex natively ("#[" is an AttributeStart token, its contents and the
// closing "]" are ordinary tokens), so most fixers see inside them for free. The
// helpers here cover the few rules that still reason about an attribute as a whole.

// attributeStartsAt reports whether an attribute ("#[") begins at i.
func attributeStartsAt(s *tokens.Stream, i int) bool {
return i >= 0 && i < s.Len() && s.At(i).Kind == token.AttributeStart
}

// attributeEndsAt reports whether the attribute-closing "]" is at i.
func attributeEndsAt(s *tokens.Stream, i int) bool {
return s.AttributeStartOf(i) >= 0
}

// prevMeaningfulSkippingAttributes is prevMeaningfulIndex but also steps back over
// any attributes ("new #[A] #[B] class" -> the "new"), since an attribute decorates
// the token that follows it.
func prevMeaningfulSkippingAttributes(s *tokens.Stream, i int) int {
p := prevMeaningfulIndex(s, i)
for p >= 0 && attributeEndsAt(s, p) {
p = prevMeaningfulIndex(s, s.AttributeStartOf(p))
}
return p
}

// attributeText renders the attribute tokens [start..end] back to source text.
func attributeText(s *tokens.Stream, start, end int) string {
var b strings.Builder
for j := start; j <= end; j++ {
b.WriteString(s.At(j).Value)
}
return b.String()
}

// rewriteAttributeText feeds the attribute [start..end] to transform as its source
// text ("#[...]"), re-lexes the result and splices it back in place. It bridges the
// text-shaped attribute transforms to the native token stream. Returns whether the
// stream changed.
func rewriteAttributeText(s *tokens.Stream, start, end int, transform func(string) (string, bool)) bool {
old := attributeText(s, start, end)
nv, ok := transform(old)
if !ok || nv == old {
return false
}
s.ReplaceRange(start, end, lexAttributeFragment(nv))
return true
}

// lexAttributeFragment lexes a standalone "#[...]" fragment into its tokens, from
// the AttributeStart through the end (the opening PHP tag used to drive the lexer
// is dropped).
func lexAttributeFragment(text string) []token.Token {
toks := lexer.Lex("<?php " + text)
for i := range toks {
if toks[i].Kind == token.AttributeStart {
return toks[i:]
}
}
return toks
}
8 changes: 8 additions & 0 deletions blink/internal/fixer/rules/blocks.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,14 @@ func classMemberStarts(s *tokens.Stream, open int) []int {
if t.Kind == token.Whitespace || t.Kind == token.Comment || t.Kind == token.DocComment {
continue
}
if t.Kind == token.AttributeStart {
// an attribute decorates the member that follows it; skip it as trivia so
// the member start is the keyword, as when attributes were comment tokens
if end := s.AttributeEnd(k); end > k {
k = end
}
continue
}
if expect {
starts = append(starts, k)
expect = false
Expand Down
7 changes: 5 additions & 2 deletions blink/internal/fixer/rules/braces_position.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,9 @@ func (f BracesPosition) Fix(s *tokens.Stream) bool {
case braceClassLike:
category = "classes"
// an anonymous class ("new class ... {", also "new #[Attr] class ... {")
// is configured separately; prevMeaningfulIndex steps over the attribute
// is configured separately; step back over any leading attribute
if kw >= 0 {
if p := prevMeaningfulIndex(s, kw); p >= 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"
}
Expand Down Expand Up @@ -230,6 +230,9 @@ func funcSignatureMultiline(s *tokens.Stream, brace int) bool {
closeParen := -1
for j := brace - 1; j >= 0; j-- {
t := s.At(j)
if t.Kind == token.AttributeEnd {
return false // a leading attribute's parens are not the signature (e.g. a class header)
}
if t.Kind != token.Punct {
continue
}
Expand Down
11 changes: 11 additions & 0 deletions blink/internal/fixer/rules/braces_position_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,14 @@ func TestClassifyTraitBlockIsOther(t *testing.T) {
t.Fatalf("trait block classify = %d, want braceOther", kind)
}
}

// A class preceded by a multiline attribute still gets its opening brace moved to
// the next line: the attribute's own ")" must not be read as a multiline signature.
func TestBracesPositionClassAfterMultilineAttribute(t *testing.T) {
t.Parallel()
src := "<?php\n\n#[AsCommand(\n name: 'x'\n)]\nclass C extends Command {\n public $x = 1;\n}\n"
want := "<?php\n\n#[AsCommand(\n name: 'x'\n)]\nclass C extends Command\n{\n public $x = 1;\n}\n"
if got, changed := apply(t, BracesPosition{}, src); !changed || got != want {
t.Fatalf("class brace after attribute; changed=%v got=%q", changed, got)
}
}
22 changes: 15 additions & 7 deletions blink/internal/fixer/rules/class_definition.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ func (f ClassDefinition) Fix(s *tokens.Stream) bool {
// anonymous class whose keyword is at classIdx, or -1 for a named declaration.
// prevMeaningfulIndex steps over a leading attribute ("new #[Attr] class").
func anonClassNewIndex(s *tokens.Stream, classIdx int) int {
p := prevMeaningfulIndex(s, classIdx)
p := prevMeaningfulSkippingAttributes(s, classIdx)
if p >= 0 && s.At(p).Kind == token.Keyword && strings.EqualFold(s.At(p).Value, "new") {
return p
}
Expand All @@ -115,14 +115,22 @@ func anonClassNewIndex(s *tokens.Stream, classIdx int) int {
func classDefFlattenAnonHeaderAttributes(s *tokens.Stream, newIdx, classIdx int) bool {
changed := false
for j := newIdx + 1; j < classIdx; j++ {
t := s.At(j)
if !isAttributeComment(t) || !strings.ContainsRune(t.Value, '\n') {
if !attributeStartsAt(s, j) {
continue
}
end := s.AttributeEnd(j)
if end < 0 {
continue
}
if nv, ok := collapseAttributeText(t.Value, false); ok && nv != t.Value {
s.SetValue(j, nv)
before := s.Len()
if strings.ContainsRune(attributeText(s, j, end), '\n') &&
rewriteAttributeText(s, j, end, func(v string) (string, bool) {
return collapseAttributeText(v, false)
}) {
changed = true
classIdx -= before - s.Len() // the splice shifted the "class" keyword
}
j = s.AttributeEnd(j) // skip past the attribute we just handled
}
return changed
}
Expand Down Expand Up @@ -169,10 +177,10 @@ func (f ClassDefinition) classDefFlattenHeader(s *tokens.Stream, kw int) bool {
if t.Kind != token.Whitespace || !hasNewline(t.Value) {
continue
}
if j-1 >= 0 && (s.At(j-1).Kind == token.DocComment || isAttribute(s.At(j-1))) {
if j-1 >= 0 && (s.At(j-1).Kind == token.DocComment || attributeEndsAt(s, j-1)) {
continue
}
if j+1 < s.Len() && (s.At(j+1).Kind == token.DocComment || isAttribute(s.At(j+1))) {
if j+1 < s.Len() && (s.At(j+1).Kind == token.DocComment || attributeStartsAt(s, j+1)) {
continue
}
s.SetValue(j, " ")
Expand Down
2 changes: 1 addition & 1 deletion blink/internal/fixer/rules/constructs.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ func (f SingleSpaceAroundConstruct) fixNamedArgument(s *tokens.Stream) bool {
if next.Kind == token.Whitespace {
// a newline into an attribute or doc comment is kept, as php-cs-fixer does
if hasNewline(next.Value) {
if nn := i + 2; nn < s.Len() && (isAttributeComment(s.At(nn)) || s.At(nn).Kind == token.DocComment) {
if nn := i + 2; nn < s.Len() && (attributeStartsAt(s, nn) || s.At(nn).Kind == token.DocComment) {
continue
}
}
Expand Down
24 changes: 14 additions & 10 deletions blink/internal/fixer/rules/gen3_class_sep.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,13 +145,22 @@ func (f ClassAttributesSeparation) fixMemberGap(s *tokens.Stream, prevStart, nex
wsCount++
wsIdx = j
case token.DocComment, token.Comment:
// a docblock, attribute (#[...]) or a line/block comment leading the next
// member is its trivia: the blank line goes above the block. A trailing
// comment on the previous member's line is rejected by the guards below
// (its whitespace has no newline), so it is still left alone.
// a docblock or a line/block comment leading the next member is its
// trivia: the blank line goes above the block. A trailing comment on the
// previous member's line is rejected by the guards below (its whitespace
// has no newline), so it is still left alone.
if firstTrivia < 0 {
firstTrivia = j
}
case token.AttributeStart:
// an attribute (#[...]) leading the next member is trivia too; skip its
// inner tokens so the blank line goes above the whole attribute block
if firstTrivia < 0 {
firstTrivia = j
}
if end := s.AttributeEnd(j); end > j {
j = end
}
default:
return false
}
Expand Down Expand Up @@ -258,12 +267,7 @@ func hasDocOrAttrAbove(s *tokens.Stream, start int) bool {
if j < 0 {
return false
}
t := s.At(j)
return t.Kind == token.DocComment || isAttribute(t)
}

func isAttribute(t token.Token) bool {
return t.Kind == token.Comment && strings.HasPrefix(t.Value, "#[")
return s.At(j).Kind == token.DocComment || s.AttributeStartOf(j) >= 0
}

// setNewlineCount rewrites the whitespace token at idx to hold exactly req
Expand Down
41 changes: 41 additions & 0 deletions blink/internal/fixer/rules/imports_unused.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,9 +159,50 @@ func importIsUsed(s *tokens.Stream, im importInfo, lo, hi int, inImport func(int
return false
}

// attributeNameAt reports whether the identifier at k is an attribute name - the
// first segment right after "#[", or after a top-level comma in a grouped attribute
// "#[A, B]". Such a name is a class reference regardless of a following "(".
func attributeNameAt(s *tokens.Stream, k int) bool {
p := prevSignificantIndex(s, k)
if p < 0 {
return false
}
if s.At(p).Kind == token.AttributeStart {
return true
}
if s.At(p).Kind != token.Punct || s.At(p).Value != "," {
return false
}
depth := 0
for j := p - 1; j >= 0; j-- {
t := s.At(j)
if t.Kind == token.AttributeStart && depth == 0 {
return true
}
if t.Kind == token.AttributeEnd {
return false
}
if t.Kind == token.Punct {
switch t.Value {
case ")", "]", "}":
depth++
case "(", "[", "{":
if depth == 0 {
return false
}
depth--
}
}
}
return false
}

// usageMatchesKind reports whether the identifier at k is a reference of the
// given import kind.
func usageMatchesKind(s *tokens.Stream, k int, kind string) bool {
if attributeNameAt(s, k) {
return kind == "class" // an attribute is always a class reference, even with "(" after
}
prev, hasPrev := prevSignificant(s, k)
if hasPrev {
if prev.Kind == token.Punct {
Expand Down
10 changes: 10 additions & 0 deletions blink/internal/fixer/rules/imports_unused_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,13 @@ func TestNoUnusedImportsBraceNamespaceUnused(t *testing.T) {
t.Fatalf("unused brace-namespace import must be removed; changed=%v got=%q want=%q", changed, got, want)
}
}

// An import used only as an attribute name ("#[AsCommand(...)]") is a class usage,
// even though a "(" follows the name, so it must be kept.
func TestNoUnusedImportsAttributeUsage(t *testing.T) {
t.Parallel()
src := "<?php\n\nnamespace App;\n\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\n\n#[AsCommand(name: 'app:run')]\nclass RunCommand\n{\n}\n"
if got, changed := apply(t, NoUnusedImports{}, src); changed || got != src {
t.Fatalf("attribute-used import must be kept; changed=%v got=%q", changed, got)
}
}
22 changes: 12 additions & 10 deletions blink/internal/fixer/rules/line_length.go
Original file line number Diff line number Diff line change
Expand Up @@ -393,13 +393,17 @@ func (f LineLength) inlineItems(s *tokens.Stream, open, closeIdx int) bool {
return changed
}

// processAttribute expands a too-long "#[Attr(...)]" (one comment token) onto
// multiple lines by rewriting its text.
// processAttribute expands a too-long "#[Attr(...)]" onto multiple lines by
// rewriting the attribute's text and re-lexing it back into the stream.
func (f LineLength) processAttribute(s *tokens.Stream, pos int) bool {
if !f.breakLongLines {
return false
}
v := s.At(pos).Value
end := s.AttributeEnd(pos)
if end < 0 {
return false
}
v := attributeText(s, pos, end)
if !strings.Contains(v, "(") {
return false
}
Expand All @@ -411,12 +415,10 @@ func (f LineLength) processAttribute(s *tokens.Stream, pos int) bool {
return false
}
base := lineIndent(s, pos)
nv := llExpandAttribute(v, base)
if nv == "" || nv == v {
return false
}
s.SetValue(pos, nv)
return true
return rewriteAttributeText(s, pos, end, func(v string) (string, bool) {
nv := llExpandAttribute(v, base)
return nv, nv != ""
})
}

// attributeColumn is the rendered column at which the attribute token starts.
Expand Down Expand Up @@ -582,7 +584,7 @@ func llHasPromotedProperty(s *tokens.Stream, open, closeIdx int) bool {
}

func llIsAttribute(t token.Token) bool {
return t.Kind == token.Comment && strings.HasPrefix(t.Value, "#[")
return t.Kind == token.AttributeStart
}

func llIsHeredoc(t token.Token) bool {
Expand Down
Loading
Loading