From bfcf5892080501df078b02297b020fc842496817 Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Wed, 30 Sep 2026 00:14:59 +0200 Subject: [PATCH 1/2] Make 42 configurable fixers honor their ECS config options Wire per-fixer PHP-CS-Fixer options through the turbo/--blink path via the ConfigurableFixer interface: array, operator, spacing, casing, language, class, function, import, whitespace and control-structure fixers now read their options from the dumped ECS config, with full option semantics and unit tests. Zero-value structs keep today's behavior, so standalone parity stays 0/1699 and --blink parity is unchanged. PHPDoc fixers are deferred: honoring their options in a single pass diverges from ECS's iterate-to-fixpoint phpdoc handling, so they keep their tuned behavior. --- blink/internal/config/ecsconfig.go | 4 + blink/internal/fixer/rules/array_notation.go | 232 ++++++++++++++- .../fixer/rules/array_notation_config_test.go | 28 ++ .../fixer/rules/binary_operator_spaces.go | 168 ++++++++++- .../binary_operator_spaces_config_test.go | 25 ++ blink/internal/fixer/rules/blank_lines.go | 249 +++++++++++++++- .../fixer/rules/blank_lines_config_test.go | 25 ++ blink/internal/fixer/rules/braces_position.go | 109 +++++-- .../rules/braces_position_config_test.go | 25 ++ blink/internal/fixer/rules/casing.go | 22 +- .../fixer/rules/casing_config_test.go | 15 + blink/internal/fixer/rules/cast_spaces.go | 24 +- .../fixer/rules/cast_spaces_config_test.go | 22 ++ .../internal/fixer/rules/class_definition.go | 126 ++++++++- .../rules/class_definition_config_test.go | 35 +++ blink/internal/fixer/rules/class_elements.go | 53 +++- .../fixer/rules/class_elements_config_test.go | 22 ++ blink/internal/fixer/rules/concat_space.go | 52 +++- .../fixer/rules/concat_space_config_test.go | 22 ++ blink/internal/fixer/rules/constructs.go | 267 +++++++++++++++++- .../fixer/rules/constructs_config_test.go | 29 ++ .../rules/control_structure_continuation.go | 41 ++- ...trol_structure_continuation_config_test.go | 18 ++ blink/internal/fixer/rules/declare.go | 39 ++- .../fixer/rules/declare_config_test.go | 22 ++ .../fixer/rules/function_to_constant.go | 58 +++- .../rules/function_to_constant_config_test.go | 16 ++ blink/internal/fixer/rules/gen2_types.go | 103 ++++++- .../fixer/rules/gen2_types_config_test.go | 23 ++ blink/internal/fixer/rules/gen3_class_sep.go | 85 +++++- .../fixer/rules/gen3_class_sep_config_test.go | 23 ++ blink/internal/fixer/rules/gen3_func.go | 88 +++++- .../fixer/rules/gen3_func_config_test.go | 36 +++ blink/internal/fixer/rules/gen3_types.go | 175 +++++++++++- .../fixer/rules/gen3_types_config_test.go | 27 ++ blink/internal/fixer/rules/imports_order.go | 139 ++++++++- .../fixer/rules/imports_order_config_test.go | 37 +++ .../fixer/rules/method_argument_space.go | 120 +++++++- .../method_argument_space_config_test.go | 32 +++ .../rules/new_with_parentheses_config_test.go | 21 ++ .../fixer/rules/no_alternative_syntax.go | 34 ++- .../no_alternative_syntax_config_test.go | 18 ++ .../internal/fixer/rules/no_break_comment.go | 50 +++- .../rules/no_break_comment_config_test.go | 16 ++ .../fixer/rules/no_unneeded_braces.go | 73 ++++- .../rules/no_unneeded_braces_config_test.go | 15 + .../rules/no_unneeded_control_parentheses.go | 51 +++- ...nneeded_control_parentheses_config_test.go | 16 ++ .../no_useless_concat_operator_config_test.go | 15 + blink/internal/fixer/rules/offset.go | 39 ++- .../fixer/rules/offset_config_test.go | 23 ++ blink/internal/fixer/rules/percs.go | 26 +- blink/internal/fixer/rules/registry.go | 21 ++ .../fixer/rules/return_type_declaration.go | 41 ++- .../return_type_declaration_config_test.go | 18 ++ .../fixer/rules/space_after_semicolon.go | 54 +++- .../space_after_semicolon_config_test.go | 22 ++ .../fixer/rules/statement_indentation.go | 43 ++- .../statement_indentation_config_test.go | 20 ++ blink/internal/fixer/rules/string_notation.go | 19 +- .../rules/string_notation_config_test.go | 16 ++ blink/internal/fixer/rules/token_batch.go | 48 +++- blink/internal/fixer/rules/trailing_comma.go | 209 +++++++++++++- .../fixer/rules/trailing_comma_config_test.go | 30 ++ .../fixer/rules/visibility_required.go | 64 ++++- .../rules/visibility_required_config_test.go | 20 ++ blink/internal/fixer/rules/yoda_style.go | 227 +++++++++++++-- .../fixer/rules/yoda_style_config_test.go | 24 ++ 68 files changed, 3664 insertions(+), 245 deletions(-) create mode 100644 blink/internal/fixer/rules/array_notation_config_test.go create mode 100644 blink/internal/fixer/rules/binary_operator_spaces_config_test.go create mode 100644 blink/internal/fixer/rules/blank_lines_config_test.go create mode 100644 blink/internal/fixer/rules/braces_position_config_test.go create mode 100644 blink/internal/fixer/rules/casing_config_test.go create mode 100644 blink/internal/fixer/rules/cast_spaces_config_test.go create mode 100644 blink/internal/fixer/rules/class_definition_config_test.go create mode 100644 blink/internal/fixer/rules/class_elements_config_test.go create mode 100644 blink/internal/fixer/rules/concat_space_config_test.go create mode 100644 blink/internal/fixer/rules/constructs_config_test.go create mode 100644 blink/internal/fixer/rules/control_structure_continuation_config_test.go create mode 100644 blink/internal/fixer/rules/declare_config_test.go create mode 100644 blink/internal/fixer/rules/function_to_constant_config_test.go create mode 100644 blink/internal/fixer/rules/gen2_types_config_test.go create mode 100644 blink/internal/fixer/rules/gen3_class_sep_config_test.go create mode 100644 blink/internal/fixer/rules/gen3_func_config_test.go create mode 100644 blink/internal/fixer/rules/gen3_types_config_test.go create mode 100644 blink/internal/fixer/rules/imports_order_config_test.go create mode 100644 blink/internal/fixer/rules/method_argument_space_config_test.go create mode 100644 blink/internal/fixer/rules/new_with_parentheses_config_test.go create mode 100644 blink/internal/fixer/rules/no_alternative_syntax_config_test.go create mode 100644 blink/internal/fixer/rules/no_break_comment_config_test.go create mode 100644 blink/internal/fixer/rules/no_unneeded_braces_config_test.go create mode 100644 blink/internal/fixer/rules/no_unneeded_control_parentheses_config_test.go create mode 100644 blink/internal/fixer/rules/no_useless_concat_operator_config_test.go create mode 100644 blink/internal/fixer/rules/offset_config_test.go create mode 100644 blink/internal/fixer/rules/return_type_declaration_config_test.go create mode 100644 blink/internal/fixer/rules/space_after_semicolon_config_test.go create mode 100644 blink/internal/fixer/rules/statement_indentation_config_test.go create mode 100644 blink/internal/fixer/rules/string_notation_config_test.go create mode 100644 blink/internal/fixer/rules/trailing_comma_config_test.go create mode 100644 blink/internal/fixer/rules/visibility_required_config_test.go create mode 100644 blink/internal/fixer/rules/yoda_style_config_test.go diff --git a/blink/internal/config/ecsconfig.go b/blink/internal/config/ecsconfig.go index 5171ec3e62..d4fff6305a 100644 --- a/blink/internal/config/ecsconfig.go +++ b/blink/internal/config/ecsconfig.go @@ -126,6 +126,10 @@ func resolveECS(f ecsFile) (*Config, *ECSResolution, error) { } } + // apply fixers in the same canonical (priority) order as the standalone path, + // since the ECS dump lists them in set order, not execution order + rules.CanonicalOrder(config.Rules) + sort.Strings(resolution.Unsupported) sort.Strings(resolution.ConfigIgnored) sort.Strings(resolution.PerPathSkips) diff --git a/blink/internal/fixer/rules/array_notation.go b/blink/internal/fixer/rules/array_notation.go index fdf98c0bae..ba732982a4 100644 --- a/blink/internal/fixer/rules/array_notation.go +++ b/blink/internal/fixer/rules/array_notation.go @@ -3,6 +3,7 @@ package rules import ( "strings" + "blink/internal/fixer" "blink/internal/token" "blink/internal/tokens" ) @@ -43,10 +44,158 @@ func convertLongArray(s *tokens.Stream, name string) bool { return changed } +// arrayNotationStringList reads a string-list option (a []string or []any of strings). +func arrayNotationStringList(v any) ([]string, bool) { + switch list := v.(type) { + case []string: + return list, true + case []any: + out := make([]string, 0, len(list)) + for _, item := range list { + str, ok := item.(string) + if !ok { + return nil, false + } + out = append(out, str) + } + return out, true + } + return nil, false +} + +func arrayNotationContains(list []string, want string) bool { + for _, item := range list { + if item == want { + return true + } + } + return false +} + +func arrayNotationIsHeredoc(t token.Token) bool { + return t.Kind == token.String && strings.HasPrefix(t.Value, "<<<") +} + +// arrayNotationEnclosing returns the index of the innermost unclosed opener before i, or -1. +func arrayNotationEnclosing(s *tokens.Stream, i int) int { + depth := 0 + for j := i - 1; j >= 0; j-- { + if s.At(j).Kind != token.Punct { + continue + } + switch s.At(j).Value { + case ")", "]", "}": + depth++ + case "(", "[", "{": + if depth == 0 { + return j + } + depth-- + } + } + return -1 +} + +func arrayNotationIsAs(t token.Token) bool { + return (t.Kind == token.Keyword || t.Kind == token.Ident) && strings.EqualFold(t.Value, "as") +} + +// arrayNotationInForeachHead reports whether the token at i sits inside the +// "(... as ...)" head of a foreach, after the "as" keyword. +func arrayNotationInForeachHead(s *tokens.Stream, i int) bool { + depth := 0 + seenAs := false + for j := i - 1; j >= 0; j-- { + t := s.At(j) + if t.Kind == token.Punct { + switch t.Value { + case ")", "]", "}": + depth++ + case "(", "[", "{": + if depth > 0 { + depth-- + continue + } + if t.Value != "(" || !seenAs { + return false + } + p := sigPrev(s, j) + return p >= 0 && strings.EqualFold(s.At(p).Value, "foreach") + } + continue + } + if depth == 0 && arrayNotationIsAs(t) { + seenAs = true + } + } + return false +} + +// 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 { + 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 + } + } + if p := sigPrev(s, open); p >= 0 { + if arrayNotationIsAs(s.At(p)) { + return true + } + if s.At(p).Kind == token.Punct && s.At(p).Value == "=>" && arrayNotationInForeachHead(s, open) { + return true + } + } + enc := arrayNotationEnclosing(s, open) + if enc < 0 { + return false + } + switch s.At(enc).Value { + case "[": + return !isOffsetOpen(s, enc) && arrayNotationIsDestructuring(s, enc) + case "(": + q := sigPrev(s, enc) + return q >= 0 && strings.EqualFold(s.At(q).Value, "list") + } + return false +} + +// arrayNotationToLong rewrites short "[...]" to "array(...)" (literals) or +// "list(...)" (destructuring targets), depending on destructuring. +func arrayNotationToLong(s *tokens.Stream, destructuring bool) 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 { + continue + } + closeIdx := s.MatchForward(i) + if closeIdx < 0 { + continue + } + name := "array" + if destructuring { + name = "list" + } + s.SetValue(closeIdx, ")") + s.SetValue(i, "(") + s.InsertAt(i, token.Token{Kind: token.Keyword, Value: name}) + changed = true + } + return changed +} + // PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ArrayNotation/ArraySyntaxFixer.php // -// ArraySyntax rewrites long "array(...)" to the short "[...]" form. -type ArraySyntax struct{} +// ArraySyntax rewrites long "array(...)" to the short "[...]" form. With +// syntax=long it does the opposite. +type ArraySyntax struct { + Long bool +} func (ArraySyntax) Name() string { return `PhpCsFixer\Fixer\ArrayNotation\ArraySyntaxFixer` @@ -56,12 +205,27 @@ func (ArraySyntax) SourceURL() string { return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ArrayNotation/ArraySyntaxFixer.php" } -func (ArraySyntax) Fix(s *tokens.Stream) bool { return convertLongArray(s, "array") } +func (f ArraySyntax) WithConfig(config map[string]any) fixer.Fixer { + if v, ok := config["syntax"].(string); ok { + f.Long = v == "long" + } + return f +} + +func (f ArraySyntax) Fix(s *tokens.Stream) bool { + if f.Long { + return arrayNotationToLong(s, false) + } + return convertLongArray(s, "array") +} // PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ListNotation/ListSyntaxFixer.php // -// ListSyntax rewrites "list(...)" to the short "[...]" form. -type ListSyntax struct{} +// ListSyntax rewrites "list(...)" to the short "[...]" form. With syntax=long +// it does the opposite. +type ListSyntax struct { + Long bool +} func (ListSyntax) Name() string { return `PhpCsFixer\Fixer\ListNotation\ListSyntaxFixer` @@ -71,7 +235,19 @@ func (ListSyntax) SourceURL() string { return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ListNotation/ListSyntaxFixer.php" } -func (ListSyntax) Fix(s *tokens.Stream) bool { return convertLongArray(s, "list") } +func (f ListSyntax) WithConfig(config map[string]any) fixer.Fixer { + if v, ok := config["syntax"].(string); ok { + f.Long = v == "long" + } + return f +} + +func (f ListSyntax) Fix(s *tokens.Stream) bool { + if f.Long { + return arrayNotationToLong(s, true) + } + return convertLongArray(s, "list") +} // enclosingIsArray reports whether the innermost open bracket at index i is "[". func topBracketIsArray(stack []string) bool { @@ -81,8 +257,11 @@ func topBracketIsArray(stack []string) bool { // PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ArrayNotation/NoWhitespaceBeforeCommaInArrayFixer.php // // NoWhitespaceBeforeCommaInArray removes single-line whitespace before a comma -// inside an array. -type NoWhitespaceBeforeCommaInArray struct{} +// inside an array. SkipAfterHeredoc (after_heredoc=false) keeps the whitespace +// between a heredoc end and the comma. +type NoWhitespaceBeforeCommaInArray struct { + SkipAfterHeredoc bool +} func (NoWhitespaceBeforeCommaInArray) Name() string { return `PhpCsFixer\Fixer\ArrayNotation\NoWhitespaceBeforeCommaInArrayFixer` @@ -92,7 +271,14 @@ func (NoWhitespaceBeforeCommaInArray) SourceURL() string { return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ArrayNotation/NoWhitespaceBeforeCommaInArrayFixer.php" } -func (NoWhitespaceBeforeCommaInArray) Fix(s *tokens.Stream) bool { +func (f NoWhitespaceBeforeCommaInArray) WithConfig(config map[string]any) fixer.Fixer { + if v, ok := config["after_heredoc"].(bool); ok { + f.SkipAfterHeredoc = !v + } + return f +} + +func (f NoWhitespaceBeforeCommaInArray) Fix(s *tokens.Stream) bool { changed := false var stack []string for i := 0; i < s.Len(); i++ { @@ -108,6 +294,9 @@ func (NoWhitespaceBeforeCommaInArray) Fix(s *tokens.Stream) bool { case ",": if topBracketIsArray(stack) && i > 0 && s.At(i-1).Kind == token.Whitespace && !hasNewline(s.At(i-1).Value) { + if f.SkipAfterHeredoc && i > 1 && arrayNotationIsHeredoc(s.At(i-2)) { + continue + } s.RemoveAt(i - 1) i-- changed = true @@ -122,7 +311,10 @@ func (NoWhitespaceBeforeCommaInArray) Fix(s *tokens.Stream) bool { // // WhitespaceAfterCommaInArray ensures a single space after a comma inside an // array (unless a newline follows, or the comma is a trailing one before "]"). -type WhitespaceAfterCommaInArray struct{} +// EnsureSingleSpace also collapses horizontal whitespace after the comma to one space. +type WhitespaceAfterCommaInArray struct { + EnsureSingleSpace bool +} func (WhitespaceAfterCommaInArray) Name() string { return `PhpCsFixer\Fixer\ArrayNotation\WhitespaceAfterCommaInArrayFixer` @@ -132,7 +324,18 @@ func (WhitespaceAfterCommaInArray) SourceURL() string { return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ArrayNotation/WhitespaceAfterCommaInArrayFixer.php" } -func (WhitespaceAfterCommaInArray) Fix(s *tokens.Stream) bool { +func (f WhitespaceAfterCommaInArray) WithConfig(config map[string]any) fixer.Fixer { + if v, ok := config["ensure_single_space"].(bool); ok { + f.EnsureSingleSpace = v + } + return f +} + +func arrayNotationIsHorizontal(t token.Token) bool { + return t.Kind == token.Whitespace && t.Value != "" && strings.Trim(t.Value, " \t") == "" +} + +func (f WhitespaceAfterCommaInArray) Fix(s *tokens.Stream) bool { changed := false var stack []string for i := 0; i < s.Len(); i++ { @@ -153,6 +356,13 @@ func (WhitespaceAfterCommaInArray) Fix(s *tokens.Stream) bool { } next := s.At(i + 1) if next.Kind == token.Whitespace { + if f.EnsureSingleSpace && next.Value != " " && arrayNotationIsHorizontal(next) { + afterComment := i+2 < s.Len() && (s.At(i+2).Kind == token.Comment || s.At(i+2).Kind == token.DocComment) + if !afterComment || (i+3 < s.Len() && arrayNotationIsHorizontal(s.At(i+3))) { + s.SetValue(i+1, " ") + changed = true + } + } continue // already spaced or newline } if next.Kind == token.Punct && next.Value == "]" { diff --git a/blink/internal/fixer/rules/array_notation_config_test.go b/blink/internal/fixer/rules/array_notation_config_test.go new file mode 100644 index 0000000000..9f009b7153 --- /dev/null +++ b/blink/internal/fixer/rules/array_notation_config_test.go @@ -0,0 +1,28 @@ +package rules + +import "testing" + +func TestArrayNotationConfigCases(t *testing.T) { + cases := []struct { + name string + rule fixerRule + src string + want string + }{ + {"array default short", ArraySyntax{}, "": true, } -// BinaryOperatorSpaces normalizes spacing to a single space around binary -// operators (assignment "=", arrow "=>", comparison and logical operators). -// Whitespace spanning a newline is left alone to preserve alignment. Reference -// assignment ("=& $x") and declare() headers are skipped. -type BinaryOperatorSpaces struct{} +// BinaryOperatorSpaces normalizes spacing around binary operators (assignment +// "=", arrow "=>", comparison and logical operators). The zero value applies a +// single space to the default operator set. Whitespace spanning a newline is left +// alone to preserve alignment. Reference assignment ("=& $x") and declare() +// headers are skipped. +// +// Options: Default is the mode for operators without an override and Operators +// maps an operator to its own mode. Modes are "single", "no_space", +// "at_least_single" and "skip". The align_* modes and null are unsupported in a +// token model and map to "skip" (operator left untouched). +type BinaryOperatorSpaces struct { + Default string + Operators map[string]string +} func (BinaryOperatorSpaces) Name() string { return `PhpCsFixer\Fixer\Operator\BinaryOperatorSpacesFixer` @@ -30,16 +40,146 @@ func (BinaryOperatorSpaces) SourceURL() string { return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/BinaryOperatorSpacesFixer.php" } -func (BinaryOperatorSpaces) Fix(s *tokens.Stream) bool { - return normalizeSpaceAround(s, func(s *tokens.Stream, i int) bool { - t := s.At(i) - if t.Kind != token.Punct { +func (f BinaryOperatorSpaces) WithConfig(config map[string]any) fixer.Fixer { + if v, ok := config["default"]; ok { + f.Default = binaryOperatorSpacesMode(v) + } + if raw, ok := config["operators"].(map[string]any); ok { + f.Operators = make(map[string]string, len(raw)) + for op, v := range raw { + f.Operators[op] = binaryOperatorSpacesMode(v) + } + } + return f +} + +func binaryOperatorSpacesMode(v any) string { + str, _ := v.(string) + switch str { + case "single_space": + return "single" + case "no_space": + return "no_space" + case "at_least_single_space": + return "at_least_single" + } + return "skip" +} + +func (f BinaryOperatorSpaces) Fix(s *tokens.Stream) bool { + changed := false + i := 0 + for i < s.Len() { + mode := f.modeAt(s, i) + if mode == "" || mode == "skip" { + i++ + continue + } + var ch bool + i, ch = binaryOperatorSpacesApply(s, i, mode) + if ch { + changed = true + } + i++ + } + return changed +} + +// modeAt returns the spacing mode for the token at i, or "" when it is not a +// handled operator. +func (f BinaryOperatorSpaces) modeAt(s *tokens.Stream, i int) string { + t := s.At(i) + if t.Kind != token.Punct { + return "" + } + legacy := binaryOperatorSpacesDefaultTarget(s, i) + if m, ok := f.Operators[t.Value]; ok { + if legacy || binaryOperatorSpacesExtraTarget(s, i) { + return m + } + return "" + } + if !legacy { + return "" + } + if f.Default == "" { + return "single" + } + return f.Default +} + +func binaryOperatorSpacesDefaultTarget(s *tokens.Stream, i int) bool { + t := s.At(i) + if t.Kind != token.Punct { + return false + } + if t.Value == "=" { + // leave reference assignment ("=& $x") and declare(...) headers alone + return nextSignificantValue(s, i) != "&" && !insideDeclareArgs(s, i) + } + return binaryOperators[t.Value] +} + +// binaryOperatorSpacesExtraTarget covers operators outside the default set that +// may only be spaced when explicitly configured through the operators option. +func binaryOperatorSpacesExtraTarget(s *tokens.Stream, i int) bool { + t := s.At(i) + switch t.Value { + case "+=", "-=", "*=", "/=", ".=", "%=", "**=", "??=", "&=", "|=", "^=", "<<=", ">>=", + "**", "/", "%", "^", "<<", ">>": + return true + case "+", "-", "*": + prev, ok := prevSignificant(s, i) + return ok && (isOperand(prev) || prev.Kind == token.Number || prev.Kind == token.String) + case "&", "|": + prev, ok := prevSignificant(s, i) + if !ok || isTypeUnionOperator(s, i) { return false } - if t.Value == "=" { - // leave reference assignment ("=& $x") and declare(...) headers alone - return nextSignificantValue(s, i) != "&" && !insideDeclareArgs(s, i) + return prev.Kind == token.Variable || prev.Kind == token.Number || prev.Kind == token.String || + prev.Value == ")" || prev.Value == "]" + } + return false +} + +// binaryOperatorSpacesApply spaces the operator at i and returns its new index. +func binaryOperatorSpacesApply(s *tokens.Stream, i int, mode string) (int, bool) { + changed := false + if i > 0 { + prev := s.At(i - 1) + if prev.Kind == token.Whitespace { + switch { + case hasNewline(prev.Value): + case mode == "no_space": + s.RemoveAt(i - 1) + i-- + changed = true + case mode == "single" && prev.Value != " ": + s.SetValue(i-1, " ") + changed = true + } + } else if mode != "no_space" { + s.InsertAt(i, token.Token{Kind: token.Whitespace, Value: " "}) + i++ + changed = true + } + } + if i+1 < s.Len() { + next := s.At(i + 1) + if next.Kind == token.Whitespace { + switch { + case hasNewline(next.Value): + case mode == "no_space": + s.RemoveAt(i + 1) + changed = true + case mode == "single" && next.Value != " ": + s.SetValue(i+1, " ") + changed = true + } + } else if mode != "no_space" { + s.InsertAt(i+1, token.Token{Kind: token.Whitespace, Value: " "}) + changed = true } - return binaryOperators[t.Value] - }) + } + return i, changed } diff --git a/blink/internal/fixer/rules/binary_operator_spaces_config_test.go b/blink/internal/fixer/rules/binary_operator_spaces_config_test.go new file mode 100644 index 0000000000..314ab7c58e --- /dev/null +++ b/blink/internal/fixer/rules/binary_operator_spaces_config_test.go @@ -0,0 +1,25 @@ +package rules + +import "testing" + +func TestBinaryOperatorSpacesConfigCases(t *testing.T) { + cases := []struct { + name string + rule fixerRule + src string + want string + }{ + {"default single", BinaryOperatorSpaces{}, "2];", " 2];"}, + {"no space", BinaryOperatorSpaces{}.WithConfig(map[string]any{"default": "no_space"}).(fixerRule), " 2];", "2];"}, + {"operator override", BinaryOperatorSpaces{}.WithConfig(map[string]any{"default": "single_space", "operators": map[string]any{"=>": "no_space"}}).(fixerRule), " 2];", "2];"}, + {"align unsupported is a no-op", BinaryOperatorSpaces{}.WithConfig(map[string]any{"default": "align"}).(fixerRule), "2];", "2];"}, + {"extra operator via map", BinaryOperatorSpaces{}.WithConfig(map[string]any{"operators": map[string]any{"+=": "single_space"}}).(fixerRule), " 0; i-- { + t := s.At(i) + if t.Kind == token.Keyword && strings.EqualFold(t.Value, "function") && functionBraceKind(s, i) == braceClosure { + return false + } + if t.Kind == token.Keyword && strings.EqualFold(t.Value, "class") { + if prev, ok := prevSignificant(s, i); ok && prev.Kind == token.Keyword && strings.EqualFold(prev.Value, "new") { + return false + } + } + if t.Kind == token.Punct && t.Value == "[" && !isOffsetOpen(s, i) { + return false + } + if t.Kind == token.Whitespace && hasNewline(t.Value) { + break + } + } + return noExtraBlankLinesRemoveAfter(s, index) +} + +// noExtraBlankLinesRemoveAfter finds the whitespace that ends the line at index +// (respecting parentheses) and collapses its leading blank lines to one newline, +// keeping the trailing indentation. +func noExtraBlankLinesRemoveAfter(s *tokens.Stream, index int) bool { + depth := 0 + end := -1 + for i := index; i < s.Len(); i++ { + t := s.At(i) + if t.Kind == token.Punct { + switch t.Value { + case "(": + depth++ + continue + case ")": + depth-- + if depth < 0 { + return false + } + continue + case "}": + end = i + } + } + if end < 0 && t.Kind == token.Whitespace && hasNewline(t.Value) { + end = i + } + if end >= 0 { + break + } + } + if end < 0 { + return false + } + changed := false + for i := end; i < s.Len() && s.At(i).Kind == token.Whitespace; i++ { + v := s.At(i).Value + if !hasNewline(v) { + continue + } + nl := strings.LastIndexByte(v, '\n') + nv := "\n" + v[nl+1:] + if nv != v { + s.SetValue(i, nv) + changed = true + } + } + return changed +} + +// noExtraBlankLinesUse collapses blank lines between consecutive import (or, with +// wantTrait, trait) use statements. Closure use lists are never touched. +func noExtraBlankLinesUse(s *tokens.Stream, wantTrait bool) bool { + changed := false + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if t.Kind != token.Keyword || !strings.EqualFold(t.Value, "use") { + continue + } + if noExtraBlankLinesIsClosureUse(s, i) { + continue + } + if noExtraBlankLinesInClassBody(s, i) != wantTrait { + continue + } + semi := noExtraBlankLinesUseSemicolon(s, i) + if semi < 0 { + continue + } + n := nextSignificantIndex(s, semi) + if n < 0 || s.At(n).Kind != token.Keyword || !strings.EqualFold(s.At(n).Value, "use") { + continue + } + if noExtraBlankLinesIsClosureUse(s, n) || noExtraBlankLinesInClassBody(s, n) != wantTrait { + continue + } + if !rangeHasNewline(s, i, n) { + continue + } + if noExtraBlankLinesRemoveAfter(s, semi) { + changed = true + } + } + return changed +} + +// noExtraBlankLinesIsClosureUse reports whether the use at index opens a closure +// "use (...)" binding list rather than an import statement. +func noExtraBlankLinesIsClosureUse(s *tokens.Stream, index int) bool { + n := nextSignificantIndex(s, index) + return n >= 0 && s.At(n).Kind == token.Punct && s.At(n).Value == "(" +} + +// noExtraBlankLinesUseSemicolon returns the index of the ";" ending the use +// statement at index, or -1 if a close tag comes first. +func noExtraBlankLinesUseSemicolon(s *tokens.Stream, index int) int { + for j := index + 1; j < s.Len(); j++ { + t := s.At(j) + if t.Kind == token.CloseTag { + return -1 + } + if t.Kind == token.Punct && t.Value == ";" { + return j + } + } + return -1 +} + +// noExtraBlankLinesInClassBody reports whether index sits directly inside a +// class-like body (so a use there is a trait import, not a namespace import). +func noExtraBlankLinesInClassBody(s *tokens.Stream, index int) bool { + var stack []int + for j := range index { + if s.At(j).Kind != token.Punct { + continue + } + switch s.At(j).Value { + case "{": + stack = append(stack, j) + case "}": + if len(stack) > 0 { + stack = stack[:len(stack)-1] + } + } + } + if len(stack) == 0 { + return false + } + kind, _ := classifyBrace(s, stack[len(stack)-1]) + return kind == braceClassLike +} diff --git a/blink/internal/fixer/rules/blank_lines_config_test.go b/blink/internal/fixer/rules/blank_lines_config_test.go new file mode 100644 index 0000000000..8d2e541f1d --- /dev/null +++ b/blink/internal/fixer/rules/blank_lines_config_test.go @@ -0,0 +1,25 @@ +package rules + +import "testing" + +func TestNoExtraBlankLinesConfig(t *testing.T) { + // default: collapse runs of blank lines only + got, changed := apply(t, NoExtraBlankLines{}, "= 0 { if prev, ok := prevSignificant(s, kw); ok && prev.Kind == token.Keyword && strings.EqualFold(prev.Value, "new") { - nextLine = false - break + category = "anon_classes" } } - nextLine = true case braceFunctionDecl: - // PSR-12 keeps "){" on one line when the signature is multiline - nextLine = !funcSignatureMultiline(s, i) + category = "functions" + case braceClosure: + category = "anon_functions" case braceControl: - nextLine = false + category = "control" default: continue } + // an empty class/function body already collapsed to "{}" stays on its + // line - ECS keeps "class A {}" as-is (single_line_empty_body owns the + // collapse); only reflow a body with content + if (kind == braceClassLike || kind == braceFunctionDecl) && s.MatchForward(i) == i+1 { + continue + } + + nextLine, handle := f.wantNextLine(category, s, i) + if !handle { + continue + } + want := " " if nextLine { // depth-based so it stays correct regardless of the header's own @@ -79,6 +118,38 @@ func (BracesPosition) Fix(s *tokens.Stream) bool { return changed } +// wantNextLine resolves the configured position for a brace category to whether +// the "{" goes on the next line. handle is false for closures left at their +// default (blink does not move anonymous-function braces unless configured). +func (f BracesPosition) wantNextLine(category string, s *tokens.Stream, brace int) (nextLine, handle bool) { + var pos, def string + switch category { + case "classes": + pos, def = f.classesOpeningBrace, bracesNextLine + case "functions": + pos, def = f.functionsOpeningBrace, bracesNextLine + case "anon_classes": + pos, def = f.anonymousClassesOpeningBrace, bracesSameLine + case "control": + pos, def = f.controlStructuresOpeningBrace, bracesSameLine + case "anon_functions": + if f.anonymousFunctionsOpeningBrace == "" { + return false, false + } + pos, def = f.anonymousFunctionsOpeningBrace, bracesSameLine + default: + return false, false + } + if pos == "" { + pos = def + } + if pos == bracesSameLine { + return false, true + } + // next_line_unless_newline_at_signature_end + return !funcSignatureMultiline(s, brace), true +} + // funcSignatureMultiline reports whether the parameter list of the function // whose body opens at brace spans more than one line. func funcSignatureMultiline(s *tokens.Stream, brace int) bool { diff --git a/blink/internal/fixer/rules/braces_position_config_test.go b/blink/internal/fixer/rules/braces_position_config_test.go new file mode 100644 index 0000000000..12f2be1ff4 --- /dev/null +++ b/blink/internal/fixer/rules/braces_position_config_test.go @@ -0,0 +1,25 @@ +package rules + +import "testing" + +func TestBracesPositionConfig(t *testing.T) { + // default: a class body brace moves to the next line + got, changed := apply(t, BracesPosition{}, "true, Foo::null - a member name, not the constant } - if lower != t.Value { - s.SetValue(i, lower) + want := lower + if f.Upper { + want = strings.ToUpper(lower) + } + if want != t.Value { + s.SetValue(i, want) changed = true } } diff --git a/blink/internal/fixer/rules/casing_config_test.go b/blink/internal/fixer/rules/casing_config_test.go new file mode 100644 index 0000000000..8ae7e7daee --- /dev/null +++ b/blink/internal/fixer/rules/casing_config_test.go @@ -0,0 +1,15 @@ +package rules + +import "testing" + +func TestConstantCaseConfig(t *testing.T) { + got, changed := apply(t, ConstantCase{}, "= 0 && (s.At(j-1).Kind == token.DocComment || isAttribute(s.At(j-1))) { + continue + } + if j+1 < s.Len() && (s.At(j+1).Kind == token.DocComment || isAttribute(s.At(j+1))) { + continue + } + s.SetValue(j, " ") + changed = true + } + return changed +} + +// classDefTopLevelCommas counts commas at depth zero between from and to. +func classDefTopLevelCommas(s *tokens.Stream, from, to int) int { + depth, count := 0, 0 + for j := from; j < to && j < s.Len(); j++ { + t := s.At(j) + if t.Kind != token.Punct { + continue + } + switch t.Value { + case "(", "[": + depth++ + case ")", "]": + depth-- + case ",": + if depth == 0 { + count++ + } + } + } + return count +} + // normalizeHeaderSpacing collapses space runs to one after the class-like // keyword at kw and around extends/implements, up to the opening "{". func normalizeHeaderSpacing(s *tokens.Stream, kw int) bool { diff --git a/blink/internal/fixer/rules/class_definition_config_test.go b/blink/internal/fixer/rules/class_definition_config_test.go new file mode 100644 index 0000000000..f2ea9bce78 --- /dev/null +++ b/blink/internal/fixer/rules/class_definition_config_test.go @@ -0,0 +1,35 @@ +package rules + +import "testing" + +func TestClassDefinitionSingleLine(t *testing.T) { + src := " "public $a;\npublic $b;". -type SingleClassElementPerStatement struct{} +// +// elements mirrors the "elements" option (subset of const/property); a nil value +// (the zero value) means both, matching the default. +type SingleClassElementPerStatement struct { + elements []string +} + +func (f SingleClassElementPerStatement) WithConfig(config map[string]any) fixer.Fixer { + if raw, ok := config["elements"]; ok { + f.elements = singleElementConfigStrings(raw) + } + return f +} + +// singleElementConfigStrings converts a config list ([]any or []string) to []string. +func singleElementConfigStrings(raw any) []string { + switch v := raw.(type) { + case []string: + return v + case []any: + out := make([]string, 0, len(v)) + for _, e := range v { + if sv, ok := e.(string); ok { + out = append(out, sv) + } + } + return out + } + return nil +} + +func (f SingleClassElementPerStatement) singleElementEnabled(kind string) bool { + if f.elements == nil { + return true // default: both const and property + } + return slices.Contains(f.elements, kind) +} func (SingleClassElementPerStatement) Name() string { return `PhpCsFixer\Fixer\ClassNotation\SingleClassElementPerStatementFixer` @@ -22,7 +59,7 @@ func (SingleClassElementPerStatement) SourceURL() string { return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/SingleClassElementPerStatementFixer.php" } -func (SingleClassElementPerStatement) Fix(s *tokens.Stream) bool { +func (f SingleClassElementPerStatement) 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 != "{" { @@ -32,7 +69,7 @@ func (SingleClassElementPerStatement) Fix(s *tokens.Stream) bool { continue } for _, m := range slices.Backward(classMemberStarts(s, i)) { - if splitClassElement(s, m) { + if f.splitClassElement(s, m) { changed = true } } @@ -40,7 +77,7 @@ func (SingleClassElementPerStatement) Fix(s *tokens.Stream) bool { return changed } -func splitClassElement(s *tokens.Stream, m int) bool { +func (f SingleClassElementPerStatement) splitClassElement(s *tokens.Stream, m int) bool { // step over leading modifiers k := m for k < s.Len() && s.At(k).Kind == token.Keyword && memberModifiers[strings.ToLower(s.At(k).Value)] { @@ -60,6 +97,14 @@ func splitClassElement(s *tokens.Stream, m int) bool { } } + if isConst { + if !f.singleElementEnabled("const") { + return false + } + } else if !f.singleElementEnabled("property") { + return false + } + semi := memberEndSemi(s, m) if semi < 0 { return false diff --git a/blink/internal/fixer/rules/class_elements_config_test.go b/blink/internal/fixer/rules/class_elements_config_test.go new file mode 100644 index 0000000000..dfe2cbfdd7 --- /dev/null +++ b/blink/internal/fixer/rules/class_elements_config_test.go @@ -0,0 +1,22 @@ +package rules + +import "testing" + +func TestSingleClassElementPerStatementElements(t *testing.T) { + src := "= 0; i-- { + if !isConcat(s, i) { + continue + } + if i+1 < s.Len() && s.At(i+1).Kind == token.Whitespace && !hasNewline(s.At(i+1).Value) && + !concatSpaceKeepsSpace(s, nextSignificantIndex(s, i)) { + s.RemoveAt(i + 1) + changed = true + } + if i >= 1 && s.At(i-1).Kind == token.Whitespace && !hasNewline(s.At(i-1).Value) && + !concatSpaceKeepsSpace(s, prevSignificantIndex(s, i)) { + s.RemoveAt(i - 1) + changed = true + } + } + return changed +} + +// concatSpaceKeepsSpace reports whether the operand at idx must stay spaced from +// "." (a number would lex as a float, a comment would swallow the operator). +func concatSpaceKeepsSpace(s *tokens.Stream, idx int) bool { + if idx < 0 { + return false + } + k := s.At(idx).Kind + return k == token.Number || k == token.Comment || k == token.DocComment } diff --git a/blink/internal/fixer/rules/concat_space_config_test.go b/blink/internal/fixer/rules/concat_space_config_test.go new file mode 100644 index 0000000000..e3d1e8e871 --- /dev/null +++ b/blink/internal/fixer/rules/concat_space_config_test.go @@ -0,0 +1,22 @@ +package rules + +import "testing" + +func TestConcatSpaceConfigCases(t *testing.T) { + cases := []struct { + name string + rule fixerRule + src string + want string + }{ + {"default one", ConcatSpace{}, " "if (", "else{" -> "else {"). -type SingleSpaceAroundConstruct struct{} +// keyword ("if(" -> "if (", "else{" -> "else {"). The zero value uses the +// built-in keyword set. Options: Followed replaces the keywords followed by a +// single space, Preceded lists keywords ("as", "else", "elseif", "use_lambda") +// preceded by one, YieldFrom collapses whitespace inside "yield from". +type SingleSpaceAroundConstruct struct { + Followed map[string]bool + Preceded map[string]bool + YieldFrom bool +} func (SingleSpaceAroundConstruct) Name() string { return `PhpCsFixer\Fixer\LanguageConstruct\SingleSpaceAroundConstructFixer` @@ -32,12 +56,108 @@ func (SingleSpaceAroundConstruct) SourceURL() string { return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/LanguageConstruct/SingleSpaceAroundConstructFixer.php" } -func (SingleSpaceAroundConstruct) Fix(s *tokens.Stream) bool { +func (f SingleSpaceAroundConstruct) WithConfig(config map[string]any) fixer.Fixer { + if list, ok := singleSpaceStringList(config["constructs_followed_by_a_single_space"]); ok { + f.Followed = map[string]bool{} + for _, name := range list { + if singleSpaceKnownKeywords[name] { + f.Followed[name] = true + } + } + } + if list, ok := singleSpaceStringList(config["constructs_preceded_by_a_single_space"]); ok { + f.Preceded = map[string]bool{} + for _, name := range list { + f.Preceded[name] = true + } + } + if list, ok := singleSpaceStringList(config["constructs_contain_a_single_space"]); ok { + f.YieldFrom = false + for _, name := range list { + if name == "yield_from" { + f.YieldFrom = true + } + } + } + return f +} + +func singleSpaceStringList(v any) ([]string, bool) { + switch list := v.(type) { + case []string: + return list, true + case []any: + out := make([]string, 0, len(list)) + for _, item := range list { + if str, ok := item.(string); ok { + out = append(out, str) + } + } + return out, true + } + return nil, false +} + +func (f SingleSpaceAroundConstruct) followedKeyword(kw string) bool { + if f.Followed != nil { + return f.Followed[kw] + } + return constructKeywords[kw] +} + +func (f SingleSpaceAroundConstruct) fixYieldFrom(s *tokens.Stream) bool { changed := false + for i := s.Len() - 3; i >= 0; i-- { + t := s.At(i) + if t.Kind != token.Keyword || strings.ToLower(t.Value) != "yield" || + s.At(i+1).Kind != token.Whitespace || s.At(i+1).Value == " " { + continue + } + if n := s.At(i + 2); (n.Kind == token.Ident || n.Kind == token.Keyword) && strings.ToLower(n.Value) == "from" { + s.SetValue(i+1, " ") + changed = true + } + } + return changed +} + +func (f SingleSpaceAroundConstruct) fixPreceded(s *tokens.Stream) bool { + changed := false + for i := s.Len() - 1; i > 0; i-- { + t := s.At(i) + if t.Kind != token.Keyword { + continue + } + kw := strings.ToLower(t.Value) + if !f.Preceded[kw] && !(kw == "use" && f.Preceded["use_lambda"] && nextSignificantValue(s, i) == "(") { + continue + } + prev := s.At(i - 1) + if prev.Kind == token.Whitespace { + if !hasNewline(prev.Value) && prev.Value != " " { + s.SetValue(i-1, " ") + changed = true + } + continue + } + s.InsertAt(i, token.Token{Kind: token.Whitespace, Value: " "}) + changed = true + } + return changed +} + +func (f SingleSpaceAroundConstruct) Fix(s *tokens.Stream) bool { + changed := false + if f.YieldFrom && f.fixYieldFrom(s) { + changed = true + } + if len(f.Preceded) > 0 && f.fixPreceded(s) { + changed = true + } i := 0 for i < s.Len() { t := s.At(i) - if t.Kind != token.Keyword || !constructKeywords[strings.ToLower(t.Value)] || i+1 >= s.Len() { + if t.Kind != token.Keyword || !f.followedKeyword(strings.ToLower(t.Value)) || i+1 >= s.Len() { i++ continue } @@ -113,8 +233,12 @@ func (NoSpacesAfterFunctionName) Fix(s *tokens.Stream) bool { // PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/SpacesInsideParenthesesFixer.php // // NoSpacesInsideParenthesis removes single-line whitespace just inside -// parentheses ("( $a )" -> "($a)"). Newlines are kept for multi-line calls. -type NoSpacesInsideParenthesis struct{} +// parentheses ("( $a )" -> "($a)"). Newlines are kept for multi-line calls. With +// Single set (space: single) it forces one space inside instead ("($a)" -> +// "( $a )"); empty parentheses lose their inner whitespace and casts are skipped. +type NoSpacesInsideParenthesis struct { + Single bool +} func (NoSpacesInsideParenthesis) Name() string { return `PhpCsFixer\Fixer\Whitespace\SpacesInsideParenthesesFixer` @@ -124,7 +248,17 @@ func (NoSpacesInsideParenthesis) SourceURL() string { return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/SpacesInsideParenthesesFixer.php" } -func (NoSpacesInsideParenthesis) Fix(s *tokens.Stream) bool { +func (f NoSpacesInsideParenthesis) WithConfig(config map[string]any) fixer.Fixer { + if v, ok := config["space"].(string); ok { + f.Single = v == "single" + } + return f +} + +func (f NoSpacesInsideParenthesis) Fix(s *tokens.Stream) bool { + if f.Single { + return spacesInsideParenthesesSingle(s) + } changed := false for i := 0; i < s.Len(); i++ { t := s.At(i) @@ -143,11 +277,86 @@ func (NoSpacesInsideParenthesis) Fix(s *tokens.Stream) bool { return changed } +// spacesInsideParenthesesSingle walks right to left so insertions never shift +// the indices still to be visited. +func spacesInsideParenthesesSingle(s *tokens.Stream) bool { + changed := false + for i := s.Len() - 1; i >= 0; i-- { + t := s.At(i) + if t.Kind != token.Punct { + continue + } + switch t.Value { + case ")": + p := prevSignificantIndex(s, i) + if p < 0 || spacesInsideParenthesesIsCastClose(s, i, p) { + continue + } + if s.At(p).Kind == token.Punct && s.At(p).Value == "(" { + if p != i-1 && !hasNewline(s.At(i-1).Value) { + s.RemoveAt(i - 1) + changed = true + } + continue + } + if spacesInsideParenthesesEdge(s, i-1, i, p) { + changed = true + } + case "(": + if _, _, isCast := castAt(s, i); isCast { + continue + } + n := nextSignificantIndex(s, i) + if n < 0 || (s.At(n).Kind == token.Punct && s.At(n).Value == ")") { + continue + } + if spacesInsideParenthesesEdge(s, i+1, i+1, n) { + changed = true + } + } + } + return changed +} + +func spacesInsideParenthesesIsCastClose(s *tokens.Stream, closeIdx, typeIdx int) bool { + if !isCastType(s.At(typeIdx)) { + return false + } + o := prevSignificantIndex(s, typeIdx) + if o < 0 { + return false + } + _, c, ok := castAt(s, o) + return ok && c == closeIdx +} + +// spacesInsideParenthesesEdge forces a single space at wsIdx, the slot between a +// parenthesis and the inner token at innerIdx; insertAt is where a missing space +// goes. A whitespace containing a newline, or one before a comment, is kept. +func spacesInsideParenthesesEdge(s *tokens.Stream, wsIdx, insertAt, innerIdx int) bool { + if wsIdx >= 0 && wsIdx < s.Len() && s.At(wsIdx).Kind == token.Whitespace { + ws := s.At(wsIdx).Value + if ws == " " || hasNewline(ws) { + return false + } + if k := s.At(innerIdx).Kind; k == token.Comment || k == token.DocComment { + return false + } + s.SetValue(wsIdx, " ") + return true + } + s.InsertAt(insertAt, token.Token{Kind: token.Whitespace, Value: " "}) + return true +} + // PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/UnaryOperatorSpacesFixer.php // // UnaryOperatorSpaces removes whitespace between an operand and ++ or -- -// ("$i ++" -> "$i++"). -type UnaryOperatorSpaces struct{} +// ("$i ++" -> "$i++"). With Full set (only_dec_inc: false) it also removes the +// whitespace after the unary "!", "~", "@", "+" and "-" ("! $a" -> "!$a"). +type UnaryOperatorSpaces struct { + Full bool +} func (UnaryOperatorSpaces) Name() string { return `PhpCsFixer\Fixer\Operator\UnaryOperatorSpacesFixer` @@ -157,10 +366,48 @@ func (UnaryOperatorSpaces) SourceURL() string { return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/UnaryOperatorSpacesFixer.php" } -func (UnaryOperatorSpaces) Fix(s *tokens.Stream) bool { +func (f UnaryOperatorSpaces) WithConfig(config map[string]any) fixer.Fixer { + if v, ok := config["only_dec_inc"].(bool); ok { + f.Full = !v + } + return f +} + +// unaryOperatorSpacesIsPredecessor reports whether the "!", "~", "@", "+" or "-" +// at i is a prefix (unary) operator. +func unaryOperatorSpacesIsPredecessor(s *tokens.Stream, i int) bool { + switch s.At(i).Value { + case "!", "~", "@": + return true + case "+", "-": + prev, ok := prevSignificant(s, i) + if !ok { + return true + } + switch prev.Kind { + case token.Variable, token.Ident, token.Number, token.String: + return false + } + switch prev.Value { + case ")", "]", "}", "++", "--": + return false + } + return true + } + return false +} + +func (f UnaryOperatorSpaces) Fix(s *tokens.Stream) bool { changed := false for i := 0; i < s.Len(); i++ { t := s.At(i) + if f.Full && t.Kind == token.Punct && unaryOperatorSpacesIsPredecessor(s, i) { + if i+1 < s.Len() && s.At(i+1).Kind == token.Whitespace && !hasNewline(s.At(i+1).Value) { + s.RemoveAt(i + 1) + changed = true + } + continue + } if t.Kind != token.Punct || (t.Value != "++" && t.Value != "--") { continue } diff --git a/blink/internal/fixer/rules/constructs_config_test.go b/blink/internal/fixer/rules/constructs_config_test.go new file mode 100644 index 0000000000..a4dca68ae7 --- /dev/null +++ b/blink/internal/fixer/rules/constructs_config_test.go @@ -0,0 +1,29 @@ +package rules + +import "testing" + +func TestConstructsConfigCases(t *testing.T) { + cases := []struct { + name string + rule fixerRule + src string + want string + }{ + {"followed default", SingleSpaceAroundConstruct{}, " "declare(strict_types=1)". -type DeclareEqualNormalize struct{} +// "declare(strict_types = 1)" -> "declare(strict_types=1)". With Single set +// (space: single) exactly one space is forced around it instead. +type DeclareEqualNormalize struct { + Single bool +} func (DeclareEqualNormalize) Name() string { return `PhpCsFixer\Fixer\LanguageConstruct\DeclareEqualNormalizeFixer` @@ -19,13 +23,42 @@ func (DeclareEqualNormalize) SourceURL() string { return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/LanguageConstruct/DeclareEqualNormalizeFixer.php" } -func (DeclareEqualNormalize) Fix(s *tokens.Stream) bool { +func (f DeclareEqualNormalize) WithConfig(config map[string]any) fixer.Fixer { + if v, ok := config["space"].(string); ok { + f.Single = v == "single" + } + return f +} + +func (f DeclareEqualNormalize) Fix(s *tokens.Stream) bool { changed := false for i := 0; i < s.Len(); i++ { t := s.At(i) if t.Kind != token.Punct || t.Value != "=" || !insideDeclareArgs(s, i) { continue } + if f.Single { + if i+1 < s.Len() && s.At(i+1).Kind == token.Whitespace { + if s.At(i+1).Value != " " { + s.SetValue(i+1, " ") + changed = true + } + } else { + s.InsertAt(i+1, token.Token{Kind: token.Whitespace, Value: " "}) + changed = true + } + if i >= 1 && s.At(i-1).Kind == token.Whitespace { + if s.At(i-1).Value != " " { + s.SetValue(i-1, " ") + changed = true + } + } else { + s.InsertAt(i, token.Token{Kind: token.Whitespace, Value: " "}) + i++ + changed = true + } + continue + } if i+1 < s.Len() && s.At(i+1).Kind == token.Whitespace { s.RemoveAt(i + 1) changed = true diff --git a/blink/internal/fixer/rules/declare_config_test.go b/blink/internal/fixer/rules/declare_config_test.go new file mode 100644 index 0000000000..d5252e0cfb --- /dev/null +++ b/blink/internal/fixer/rules/declare_config_test.go @@ -0,0 +1,22 @@ +package rules + +import "testing" + +func TestDeclareEqualNormalizeConfigCases(t *testing.T) { + cases := []struct { + name string + rule fixerRule + src string + want string + }{ + {"default none", DeclareEqualNormalize{}, " M_PI, phpversion() -> PHP_VERSION, php_sapi_name() -> // PHP_SAPI, get_called_class() -> static::class, get_class($this) -> // static::class. -type FunctionToConstant struct{} +type FunctionToConstant struct { + // Functions limits the converted functions; nil means the built-in default set. + Functions []string +} + +func (f FunctionToConstant) WithConfig(config map[string]any) fixer.Fixer { + switch list := config["functions"].(type) { + case []string: + f.Functions = append([]string{}, list...) + case []any: + f.Functions = []string{} + for _, v := range list { + if name, ok := v.(string); ok { + f.Functions = append(f.Functions, name) + } + } + } + return f +} + +// functionToConstantEnabled reports whether the option key is enabled. +func (f FunctionToConstant) enabled(key string) bool { + if f.Functions == nil { + return true + } + for _, name := range f.Functions { + if name == key { + return true + } + } + return false +} func (FunctionToConstant) Name() string { return `PhpCsFixer\Fixer\LanguageConstruct\FunctionToConstantFixer` @@ -29,7 +61,7 @@ var noArgConstants = map[string]string{ "php_sapi_name": "PHP_SAPI", } -func (FunctionToConstant) Fix(s *tokens.Stream) bool { +func (f FunctionToConstant) Fix(s *tokens.Stream) bool { changed := false for i := 0; i < s.Len(); i++ { if s.At(i).Kind != token.Ident { @@ -40,6 +72,9 @@ func (FunctionToConstant) Fix(s *tokens.Stream) bool { if !isNoArg && name != "get_called_class" && name != "get_class" { continue } + if name != "get_class" && !f.enabled(name) { + continue + } // a plain function call: not a method/static call, not a declaration, // and optionally prefixed by a single leading "\" start := i @@ -79,6 +114,17 @@ func (FunctionToConstant) Fix(s *tokens.Stream) bool { repl = staticClassTokens() case name == "get_class": arg := nextSignificantIndex(s, open) + if arg == closeIdx { + // get_class() without arguments -> self::class, only when opted in + if f.Functions == nil || !f.enabled("get_class") { + continue + } + repl = functionToConstantSelfClassTokens() + break + } + if !f.enabled("get_class_this") { + continue + } if arg < 0 || s.At(arg).Kind != token.Variable || !strings.EqualFold(s.At(arg).Value, "$this") { continue } @@ -101,3 +147,11 @@ func staticClassTokens() []token.Token { {Kind: token.Ident, Value: "class"}, } } + +func functionToConstantSelfClassTokens() []token.Token { + return []token.Token{ + {Kind: token.Keyword, Value: "self"}, + {Kind: token.Punct, Value: "::"}, + {Kind: token.Ident, Value: "class"}, + } +} diff --git a/blink/internal/fixer/rules/function_to_constant_config_test.go b/blink/internal/fixer/rules/function_to_constant_config_test.go new file mode 100644 index 0000000000..8767c38ec8 --- /dev/null +++ b/blink/internal/fixer/rules/function_to_constant_config_test.go @@ -0,0 +1,16 @@ +package rules + +import "testing" + +func TestFunctionToConstantConfig(t *testing.T) { + src := " "int|string", "A & B" -> // "A&B"). It acts only in confirmed type positions (parameter, return, typed // property) and never on a bitwise "|"/"&". Line breaks are preserved. -type TypesSpaces struct{} +// +// Options: Single (space: single) forces one space around the operator instead. +// Once WithConfig is called, multi-catch types ("catch (A | B $e)") are handled +// too, using CatchSingle (space_multiple_catch) or, when unset, Single. +type TypesSpaces struct { + Single bool + CatchActive bool + CatchSingle bool +} func (TypesSpaces) Name() string { return `PhpCsFixer\Fixer\Whitespace\TypesSpacesFixer` @@ -130,25 +139,103 @@ func (TypesSpaces) SourceURL() string { return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/TypesSpacesFixer.php" } -func (TypesSpaces) Fix(s *tokens.Stream) bool { +func (f TypesSpaces) WithConfig(config map[string]any) fixer.Fixer { + f.CatchActive = true + if v, ok := config["space"].(string); ok { + f.Single = v == "single" + } + f.CatchSingle = f.Single + if v, ok := config["space_multiple_catch"].(string); ok { + f.CatchSingle = v == "single" + } + return f +} + +func (f TypesSpaces) Fix(s *tokens.Stream) bool { changed := false for i := 0; i < s.Len(); i++ { t := s.At(i) + if f.CatchActive && t.Kind == token.Keyword && strings.EqualFold(t.Value, "catch") { + if ch, end := typesSpacesCatch(s, i, f.CatchSingle); end > i { + changed = changed || ch + i = end + continue + } + } if t.Kind != token.Punct || (t.Value != "|" && t.Value != "&") { continue } if !isTypeUnionOperator(s, i) { continue } - if i+1 < s.Len() && s.At(i+1).Kind == token.Whitespace && !hasNewline(s.At(i+1).Value) { - s.RemoveAt(i + 1) + var ch bool + i, ch = typesSpacesFix(s, i, f.Single) + changed = changed || ch + } + return changed +} + +// typesSpacesFix spaces the operator at i and returns its new index. +func typesSpacesFix(s *tokens.Stream, i int, single bool) (int, bool) { + changed := false + if i+1 < s.Len() { + next := s.At(i + 1) + switch { + case next.Kind == token.Whitespace && !hasNewline(next.Value): + if !single { + s.RemoveAt(i + 1) + changed = true + } else if next.Value != " " { + s.SetValue(i+1, " ") + changed = true + } + case next.Kind != token.Whitespace && single: + s.InsertAt(i+1, token.Token{Kind: token.Whitespace, Value: " "}) changed = true } - if i-1 >= 0 && s.At(i-1).Kind == token.Whitespace && !hasNewline(s.At(i-1).Value) { - s.RemoveAt(i - 1) - i-- + } + if i >= 1 { + prev := s.At(i - 1) + switch { + case prev.Kind == token.Whitespace && !hasNewline(prev.Value): + if !single { + s.RemoveAt(i - 1) + i-- + changed = true + } else if prev.Value != " " { + s.SetValue(i-1, " ") + changed = true + } + case prev.Kind != token.Whitespace && single: + s.InsertAt(i, token.Token{Kind: token.Whitespace, Value: " "}) + i++ changed = true } } - return changed + return i, changed +} + +// typesSpacesCatch spaces every "|" directly inside the parentheses of the catch +// at i and returns whether anything changed and the index of the closing ")". +func typesSpacesCatch(s *tokens.Stream, i int, single bool) (bool, int) { + open := nextSignificantIndex(s, i) + if open < 0 || s.At(open).Kind != token.Punct || s.At(open).Value != "(" { + return false, i + } + changed := false + for j := open + 1; j < s.Len(); j++ { + t := s.At(j) + if t.Kind != token.Punct { + continue + } + switch t.Value { + case ")": + return changed, j + case "|": + var ch bool + j, ch = typesSpacesFix(s, j, single) + changed = changed || ch + } + } + return changed, i } diff --git a/blink/internal/fixer/rules/gen2_types_config_test.go b/blink/internal/fixer/rules/gen2_types_config_test.go new file mode 100644 index 0000000000..fb63f6d070 --- /dev/null +++ b/blink/internal/fixer/rules/gen2_types_config_test.go @@ -0,0 +1,23 @@ +package rules + +import "testing" + +func TestTypesSpacesConfigCases(t *testing.T) { + cases := []struct { + name string + rule fixerRule + src string + want string + }{ + {"default none", TypesSpaces{}, " one|none|only_if_meta); a +// nil value (the zero value) means the default map below. A member type absent +// from the map is left untouched, matching PHP-CS-Fixer. +type ClassAttributesSeparation struct { + elements map[string]string +} + +var classSepDefaultSpacing = map[string]string{ + "const": "one", "method": "one", "property": "one", + "trait_import": "none", "case": "none", +} + +func (f ClassAttributesSeparation) WithConfig(config map[string]any) fixer.Fixer { + if raw, ok := config["elements"].(map[string]any); ok { + m := make(map[string]string, len(raw)) + for k, v := range raw { + if sv, ok := v.(string); ok { + m[k] = sv + } + } + f.elements = m + } + return f +} + +// classSepTypeName maps a sep* member kind to its option element name. +func classSepTypeName(sepType int) string { + switch sepType { + case sepConst: + return "const" + case sepMethod: + return "method" + case sepProperty: + return "property" + case sepTraitImport: + return "trait_import" + case sepCase: + return "case" + } + return "" +} + +// classSepSpacingFor returns the configured spacing for a member kind and whether +// that kind is targeted at all. +func (f ClassAttributesSeparation) classSepSpacingFor(sepType int) (string, bool) { + name := classSepTypeName(sepType) + if name == "" { + return "", false + } + if f.elements == nil { + return classSepDefaultSpacing[name], true + } + sp, ok := f.elements[name] + return sp, ok +} func (ClassAttributesSeparation) Name() string { return `PhpCsFixer\Fixer\ClassNotation\ClassAttributesSeparationFixer` @@ -44,7 +100,7 @@ func (ClassAttributesSeparation) SourceURL() string { // Gaps that glue members on one line, contain plain comments, or hold a detached // docblock are left untouched. The first member (after "{") and the last member's // gap to "}" are left to the blank-line-after-opening / brace fixers. -func (ClassAttributesSeparation) Fix(s *tokens.Stream) bool { +func (f ClassAttributesSeparation) 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 != "{" { @@ -55,7 +111,7 @@ func (ClassAttributesSeparation) Fix(s *tokens.Stream) bool { } starts := classMemberStarts(s, i) for k := 0; k+1 < len(starts); k++ { - if fixMemberGap(s, starts[k], starts[k+1]) { + if f.fixMemberGap(s, starts[k], starts[k+1]) { changed = true } } @@ -66,7 +122,7 @@ func (ClassAttributesSeparation) Fix(s *tokens.Stream) bool { // fixMemberGap normalizes the whitespace separating the member at prevStart from // the one at nextStart. Only whitespace values change, so token indices stay // stable across calls. Returns whether it changed anything. -func fixMemberGap(s *tokens.Stream, prevStart, nextStart int) bool { +func (f ClassAttributesSeparation) fixMemberGap(s *tokens.Stream, prevStart, nextStart int) bool { prevEnd := memberSpanEnd(s, prevStart) if prevEnd < 0 || prevEnd >= nextStart { return false @@ -75,6 +131,10 @@ func fixMemberGap(s *tokens.Stream, prevStart, nextStart int) bool { if nextType == sepUnknown { return false } + spacing, ok := f.classSepSpacingFor(nextType) + if !ok { + return false // member type not targeted by configuration + } firstTrivia := -1 wsCount, wsIdx := 0, -1 @@ -101,7 +161,7 @@ func fixMemberGap(s *tokens.Stream, prevStart, nextStart int) bool { return false } prevType := classSepMemberType(s, prevStart) - return setNewlineCount(s, wsIdx, requiredNewlines(s, prevStart, prevType, nextType)) + return setNewlineCount(s, wsIdx, requiredNewlines(s, prevStart, prevType, nextType, spacing)) } // docblock/attribute block leading the next member: blank line goes above it. @@ -124,17 +184,20 @@ func fixMemberGap(s *tokens.Stream, prevStart, nextStart int) bool { } // requiredNewlines returns the number of newlines wanted in a plain whitespace -// gap above a member of nextType, following the fixer's default spacing. -func requiredNewlines(s *tokens.Stream, prevStart, prevType, nextType int) int { - if nextType == sepTraitImport || nextType == sepCase { - // SPACING_NONE: tight only between same-type members whose upper element - // carries no docblock/attribute of its own. +// gap above a member of nextType, following the configured spacing. +func requiredNewlines(s *tokens.Stream, prevStart, prevType, nextType int, spacing string) int { + switch spacing { + case "one": + return 2 + case "only_if_meta": + // no docblock/attribute directly above the next member in a plain gap + return 1 + default: // "none" if prevType == nextType && !hasDocOrAttrAbove(s, prevStart) { return 1 } return 2 } - return 2 // SPACING_ONE for const / method / property } // classSepMemberType classifies the member starting at m as one of the sep* diff --git a/blink/internal/fixer/rules/gen3_class_sep_config_test.go b/blink/internal/fixer/rules/gen3_class_sep_config_test.go new file mode 100644 index 0000000000..bd5f4e5a3a --- /dev/null +++ b/blink/internal/fixer/rules/gen3_class_sep_config_test.go @@ -0,0 +1,23 @@ +package rules + +import "testing" + +func TestClassAttributesSeparationElements(t *testing.T) { + // two properties separated by two blank lines + src := " collapse to a single blank line + got, changed := apply(t, ClassAttributesSeparation{}, src) + if want := "" and inner-parenthesis // spacing (owned by other fixers) are left alone. -type FunctionDeclaration struct{} +// +// The fields mirror the fixer options; their zero values reproduce today's +// behavior (one space for closures and arrow fns, trailing commas left in place): +// - closureFunctionSpacingNone: "closure_function_spacing" == "none" +// - closureFnSpacingNone: "closure_fn_spacing" == "none" +// - removeTrailingCommaSingleLine: "trailing_comma_single_line" == false +type FunctionDeclaration struct { + closureFunctionSpacingNone bool + closureFnSpacingNone bool + removeTrailingCommaSingleLine bool +} + +func (f FunctionDeclaration) WithConfig(config map[string]any) fixer.Fixer { + if v, ok := config["closure_function_spacing"].(string); ok { + f.closureFunctionSpacingNone = v == "none" + } + if v, ok := config["closure_fn_spacing"].(string); ok { + f.closureFnSpacingNone = v == "none" + } + if v, ok := config["trailing_comma_single_line"].(bool); ok { + f.removeTrailingCommaSingleLine = !v + } + return f +} + +// fnRemoveTrailingCommaSingleLine drops a trailing comma before the ")" that the +// "(" at open matches, but only when the parenthesized list is on a single line. +func fnRemoveTrailingCommaSingleLine(s *tokens.Stream, open int) bool { + closeParen := s.MatchForward(open) + if closeParen < 0 { + return false + } + for k := open; k <= closeParen; k++ { + if s.At(k).Kind == token.Whitespace && hasNewline(s.At(k).Value) { + return false // multiline signature: leave as-is + } + } + p := prevSignificantIndex(s, closeParen) + if p <= open || !(s.At(p).Kind == token.Punct && s.At(p).Value == ",") { + return false + } + // drop any single-line whitespace between the comma and ")", then the comma + if p+1 < closeParen && s.At(p+1).Kind == token.Whitespace { + s.RemoveAt(p + 1) + } + s.RemoveAt(p) + return true +} func (FunctionDeclaration) Name() string { return `PhpCsFixer\Fixer\FunctionNotation\FunctionDeclarationFixer` @@ -73,7 +121,7 @@ func (FunctionDeclaration) SourceURL() string { return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/FunctionNotation/FunctionDeclarationFixer.php" } -func (FunctionDeclaration) Fix(s *tokens.Stream) bool { +func (f FunctionDeclaration) Fix(s *tokens.Stream) bool { changed := false for i := 0; i < s.Len(); i++ { t := s.At(i) @@ -82,15 +130,20 @@ func (FunctionDeclaration) Fix(s *tokens.Stream) bool { } switch strings.ToLower(t.Value) { case "function": - if fixFunctionDeclaration(s, i) { + if f.fixFunctionDeclaration(s, i) { changed = true } case "fn": - // arrow function: one space between "fn" and "(" (closure_fn_spacing) + // arrow function: spacing between "fn" and "(" (closure_fn_spacing) if n := nextSignificantIndex(s, i); n >= 0 && - s.At(n).Kind == token.Punct && s.At(n).Value == "(" && - fnEnsureSingleSpaceAfter(s, i) { - changed = true + s.At(n).Kind == token.Punct && s.At(n).Value == "(" { + if f.closureFnSpacingNone { + if fnGlueAfter(s, i) { + changed = true + } + } else if fnEnsureSingleSpaceAfter(s, i) { + changed = true + } } } } @@ -100,12 +153,19 @@ func (FunctionDeclaration) Fix(s *tokens.Stream) bool { // fixFunctionDeclaration normalizes the single declaration whose "function" // keyword is at fi. Mutations happen only at indices >= fi, so fi and the outer // loop stay valid. -func fixFunctionDeclaration(s *tokens.Stream, fi int) bool { +func (f FunctionDeclaration) fixFunctionDeclaration(s *tokens.Stream, fi int) bool { startParen := findFunctionParamOpen(s, fi) if startParen < 0 { return false } + changed := false + if f.removeTrailingCommaSingleLine { + if fnRemoveTrailingCommaSingleLine(s, startParen) { + changed = true + } + } + // classify: optional leading "&", optional name directly before "(" a := nextSignificantIndex(s, fi) ampIndex := -1 @@ -123,8 +183,6 @@ func fixFunctionDeclaration(s *tokens.Stream, fi int) bool { } named := nameIndex >= 0 - changed := false - // closure "use": one space on each side (processed first, rightmost region) if !named { if closeParen := s.MatchForward(startParen); closeParen >= 0 { @@ -150,8 +208,14 @@ func fixFunctionDeclaration(s *tokens.Stream, fi int) bool { } } - // one space after the "function" keyword (before name, "&" or "(") - if fnEnsureSingleSpaceAfter(s, fi) { + // spacing after the "function" keyword (before name, "&" or "("). A closure + // with closure_function_spacing "none" glues "function" to "("; otherwise one + // space, and a named declaration always keeps one space before its name. + if !named && f.closureFunctionSpacingNone { + if fnGlueAfter(s, fi) { + changed = true + } + } else if fnEnsureSingleSpaceAfter(s, fi) { changed = true } diff --git a/blink/internal/fixer/rules/gen3_func_config_test.go b/blink/internal/fixer/rules/gen3_func_config_test.go new file mode 100644 index 0000000000..375a83935d --- /dev/null +++ b/blink/internal/fixer/rules/gen3_func_config_test.go @@ -0,0 +1,36 @@ +package rules + +import "testing" + +func TestFunctionDeclarationClosureFunctionSpacing(t *testing.T) { + // default: one space after "function" for a closure + got, changed := apply(t, FunctionDeclaration{}, " 1;") + if want := " 1;"; !changed || got != want { + t.Fatalf("closure_fn_spacing=none: changed=%v got=%q want=%q", changed, got, want) + } +} + +func TestFunctionDeclarationTrailingCommaSingleLine(t *testing.T) { + // trailing_comma_single_line=false: drop the trailing comma in a single-line signature + cfg := FunctionDeclaration{}.WithConfig(map[string]any{"trailing_comma_single_line": false}) + got, changed := apply(t, cfg, " "int $x"), in function parameters and typed -// properties. This is the canonical rule replacing the deprecated -// function_typehint_space; it also covers native types that lex as keywords -// ("array", "callable"). Line breaks between type and variable are preserved. -type TypeDeclarationSpaces struct{} +// ("int$x" and "int $x" -> "int $x"). This is the canonical rule replacing the +// deprecated function_typehint_space; it also covers native types that lex as +// keywords ("array", "callable"). Line breaks between type and variable are +// preserved. +// +// Elements selects where it applies ("function", "property", "constant"); nil +// means function and property. +type TypeDeclarationSpaces struct { + Elements map[string]bool +} func (TypeDeclarationSpaces) Name() string { return `PhpCsFixer\Fixer\Whitespace\TypeDeclarationSpacesFixer` @@ -41,9 +47,32 @@ func (TypeDeclarationSpaces) SourceURL() string { return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/TypeDeclarationSpacesFixer.php" } -func (TypeDeclarationSpaces) Fix(s *tokens.Stream) bool { +func (f TypeDeclarationSpaces) WithConfig(config map[string]any) fixer.Fixer { + if list, ok := singleSpaceStringList(config["elements"]); ok { + f.Elements = map[string]bool{} + for _, name := range list { + f.Elements[name] = true + } + } + return f +} + +func (f TypeDeclarationSpaces) has(element string) bool { + if f.Elements == nil { + return element != "constant" + } + return f.Elements[element] +} + +func (f TypeDeclarationSpaces) Fix(s *tokens.Stream) bool { changed := false for i := 0; i < s.Len(); i++ { + if f.has("constant") && s.At(i).Kind == token.Keyword && strings.EqualFold(s.At(i).Value, "const") { + if typeDeclarationSpacesConst(s, i) { + changed = true + } + continue + } if s.At(i).Kind != token.Variable { continue } @@ -54,6 +83,9 @@ func (TypeDeclarationSpaces) Fix(s *tokens.Stream) bool { if !isTypeDeclarationVariable(s, i, p) { continue } + if inFunc := enclosingFuncParamOpen(s, i) >= 0; (inFunc && !f.has("function")) || (!inFunc && !f.has("property")) { + continue + } // type token abuts the variable: insert a single space if p == i-1 { s.InsertAt(i, token.Token{Kind: token.Whitespace, Value: " "}) @@ -72,6 +104,43 @@ func (TypeDeclarationSpaces) Fix(s *tokens.Stream) bool { return changed } +// typeDeclarationSpacesConst fixes the space between the type and the name of a +// typed constant ("const int X = 1;") at the const keyword at i. +func typeDeclarationSpacesConst(s *tokens.Stream, i int) bool { + eq := -1 + for j := i + 1; j < s.Len(); j++ { + if t := s.At(j); t.Kind == token.Punct && (t.Value == "=" || t.Value == ";") { + if t.Value == "=" { + eq = j + } + break + } + } + if eq < 0 { + return false + } + name := prevSignificantIndex(s, eq) + if name < 0 || s.At(name).Kind != token.Ident { + return false + } + typeEnd := prevSignificantIndex(s, name) + if typeEnd < 0 || typeEnd == i || !isTypeNameToken(s.At(typeEnd)) { + return false + } + if typeEnd == name-1 { + s.InsertAt(name, token.Token{Kind: token.Whitespace, Value: " "}) + return true + } + if typeEnd == name-2 && s.At(name-1).Kind == token.Whitespace { + ws := s.At(name - 1).Value + if ws != " " && !hasNewline(ws) { + s.SetValue(name-1, " ") + return true + } + } + return false +} + // hasNullDefault reports whether the parameter variable at v has a default value // that is exactly "null" (the "=" then "null" then the parameter's "," or ")"). func hasNullDefault(s *tokens.Stream, v int) bool { @@ -135,7 +204,15 @@ func typeRunStart(s *tokens.Stream, end int) (start int, hasUnion, hasNullable b // "function f(?int $x = null)"). It acts only inside function/method/closure // parameter lists and skips a type that is already nullable, "mixed", standalone // "null", or a union/intersection (which would need "|null", not a "?" prefix). -type NullableTypeDeclarationForDefaultNullValue struct{} +// +// Options: UnionNull (use_nullable_type_declaration: true) also appends "|null" to +// plain unions. NoNullable (use_nullable_type_declaration: false) instead removes +// "?" and "|null" from parameters defaulting to null, skipping promoted +// constructor properties. +type NullableTypeDeclarationForDefaultNullValue struct { + UnionNull bool + NoNullable bool +} func (NullableTypeDeclarationForDefaultNullValue) Name() string { return `PhpCsFixer\Fixer\FunctionNotation\NullableTypeDeclarationForDefaultNullValueFixer` @@ -145,7 +222,63 @@ func (NullableTypeDeclarationForDefaultNullValue) SourceURL() string { return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/FunctionNotation/NullableTypeDeclarationForDefaultNullValueFixer.php" } -func (NullableTypeDeclarationForDefaultNullValue) Fix(s *tokens.Stream) bool { +func (f NullableTypeDeclarationForDefaultNullValue) WithConfig(config map[string]any) fixer.Fixer { + if v, ok := config["use_nullable_type_declaration"].(bool); ok { + f.UnionNull = v + f.NoNullable = !v + } + return f +} + +// nullableTypeRunHas returns the index of the first token in the type run [start, end] +// whose value equals the given one, or -1. +func nullableTypeRunHas(s *tokens.Stream, start, end int, value string) int { + for j := start; j <= end; j++ { + if strings.EqualFold(s.At(j).Value, value) && s.At(j).Kind != token.Whitespace { + return j + } + } + return -1 +} + +// nullableTypeRemove drops the nullable marker or "null" member from the type run +// [start, end] and reports whether it changed. +func nullableTypeRemove(s *tokens.Stream, start, end int, hasNullable bool) bool { + if hasNullable { + s.RemoveAt(start) + if start < s.Len() && s.At(start).Kind == token.Whitespace { + s.RemoveAt(start) + } + return true + } + if nullableTypeRunHas(s, start, end, "&") >= 0 { + return false + } + k := nullableTypeRunHas(s, start, end, "null") + if k < 0 { + return false + } + lo, hi := k, k + if n := nextSignificantIndex(s, k); n >= 0 && n <= end && s.At(n).Value == "|" { + hi = n + if hi+1 < s.Len() && s.At(hi+1).Kind == token.Whitespace { + hi++ + } + } else if p := prevSignificantIndex(s, k); p >= start && s.At(p).Value == "|" { + lo = p + if lo-1 >= start && s.At(lo-1).Kind == token.Whitespace { + lo-- + } + } else { + return false + } + for j := hi; j >= lo; j-- { + s.RemoveAt(j) + } + return true +} + +func (f NullableTypeDeclarationForDefaultNullValue) Fix(s *tokens.Stream) bool { changed := false // Right-to-left so an inserted "?" (always left of the current index) never // disturbs positions still to be scanned. @@ -174,9 +307,6 @@ func (NullableTypeDeclarationForDefaultNullValue) Fix(s *tokens.Stream) bool { continue } start, hasUnion, hasNullable := typeRunStart(s, p) - if hasNullable || hasUnion { - continue - } // atomic single type: skip "mixed" and standalone "null" if start == p { switch strings.ToLower(s.At(p).Value) { @@ -184,6 +314,29 @@ func (NullableTypeDeclarationForDefaultNullValue) Fix(s *tokens.Stream) bool { continue } } + if f.NoNullable { + if b := prevSignificantIndex(s, start); b >= 0 && s.At(b).Kind == token.Keyword && isVisibilityModifier(s.At(b).Value) { + continue + } + if (hasNullable || hasUnion) && nullableTypeRemove(s, start, p, hasNullable) { + changed = true + i = start + } + continue + } + if hasUnion && !hasNullable && f.UnionNull { + if nullableTypeRunHas(s, start, p, "&") >= 0 || nullableTypeRunHas(s, start, p, "null") >= 0 { + continue + } + s.InsertAt(p+1, token.Token{Kind: token.Punct, Value: "|"}) + s.InsertAt(p+2, token.Token{Kind: token.Ident, Value: "null"}) + changed = true + i = start + continue + } + if hasNullable || hasUnion { + continue + } s.InsertAt(start, token.Token{Kind: token.Punct, Value: "?"}) changed = true i = start diff --git a/blink/internal/fixer/rules/gen3_types_config_test.go b/blink/internal/fixer/rules/gen3_types_config_test.go new file mode 100644 index 0000000000..cb7fbcbc5e --- /dev/null +++ b/blink/internal/fixer/rules/gen3_types_config_test.go @@ -0,0 +1,27 @@ +package rules + +import "testing" + +func TestGen3TypesConfigCases(t *testing.T) { + cases := []struct { + name string + rule fixerRule + src string + want string + }{ + {"nullable default", NullableTypeDeclarationForDefaultNullValue{}, " 0 && s.At(i-1).Kind == token.Whitespace && !hasNewline(s.At(i-1).Value) { - s.RemoveAt(i - 1) - i-- - changed = true + if !(f.keepSpaceAfterHeredoc && methodArgSpacePrevIsHeredoc(s, i-1)) { + s.RemoveAt(i - 1) + i-- + changed = true + } } // Exactly one space after the comma, except a trailing comma before ")". if i+1 < s.Len() { next := s.At(i + 1) if next.Kind == token.Whitespace { - if next.Value != " " { + if next.Value != " " && !f.keepMultipleSpacesAfterComma { s.SetValue(i+1, " ") changed = true } @@ -76,6 +117,65 @@ func (MethodArgumentSpace) Fix(s *tokens.Stream) bool { return changed } +// methodArgSpacePrevIsHeredoc reports whether the significant token before the +// whitespace at wsIdx is a heredoc/nowdoc literal. +func methodArgSpacePrevIsHeredoc(s *tokens.Stream, wsIdx int) bool { + p := prevSignificantIndex(s, wsIdx) + return p >= 0 && arrayNotationIsHeredoc(s.At(p)) +} + +// methodArgSpaceCollapse joins every already-multiline call/declaration argument +// list onto a single line. Only top-level newlines are removed; a newline kept +// inside a nested array or closure argument is left in place. +func methodArgSpaceCollapse(s *tokens.Stream) bool { + changed := false + for open := 0; open < s.Len(); open++ { + if s.At(open).Kind != token.Punct || s.At(open).Value != "(" { + continue + } + closeIdx := s.MatchForward(open) + if closeIdx < 0 || !argListIsMultiline(s, open, closeIdx) { + continue + } + if !isCallOrDeclParen(s, open) { + continue + } + if methodArgSpaceCollapseParen(s, open, closeIdx) { + changed = true + } + } + return changed +} + +// methodArgSpaceCollapseParen removes top-level newlines within the paren at open, +// dropping the whitespace next to "(" and ")" and collapsing the rest to a space. +func methodArgSpaceCollapseParen(s *tokens.Stream, open, closeIdx int) bool { + changed := false + depth := 0 + for j := closeIdx - 1; j > open; j-- { + t := s.At(j) + if t.Kind == token.Punct { + switch t.Value { + case ")", "]", "}": + depth++ + case "(", "[", "{": + depth-- + } + continue + } + if depth != 0 || t.Kind != token.Whitespace || !hasNewline(t.Value) { + continue + } + if prevSignificantIndex(s, j) == open || nextSignificantIndex(s, j) == closeIdx { + s.RemoveAt(j) + } else { + s.SetValue(j, " ") + } + changed = true + } + return changed +} + // reflowMultilineArgs makes every already-multiline call/declaration argument // list fully multiline: "(" then each argument on its own line indented one // level past the call, and ")" on its own line at the call's indentation. diff --git a/blink/internal/fixer/rules/method_argument_space_config_test.go b/blink/internal/fixer/rules/method_argument_space_config_test.go new file mode 100644 index 0000000000..9cc5a525f1 --- /dev/null +++ b/blink/internal/fixer/rules/method_argument_space_config_test.go @@ -0,0 +1,32 @@ +package rules + +import "testing" + +func TestMethodArgumentSpaceConfig(t *testing.T) { + // default: one space after each comma + got, changed := apply(t, MethodArgumentSpace{}, " "if (x) { ... }"). Matches ECS's default // (fix_non_monolithic_code=true), so inline-HTML blocks are converted too. -type NoAlternativeSyntax struct{} +type NoAlternativeSyntax struct { + // MonolithicOnly skips files with inline HTML (fix_non_monolithic_code=false). + MonolithicOnly bool +} + +func (f NoAlternativeSyntax) WithConfig(config map[string]any) fixer.Fixer { + if v, ok := config["fix_non_monolithic_code"].(bool); ok { + f.MonolithicOnly = !v + } + return f +} + +func nasIsMonolithic(s *tokens.Stream) bool { + opens, html := 0, 0 + for i := 0; i < s.Len(); i++ { + switch s.At(i).Kind { + case token.OpenTag: + opens++ + case token.InlineHTML: + html++ + } + } + if opens != 1 { + return false + } + return html == 0 || (html == 1 && s.At(0).Kind == token.InlineHTML && strings.HasPrefix(s.At(0).Value, "#!")) +} func (NoAlternativeSyntax) Name() string { return `PhpCsFixer\Fixer\ControlStructure\NoAlternativeSyntaxFixer` @@ -35,7 +62,10 @@ func nasIsKeyword(t token.Token, set map[string]bool) bool { return t.Kind == token.Keyword && set[strings.ToLower(t.Value)] } -func (NoAlternativeSyntax) Fix(s *tokens.Stream) bool { +func (f NoAlternativeSyntax) Fix(s *tokens.Stream) bool { + if f.MonolithicOnly && !nasIsMonolithic(s) { + return false + } changed := false for index := s.Len() - 1; index >= 0; index-- { tok := s.At(index) diff --git a/blink/internal/fixer/rules/no_alternative_syntax_config_test.go b/blink/internal/fixer/rules/no_alternative_syntax_config_test.go new file mode 100644 index 0000000000..13d23df674 --- /dev/null +++ b/blink/internal/fixer/rules/no_alternative_syntax_config_test.go @@ -0,0 +1,18 @@ +package rules + +import "testing" + +func TestNoAlternativeSyntaxConfig(t *testing.T) { + src := "\nHi\n\n" + if _, changed := apply(t, NoAlternativeSyntax{}, src); !changed { + t.Fatal("default should fix inline HTML code") + } + f := NoAlternativeSyntax{}.WithConfig(map[string]any{"fix_non_monolithic_code": false}) + if _, changed := apply(t, f.(fixerRule), src); changed { + t.Fatal("fix_non_monolithic_code=false should skip inline HTML code") + } + got, changed := apply(t, f.(fixerRule), "= 0; i-- { t := s.At(i) @@ -64,12 +90,12 @@ func (NoBreakComment) Fix(s *tokens.Stream) bool { if colon < 0 { continue } - fixNoBreakCase(s, colon) + fixNoBreakCase(s, colon, m) } return s.Render() != before } -func fixNoBreakCase(s *tokens.Stream, casePosition int) { +func fixNoBreakCase(s *tokens.Stream, casePosition int, m noBreakCommentMatcher) { empty := true fallThrough := true commentPosition := -1 @@ -100,7 +126,7 @@ func fixNoBreakCase(s *tokens.Stream, casePosition int) { } return case "case", "default": - handleNextCase(s, i, empty, fallThrough, commentPosition) + handleNextCase(s, i, empty, fallThrough, commentPosition, m) return } } @@ -112,7 +138,7 @@ func fixNoBreakCase(s *tokens.Stream, casePosition int) { return } - if isNoBreakCommentToken(t) { + if isNoBreakCommentToken(t, m.re) { commentPosition = i continue } @@ -125,14 +151,14 @@ func fixNoBreakCase(s *tokens.Stream, casePosition int) { // handleNextCase applies the fall-through decision when the scan reaches the // following case/default. -func handleNextCase(s *tokens.Stream, casePos int, empty, fallThrough bool, commentPosition int) { +func handleNextCase(s *tokens.Stream, casePos int, empty, fallThrough bool, commentPosition int, m noBreakCommentMatcher) { if !empty && fallThrough { if commentPosition >= 0 && getPrevNonWhitespace(s, casePos) != commentPosition { removeNoBreakComment(s, commentPosition) commentPosition = -1 } if commentPosition < 0 { - insertNoBreakCommentAt(s, casePos) + insertNoBreakCommentAt(s, casePos, m.text) } else { ensureNewLineAt(s, commentPosition) } @@ -156,11 +182,11 @@ func isThrowStatementStart(s *tokens.Stream, prev int) bool { return false } -func isNoBreakCommentToken(t token.Token) bool { +func isNoBreakCommentToken(t token.Token, re *regexp.Regexp) bool { if t.Kind != token.Comment && t.Kind != token.DocComment { return false } - return noBreakCommentRe.MatchString(t.Value) + return re.MatchString(t.Value) } // noBreakStructureEnd mirrors getStructureEnd: the index of the token closing @@ -203,7 +229,7 @@ func noBreakStructureEnd(s *tokens.Stream, position int) int { return position } -func insertNoBreakCommentAt(s *tokens.Stream, casePosition int) { +func insertNoBreakCommentAt(s *tokens.Stream, casePosition int, text string) { newlinePosition := ensureNewLineAt(s, casePosition) content := s.At(newlinePosition).Value nbNewlines := strings.Count(content, "\n") @@ -226,7 +252,7 @@ func insertNoBreakCommentAt(s *tokens.Stream, casePosition int) { s.InsertAt(newlinePosition, token.Token{Kind: token.Whitespace, Value: tail}) } - s.InsertAt(newlinePosition, token.Token{Kind: token.Comment, Value: "// " + noBreakCommentText}) + s.InsertAt(newlinePosition, token.Token{Kind: token.Comment, Value: "// " + text}) ensureNewLineAt(s, newlinePosition) } diff --git a/blink/internal/fixer/rules/no_break_comment_config_test.go b/blink/internal/fixer/rules/no_break_comment_config_test.go new file mode 100644 index 0000000000..63e88e7fae --- /dev/null +++ b/blink/internal/fixer/rules/no_break_comment_config_test.go @@ -0,0 +1,16 @@ +package rules + +import "testing" + +func TestNoBreakCommentConfig(t *testing.T) { + src := " "use A\B;"). The namespaces option is off by default, so // bracketed namespaces are left untouched. Named NoUnneededCurlyBracesFixer to // match the set membership (the rule's former name). -type NoUnneededBraces struct{} +type NoUnneededBraces struct { + Namespaces bool +} + +func (f NoUnneededBraces) WithConfig(config map[string]any) fixer.Fixer { + if v, ok := config["namespaces"].(bool); ok { + f.Namespaces = v + } + return f +} func (NoUnneededBraces) Name() string { return `PhpCsFixer\Fixer\ControlStructure\NoUnneededCurlyBracesFixer` @@ -24,7 +34,7 @@ func (NoUnneededBraces) SourceURL() string { return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/NoUnneededCurlyBracesFixer.php" } -func (NoUnneededBraces) Fix(s *tokens.Stream) bool { +func (f NoUnneededBraces) Fix(s *tokens.Stream) bool { changed := false for i := s.Len() - 1; i > 0; i-- { if !nubIsPunct(s.At(i), "{") { @@ -55,9 +65,68 @@ func (NoUnneededBraces) Fix(s *tokens.Stream) bool { } } } + if f.Namespaces && nubFixNamespaceBlock(s) { + changed = true + } return changed } +// nubFixNamespaceBlock turns a lone "namespace Foo { ... }" spanning the rest of +// the file into "namespace Foo; ...". +func nubFixNamespaceBlock(s *tokens.Stream) bool { + nsIndex := -1 + for i := 0; i < s.Len(); i++ { + t := s.At(i) + if (t.Kind == token.Keyword || t.Kind == token.Ident) && strings.EqualFold(t.Value, "namespace") { + if nsIndex != -1 { + return false + } + nsIndex = i + } + } + if nsIndex == -1 { + return false + } + named := false + idx := nubNextMeaningful(s, nsIndex) + for idx != -1 && (s.At(idx).Kind == token.Ident || nubIsPunct(s.At(idx), `\`)) { + named = true + idx = nubNextMeaningful(s, idx) + } + if !named || idx == -1 || !nubIsPunct(s.At(idx), "{") { + return false + } + closeIdx := s.MatchForward(idx) + if closeIdx == -1 { + return false + } + after := nubNextMeaningful(s, closeIdx) + if after != -1 && (s.At(after).Kind != token.CloseTag || nubNextMeaningful(s, after) != -1) { + return false + } + s.RemoveAt(closeIdx) + if closeIdx < s.Len() && closeIdx > 0 && s.At(closeIdx).Kind == token.Whitespace && s.At(closeIdx-1).Kind == token.Whitespace { + s.SetValue(closeIdx-1, s.At(closeIdx-1).Value+s.At(closeIdx).Value) + s.RemoveAt(closeIdx) + } + s.Set(idx, token.Token{Kind: token.Punct, Value: ";"}) + if idx > 1 && s.At(idx-1).Kind == token.Whitespace && strings.Trim(s.At(idx-1).Value, " \t") == "" && s.At(idx-2).Kind != token.Comment && s.At(idx-2).Kind != token.DocComment { + s.RemoveAt(idx - 1) + } + return true +} + +func nubNextMeaningful(s *tokens.Stream, i int) int { + for j := i + 1; j < s.Len(); j++ { + k := s.At(j).Kind + if k == token.Whitespace || k == token.Comment || k == token.DocComment { + continue + } + return j + } + return -1 +} + // nubRegularOverComplete reports whether a "{" whose previous meaningful token is // at prev opens a superfluous block (prev is "{", "}", ""/"::" is a member name (Foo::case(...)), not a control // statement if memberPrev(s, i) { diff --git a/blink/internal/fixer/rules/no_unneeded_control_parentheses_config_test.go b/blink/internal/fixer/rules/no_unneeded_control_parentheses_config_test.go new file mode 100644 index 0000000000..02fdc035f6 --- /dev/null +++ b/blink/internal/fixer/rules/no_unneeded_control_parentheses_config_test.go @@ -0,0 +1,16 @@ +package rules + +import "testing" + +func TestNoUnneededControlParenthesesConfig(t *testing.T) { + src := " "$a[0]"). Array literals are left alone. -type NoSpacesAroundOffset struct{} +// ("$a[ 0 ]" -> "$a[0]"). Array literals are left alone. Outside also removes +// whitespace before the "[" and NoInside stops fixing the inside; both are set +// from the positions option. +type NoSpacesAroundOffset struct { + Outside bool + NoInside bool +} func (NoSpacesAroundOffset) Name() string { return `PhpCsFixer\Fixer\Whitespace\NoSpacesAroundOffsetFixer` @@ -32,7 +38,17 @@ func (NoSpacesAroundOffset) SourceURL() string { return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/NoSpacesAroundOffsetFixer.php" } -func (NoSpacesAroundOffset) Fix(s *tokens.Stream) bool { +func (f NoSpacesAroundOffset) WithConfig(config map[string]any) fixer.Fixer { + if v, ok := config["positions"]; ok { + if list, ok := arrayNotationStringList(v); ok { + f.Outside = arrayNotationContains(list, "outside") + f.NoInside = !arrayNotationContains(list, "inside") + } + } + return f +} + +func (f NoSpacesAroundOffset) 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 != "[" || !isOffsetOpen(s, i) { @@ -42,12 +58,19 @@ func (NoSpacesAroundOffset) Fix(s *tokens.Stream) bool { if closeIdx < 0 { continue } - if closeIdx-1 > i && s.At(closeIdx-1).Kind == token.Whitespace && !hasNewline(s.At(closeIdx-1).Value) { - s.RemoveAt(closeIdx - 1) - changed = true + if !f.NoInside { + if closeIdx-1 > i && s.At(closeIdx-1).Kind == token.Whitespace && !hasNewline(s.At(closeIdx-1).Value) { + s.RemoveAt(closeIdx - 1) + changed = true + } + if i+1 < s.Len() && s.At(i+1).Kind == token.Whitespace && !hasNewline(s.At(i+1).Value) { + s.RemoveAt(i + 1) + changed = true + } } - if i+1 < s.Len() && s.At(i+1).Kind == token.Whitespace && !hasNewline(s.At(i+1).Value) { - s.RemoveAt(i + 1) + if f.Outside && i > 0 && s.At(i-1).Kind == token.Whitespace { + s.RemoveAt(i - 1) + i-- changed = true } } diff --git a/blink/internal/fixer/rules/offset_config_test.go b/blink/internal/fixer/rules/offset_config_test.go new file mode 100644 index 0000000000..3c3dbf873e --- /dev/null +++ b/blink/internal/fixer/rules/offset_config_test.go @@ -0,0 +1,23 @@ +package rules + +import "testing" + +func TestNoSpacesAroundOffsetConfig(t *testing.T) { + src := " // "new Foo()". Anonymous classes and dynamic "new $var" are left alone. -type NewWithParentheses struct{} +type NewWithParentheses struct { + // SkipNamedClass and SkipAnonymousClass disable the named_class/anonymous_class options. + SkipNamedClass bool + SkipAnonymousClass bool +} + +func (f NewWithParentheses) WithConfig(config map[string]any) fixer.Fixer { + if v, ok := config["named_class"].(bool); ok { + f.SkipNamedClass = !v + } + if v, ok := config["anonymous_class"].(bool); ok { + f.SkipAnonymousClass = !v + } + return f +} func (NewWithParentheses) Name() string { return `PhpCsFixer\Fixer\Operator\NewWithParenthesesFixer` @@ -21,7 +36,7 @@ func (NewWithParentheses) SourceURL() string { return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/NewWithParenthesesFixer.php" } -func (NewWithParentheses) Fix(s *tokens.Stream) bool { +func (f NewWithParentheses) Fix(s *tokens.Stream) bool { changed := false for i := 0; i < s.Len(); i++ { t := s.At(i) @@ -38,14 +53,17 @@ func (NewWithParentheses) Fix(s *tokens.Stream) bool { if nt.Kind == token.Keyword && strings.ToLower(nt.Value) == "class" { // anonymous class: "new class extends X" -> "new class() extends X" // (ECS's psr12 config sets anonymous_class => true) - if nextSignificantValue(s, j) == "(" { - continue // already "new class(...)" + if f.SkipAnonymousClass || nextSignificantValue(s, j) == "(" { + continue // disabled, or already "new class(...)" } s.InsertAt(j+1, token.Token{Kind: token.Punct, Value: "("}) s.InsertAt(j+2, token.Token{Kind: token.Punct, Value: ")"}) changed = true continue } + if f.SkipNamedClass { + continue + } if nt.Kind == token.Variable { k := consumeNewVarRef(s, j) if nextSignificantValue(s, k-1) == "(" { diff --git a/blink/internal/fixer/rules/registry.go b/blink/internal/fixer/rules/registry.go index 2e4ba71664..863d1abdb2 100644 --- a/blink/internal/fixer/rules/registry.go +++ b/blink/internal/fixer/rules/registry.go @@ -274,6 +274,27 @@ func All() []fixer.Fixer { return all } +// CanonicalOrder sorts fixers in place to match All()'s execution order +// (PHP-CS-Fixer priority plus the curated tie-break), so the turbo/--blink path +// applies rules in the same order as the standalone path. Fixers unknown to +// All() are placed last. +func CanonicalOrder(fixers []fixer.Fixer) { + idx := map[string]int{} + for i, f := range All() { + idx[f.Name()] = i + } + const last = 1 << 30 + pos := func(f fixer.Fixer) int { + if i, ok := idx[f.Name()]; ok { + return i + } + return last + } + sort.SliceStable(fixers, func(a, b int) bool { + return pos(fixers[a]) < pos(fixers[b]) + }) +} + // fixerPriorityOf returns a fixer's PHP-CS-Fixer priority (0 when unknown). func fixerPriorityOf(f fixer.Fixer) int { name := f.Name() diff --git a/blink/internal/fixer/rules/return_type_declaration.go b/blink/internal/fixer/rules/return_type_declaration.go index be8f975e7e..274e010a76 100644 --- a/blink/internal/fixer/rules/return_type_declaration.go +++ b/blink/internal/fixer/rules/return_type_declaration.go @@ -3,6 +3,7 @@ package rules import ( "strings" + "blink/internal/fixer" "blink/internal/token" "blink/internal/tokens" ) @@ -11,7 +12,19 @@ import ( // space after ("function f() : int" and "function f():int" -> "function f(): int"). // The colon is only a return type when it follows the ")" of a function parameter // list, so ternary, case and alternative-syntax colons are left untouched. -type ReturnTypeDeclaration struct{} +// +// spaceBeforeOne mirrors the "space_before" option: the zero value ("none") +// removes the space before the colon; when set it keeps exactly one space. +type ReturnTypeDeclaration struct { + spaceBeforeOne bool +} + +func (f ReturnTypeDeclaration) WithConfig(config map[string]any) fixer.Fixer { + if v, ok := config["space_before"].(string); ok { + f.spaceBeforeOne = v == "one" + } + return f +} func (ReturnTypeDeclaration) Name() string { return `PhpCsFixer\Fixer\FunctionNotation\ReturnTypeDeclarationFixer` @@ -21,7 +34,7 @@ func (ReturnTypeDeclaration) SourceURL() string { return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/FunctionNotation/ReturnTypeDeclarationFixer.php" } -func (ReturnTypeDeclaration) Fix(s *tokens.Stream) bool { +func (f ReturnTypeDeclaration) Fix(s *tokens.Stream) bool { changed := false i := 0 for i < s.Len() { @@ -48,11 +61,25 @@ func (ReturnTypeDeclaration) Fix(s *tokens.Stream) bool { } } - // before side: no space, but keep newline-spanning whitespace intact - if i > 0 && s.At(i-1).Kind == token.Whitespace && !hasNewline(s.At(i-1).Value) { - s.RemoveAt(i - 1) - changed = true - i-- // colon shifted left by the removed whitespace + if f.spaceBeforeOne { + // exactly one space before the colon, keeping newline-spanning whitespace + if i > 0 && s.At(i-1).Kind == token.Whitespace { + if !hasNewline(s.At(i-1).Value) && s.At(i-1).Value != " " { + s.SetValue(i-1, " ") + changed = true + } + } else if i > 0 { + s.InsertAt(i, token.Token{Kind: token.Whitespace, Value: " "}) + changed = true + i++ // colon shifted right by the inserted whitespace + } + } else { + // before side: no space, but keep newline-spanning whitespace intact + if i > 0 && s.At(i-1).Kind == token.Whitespace && !hasNewline(s.At(i-1).Value) { + s.RemoveAt(i - 1) + changed = true + i-- // colon shifted left by the removed whitespace + } } i++ } diff --git a/blink/internal/fixer/rules/return_type_declaration_config_test.go b/blink/internal/fixer/rules/return_type_declaration_config_test.go new file mode 100644 index 0000000000..da30fe504a --- /dev/null +++ b/blink/internal/fixer/rules/return_type_declaration_config_test.go @@ -0,0 +1,18 @@ +package rules + +import "testing" + +func TestReturnTypeDeclarationSpaceBefore(t *testing.T) { + // default: no space before the colon, one space after + got, changed := apply(t, ReturnTypeDeclaration{}, "= 0; j-- { + t := s.At(j) + if t.Kind != token.Punct { + continue + } + switch t.Value { + case ")": + depth++ + case "(": + if depth > 0 { + depth-- + continue + } + prev, ok := prevSignificant(s, j) + return ok && prev.Kind == token.Keyword && strings.EqualFold(prev.Value, "for") + case ";", "{", "}": + if depth == 0 && t.Value != ";" { + return false + } + } + } + return false +} diff --git a/blink/internal/fixer/rules/space_after_semicolon_config_test.go b/blink/internal/fixer/rules/space_after_semicolon_config_test.go new file mode 100644 index 0000000000..d6ad4ba867 --- /dev/null +++ b/blink/internal/fixer/rules/space_after_semicolon_config_test.go @@ -0,0 +1,22 @@ +package rules + +import "testing" + +func TestSpaceAfterSemicolonConfigCases(t *testing.T) { + cases := []struct { + name string + rule fixerRule + src string + want string + }{ + {"default keeps empty for", SpaceAfterSemicolon{}, " // "'ab'"). Merges across a newline are left alone. -type NoUselessConcatOperator struct{} +type NoUselessConcatOperator struct { + JuggleSimpleStrings bool +} + +func (f NoUselessConcatOperator) WithConfig(config map[string]any) fixer.Fixer { + if v, ok := config["juggle_simple_strings"].(bool); ok { + f.JuggleSimpleStrings = v + } + return f +} func (NoUselessConcatOperator) Name() string { return `PhpCsFixer\Fixer\Operator\NoUselessConcatOperatorFixer` @@ -310,7 +320,7 @@ func (NoUselessConcatOperator) SourceURL() string { return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/NoUselessConcatOperatorFixer.php" } -func (NoUselessConcatOperator) Fix(s *tokens.Stream) bool { +func (f NoUselessConcatOperator) Fix(s *tokens.Stream) bool { changed := false i := 0 for i < s.Len() { @@ -335,15 +345,20 @@ func (NoUselessConcatOperator) Fix(s *tokens.Stream) bool { continue } next := s.At(k).Value - if stringQuote(next) != q || (q == '"' && strings.Contains(next, "$")) { - i++ - continue + merged := t.Value[:len(t.Value)-1] + next[1:] + if nq := stringQuote(next); nq != q || (q == '"' && strings.Contains(next, "$")) { + simple, ok := noUselessConcatJuggle(f.JuggleSimpleStrings, t.Value, q, next, nq) + if !ok { + i++ + continue + } + merged = simple } if rangeHasNewline(s, i, k) { i++ continue } - s.SetValue(i, t.Value[:len(t.Value)-1]+next[1:]) + s.SetValue(i, merged) for idx := k; idx > i; idx-- { s.RemoveAt(idx) } @@ -389,3 +404,24 @@ func (NoBinaryString) Fix(s *tokens.Stream) bool { } return changed } + +// noUselessConcatJuggle merges a double-quoted string with a simple single-quoted +// one (either order) into a double-quoted string when juggle_simple_strings is on. +func noUselessConcatJuggle(enabled bool, first string, firstQuote byte, second string, secondQuote byte) (string, bool) { + if !enabled || first == "" || second == "" { + return "", false + } + var single string + switch { + case firstQuote == '"' && secondQuote == '\'' && !strings.Contains(first, "$"): + single = second + case firstQuote == '\'' && secondQuote == '"' && !strings.Contains(second, "$"): + single = first + default: + return "", false + } + if strings.ContainsAny(single[1:len(single)-1], "$\"'\\") { + return "", false + } + return `"` + first[1:len(first)-1] + second[1:len(second)-1] + `"`, true +} diff --git a/blink/internal/fixer/rules/trailing_comma.go b/blink/internal/fixer/rules/trailing_comma.go index afa3a0d06a..0a6d415de2 100644 --- a/blink/internal/fixer/rules/trailing_comma.go +++ b/blink/internal/fixer/rules/trailing_comma.go @@ -1,15 +1,82 @@ package rules import ( + "strings" + + "blink/internal/fixer" "blink/internal/token" "blink/internal/tokens" ) +// 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 { + switch s.At(open).Value { + case "[": + if isOffsetOpen(s, open) { + return "" + } + if arrayNotationIsDestructuring(s, open) { + return "array_destructuring" + } + return "arrays" + case "{": + p := sigPrev(s, open) + if p < 0 { + return "" + } + if s.At(p).Kind == token.Punct && s.At(p).Value == `\` { + return "group_import" + } + if s.At(p).Kind == token.Punct && s.At(p).Value == ")" { + if o := s.MatchBackward(p); o >= 0 { + if q := sigPrev(s, o); q >= 0 && strings.EqualFold(s.At(q).Value, "match") { + return "match" + } + } + } + return "" + case "(": + p := sigPrev(s, open) + if p < 0 { + return "" + } + prev := s.At(p) + lower := strings.ToLower(prev.Value) + switch { + case (prev.Kind == token.Keyword || prev.Kind == token.Ident) && lower == "array": + return "arrays" + case (prev.Kind == token.Keyword || prev.Kind == token.Ident) && lower == "list": + return "array_destructuring" + case prev.Kind == token.Keyword && (lower == "function" || lower == "fn"): + return "parameters" + case prev.Kind == token.Ident: + if pp := sigPrev(s, p); pp >= 0 && strings.EqualFold(s.At(pp).Value, "function") { + return "parameters" + } + return "arguments" + case prev.Kind == token.Variable: + return "arguments" + case prev.Kind == token.Keyword && (lower == "class" || lower == "static" || lower == "isset" || lower == "unset"): + return "arguments" + case prev.Kind == token.Punct && prev.Value == "]": + return "arguments" + } + } + return "" +} + // PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/TrailingCommaInMultilineFixer.php // // TrailingCommaInMultiline adds a trailing comma to the last element of a -// multi-line array literal (the fixer default: elements = ['arrays']). -type TrailingCommaInMultiline struct{} +// multi-line array literal (the fixer default: elements = ['arrays']). When +// Elements is set (elements option), the listed constructs are fixed instead. +// SkipAfterHeredoc (after_heredoc=false) leaves a heredoc end without a comma. +type TrailingCommaInMultiline struct { + Elements []string + SkipAfterHeredoc bool +} func (TrailingCommaInMultiline) Name() string { return `PhpCsFixer\Fixer\ControlStructure\TrailingCommaInMultilineFixer` @@ -19,16 +86,52 @@ func (TrailingCommaInMultiline) SourceURL() string { return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/TrailingCommaInMultilineFixer.php" } -func (TrailingCommaInMultiline) Fix(s *tokens.Stream) bool { +func (f TrailingCommaInMultiline) WithConfig(config map[string]any) fixer.Fixer { + if v, ok := config["after_heredoc"].(bool); ok { + f.SkipAfterHeredoc = !v + } + if v, ok := config["elements"]; ok { + if list, ok := arrayNotationStringList(v); ok { + f.Elements = append([]string{}, list...) + } + } + return f +} + +func (f TrailingCommaInMultiline) included(s *tokens.Stream, open, closeIdx int) bool { + closer := s.At(closeIdx).Value + if f.Elements == nil { + return closer == "]" && !isOffsetOpen(s, open) + } + switch trailingCommaCategory(s, open) { + case "arrays": + return arrayNotationContains(f.Elements, "arrays") + case "array_destructuring": + return arrayNotationContains(f.Elements, "array_destructuring") || + (closer == ")" && arrayNotationContains(f.Elements, "arguments")) + case "arguments": + return arrayNotationContains(f.Elements, "arguments") + case "parameters": + return arrayNotationContains(f.Elements, "parameters") + case "match": + return arrayNotationContains(f.Elements, "match") + } + return false +} + +func (f TrailingCommaInMultiline) Fix(s *tokens.Stream) bool { changed := false - for i := 0; i < s.Len(); i++ { + for i := s.Len() - 1; i >= 0; i-- { t := s.At(i) - if t.Kind != token.Punct || t.Value != "]" { + if t.Kind != token.Punct || (t.Value != "]" && t.Value != ")" && t.Value != "}") { + continue + } + if f.Elements == nil && t.Value != "]" { continue } open := s.MatchBackward(i) - if open < 0 || isOffsetOpen(s, open) { - continue // offset access, not an array literal + if open < 0 || !f.included(s, open, i) { + continue } // last real element before the close (skip trailing whitespace/comments) @@ -43,6 +146,9 @@ func (TrailingCommaInMultiline) Fix(s *tokens.Stream) bool { if s.At(p).Value == "," || s.At(p).Value == "..." { continue // already trailing, or a spread (no trailing comma after it) } + if f.SkipAfterHeredoc && arrayNotationIsHeredoc(s.At(p)) { + continue + } // multi-line only: a newline sits between the last element and the close multiline := false for k := p + 1; k < i; k++ { @@ -63,8 +169,11 @@ func (TrailingCommaInMultiline) Fix(s *tokens.Stream) bool { // PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Basic/NoTrailingCommaInSinglelineFixer.php // // NoTrailingCommaInSingleline removes a trailing comma before a single-line -// closing ")", "]" or "}". -type NoTrailingCommaInSingleline struct{} +// closing ")", "]" or "}". When Elements is set (elements option), only the +// listed constructs are fixed. +type NoTrailingCommaInSingleline struct { + Elements []string +} func (NoTrailingCommaInSingleline) Name() string { return `PhpCsFixer\Fixer\Basic\NoTrailingCommaInSinglelineFixer` @@ -74,7 +183,19 @@ func (NoTrailingCommaInSingleline) SourceURL() string { return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Basic/NoTrailingCommaInSinglelineFixer.php" } -func (NoTrailingCommaInSingleline) Fix(s *tokens.Stream) bool { +func (f NoTrailingCommaInSingleline) WithConfig(config map[string]any) fixer.Fixer { + if v, ok := config["elements"]; ok { + if list, ok := arrayNotationStringList(v); ok { + f.Elements = append([]string{}, list...) + } + } + return f +} + +func (f NoTrailingCommaInSingleline) Fix(s *tokens.Stream) bool { + if f.Elements != nil { + return f.fixElements(s) + } changed := false for i := 0; i < s.Len(); i++ { if s.At(i).Kind != token.Punct || s.At(i).Value != "," { @@ -99,3 +220,71 @@ func (NoTrailingCommaInSingleline) Fix(s *tokens.Stream) bool { } return changed } + +func (f NoTrailingCommaInSingleline) shouldClear(s *tokens.Stream, open int) bool { + switch trailingCommaCategory(s, open) { + case "arrays": + return arrayNotationContains(f.Elements, "array") + case "array_destructuring": + return arrayNotationContains(f.Elements, "array_destructuring") + case "group_import": + return arrayNotationContains(f.Elements, "group_import") + case "arguments", "parameters": + if s.At(open).Value != "(" { + return false + } + p := sigPrev(s, open) + if p >= 0 && s.At(p).Kind == token.Keyword { + if l := strings.ToLower(s.At(p).Value); l == "function" || l == "fn" { + return false // closure parameter lists are not fixed + } + } + return arrayNotationContains(f.Elements, "arguments") + } + // a call on a closing paren, e.g. "$f()(1,)" + if s.At(open).Value == "(" { + if p := sigPrev(s, open); p >= 0 && s.At(p).Kind == token.Punct && s.At(p).Value == ")" { + return arrayNotationContains(f.Elements, "arguments") + } + } + return false +} + +func (f NoTrailingCommaInSingleline) fixElements(s *tokens.Stream) bool { + changed := false + for i := s.Len() - 1; i >= 0; i-- { + t := s.At(i) + if t.Kind != token.Punct || (t.Value != ")" && t.Value != "]" && t.Value != "}") { + continue + } + c := sigPrev(s, i) + if c < 0 || s.At(c).Kind != token.Punct || s.At(c).Value != "," { + continue + } + open := s.MatchBackward(i) + if open < 0 { + continue + } + multiline := false + for k := open; k <= i; k++ { + if strings.ContainsAny(s.At(k).Value, "\n\r") { + multiline = true + break + } + } + if multiline || !f.shouldClear(s, open) { + continue + } + for c >= 0 && s.At(c).Kind == token.Punct && s.At(c).Value == "," { + s.RemoveAt(c) + i-- + c = sigPrev(s, c) + } + for c >= 0 && c+1 < s.Len() && s.At(c+1).Kind == token.Whitespace { + s.RemoveAt(c + 1) + i-- + } + changed = true + } + return changed +} diff --git a/blink/internal/fixer/rules/trailing_comma_config_test.go b/blink/internal/fixer/rules/trailing_comma_config_test.go new file mode 100644 index 0000000000..93c332105c --- /dev/null +++ b/blink/internal/fixer/rules/trailing_comma_config_test.go @@ -0,0 +1,30 @@ +package rules + +import "testing" + +func TestTrailingCommaConfigCases(t *testing.T) { + multi := "= 0 { s.SetValue(varIdx, "public") return true diff --git a/blink/internal/fixer/rules/visibility_required_config_test.go b/blink/internal/fixer/rules/visibility_required_config_test.go new file mode 100644 index 0000000000..8aba95030d --- /dev/null +++ b/blink/internal/fixer/rules/visibility_required_config_test.go @@ -0,0 +1,20 @@ +package rules + +import "testing" + +func TestVisibilityRequiredElements(t *testing.T) { + src := " "$x === -// null"). "<"/">" comparisons are left untouched. -type YodaStyle struct{} +// YodaStyle rewrites comparisons to the configured style per operator group. +// The zero value follows ECS's common set (equal=false, identical=false, +// less_and_greater=false): a constant on the left of a comparison and a variable +// on the right are swapped so the variable comes first ("null === $x" -> "$x === +// null"). false is non-yoda, null leaves the group alone, true is yoda. +type YodaStyle struct { + equal yodaMode + identical yodaMode + lessGreater yodaMode +} func (YodaStyle) Name() string { return `PhpCsFixer\Fixer\ControlStructure\YodaStyleFixer` @@ -25,6 +41,46 @@ func (YodaStyle) SourceURL() string { return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/YodaStyleFixer.php" } +func (f YodaStyle) WithConfig(config map[string]any) fixer.Fixer { + f.equal = yodaModeFromConfig(config, "equal", f.equal) + f.identical = yodaModeFromConfig(config, "identical", f.identical) + f.lessGreater = yodaModeFromConfig(config, "less_and_greater", f.lessGreater) + // always_move_variable is accepted but not honoured: blink's variable + // detection does not model it. + return f +} + +// yodaModeFromConfig maps a bool|null option to a yodaMode; a missing key keeps +// the current value and an explicit null leaves that group untouched. +func yodaModeFromConfig(config map[string]any, key string, def yodaMode) yodaMode { + v, ok := config[key] + if !ok { + return def + } + if v == nil { + return yodaLeave + } + if b, ok := v.(bool); ok { + if b { + return yodaYoda + } + return yodaNonYoda + } + return def +} + +// modeFor returns the configured mode for a comparison operator. +func (f YodaStyle) modeFor(op string) yodaMode { + switch op { + case "===", "!==": + return f.identical + case "<", ">", "<=", ">=": + return f.lessGreater + default: // "==", "!=" + return f.equal + } +} + func yodaOp(v string) bool { switch v { case "==", "===", "!=", "!==", "<", ">", "<=", ">=": @@ -64,7 +120,7 @@ func isYodaLiteral(t token.Token) bool { return false } -func (YodaStyle) Fix(s *tokens.Stream) bool { +func (f YodaStyle) Fix(s *tokens.Stream) bool { type swap struct{ ls, le, rs, re int } var swaps []swap for i := 0; i < s.Len(); i++ { @@ -72,27 +128,54 @@ func (YodaStyle) Fix(s *tokens.Stream) bool { if t.Kind != token.Punct || !yodaOp(t.Value) { continue } - // left must be a bounded constant - ls, le, ok := leftLiteralOperand(s, i) - if !ok || !isLeftBoundary(s, prevSignificantIndex(s, ls)) { - continue - } - // right must be a variable expression (not itself a constant), bounded - rs := nextSignificantIndex(s, i) - if rs < 0 { - continue - } - re := rightPrimaryEnd(s, rs) - if re < 0 { - continue - } - if rs == re && isYodaLiteral(s.At(rs)) { - continue // both sides constant - } - if !isRightBoundary(s, nextSignificantIndex(s, re)) { + switch f.modeFor(t.Value) { + case yodaLeave: continue + case yodaNonYoda: + // left must be a bounded constant + ls, le, ok := leftLiteralOperand(s, i) + if !ok || !isLeftBoundary(s, prevSignificantIndex(s, ls)) { + continue + } + // right must be a variable expression (not itself a constant), bounded + rs := nextSignificantIndex(s, i) + if rs < 0 { + continue + } + re := rightPrimaryEnd(s, rs) + if re < 0 { + continue + } + if rs == re && isYodaLiteral(s.At(rs)) { + continue // both sides constant + } + if !isRightBoundary(s, nextSignificantIndex(s, re)) { + continue + } + swaps = append(swaps, swap{ls, le, rs, re}) + case yodaYoda: + // right must be a bounded constant + rs, re, ok := yodaRightLiteralOperand(s, i) + if !ok || !isRightBoundary(s, nextSignificantIndex(s, re)) { + continue + } + // left must be a variable expression (not itself a constant), bounded + le := prevSignificantIndex(s, i) + if le < 0 { + continue + } + ls := yodaLeftPrimaryStart(s, le) + if ls < 0 { + continue + } + if ls == le && isYodaLiteral(s.At(ls)) { + continue // both sides constant + } + if !isLeftBoundary(s, prevSignificantIndex(s, ls)) { + continue + } + swaps = append(swaps, swap{ls, le, rs, re}) } - swaps = append(swaps, swap{ls, le, rs, re}) } if len(swaps) == 0 { return false @@ -254,3 +337,95 @@ func isRightBoundary(s *tokens.Stream, j int) bool { } return false } + +// yodaRightLiteralOperand returns the span of a constant right operand starting +// just after the comparison at op: a plain literal, signed number, empty array, +// bare constant, or "Name::class". It mirrors leftLiteralOperand. +func yodaRightLiteralOperand(s *tokens.Stream, op int) (int, int, bool) { + rs := nextSignificantIndex(s, op) + if rs < 0 { + return 0, 0, false + } + t := s.At(rs) + // signed number "-1" + if t.Kind == token.Punct && (t.Value == "-" || t.Value == "+") { + n := nextSignificantIndex(s, rs) + if n >= 0 && s.At(n).Kind == token.Number { + return rs, n, true + } + return 0, 0, false + } + // "[" of an empty array "[]" + if t.Kind == token.Punct && t.Value == "[" { + c := s.MatchForward(rs) + if c >= 0 && nextSignificantIndex(s, rs) == c { + return rs, c, true + } + return 0, 0, false + } + if isYodaLiteral(t) { + // "Name::class" + if t.Kind == token.Ident { + n := nextSignificantIndex(s, rs) + if n >= 0 && s.At(n).Value == "::" { + c := nextSignificantIndex(s, n) + if c >= 0 && s.At(c).Kind == token.Ident && strings.EqualFold(s.At(c).Value, "class") { + return rs, c, true + } + } + } + return rs, rs, true + } + return 0, 0, false +} + +// yodaLeftPrimaryStart walks left from a primary's last token (end) over member, +// static and namespace access and matched call/index groups, returning the first +// token index of that primary, or -1 when end does not end a primary. +func yodaLeftPrimaryStart(s *tokens.Stream, end int) int { + if !yodaIsPrimaryEndToken(s.At(end)) { + return -1 + } + cur := end + for { + t := s.At(cur) + if t.Kind == token.Punct && (t.Value == ")" || t.Value == "]") { + o := s.MatchBackward(cur) + if o < 0 { + return -1 + } + p := prevSignificantIndex(s, o) + if p < 0 { + return -1 // a bare "(...)" group, not a variable primary + } + cur = p + continue + } + p := prevSignificantIndex(s, cur) + if p >= 0 && s.At(p).Kind == token.Punct { + switch s.At(p).Value { + case "->", "?->", "::", `\`: + pp := prevSignificantIndex(s, p) + if pp < 0 { + return cur + } + cur = pp + continue + } + } + return cur + } +} + +// yodaIsPrimaryEndToken reports whether t can end a primary (variable) expression. +func yodaIsPrimaryEndToken(t token.Token) bool { + switch t.Kind { + case token.Variable, token.Ident: + return true + case token.Keyword: + return isPrimaryKeyword(t.Value) + case token.Punct: + return t.Value == ")" || t.Value == "]" + } + return false +} diff --git a/blink/internal/fixer/rules/yoda_style_config_test.go b/blink/internal/fixer/rules/yoda_style_config_test.go new file mode 100644 index 0000000000..a7099d82de --- /dev/null +++ b/blink/internal/fixer/rules/yoda_style_config_test.go @@ -0,0 +1,24 @@ +package rules + +import "testing" + +func TestYodaStyleConfig(t *testing.T) { + // default: non-yoda, the variable moves to the left + got, changed := apply(t, YodaStyle{}, " Date: Wed, 30 Sep 2026 00:33:25 +0200 Subject: [PATCH 2/2] Satisfy modernize and staticcheck in configurable fixers --- blink/internal/fixer/rules/array_notation.go | 8 ++------ blink/internal/fixer/rules/constructs.go | 8 +++----- blink/internal/fixer/rules/function_to_constant.go | 8 ++------ blink/internal/fixer/rules/gen3_func.go | 2 +- blink/internal/fixer/rules/method_argument_space.go | 2 +- .../fixer/rules/no_unneeded_control_parentheses.go | 8 ++------ 6 files changed, 11 insertions(+), 25 deletions(-) diff --git a/blink/internal/fixer/rules/array_notation.go b/blink/internal/fixer/rules/array_notation.go index ba732982a4..3c78f5edc0 100644 --- a/blink/internal/fixer/rules/array_notation.go +++ b/blink/internal/fixer/rules/array_notation.go @@ -1,6 +1,7 @@ package rules import ( + "slices" "strings" "blink/internal/fixer" @@ -64,12 +65,7 @@ func arrayNotationStringList(v any) ([]string, bool) { } func arrayNotationContains(list []string, want string) bool { - for _, item := range list { - if item == want { - return true - } - } - return false + return slices.Contains(list, want) } func arrayNotationIsHeredoc(t token.Token) bool { diff --git a/blink/internal/fixer/rules/constructs.go b/blink/internal/fixer/rules/constructs.go index 448cf60e18..d82d08f5d1 100644 --- a/blink/internal/fixer/rules/constructs.go +++ b/blink/internal/fixer/rules/constructs.go @@ -129,7 +129,7 @@ func (f SingleSpaceAroundConstruct) fixPreceded(s *tokens.Stream) bool { continue } kw := strings.ToLower(t.Value) - if !f.Preceded[kw] && !(kw == "use" && f.Preceded["use_lambda"] && nextSignificantValue(s, i) == "(") { + if !f.Preceded[kw] && (kw != "use" || !f.Preceded["use_lambda"] || nextSignificantValue(s, i) != "(") { continue } prev := s.At(i - 1) @@ -147,10 +147,8 @@ func (f SingleSpaceAroundConstruct) fixPreceded(s *tokens.Stream) bool { } func (f SingleSpaceAroundConstruct) Fix(s *tokens.Stream) bool { - changed := false - if f.YieldFrom && f.fixYieldFrom(s) { - changed = true - } + changed := f.YieldFrom && f.fixYieldFrom(s) + if len(f.Preceded) > 0 && f.fixPreceded(s) { changed = true } diff --git a/blink/internal/fixer/rules/function_to_constant.go b/blink/internal/fixer/rules/function_to_constant.go index 823d0e74f0..547455b266 100644 --- a/blink/internal/fixer/rules/function_to_constant.go +++ b/blink/internal/fixer/rules/function_to_constant.go @@ -1,6 +1,7 @@ package rules import ( + "slices" "strings" "blink/internal/fixer" @@ -39,12 +40,7 @@ func (f FunctionToConstant) enabled(key string) bool { if f.Functions == nil { return true } - for _, name := range f.Functions { - if name == key { - return true - } - } - return false + return slices.Contains(f.Functions, key) } func (FunctionToConstant) Name() string { diff --git a/blink/internal/fixer/rules/gen3_func.go b/blink/internal/fixer/rules/gen3_func.go index e85a6fbbe7..fe7d4c70ec 100644 --- a/blink/internal/fixer/rules/gen3_func.go +++ b/blink/internal/fixer/rules/gen3_func.go @@ -102,7 +102,7 @@ func fnRemoveTrailingCommaSingleLine(s *tokens.Stream, open int) bool { } } p := prevSignificantIndex(s, closeParen) - if p <= open || !(s.At(p).Kind == token.Punct && s.At(p).Value == ",") { + if p <= open || (s.At(p).Kind != token.Punct || s.At(p).Value != ",") { return false } // drop any single-line whitespace between the comma and ")", then the comma diff --git a/blink/internal/fixer/rules/method_argument_space.go b/blink/internal/fixer/rules/method_argument_space.go index 72902daec0..77a0cf3b11 100644 --- a/blink/internal/fixer/rules/method_argument_space.go +++ b/blink/internal/fixer/rules/method_argument_space.go @@ -92,7 +92,7 @@ func (f MethodArgumentSpace) Fix(s *tokens.Stream) bool { // No space before the comma (single-line only), unless after_heredoc is // off and a heredoc precedes the comma. if i > 0 && s.At(i-1).Kind == token.Whitespace && !hasNewline(s.At(i-1).Value) { - if !(f.keepSpaceAfterHeredoc && methodArgSpacePrevIsHeredoc(s, i-1)) { + if !f.keepSpaceAfterHeredoc || !methodArgSpacePrevIsHeredoc(s, i-1) { s.RemoveAt(i - 1) i-- changed = true diff --git a/blink/internal/fixer/rules/no_unneeded_control_parentheses.go b/blink/internal/fixer/rules/no_unneeded_control_parentheses.go index 671d6dd9cd..cfcbad6eb2 100644 --- a/blink/internal/fixer/rules/no_unneeded_control_parentheses.go +++ b/blink/internal/fixer/rules/no_unneeded_control_parentheses.go @@ -1,6 +1,7 @@ package rules import ( + "slices" "strings" "blink/internal/fixer" @@ -41,12 +42,7 @@ func (f NoUnneededControlParentheses) enabled(key string) bool { if f.Statements == nil { return true } - for _, name := range f.Statements { - if name == key { - return true - } - } - return false + return slices.Contains(f.Statements, key) } func nucpStatementKey(keyword string) string {