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
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package rules

import "testing"

func TestBlankLinesBeforeNamespaceConfig(t *testing.T) {
t.Parallel()

src := "<?php\n\n\nnamespace Foo;"

// default collapses to exactly one blank line (2 line breaks).
if got, changed := apply(t, BlankLinesBeforeNamespace{}, src); !changed || got != "<?php\n\nnamespace Foo;" {
t.Fatalf("default changed=%v got=%q", changed, got)
}

// min=max=1 keeps a single line break.
f := BlankLinesBeforeNamespace{}.WithConfig(map[string]any{"min_line_breaks": float64(1), "max_line_breaks": float64(1)})
if got, changed := apply(t, f.(fixerRule), src); !changed || got != "<?php\nnamespace Foo;" {
t.Fatalf("min=max=1 changed=%v got=%q", changed, got)
}

// min=3 raises a single break up to three.
f = BlankLinesBeforeNamespace{}.WithConfig(map[string]any{"min_line_breaks": float64(3), "max_line_breaks": float64(4)})
if got, changed := apply(t, f.(fixerRule), "<?php\nnamespace Foo;"); !changed || got != "<?php\n\n\nnamespace Foo;" {
t.Fatalf("min=3 changed=%v got=%q", changed, got)
}
}
155 changes: 144 additions & 11 deletions blink/internal/fixer/rules/imports.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package rules
import (
"strings"

"blink/internal/fixer"
"blink/internal/token"
"blink/internal/tokens"
)
Expand Down Expand Up @@ -58,9 +59,13 @@ func skipWhitespace(s *tokens.Stream, i int) int {
// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Import/SingleImportPerStatementFixer.php
//
// SingleImportPerStatement splits a grouped import into one per line:
// "use A, B;" -> "use A;\nuse B;". Group imports ("use A\{B, C};") and closure
// "use (...)" are left alone.
type SingleImportPerStatement struct{}
// "use A, B;" -> "use A;\nuse B;". With group_to_single_imports (the default) a
// brace group "use A\{B, C};" is also expanded; set it to false to keep groups.
// Closure "use (...)" is always left alone.
type SingleImportPerStatement struct {
// keepGroups leaves brace group imports untouched (group_to_single_imports:false).
keepGroups bool
}

func (SingleImportPerStatement) Name() string {
return `PhpCsFixer\Fixer\Import\SingleImportPerStatementFixer`
Expand All @@ -70,8 +75,15 @@ func (SingleImportPerStatement) SourceURL() string {
return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Import/SingleImportPerStatementFixer.php"
}

func (SingleImportPerStatement) Fix(s *tokens.Stream) bool {
return splitUseStatements(s, false)
func (f SingleImportPerStatement) WithConfig(config map[string]any) fixer.Fixer {
if v, ok := config["group_to_single_imports"].(bool); ok {
f.keepGroups = !v
}
return f
}

func (f SingleImportPerStatement) Fix(s *tokens.Stream) bool {
return splitUseStatements(s, false, !f.keepGroups)
}

// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/SingleTraitInsertPerStatementFixer.php
Expand All @@ -89,20 +101,21 @@ func (SingleTraitInsertPerStatement) SourceURL() string {
}

func (SingleTraitInsertPerStatement) Fix(s *tokens.Stream) bool {
return splitUseStatements(s, true)
return splitUseStatements(s, true, false)
}

// splitUseStatements splits grouped "use" statements one per line. inClass picks
// the scope: false handles top-level imports, true handles in-class trait use.
func splitUseStatements(s *tokens.Stream, inClass bool) bool {
// expandGroups also expands brace group imports ("use A\{B, C};").
func splitUseStatements(s *tokens.Stream, inClass, expandGroups bool) bool {
changed := false
i := 0
for i < s.Len() {
if s.At(i).Kind != token.Keyword || strings.ToLower(s.At(i).Value) != "use" {
i++
continue
}
next, c := splitUseAt(s, i, inClass)
next, c := splitUseAt(s, i, inClass, expandGroups)
i = next
if c {
changed = true
Expand All @@ -113,7 +126,7 @@ func splitUseStatements(s *tokens.Stream, inClass bool) bool {

// splitUseAt splits the "use" statement at index i when its scope matches
// inClass. It returns the index to continue from and whether it changed.
func splitUseAt(s *tokens.Stream, i int, inClass bool) (int, bool) {
func splitUseAt(s *tokens.Stream, i int, inClass, expandGroups bool) (int, bool) {
if inClassLikeBody(s, i) != inClass {
return i + 1, false
}
Expand All @@ -130,7 +143,7 @@ func splitUseAt(s *tokens.Stream, i int, inClass bool) (int, bool) {
return i + 1, false // closure "use (...)"
}

semi, group := -1, false
semi, group, openBrace := -1, false, -1
var commas []int
for k := j; k < s.Len(); k++ {
if s.At(k).Kind != token.Punct {
Expand All @@ -139,6 +152,7 @@ func splitUseAt(s *tokens.Stream, i int, inClass bool) (int, bool) {
switch s.At(k).Value {
case "{":
group = true
openBrace = k
case ";":
semi = k
case ",":
Expand All @@ -148,7 +162,13 @@ func splitUseAt(s *tokens.Stream, i int, inClass bool) (int, bool) {
break
}
}
if group || semi < 0 || len(commas) == 0 {
if group {
if !expandGroups {
return i + 1, false
}
return expandGroupImport(s, i, j, openBrace, modifier)
}
if semi < 0 || len(commas) == 0 {
return i + 1, false
}

Expand All @@ -171,6 +191,119 @@ func splitUseAt(s *tokens.Stream, i int, inClass bool) (int, bool) {
return i + len(repl), true
}

// expandGroupImport expands a brace group import "use Prefix\{A, function b, const C};"
// into one single import per member, carrying the common prefix and any outer or
// per-member "function"/"const" modifier. prefixStart points at the first prefix
// token (after "use" and an outer modifier), openBrace at the "{".
func expandGroupImport(s *tokens.Stream, useIdx, prefixStart, openBrace int, outerModifier string) (int, bool) {
closeBrace := -1
depth := 0
for k := openBrace; k < s.Len(); k++ {
if s.At(k).Kind != token.Punct {
continue
}
switch s.At(k).Value {
case "{":
depth++
case "}":
depth--
if depth == 0 {
closeBrace = k
}
}
if closeBrace >= 0 {
break
}
}
if closeBrace < 0 {
return useIdx + 1, false
}

semi := -1
for k := closeBrace + 1; k < s.Len(); k++ {
if s.At(k).Kind == token.Whitespace || s.At(k).Kind == token.Comment || s.At(k).Kind == token.DocComment {
continue
}
if s.At(k).Kind == token.Punct && s.At(k).Value == ";" {
semi = k
}
break
}
if semi < 0 {
return useIdx + 1, false
}

var prefix []token.Token
for k := prefixStart; k < openBrace; k++ {
prefix = append(prefix, s.At(k))
}
prefix = trimWhitespace(prefix)

items := splitGroupItems(s, openBrace+1, closeBrace)
indent := indentBefore(s, useIdx)

var repl []token.Token
emitted := 0
for _, item := range items {
item = trimWhitespace(item)
if len(item) == 0 {
continue // trailing comma
}
modifier := outerModifier
rest := item
if item[0].Kind == token.Keyword {
if lw := strings.ToLower(item[0].Value); lw == "function" || lw == "const" {
modifier = item[0].Value
rest = trimWhitespace(item[1:])
}
}
if emitted > 0 {
repl = append(repl, token.Token{Kind: token.Whitespace, Value: "\n" + indent})
}
repl = append(repl, token.Token{Kind: token.Keyword, Value: "use"}, token.Token{Kind: token.Whitespace, Value: " "})
if modifier != "" {
repl = append(repl, token.Token{Kind: token.Keyword, Value: modifier}, token.Token{Kind: token.Whitespace, Value: " "})
}
repl = append(repl, prefix...)
repl = append(repl, rest...)
repl = append(repl, token.Token{Kind: token.Punct, Value: ";"})
emitted++
}
if emitted == 0 {
return useIdx + 1, false
}

s.ReplaceRange(useIdx, semi, repl)
return useIdx + len(repl), true
}

// splitGroupItems slices the tokens in (open, close) at top-level commas.
func splitGroupItems(s *tokens.Stream, from, close int) [][]token.Token {
var items [][]token.Token
var cur []token.Token
depth := 0
for k := from; k < close; k++ {
t := s.At(k)
if t.Kind == token.Punct {
switch t.Value {
case "{", "[", "(":
depth++
case "}", "]", ")":
depth--
case ",":
if depth == 0 {
items = append(items, cur)
cur = nil
continue
}
}
}
cur = append(cur, t)
}
items = append(items, cur)
return items
}

// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Import/SingleLineAfterImportsFixer.php
//
// SingleLineAfterImports ensures one blank line after the last import in a block
Expand Down
50 changes: 46 additions & 4 deletions blink/internal/fixer/rules/modifier_keywords.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"slices"
"strings"

"blink/internal/fixer"
"blink/internal/token"
"blink/internal/tokens"
)
Expand All @@ -18,7 +19,31 @@ import (
// This handles class-body const/property/method members. Constructor promoted
// properties and PHP 8.4 asymmetric set-visibility (public(set)) are left
// untouched; members whose modifier run contains a comment are skipped.
type ModifierKeywords struct{}
//
// The `elements` option limits which member kinds (const, property, method) are
// fixed; nil fixes all three, matching the PHP-CS-Fixer default.
type ModifierKeywords struct {
// elements is the set of enabled member kinds; nil means all three.
elements map[string]bool
}

func (f ModifierKeywords) WithConfig(config map[string]any) fixer.Fixer {
if list, ok := arrayNotationStringList(config["elements"]); ok {
set := make(map[string]bool, len(list))
for _, item := range list {
set[item] = true
}
f.elements = set
}
return f
}

func (f ModifierKeywords) enabled(kind string) bool {
if f.elements == nil {
return true
}
return f.elements[kind]
}

func (ModifierKeywords) Name() string {
return `PhpCsFixer\Fixer\ClassNotation\ModifierKeywordsFixer`
Expand All @@ -28,7 +53,7 @@ func (ModifierKeywords) SourceURL() string {
return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/ModifierKeywordsFixer.php"
}

func (ModifierKeywords) Fix(s *tokens.Stream) bool {
func (f ModifierKeywords) Fix(s *tokens.Stream) bool {
changed := false
for i := 0; i < s.Len(); i++ {
if s.At(i).Kind != token.Punct || s.At(i).Value != "{" {
Expand All @@ -38,15 +63,29 @@ func (ModifierKeywords) Fix(s *tokens.Stream) bool {
continue
}
for _, m := range slices.Backward(classMemberStarts(s, i)) {
if orderModifierKeywords(s, m) {
if f.orderModifierKeywords(s, m) {
changed = true
}
}
}
return changed
}

func orderModifierKeywords(s *tokens.Stream, m int) bool {
// modifierMemberKind classifies the member whose modifier run ends at index
// after: "const", "method" (a function) or "property".
func modifierMemberKind(s *tokens.Stream, after int) string {
if after < s.Len() && s.At(after).Kind == token.Keyword {
switch strings.ToLower(s.At(after).Value) {
case "const":
return "const"
case "function":
return "method"
}
}
return "property"
}

func (f ModifierKeywords) orderModifierKeywords(s *tokens.Stream, m int) bool {
var absFinal []string
visibility := ""
static := ""
Expand Down Expand Up @@ -97,6 +136,9 @@ func orderModifierKeywords(s *tokens.Stream, m int) bool {
if after < s.Len() && (s.At(after).Kind == token.Comment || s.At(after).Kind == token.DocComment) {
return false
}
if !f.enabled(modifierMemberKind(s, after)) {
return false
}
if visibility == "" {
visibility = "public"
}
Expand Down
29 changes: 29 additions & 0 deletions blink/internal/fixer/rules/modifier_keywords_config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package rules

import "testing"

func TestModifierKeywordsConfig(t *testing.T) {
t.Parallel()

src := "<?php class C { const A = 1; static $b; static function c() {} }"

// default adds visibility to const, property and method.
wantAll := "<?php class C { public const A = 1; public static $b; public static function c() {} }"
if got, changed := apply(t, ModifierKeywords{}, src); !changed || got != wantAll {
t.Fatalf("default changed=%v got=%q", changed, got)
}

// elements:[method] fixes only the method, leaving const and property.
f := ModifierKeywords{}.WithConfig(map[string]any{"elements": []any{"method"}})
wantMethod := "<?php class C { const A = 1; static $b; public static function c() {} }"
if got, changed := apply(t, f.(fixerRule), src); !changed || got != wantMethod {
t.Fatalf("elements=[method] changed=%v got=%q", changed, got)
}

// elements:[const] fixes only the const.
f = ModifierKeywords{}.WithConfig(map[string]any{"elements": []any{"const"}})
wantConst := "<?php class C { public const A = 1; static $b; static function c() {} }"
if got, changed := apply(t, f.(fixerRule), src); !changed || got != wantConst {
t.Fatalf("elements=[const] changed=%v got=%q", changed, got)
}
}
Loading
Loading