From 10ee865c9a2d5be0a5ba62810fd98d97b25a82f2 Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Sun, 4 Oct 2026 20:54:53 +0200 Subject: [PATCH 1/4] Port php-cs-fixer StatementIndentation test cases (WIP, gated) Move all 86 cases from php-cs-fixer StatementIndentationFixerTest into a Go table test. blink matches 27; the 59 it does not yet match are gated behind a documented known-failures allowlist so the port lands without changing the rule. A listed case that starts passing fails the test so the list is pruned. --- .../statement_indentation_phpcsfixer_test.go | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 blink/internal/fixer/rules/statement_indentation_phpcsfixer_test.go diff --git a/blink/internal/fixer/rules/statement_indentation_phpcsfixer_test.go b/blink/internal/fixer/rules/statement_indentation_phpcsfixer_test.go new file mode 100644 index 0000000000..048cc3921e --- /dev/null +++ b/blink/internal/fixer/rules/statement_indentation_phpcsfixer_test.go @@ -0,0 +1,190 @@ +package rules + +import "testing" + +// Cases ported verbatim from php-cs-fixer StatementIndentationFixerTest +// (tests/Fixer/Whitespace/StatementIndentationFixerTest.php). blink does not +// yet match every case; the ones it misses are gated behind +// stmtIndentKnownFailures so this port can land without fixing the rule. A +// listed case that starts passing fails the test so the list gets pruned. +// The single tab-indent case cannot be expressed through blink's test harness +// (no WhitespacesFixerConfig), so it is a permanent known gap. +func TestStatementIndentationPhpCsFixerCases(t *testing.T) { + cases := []struct { + name string + cfg map[string]any + input string + expected string + }{ + {"no brace block", nil, "bar()\n ->baz()\n ;\n }", "bar()\n ->baz()\n ;\n}"}, + {"nested arrays (long syntax)", nil, "bar()\n ,\n array($baz)\n )\n ;\n }", "bar()\n ,\n array($baz)\n )\n ;\n}"}, + {"nested arrays (short syntax)", nil, "bar()\n ,\n [$baz]\n ]\n ;\n }", "bar()\n ,\n [$baz]\n ]\n ;\n}"}, + {"array (long syntax) with function call", nil, "bar(\n $baz\n );", "bar(\n $baz\n );"}, + {"argument separator on its own line", nil, " null;", " null;"}, + {"multiline list in foreach", nil, " $foo,\n \"bar\" => $bar,\n]) {\n}", " $foo,\n \"bar\" => $bar,\n]) {\n}"}, + {"switch case with control structure", nil, "baz()\n /* ->baz() */\n;", "baz()\n /* ->baz() */\n;"}, + {"multiple anonymous functions as function arguments", nil, "bar(function ($a) {\n echo $a;\n }, function ($b) {\n echo $b;\n })\n;", "bar(function ($a) {\n echo $a;\n }, function ($b) {\n echo $b;\n })\n;"}, + {"semicolon on a newline inside a switch case without break statement", nil, "baz()\n ;\n}", "baz()\n ;\n}"}, + {"alternative syntax", nil, "\n
\n\n \n
\n \n
\n \n\n", "\n
\n\n \n
\n \n
\n \n\n"}, + {"trait import with conflict resolution", nil, " \"bar\",\n ],\n BAR = \"Foo\",\n STOP = \"STOP\";\n}", " \"bar\",\n ],\n BAR = \"Foo\",\n STOP = \"STOP\";\n}"}, + {"multiline class constant with semicolon on next line", nil, " 1,\n 'b' => 2,\n ];\n}", " 1,\n 'b' => 2,\n ];\n}"}, + {"array with static method call", nil, " Date: Sun, 4 Oct 2026 21:21:06 +0200 Subject: [PATCH 2/4] Reimplement StatementIndentation as a faithful scope-stack reindenter Port php-cs-fixer's StatementIndentationFixer (bracesFixerCompatibility=false): walk block, block_signature and statement scopes and reindent each line to four spaces per nesting level, including continuation lines inside multiline calls and arrays that the previous rule left untouched. Promoted constructor parameters are not treated as property starts, so their modifiers do not stack signature scopes. Passes 83 of 86 ported php-cs-fixer cases; the 3 gated are multiline trait use (CT::T_USE_TRAIT) and the tab-indent case the harness cannot express. Mautic parity improves from 53 to 40 differing files with no regression. --- .../fixer/rules/statement_indentation.go | 713 +++++++++++++++--- .../statement_indentation_phpcsfixer_test.go | 62 +- 2 files changed, 610 insertions(+), 165 deletions(-) diff --git a/blink/internal/fixer/rules/statement_indentation.go b/blink/internal/fixer/rules/statement_indentation.go index 2136969f29..29141ba8ca 100644 --- a/blink/internal/fixer/rules/statement_indentation.go +++ b/blink/internal/fixer/rules/statement_indentation.go @@ -10,15 +10,17 @@ import ( // PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/StatementIndentationFixer.php // -// StatementIndentation reindents statement lines to four spaces per brace level. -// It only touches lines that begin a new statement at brace scope (after ";", -// "{" or "}" and not inside "(...)"/"[...]"), so continuation lines - multiline -// arguments, arrays and method chains - keep their own alignment. Heredoc bodies -// and comment interiors are single tokens and are never reindented. +// StatementIndentation reindents every statement and continuation line to four +// spaces per nesting level, mirroring php-cs-fixer's StatementIndentationFixer +// (bracesFixerCompatibility=false, as ECS configures it). It walks a scope stack +// - block, block_signature and statement scopes - and rewrites the leading +// whitespace of each line to the scope's indent, keeping the extra alignment of +// continuation lines inside multiline calls and arrays. Heredoc bodies and +// comment interiors are single tokens and are never reindented. type StatementIndentation struct { // stickComment mirrors stick_comment_to_next_continuous_control_statement: a // trailing comment of an if/elseif block before else/elseif dedents to the - // enclosing level. The zero value (false) keeps the current indentation. + // enclosing level. The zero value (false) keeps the inner indentation. stickComment bool } @@ -37,153 +39,652 @@ func (f StatementIndentation) WithConfig(config map[string]any) fixer.Fixer { return f } +const stmtIndentUnit = " " + +type stmtScope struct { + kind string // "block" | "block_signature" | "statement" + skip bool + endIndex int + endInclusive bool + initialIndent string + newIndent string + indentedBlock bool +} + func (f StatementIndentation) Fix(s *tokens.Stream) bool { + n := s.Len() + if n == 0 { + return false + } + endIndex := n - 1 + if s.At(endIndex).Kind == token.Whitespace { + endIndex-- + } + + lastIndent := stmtExtractIndent(stmtNewLineContent(s, 0)) + + scopes := []stmtScope{{ + kind: "block", + endIndex: endIndex, + endInclusive: true, + initialIndent: lastIndent, + indentedBlock: false, + }} + + previousLineInitialIndent := "" + previousLineNewIndent := "" + noBracesBlockStarts := map[int]bool{} + caseBlockStarts := map[int]int{} + changed := false - // braceStack holds one entry per open "{"; true marks a switch brace, whose - // case bodies indent one level deeper than the case/default labels. - var braceStack []bool - paren := 0 - for i := 0; i < s.Len(); i++ { - t := s.At(i) - if t.Kind == token.Punct { - switch t.Value { - case "{": - braceStack = append(braceStack, isSwitchBrace(s, i)) - case "}": - if len(braceStack) > 0 { - braceStack = braceStack[:len(braceStack)-1] - } - case "(", "[": - paren++ - case ")", "]": - if paren > 0 { - paren-- - } - } - } - if t.Kind != token.Whitespace || !hasNewline(t.Value) || paren != 0 { - continue + + for index := 0; index < n; index++ { + t := s.At(index) + cur := len(scopes) - 1 + + if noBracesBlockStarts[index] { + scopes = append(scopes, stmtScope{ + kind: "block", + endIndex: stmtFindStatementEndIndex(s, index, n-1) + 1, + endInclusive: true, + initialIndent: lastIndent, + indentedBlock: true, + }) + cur++ } - prevIndex := prevSignificantIndex(s, i) - if prevIndex < 0 { + + if _, isCase := caseBlockStarts[index]; stmtIsBlockFirst(s, index) || isCase { + ei := -1 + eiInclusive := true + + switch { + case t.Kind == token.Keyword && stmtKwIn(t.Value, "extends", "implements"): + ei = stmtNextValue(s, index, "{") + case t.Kind == token.Punct && t.Value == ":": + if _, ok := caseBlockStarts[index]; ok { + ei, eiInclusive = f.findCaseBlockEnd(s, index) + } + case t.Kind == token.Punct && t.Value == "{": + ei = s.MatchForward(index) + case t.Kind == token.Punct && t.Value == "(": + ei = s.MatchForward(index) + case t.Kind == token.Punct && t.Value == "[": // destructuring target + ei = s.MatchForward(index) + } + if ei < 0 { + ei = endIndex + } + + initialIndent := lastIndent + if scopes[cur].kind == "block_signature" { + initialIndent = scopes[cur].initialIndent + } + + scopes = append(scopes, stmtScope{ + kind: "block", + endIndex: ei, + endInclusive: eiInclusive, + initialIndent: initialIndent, + indentedBlock: true, + }) + cur++ + + for index >= scopes[cur].endIndex { + scopes = scopes[:len(scopes)-1] + cur-- + } continue } - prevValue := s.At(prevIndex).Value - isBoundary := prevValue == ";" || prevValue == "{" || prevValue == "}" - if !isBoundary && prevValue == ":" && isCaseColon(s, prevIndex) { - // the first statement after a case/default label starts a new line too - isBoundary = true + + if stmtIsArrayOpen(s, index) { + scopes = append(scopes, stmtScope{ + kind: "statement", + skip: true, + endIndex: s.MatchForward(index), + endInclusive: true, + initialIndent: previousLineInitialIndent, + newIndent: previousLineNewIndent, + indentedBlock: false, + }) + continue } - if !isBoundary { - continue // continuation line - leave its alignment alone + + isProp := stmtIsPropertyStart(s, index) + if isProp || stmtIsBlockSignatureFirst(s, index) { + lastWhitespaceIndex := -1 + closingParenthesisIndex := -1 + ternaryLevel := 0 + sigEnd := index + isNoBrace := stmtIsControlWithoutBracesKw(s, index) + + for e := index + 1; e < n; e++ { + et := s.At(e) + if et.Kind == token.Punct && et.Value == "(" { + c := s.MatchForward(e) + if c < 0 { + c = e + } + closingParenthesisIndex = c + e = c + sigEnd = e + continue + } + if et.Kind == token.Punct && et.Value == "[" && isArrayLiteralOpen(s, e) { + c := s.MatchForward(e) + if c < 0 { + c = e + } + e = c + sigEnd = e + continue + } + if et.Kind == token.Punct && (et.Value == "{" || et.Value == ";" || et.Value == "=>") { + sigEnd = e + break + } + if et.Kind == token.Keyword && stmtKwIn(et.Value, "implements") { + sigEnd = e + break + } + if et.Kind == token.Punct && et.Value == "?" { + ternaryLevel++ + sigEnd = e + continue + } + if et.Kind == token.Punct && et.Value == ":" { + if ternaryLevel > 0 { + ternaryLevel-- + sigEnd = e + continue + } + if t.Kind == token.Keyword && stmtKwIn(t.Value, "case", "default") { + caseBlockStarts[e] = index + } + sigEnd = e + break + } + if !isNoBrace { + sigEnd = e + continue + } + if et.Kind == token.Whitespace { + lastWhitespaceIndex = e + continue + } + if !stmtIsComment(s, e) { + start := e + if lastWhitespaceIndex >= 0 { + start = lastWhitespaceIndex + } + noBracesBlockStarts[start] = true + if closingParenthesisIndex >= 0 { + sigEnd = closingParenthesisIndex + } else { + sigEnd = index + } + break + } + sigEnd = e + } + + scopes = append(scopes, stmtScope{ + kind: "block_signature", + endIndex: sigEnd, + endInclusive: true, + initialIndent: lastIndent, + indentedBlock: isProp || (t.Kind == token.Keyword && stmtKwIn(t.Value, "extends", "implements", "const", "case")), + }) + continue } - if nextSignificantValue(s, i) == "" { + + if t.Kind == token.Keyword && stmtKwIn(t.Value, "function") { + e := index + 1 + for ; e < n; e++ { + et := s.At(e) + if et.Kind == token.Punct && et.Value == "(" { + c := s.MatchForward(e) + if c < 0 { + c = e + } + e = c + continue + } + if et.Kind == token.Punct && (et.Value == "{" || et.Value == ";") { + break + } + } + scopes = append(scopes, stmtScope{ + kind: "block_signature", + endIndex: e, + endInclusive: true, + initialIndent: lastIndent, + indentedBlock: false, + }) continue } - switchExtra := 0 - for _, isSwitch := range braceStack { - if isSwitch { - switchExtra++ // each enclosing switch indents its case body once + if t.Kind == token.Whitespace { + content := t.Value + if !hasNewline(content) { + continue } + nextTok := index + 1 + + if scopes[cur].kind == "block" || scopes[cur].kind == "block_signature" { + indent := false + if scopes[cur].indentedBlock { + indent = f.indentDecision(s, index, scopes[cur]) + } + previousLineInitialIndent = stmtExtractIndent(content) + var whitespaces string + if scopes[cur].skip { + whitespaces = previousLineInitialIndent + } else { + whitespaces = scopes[cur].initialIndent + if indent { + whitespaces += stmtIndentUnit + } + } + content = stmtReplaceBlockIndent(content, whitespaces) + previousLineNewIndent = stmtExtractIndent(content) + } else { + content = stmtReplaceStatementIndent(content, scopes[cur].initialIndent, scopes[cur].newIndent) + } + + lastIndent = stmtExtractIndent(content) + + if content != t.Value { + s.SetValue(index, content) + changed = true + } + + if nextTok < n && stmtIsComment(s, nextTok) { + newComment := stmtReplaceCommentIndent(s.At(nextTok).Value, previousLineInitialIndent, previousLineNewIndent) + if newComment != s.At(nextTok).Value { + s.SetValue(nextTok, newComment) + changed = true + } + } + continue } - level := len(braceStack) + switchExtra - if nextSignificantValue(s, i) == "}" { - level-- // a closing brace dedents to the outer level - if len(braceStack) > 0 && braceStack[len(braceStack)-1] { - level-- // the switch's own "}" sits at the case-label level + for index >= scopes[cur].endIndex { + scopes = scopes[:len(scopes)-1] + if len(scopes) == 0 { + return changed } - } else if isSwitchLabel(s, i) && len(braceStack) > 0 && braceStack[len(braceStack)-1] { - level-- // a case/default label sits one level above its body - } else if f.stickComment && statementIndentationSticksToNext(s, i) { - level-- // trailing comment counts as the next control block's comment + cur-- } - if level < 0 { - level = 0 + + if stmtIsComment(s, index) || + (t.Kind == token.Punct && (t.Value == ";" || t.Value == "," || t.Value == "}")) || + t.Kind == token.OpenTag || t.Kind == token.CloseTag { + continue } - target := strings.Repeat(" ", level) - v := t.Value - nl := strings.LastIndexByte(v, '\n') - if v[nl+1:] != target { - s.SetValue(i, v[:nl+1]+target) - changed = true + if scopes[cur].kind != "statement" && scopes[cur].kind != "block_signature" { + se := stmtFindStatementEndIndex(s, index, scopes[cur].endIndex) + if se == index { + continue + } + scopes = append(scopes, stmtScope{ + kind: "statement", + endIndex: se, + endInclusive: false, + initialIndent: previousLineInitialIndent, + newIndent: previousLineNewIndent, + indentedBlock: true, + }) } } return changed } -// isSwitchBrace reports whether the "{" at brace opens a switch block, i.e. it is -// preceded by "switch (...)". -func isSwitchBrace(s *tokens.Stream, brace int) bool { - closeParen := prevSignificantIndex(s, brace) - if closeParen < 0 || s.At(closeParen).Kind != token.Punct || s.At(closeParen).Value != ")" { +// indentDecision ports the is_indented_block branch: whether the line starting +// after the whitespace at index gains one extra indent level. +func (f StatementIndentation) indentDecision(s *tokens.Stream, index int, sc stmtScope) bool { + n := s.Len() + firstNonWS := -1 + nextNewline := -1 + for j := index + 1; j < n; j++ { + if s.At(j).Kind != token.Whitespace { + if firstNonWS < 0 { + firstNonWS = j + } + continue + } + if hasNewline(s.At(j).Value) { + nextNewline = j + break + } + } + end := sc.endIndex + if !sc.endInclusive { + end++ + } + contentBeforeEnd := (firstNonWS >= 0 && firstNonWS < end) || (nextNewline >= 0 && nextNewline < end) + if !contentBeforeEnd { return false } - openParen := s.MatchBackward(closeParen) - if openParen < 0 { + // comment directly before "}" gets special handling + if firstNonWS >= 0 && stmtIsPlainComment(s, firstNonWS) { + nm := nextMeaningfulIndex(s, firstNonWS) + if nm >= 0 && s.At(nm).Kind == token.Punct && s.At(nm).Value == "}" { + pm := prevMeaningfulIndex(s, firstNonWS) + if pm >= 0 && s.At(pm).Kind == token.Punct && s.At(pm).Value == "{" { + return true + } + nn := nextMeaningfulIndex(s, nm) + if nn >= 0 && s.At(nn).Kind == token.Keyword && stmtKwIn(s.At(nn).Value, "else", "elseif") { + return !f.stickComment + } + return true + } + } + return true +} + +// stmtIsBlockFirst reports whether the token at index opens a block scope: "{", +// a destructuring "[", or a "(" that is not an array(...) opener. +func stmtIsBlockFirst(s *tokens.Stream, index int) bool { + t := s.At(index) + if t.Kind != token.Punct { return false } - keyword := prevSignificantIndex(s, openParen) - if keyword < 0 || s.At(keyword).Kind != token.Keyword { + switch t.Value { + case "{": + return true + case "[": + return isDestructuringAssignOpen(s, index) + case "(": + p := prevMeaningfulIndex(s, index) + if p >= 0 && s.At(p).Kind == token.Keyword && strings.EqualFold(s.At(p).Value, "array") { + return false + } + return true + } + return false +} + +// stmtIsArrayOpen reports whether the token at index opens an array literal that +// forms a statement scope: "array(" or an array-literal "[". +func stmtIsArrayOpen(s *tokens.Stream, index int) bool { + t := s.At(index) + if t.Kind != token.Punct { return false } - return strings.EqualFold(s.At(keyword).Value, "switch") + if t.Value == "(" { + p := prevMeaningfulIndex(s, index) + return p >= 0 && s.At(p).Kind == token.Keyword && strings.EqualFold(s.At(p).Value, "array") + } + if t.Value == "[" { + return isArrayLiteralOpen(s, index) && !isDestructuringAssignOpen(s, index) + } + return false } -// isSwitchLabel reports whether the line starting after the whitespace at i -// begins with a case or default keyword. -func isSwitchLabel(s *tokens.Stream, i int) bool { - n := nextSignificantIndex(s, i) - if n < 0 || s.At(n).Kind != token.Keyword { +func stmtIsBlockSignatureFirst(s *tokens.Stream, index int) bool { + t := s.At(index) + if t.Kind != token.Keyword { return false } - lw := strings.ToLower(s.At(n).Value) - return lw == "case" || lw == "default" + return stmtKwIn(t.Value, "use", "if", "else", "elseif", "for", "foreach", "while", + "do", "switch", "case", "default", "try", "class", "interface", "trait", + "extends", "implements", "const", "match", "enum") } -// isCaseColon reports whether the ":" at colon terminates a case/default label -// rather than a ternary or other colon. -func isCaseColon(s *tokens.Stream, colon int) bool { - for j := prevSignificantIndex(s, colon); j >= 0; j = prevSignificantIndex(s, j) { - t := s.At(j) - if t.Kind == token.Keyword { - switch strings.ToLower(t.Value) { - case "case", "default": - return true +func stmtIsControlWithoutBracesKw(s *tokens.Stream, index int) bool { + t := s.At(index) + return t.Kind == token.Keyword && stmtKwIn(t.Value, "if", "else", "elseif", "for", "foreach", "while", "do") +} + +func stmtKwIn(v string, set ...string) bool { + lv := strings.ToLower(v) + for _, w := range set { + if lv == w { + return true + } + } + return false +} + +// stmtIsComment reports whether token i is a comment (line/block/doc), excluding +// a folded attribute token. +func stmtIsComment(s *tokens.Stream, i int) bool { + if i < 0 || i >= s.Len() { + return false + } + t := s.At(i) + if t.Kind == token.DocComment { + return true + } + return t.Kind == token.Comment && !isAttributeComment(t) +} + +// stmtIsPlainComment reports whether token i is a non-doc line/block comment +// (php-cs-fixer T_COMMENT), excluding attributes and doc comments. +func stmtIsPlainComment(s *tokens.Stream, i int) bool { + if i < 0 || i >= s.Len() { + return false + } + t := s.At(i) + return t.Kind == token.Comment && !isAttributeComment(t) +} + +// stmtNextValue returns the index of the next token equal to value, or -1. +func stmtNextValue(s *tokens.Stream, from int, value string) int { + for j := from + 1; j < s.Len(); j++ { + if s.At(j).Kind == token.Punct && s.At(j).Value == value { + return j + } + } + return -1 +} + +// stmtNewLineContent mirrors computeNewLineContent: blink's open/close tags carry +// no whitespace, so it is just the token's own content. +func stmtNewLineContent(s *tokens.Stream, index int) string { + return s.At(index).Value +} + +// stmtExtractIndent returns the horizontal whitespace after the last newline. +func stmtExtractIndent(content string) string { + nl := strings.LastIndexAny(content, "\n\r") + if nl < 0 { + return "" + } + rest := content[nl+1:] + i := 0 + for i < len(rest) && (rest[i] == ' ' || rest[i] == '\t') { + i++ + } + return rest[:i] +} + +// stmtReplaceBlockIndent replaces the trailing "(newlines)(hspaces)" of content +// with the same newlines followed by whitespaces (Preg '/(\R+)\h*$/'). +func stmtReplaceBlockIndent(content, whitespaces string) string { + e := len(content) + for e > 0 && (content[e-1] == ' ' || content[e-1] == '\t') { + e-- + } + return content[:e] + whitespaces +} + +// stmtReplaceStatementIndent replaces a trailing "newline + initial + hspaces" +// with "newline + newIndent + hspaces" (Preg '/(\R)INITIAL(\h*)$/D'), preserving +// extra alignment beyond the scope's initial indent. +func stmtReplaceStatementIndent(content, initial, newIndent string) string { + nl := strings.LastIndexAny(content, "\n\r") + if nl < 0 { + return content + } + head := content[:nl+1] + tail := content[nl+1:] // all horizontal whitespace + if !strings.HasPrefix(tail, initial) { + return content + } + return head + newIndent + tail[len(initial):] +} + +// stmtReplaceCommentIndent reindents the continuation lines of a comment token +// whose first line was reindented (Preg '/(\R)INITIAL(\h*\S+.*)/'). +func stmtReplaceCommentIndent(comment, initial, newIndent string) string { + if initial == newIndent || !strings.Contains(comment, "\n") && !strings.Contains(comment, "\r") { + return comment + } + lines := strings.SplitAfter(comment, "\n") + for i := 1; i < len(lines); i++ { + line := lines[i] + // strip the newline suffix handling: line keeps its trailing "\n" + body := line + nlSuffix := "" + if strings.HasSuffix(body, "\n") { + body = body[:len(body)-1] + nlSuffix = "\n" + } + if strings.HasPrefix(body, initial) { + rest := body[len(initial):] + trimmed := strings.TrimLeft(rest, " \t") + if trimmed != "" { // only lines with meaningful content + lines[i] = newIndent + rest + nlSuffix + } + } + } + return strings.Join(lines, "") +} + +// stmtFindStatementEndIndex ports findStatementEndIndex: the last meaningful +// token of the statement starting at index, within the parent scope. +func stmtFindStatementEndIndex(s *tokens.Stream, index, parentScopeEndIndex int) int { + endIndex := -1 + ifLevel := 0 + doWhileLevel := 0 + for se := index; se <= parentScopeEndIndex && se < s.Len(); se++ { + et := s.At(se) + if et.Kind == token.Keyword && stmtKwIn(et.Value, "if") { + p := prevMeaningfulIndex(s, se) + prevIsElse := p >= 0 && s.At(p).Kind == token.Keyword && stmtKwIn(s.At(p).Value, "else") + if !prevIsElse { + ifLevel++ + continue } } - if t.Kind == token.Punct { - switch t.Value { - case ";", "{", "}", "?", ":": - return false + if et.Kind == token.Keyword && stmtKwIn(et.Value, "do") { + doWhileLevel++ + continue + } + if et.Kind == token.Punct && (et.Value == "(" || et.Value == "{" || (et.Value == "[" && isArrayLiteralOpen(s, se))) { + c := s.MatchForward(se) + if c >= 0 { + se = c + et = s.At(se) } } + isStatementEnd := (et.Kind == token.Punct && (et.Value == ";" || et.Value == "," || et.Value == "}")) || et.Kind == token.CloseTag + if !isStatementEnd { + continue + } + cont := nextMeaningfulIndex(s, se) + if ifLevel > 0 && cont >= 0 && s.At(cont).Kind == token.Keyword && stmtKwIn(s.At(cont).Value, "else", "elseif") { + if stmtKwIn(s.At(cont).Value, "else") { + nn := nextMeaningfulIndex(s, cont) + nextIsIf := nn >= 0 && s.At(nn).Kind == token.Keyword && stmtKwIn(s.At(nn).Value, "if") + if !nextIsIf { + ifLevel-- + } + } + se = cont + continue + } + if doWhileLevel > 0 && cont >= 0 && s.At(cont).Kind == token.Keyword && stmtKwIn(s.At(cont).Value, "while") { + doWhileLevel-- + se = cont + continue + } + endIndex = prevSignificantIndex(s, se) + break } - return false + if endIndex >= 0 { + return endIndex + } + return prevMeaningfulIndex(s, parentScopeEndIndex) } -// statementIndentationSticksToNext reports whether the line starting after the -// whitespace at i is a comment that is the last content of its block before a "}" -// followed by else/elseif - the case stick_comment... dedents by one level. -func statementIndentationSticksToNext(s *tokens.Stream, i int) bool { - c := nextSignificantIndex(s, i) - if c < 0 || s.At(c).Kind != token.Comment { +// findCaseBlockEnd ports findCaseBlockEnd: the end of a case/default body. +func (f StatementIndentation) findCaseBlockEnd(s *tokens.Stream, index int) (int, bool) { + n := s.Len() + for ; index < n; index++ { + t := s.At(index) + if t.Kind == token.Keyword && stmtKwIn(t.Value, "switch") { + op := nextMeaningfulIndex(s, index) + if op >= 0 && s.At(op).Kind == token.Punct && s.At(op).Value == "(" { + cp := s.MatchForward(op) + if cp >= 0 { + brace := nextMeaningfulIndex(s, cp) + if brace >= 0 && s.At(brace).Kind == token.Punct && s.At(brace).Value == "{" { + if be := s.MatchForward(brace); be >= 0 { + index = be + } + } + } + } + continue + } + if t.Kind == token.Punct && t.Value == "{" { + if be := s.MatchForward(index); be >= 0 { + index = be + } + continue + } + if t.Kind == token.Keyword && stmtKwIn(t.Value, "case", "default") { + return index, true + } + if t.Kind == token.Punct && t.Value == "}" { + return prevSignificantIndex(s, index), false + } + } + return n - 1, false +} + +// stmtIsPropertyStart ports isPropertyStart: the last modifier before a typed or +// named property declaration. +func stmtIsPropertyStart(s *tokens.Stream, index int) bool { + ni := nextMeaningfulIndex(s, index) + if ni < 0 { return false } - // a comment that is the only content of the block keeps the inner indent - if p := prevSignificantIndex(s, c); p < 0 || (s.At(p).Kind == token.Punct && s.At(p).Value == "{") { + nt := s.At(ni) + if nt.Kind == token.Keyword && stmtPropertyKeyword(nt.Value) { return false } - brace := nextSignificantIndex(s, c) - if brace < 0 || s.At(brace).Kind != token.Punct || s.At(brace).Value != "}" { + if nt.Kind == token.Keyword && stmtKwIn(nt.Value, "const", "function") { return false } - kw := nextSignificantIndex(s, brace) - if kw < 0 || s.At(kw).Kind != token.Keyword { + // walk back over the modifier chain, tracking a visibility modifier and the + // token that precedes the chain + i := index + foundVisibility := false + chainStart := index + for i >= 0 && s.At(i).Kind == token.Keyword && stmtPropertyKeyword(s.At(i).Value) { + if stmtKwIn(s.At(i).Value, "var", "public", "protected", "private") { + foundVisibility = true + } + chainStart = i + i = prevMeaningfulIndex(s, i) + } + if !foundVisibility { return false } - lw := strings.ToLower(s.At(kw).Value) - return lw == "else" || lw == "elseif" + // a promoted constructor parameter ("(private A $a, protected B $b)") is not a + // class property: its modifier chain is preceded by "(" or "," + before := prevMeaningfulIndex(s, chainStart) + if before >= 0 && s.At(before).Kind == token.Punct && (s.At(before).Value == "(" || s.At(before).Value == ",") { + return false + } + return true +} + +func stmtPropertyKeyword(v string) bool { + return stmtKwIn(v, "var", "public", "protected", "private", "static", "readonly") } diff --git a/blink/internal/fixer/rules/statement_indentation_phpcsfixer_test.go b/blink/internal/fixer/rules/statement_indentation_phpcsfixer_test.go index 048cc3921e..cc4e7bc475 100644 --- a/blink/internal/fixer/rules/statement_indentation_phpcsfixer_test.go +++ b/blink/internal/fixer/rules/statement_indentation_phpcsfixer_test.go @@ -128,63 +128,7 @@ func TestStatementIndentationPhpCsFixerCases(t *testing.T) { } var stmtIndentKnownFailures = map[string]bool{ - "no brace block": true, - "with several opening braces on same line": true, - "function definition arguments": true, - "anonymous function definition arguments": true, - "interface method definition arguments": true, - "class method definition arguments": true, - "trait method definition arguments": true, - "function call arguments": true, - "variable function call arguments": true, - "chained method calls": true, - "nested arrays (long syntax)": true, - "nested arrays (short syntax)": true, - "array (long syntax) with function call": true, - "array (short syntax) with function call": true, - "array (long syntax) with class instantiation": true, - "array (short syntax) with class instantiation": true, - "implements list": true, - "extends list": true, - "use list": true, - "chained method call with argument": true, - "argument separator on its own line": true, - "statement end on its own line": true, - "array (long syntax) with anonymous class": true, - "array (short syntax) with anonymous class": true, - "expression function call arguments": true, - "arrow function definition arguments": true, - "trait import with conflict resolution": true, - "multiline class definition": true, - "comment before else blocks WITHOUT stick_comment_to_next_continuous_control_statement": true, - "comment before else blocks WITH stick_comment_to_next_continuous_control_statement": true, - "multiline comment in block - describing next block": true, - "multiline comment in block - the only content in block": true, - "comment before elseif blocks": true, - "if-elseif-else without braces": true, - "for without braces": true, - "foreach without braces": true, - "while without braces": true, - "do-while without braces": true, - "nested control structures without braces": true, - "mixex if-else with and without braces": true, - "empty if and else without braces": true, - "multiline class constant": true, - "multiline class constant with visibility": true, - "multiline comma-separated class constants": true, - "multiline class constant with array value": true, - "multiline class constants with array key/value": true, - "multiline class constant with semicolon on next line": true, - "multiline class property": true, - "multiline class property with default value": true, - "multiline class typed property": true, - "multiline class static property": true, - "multiline comma-separated class properties": true, - "multiline class property with array value": true, - "multiline class property with semicolon on next line": true, - "multiline class property with var": true, - "multiline ternary operator in class constant": true, - "multiline nested ternary operator in class constant": true, - "multiline ternary operator in class constant with indentation to adjust": true, - "with tabs": true, + "use list": true, // multiline trait use (CT::T_USE_TRAIT) deeper indent not modelled + "trait import with conflict resolution": true, // trait use with conflict block + "with tabs": true, // harness cannot pass a tab WhitespacesFixerConfig } From fa363095fa94b760076ceb56e27688797bb88954 Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Sun, 4 Oct 2026 21:21:43 +0200 Subject: [PATCH 3/4] Ratchet mautic parity gate to 1.0% --- .github/workflows/blink_parity_mautic.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/blink_parity_mautic.yaml b/.github/workflows/blink_parity_mautic.yaml index 9442706a57..0ad8867f88 100644 --- a/.github/workflows/blink_parity_mautic.yaml +++ b/.github/workflows/blink_parity_mautic.yaml @@ -95,7 +95,7 @@ jobs: - name: Compare the two trees run: | # ratchet gate: fail once the differing share crosses this; lower it as parity improves - MAX_DIFF_PERCENT=1.6 + MAX_DIFF_PERCENT=1.0 total=0 differ=0 differing_files="" From 5cc162a209868206dbc67dd2969494ea888bf18d Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Sun, 4 Oct 2026 21:37:45 +0200 Subject: [PATCH 4/4] Fix StatementIndentation: do not treat a keyword used as a name as a signature A contextual keyword lexed as a keyword but used as an identifier ("Enum::MODE_ADD", "new Match", "$o->enum") no longer starts a block_signature scope, which had stacked unpopped scopes and over-indented the closing "]);" of an array call argument. A following "\" is a namespace prefix on the operand ("case \Foo::BAR:") and does not count. --- .../fixer/rules/statement_indentation.go | 44 ++++++++++++++----- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/blink/internal/fixer/rules/statement_indentation.go b/blink/internal/fixer/rules/statement_indentation.go index 29141ba8ca..cd9f08ada3 100644 --- a/blink/internal/fixer/rules/statement_indentation.go +++ b/blink/internal/fixer/rules/statement_indentation.go @@ -1,6 +1,7 @@ package rules import ( + "slices" "strings" "blink/internal/fixer" @@ -78,7 +79,7 @@ func (f StatementIndentation) Fix(s *tokens.Stream) bool { changed := false - for index := 0; index < n; index++ { + for index := range n { t := s.At(index) cur := len(scopes) - 1 @@ -428,9 +429,38 @@ func stmtIsBlockSignatureFirst(s *tokens.Stream, index int) bool { if t.Kind != token.Keyword { return false } - return stmtKwIn(t.Value, "use", "if", "else", "elseif", "for", "foreach", "while", + if !stmtKwIn(t.Value, "use", "if", "else", "elseif", "for", "foreach", "while", "do", "switch", "case", "default", "try", "class", "interface", "trait", - "extends", "implements", "const", "match", "enum") + "extends", "implements", "const", "match", "enum") { + return false + } + // a contextual keyword used as a name ("Enum::X", "new Match", "$o->enum") + // is an identifier, not a block signature + return !stmtKeywordIsIdentifierUse(s, index) +} + +// stmtKeywordIsIdentifierUse reports whether the keyword at index is actually a +// class/member name reference rather than a language construct. +func stmtKeywordIsIdentifierUse(s *tokens.Stream, index int) bool { + if p := prevMeaningfulIndex(s, index); p >= 0 { + pt := s.At(p) + if pt.Kind == token.Keyword && stmtKwIn(pt.Value, "new", "function", "const", "instanceof") { + return true + } + if pt.Kind == token.Punct && (pt.Value == "::" || pt.Value == "->" || pt.Value == "?->" || pt.Value == `\`) { + return true + } + } + // a "::" directly after marks a class-name reference ("Enum::X"); a following + // "\" is a namespace prefix on an operand (e.g. "case \Foo::BAR:") and does not + // make the keyword itself a name + if nmi := nextMeaningfulIndex(s, index); nmi >= 0 { + nt := s.At(nmi) + if nt.Kind == token.Punct && nt.Value == "::" { + return true + } + } + return false } func stmtIsControlWithoutBracesKw(s *tokens.Stream, index int) bool { @@ -439,13 +469,7 @@ func stmtIsControlWithoutBracesKw(s *tokens.Stream, index int) bool { } func stmtKwIn(v string, set ...string) bool { - lv := strings.ToLower(v) - for _, w := range set { - if lv == w { - return true - } - } - return false + return slices.Contains(set, strings.ToLower(v)) } // stmtIsComment reports whether token i is a comment (line/block/doc), excluding