diff --git a/blink/internal/fixer/rules/blank_lines_before_namespace_config_test.go b/blink/internal/fixer/rules/blank_lines_before_namespace_config_test.go new file mode 100644 index 0000000000..bc94ed45a7 --- /dev/null +++ b/blink/internal/fixer/rules/blank_lines_before_namespace_config_test.go @@ -0,0 +1,26 @@ +package rules + +import "testing" + +func TestBlankLinesBeforeNamespaceConfig(t *testing.T) { + t.Parallel() + + src := " "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` @@ -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 @@ -89,12 +101,13 @@ 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() { @@ -102,7 +115,7 @@ func splitUseStatements(s *tokens.Stream, inClass bool) bool { i++ continue } - next, c := splitUseAt(s, i, inClass) + next, c := splitUseAt(s, i, inClass, expandGroups) i = next if c { changed = true @@ -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 } @@ -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 { @@ -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 ",": @@ -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 } @@ -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 diff --git a/blink/internal/fixer/rules/modifier_keywords.go b/blink/internal/fixer/rules/modifier_keywords.go index 01dfb266a6..5a367adc6f 100644 --- a/blink/internal/fixer/rules/modifier_keywords.go +++ b/blink/internal/fixer/rules/modifier_keywords.go @@ -4,6 +4,7 @@ import ( "slices" "strings" + "blink/internal/fixer" "blink/internal/token" "blink/internal/tokens" ) @@ -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` @@ -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 != "{" { @@ -38,7 +63,7 @@ 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 } } @@ -46,7 +71,21 @@ func (ModifierKeywords) Fix(s *tokens.Stream) bool { 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 := "" @@ -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" } diff --git a/blink/internal/fixer/rules/modifier_keywords_config_test.go b/blink/internal/fixer/rules/modifier_keywords_config_test.go new file mode 100644 index 0000000000..2b286fc4f4 --- /dev/null +++ b/blink/internal/fixer/rules/modifier_keywords_config_test.go @@ -0,0 +1,29 @@ +package rules + +import "testing" + +func TestModifierKeywordsConfig(t *testing.T) { + t.Parallel() + + src := ""/"?->" and "::" are moved too, // matching php-cs-fixer's getObjectOperatorKinds() + T_PAAMAYIM_NEKUDOTAYIM. -type OperatorLinebreak struct{} +// +// The `position` option places the operator at the beginning (default) or end of +// the line; `only_booleans` limits the set to the boolean operators. +type OperatorLinebreak struct { + // onlyBooleans limits the operator set to &&, ||, and, or, xor. + onlyBooleans bool + // position is "beginning" (the default when empty) or "end". + position string +} + +// operatorLinebreakBooleanPunct are the boolean operator tokens kept when +// only_booleans is set. +var operatorLinebreakBooleanPunct = map[string]bool{ + "||": true, "&&": true, +} // operatorLinebreakPunct are the always-unambiguous operator tokens. var operatorLinebreakPunct = map[string]bool{ @@ -40,7 +55,17 @@ func (OperatorLinebreak) SourceURL() string { return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/OperatorLinebreakFixer.php" } -func isOperatorLinebreakToken(t token.Token) bool { +func (f OperatorLinebreak) WithConfig(config map[string]any) fixer.Fixer { + if v, ok := config["only_booleans"].(bool); ok { + f.onlyBooleans = v + } + if v, ok := config["position"].(string); ok { + f.position = v + } + return f +} + +func (f OperatorLinebreak) isOperatorToken(t token.Token) bool { switch t.Kind { case token.Keyword: switch strings.ToLower(t.Value) { @@ -48,6 +73,9 @@ func isOperatorLinebreakToken(t token.Token) bool { return true } case token.Punct: + if f.onlyBooleans { + return operatorLinebreakBooleanPunct[t.Value] + } return operatorLinebreakPunct[t.Value] } return false @@ -75,11 +103,11 @@ func operatorLinebreakExcluded(s *tokens.Stream, i int, switchColons map[int]boo return false } -func (OperatorLinebreak) Fix(s *tokens.Stream) bool { +func (f OperatorLinebreak) Fix(s *tokens.Stream) bool { changed := false switchColons := switchCaseColons(s) for i := s.Len() - 1; i > 0; i-- { - if !isOperatorLinebreakToken(s.At(i)) { + if !f.isOperatorToken(s.At(i)) { continue } if operatorLinebreakExcluded(s, i, switchColons) { @@ -104,11 +132,20 @@ func (OperatorLinebreak) Fix(s *tokens.Stream) bool { if !operatorSpanMultiline(s, prevM+1, nextM-1) { continue } - // already at the beginning of a line if there is no newline after it - if !operatorSpanMultiline(s, hi, nextM-1) { - continue + + if f.position == "end" { + // already at the end of a line if there is no newline before it + if !operatorSpanMultiline(s, prevM+1, lo) { + continue + } + moveOperatorToLineEnd(s, opIndices, prevM) + } else { + // already at the beginning of a line if there is no newline after it + if !operatorSpanMultiline(s, hi, nextM-1) { + continue + } + moveOperatorToLineStart(s, opIndices, nextM) } - moveOperatorToLineStart(s, opIndices, nextM) changed = true i = lo // resume before the moved group } @@ -155,3 +192,30 @@ func moveOperatorToLineStart(s *tokens.Stream, opIndices []int, nextM int) { s.InsertAt(at, token.Token{Kind: token.Whitespace, Value: " "}) } } + +// moveOperatorToLineEnd mirrors fixMoveToTheEnd: it clears each operator (and the +// whitespace immediately after it) and re-inserts the operators just after the +// previous meaningful token, keeping a single trailing space when a space +// followed the group. +func moveOperatorToLineEnd(s *tokens.Stream, opIndices []int, prevM int) { + hi := opIndices[len(opIndices)-1] + hadSpaceAfter := hi+1 < s.Len() && s.At(hi+1).Kind == token.Whitespace + + clones := make([]token.Token, len(opIndices)) + for k, idx := range opIndices { + clones[k] = token.Token{Kind: s.At(idx).Kind, Value: s.At(idx).Value} + } + for _, idx := range opIndices { + if idx+1 < s.Len() && s.At(idx+1).Kind == token.Whitespace { + s.SetValue(idx+1, "") + } + s.SetValue(idx, "") + } + + at := prevM + 1 + if hadSpaceAfter { + s.InsertAt(at, token.Token{Kind: token.Whitespace, Value: " "}) + at++ + } + s.InsertSliceAt(at, clones) +} diff --git a/blink/internal/fixer/rules/operator_linebreak_config_test.go b/blink/internal/fixer/rules/operator_linebreak_config_test.go new file mode 100644 index 0000000000..f6ceed8519 --- /dev/null +++ b/blink/internal/fixer/rules/operator_linebreak_config_test.go @@ -0,0 +1,27 @@ +package rules + +import "testing" + +func TestOperatorLinebreakConfig(t *testing.T) { + t.Parallel() + + // position: end moves an operator from the start of a line to the end of the + // previous one. + src := "= 0 { + indent = prev.Value[idx+1:] + } + want := strings.Repeat("\n", target) + indent + if prev.Value != want { + s.SetValue(i-1, want) changed = true } } return changed } +func clampLineBreaks(n, minBreaks, maxBreaks int) int { + if n < minBreaks { + return minBreaks + } + if n > maxBreaks { + return maxBreaks + } + return n +} + +// configInt reads an int option (a JSON number arrives as float64), falling back +// to def when absent. +func configInt(config map[string]any, key string, def int) int { + switch n := config[key].(type) { + case float64: + return int(n) + case int: + return n + } + return def +} + // PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/NamespaceNotation/BlankLineAfterNamespaceFixer.php // // BlankLineAfterNamespace ensures one blank line after a "namespace X;"