diff --git a/.github/workflows/blink_parity.yaml b/.github/workflows/blink_parity.yaml
new file mode 100644
index 0000000000..4d6e10a3bd
--- /dev/null
+++ b/.github/workflows/blink_parity.yaml
@@ -0,0 +1,162 @@
+name: Blink Parity
+
+# Runs blink and the ECS in this repository on the same real codebase (full
+# rector-src) and fails if blink's output differs from ECS (psr12 + common) on
+# any file. This is the 1:1 parity gate - it stays red until blink reproduces
+# ECS exactly.
+
+on:
+ pull_request:
+ paths:
+ - 'blink/**'
+ - 'src/**'
+ - 'packages/**'
+ - 'config/**'
+ - '.github/workflows/blink_parity.yaml'
+ workflow_dispatch: null
+
+jobs:
+ blink-parity:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version: "1.26.6"
+
+ - name: Build blink
+ working-directory: blink
+ run: go build -o /tmp/blink .
+
+ - uses: shivammathur/setup-php@v2
+ with:
+ php-version: "8.4"
+ coverage: none
+
+ # installs this repository's ECS so parity runs against the code being shipped
+ - uses: "ramsey/composer-install@v4"
+
+ - name: Fetch a real codebase sample
+ run: |
+ git clone --depth 1 https://github.com/rectorphp/rector-src.git /tmp/rector
+ # full rector-src tree; .php.inc fixtures are skipped by the *.php find below
+ mkdir -p /tmp/sample
+ cp -r /tmp/rector/src /tmp/rector/rules /tmp/rector/tests /tmp/sample/
+ cp -r /tmp/sample /tmp/ours
+ cp -r /tmp/sample /tmp/ecs
+
+ - name: Run blink
+ run: /tmp/blink --fix /tmp/ours || true
+
+ - name: Build the local ECS config
+ run: |
+ cat > /tmp/ecs.php <<'PHP'
+ withPreparedSets(psr12: true, common: true);
+ PHP
+
+ - name: Run the local ECS
+ run: bin/ecs check /tmp/ecs --fix --no-progress-bar --config /tmp/ecs.php || true
+
+ - name: Rank missing fixers (data-driven backlog)
+ run: |
+ # what ECS would apply on the pristine sample, as JSON
+ bin/ecs check /tmp/sample --output-format=json --config /tmp/ecs.php > /tmp/ecs.json 2>/dev/null || true
+ # fixers we already implement
+ /tmp/blink list-checkers | sed 's/^[[:space:]]*-[[:space:]]*//; /Registered/d' | sort -u > /tmp/impl.txt
+ # count applied checkers across files, most frequent first
+ jq -r '[.files[]?.diffs[]?.applied_checkers[]?] | group_by(.) | map("\(length) \(.[0])") | .[]' /tmp/ecs.json 2>/dev/null | sort -rn > /tmp/counts.txt || true
+ {
+ echo "## Backlog: ECS fixers not yet in blink, ranked by frequency on the sample"
+ echo ""
+ echo "| fixer | files |"
+ echo "|---|---|"
+ } >> "$GITHUB_STEP_SUMMARY"
+ n=0
+ while read -r count chk; do
+ [ "$n" -ge 25 ] && break
+ if ! grep -qxF "$chk" /tmp/impl.txt; then
+ echo "| \`$chk\` | $count |" >> "$GITHUB_STEP_SUMMARY"
+ n=$((n + 1))
+ fi
+ done < /tmp/counts.txt
+ if [ "$n" -eq 0 ]; then
+ echo "| (no data - check the ECS JSON shape) | |" >> "$GITHUB_STEP_SUMMARY"
+ fi
+ # stdout diagnostics (the summary table is not fetchable via CLI)
+ echo "=== implemented: $(wc -l < /tmp/impl.txt) | ecs.json bytes: $(wc -c < /tmp/ecs.json) ==="
+ echo "=== top applied checkers, raw (before excluding implemented) ==="
+ head -20 /tmp/counts.txt || true
+
+ - name: Compare outputs
+ run: |
+ total=0
+ differ=0
+ for f in $(cd /tmp/ours && find . -name '*.php'); do
+ total=$((total + 1))
+ if ! diff -q "/tmp/ours/$f" "/tmp/ecs/$f" >/dev/null 2>&1; then
+ differ=$((differ + 1))
+ fi
+ done
+ same=$((total - differ))
+ {
+ echo "## blink vs ECS (psr12 + common) on full rector-src"
+ echo ""
+ echo "| metric | value |"
+ echo "|---|---|"
+ echo "| files | $total |"
+ echo "| identical output | $same |"
+ echo "| differing output | $differ |"
+ echo ""
+ echo "This is the 1:1 parity gate: it fails while any file differs from ECS."
+ } >> "$GITHUB_STEP_SUMMARY"
+ echo "identical=$same differing=$differ of $total"
+
+ # Per differing file: which rules ECS applied vs blink, and the
+ # snippet they differ on. ECS applied checkers come from the JSON
+ # captured on the pristine sample; blink's from a dry-run check.
+ echo "### Divergences ($differ files): applied rules + diff" >> "$GITHUB_STEP_SUMMARY"
+ echo ""
+ echo "=== Divergences ($differ files) ==="
+ export LC_ALL=C
+ for f in $(cd /tmp/ours && find . -name '*.php' | sort); do
+ diff -q "/tmp/ours/$f" "/tmp/ecs/$f" >/dev/null 2>&1 && continue
+ rel=${f#./}
+ abs="/tmp/sample/$rel"
+ ecs_rules=$(jq -r --arg k "$abs" '.files[$k].diffs[]?.applied_checkers[]?' /tmp/ecs.json 2>/dev/null | sed -E 's#.*\\##' | sort -u)
+ go_rules=$(/tmp/blink "$abs" 2>/dev/null | sed -n 's/^[[:space:]]*\*[[:space:]]//p' | sed -E 's#.*\\##' | sort -u)
+ ecs_only=$(comm -23 <(printf '%s\n' "$ecs_rules") <(printf '%s\n' "$go_rules") | grep -v '^$' | paste -sd, - || true)
+ go_only=$(comm -13 <(printf '%s\n' "$ecs_rules") <(printf '%s\n' "$go_rules") | grep -v '^$' | paste -sd, - || true)
+ # stdout (CI log): file + which rules differ + the diff
+ echo ""
+ echo "--- $rel"
+ echo " ECS applied, blink did not: ${ecs_only:-(none)}"
+ echo " blink applied, ECS did not: ${go_only:-(none)}"
+ diff "/tmp/ours/$f" "/tmp/ecs/$f" | sed 's/^/ /' | head -40 || true
+ # job summary (rich, collapsible)
+ {
+ echo ""
+ echo "$rel
"
+ echo ""
+ echo "- **ECS applied, blink did not:** ${ecs_only:-(none)}"
+ echo "- **blink applied, ECS did not:** ${go_only:-(none)}"
+ echo ""
+ echo '```diff'
+ diff "/tmp/ours/$f" "/tmp/ecs/$f" | head -40 || true
+ echo '```'
+ echo " "
+ } >> "$GITHUB_STEP_SUMMARY"
+ done
+
+ # fail when blink output does not match ECS
+ if [ "$differ" -gt 0 ]; then
+ echo "::error::blink output differs from ECS on $differ of $total files"
+ exit 1
+ fi
diff --git a/.github/workflows/blink_rule_counts.yaml b/.github/workflows/blink_rule_counts.yaml
new file mode 100644
index 0000000000..08d6032299
--- /dev/null
+++ b/.github/workflows/blink_rule_counts.yaml
@@ -0,0 +1,32 @@
+name: Blink Rule counts
+
+on:
+ pull_request:
+ branches:
+ - main
+ paths:
+ - 'blink/**'
+ - '.github/workflows/blink_rule_counts.yaml'
+ push:
+ branches:
+ - main
+ paths:
+ - 'blink/**'
+ - '.github/workflows/blink_rule_counts.yaml'
+
+jobs:
+ rule-counts:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: blink
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version: "1.26.6"
+
+ # counts ECS/blink rules, writes them to the job summary
+ # (shown on every PR), and fails if README is stale
+ - run: bin/rule-counts.sh --check
diff --git a/.github/workflows/blink_tests.yaml b/.github/workflows/blink_tests.yaml
new file mode 100644
index 0000000000..18e0524b37
--- /dev/null
+++ b/.github/workflows/blink_tests.yaml
@@ -0,0 +1,144 @@
+name: Blink Tests
+
+on:
+ pull_request:
+ paths:
+ - 'blink/**'
+ - '.github/workflows/blink_tests.yaml'
+ push:
+ branches:
+ - main
+ paths:
+ - 'blink/**'
+ - '.github/workflows/blink_tests.yaml'
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: blink
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version: "1.26.6"
+
+ - run: go build ./...
+
+ - run: go vet ./...
+
+ - run: go test ./...
+
+ windows:
+ runs-on: windows-latest
+ defaults:
+ run:
+ working-directory: blink
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version: "1.26.6"
+
+ - run: go build -o blink.exe .
+
+ - run: go test ./...
+
+ - name: Fix a sample file
+ shell: bash
+ run: |
+ mkdir sample
+ printf ' sample/Sample.php
+ ./blink.exe --fix sample || true
+ grep -q '\$values = \[1, 2\];' sample/Sample.php
+
+ format:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: blink
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version: "1.26.6"
+
+ - name: Check gofmt -s
+ run: |
+ unformatted=$(gofmt -l -s $(find . -name '*.go'))
+ if [ -n "$unformatted" ]; then
+ echo "These files need 'gofmt -s -w':"
+ echo "$unformatted"
+ exit 1
+ fi
+
+ - name: Check go fix ./...
+ if: ${{ !cancelled() }}
+ run: |
+ changed=$(go fix -diff ./... 2>/dev/null)
+ if [ -n "$changed" ]; then
+ echo "These files need 'go fix ./...':"
+ echo "$changed"
+ exit 1
+ fi
+
+ analysis:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: blink
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version: "1.26.6"
+
+ - name: gopls modernize
+ run: go run golang.org/x/tools/gopls/internal/analysis/modernize/cmd/modernize@latest ./...
+
+ - uses: golangci/golangci-lint-action@v8
+ if: ${{ !cancelled() }}
+ with:
+ version: v2.12.2
+ working-directory: blink
+
+ security:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: blink
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version: "1.26.6"
+
+ - uses: actions/cache@v4
+ with:
+ path: ~/go/bin/govulncheck
+ key: govulncheck-v1.6.0
+ - name: install govulncheck
+ run: test -x ~/go/bin/govulncheck || go install golang.org/x/vuln/cmd/govulncheck@v1.6.0
+ - name: govulncheck
+ run: govulncheck ./...
+
+ fixer_sources:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: blink
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version: "1.26.6"
+
+ # verifies every fixer's SourceURL() points at a reachable PHP-CS-Fixer rule
+ - run: go run ./cmd/check-sources
diff --git a/.gitignore b/.gitignore
index 4866fbb652..be76b526d5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,3 +14,9 @@ php-scoper.phar
# gpt
.claude
+
+# blink Go build artifacts
+/blink/blink
+/blink/blink.exe
+*.test
+*.out
diff --git a/bin/blink b/bin/blink
new file mode 100755
index 0000000000..a0d1da0382
--- /dev/null
+++ b/bin/blink
@@ -0,0 +1,19 @@
+#!/usr/bin/env sh
+# Composer bin wrapper for blink, the Go coding-standard tool under blink/.
+# Builds the binary on first run (Go required) and execs it, so it works as
+# vendor/bin/blink after installation without a build step at install time.
+set -e
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+SRC="$ROOT/blink"
+BINARY="$SRC/blink"
+
+if [ ! -x "$BINARY" ]; then
+ if ! command -v go >/dev/null 2>&1; then
+ echo "blink: Go toolchain required to build the binary (https://go.dev/dl)" >&2
+ exit 1
+ fi
+ (cd "$SRC" && go build -o "$BINARY" .)
+fi
+
+exec "$BINARY" "$@"
diff --git a/blink/Makefile b/blink/Makefile
new file mode 100644
index 0000000000..1d890282c0
--- /dev/null
+++ b/blink/Makefile
@@ -0,0 +1,21 @@
+.PHONY: build check fix test vet clean
+
+BINARY := blink
+
+build:
+ go build -o $(BINARY) .
+
+check: build
+ ./$(BINARY) .
+
+fix: build
+ ./$(BINARY) --fix .
+
+test:
+ go test ./...
+
+vet:
+ go vet ./...
+
+clean:
+ rm -f $(BINARY)
diff --git a/blink/README.md b/blink/README.md
new file mode 100644
index 0000000000..ea0756170d
--- /dev/null
+++ b/blink/README.md
@@ -0,0 +1,123 @@
+# blink
+
+Fast, token-based PHP coding-standard checker and fixer - an [ECS](https://github.com/symplify/easy-coding-standard)-style
+tool written in Go. Runs across all CPU cores by default. Lives under `blink/`
+in the ECS repository and mirrors ECS's `psr12` + `common` output.
+
+## Rule coverage
+
+How many ECS rules blink implements. Regenerate with `bin/rule-counts.sh`;
+CI keeps it current.
+
+
+| tool | rules | of ECS |
+|---|---:|---:|
+| [ECS](https://github.com/symplify/easy-coding-standard) (baseline) | 208 | 100% |
+| blink | 209 | 100% |
+
+
+## Build
+
+Requires Go (see `go.mod` for the version).
+
+```bash
+cd blink
+make build # produces ./blink
+```
+
+## Usage
+
+Check your code (reports a diff of what would change, exit code 1 if issues):
+
+```bash
+./blink src tests
+```
+
+Fix in place:
+
+```bash
+./blink --fix src tests
+```
+
+List the active fixers:
+
+```bash
+./blink list-checkers
+```
+
+## Configuration
+
+Drop a `blink.json` in your project root (auto-loaded, or point at one with
+`--config`):
+
+```json
+{
+ "paths": ["src", "tests"],
+ "skip": ["*/Fixture/*"],
+ "sets": ["spaces"],
+ "level": {"spaces": 6}
+}
+```
+
+- `sets` - enable a prepared set: `spaces`, `casing`, `psr12`, `per-cs`, `common`.
+ `per-cs` mirrors PHP-CS-Fixer's `@PER-CS` (as ECS's `SetList::PER_CS`) and adds
+ `single_line_empty_body` on top of the full set.
+- `level` - gradual adoption: `{"spaces": N}` enables the first N rules of the
+ spaces set (safest first), so you can raise coverage one step at a time.
+- `rules` - enable individual fixers by name.
+- `paths` / `skip` - files to scan and glob patterns to ignore.
+
+With no config file, every fixer runs. CLI path arguments override `paths`.
+
+### Consuming an ECS config
+
+`blink --ecs-config ` reads the JSON that ECS's `ecs dump-config` produces
+(its resolved paths, rules and skips) and runs blink over it. Because every
+blink fixer is named by its PHP-CS-Fixer class, the ECS rules map straight onto
+blink fixers by class name. Any rule blink has no fixer for is reported and
+skipped, so the run is never silently narrower than the ECS config. Rule
+configuration is not modelled yet - a configured rule runs with blink's built-in
+behaviour, which is noted in the report.
+
+## Parity with ECS
+
+The `blink parity` CI workflow runs blink and the ECS in this repository over the
+same real codebase (full rector-src, `psr12` + `common`) and fails if blink's
+output differs from ECS on any file. It is the 1:1 parity gate: it stays red
+until blink reproduces ECS exactly, and its job summary ranks the missing fixers
+by how often ECS applies them on the sample.
+
+## PSR-12
+
+The `psr12` set implements the token-safe part of PHP-CS-Fixer's `@PSR-12`:
+casing (keywords, constants, static references, casts), operator spacing
+(assignment, arrow, comparison and logical operators), parenthesis and
+language-construct spacing, `else if` -> `elseif`, `declare` normalization,
+leading import slash removal, one import per statement, blank lines before a
+namespace, no blank lines after a class opening, and tab-to-space indentation.
+
+## What it looks like
+
+```
+1) src/Foo.php
+
+ ---------- begin diff ----------
+@@ @@
+ "
+END=""
+
+ecs=$(grep -oE '[0-9]+ rules configured in ECS' "$RULES_TXT" | grep -oE '^[0-9]+')
+
+go build -C "$ROOT" -o /tmp/blink-count . >&2
+go_count=$(/tmp/blink-count list-checkers | grep -cE '^[[:space:]]*- ')
+
+# rules ECS configures (across its sets) that blink does not implement yet
+missing=""
+if [ -f "$ALL_FIXERS" ]; then
+ /tmp/blink-count list-checkers | sed 's/^[[:space:]]*-[[:space:]]*//' | LC_ALL=C sort -u > /tmp/blink-impl.txt
+ missing=$(LC_ALL=C comm -23 <(LC_ALL=C sort -u "$ALL_FIXERS") /tmp/blink-impl.txt | sed -E 's#.*\\##')
+fi
+missing_count=$(printf '%s' "$missing" | grep -c . || true)
+
+go_pct=$(( go_count * 100 / ecs ))
+
+block=$(cat </dev/null | grep -c . || echo 0) ECS fixers)"
+ if [ -n "$missing" ]; then
+ printf '%s\n' "$missing" | sed 's/^/- `/; s/$/`/'
+ else
+ echo "None - full coverage."
+ fi
+ } >> "$GITHUB_STEP_SUMMARY"
+fi
+
+# Always print the missing list to stdout too (visible in CI logs).
+echo "Missing in blink ($missing_count):"
+if [ -n "$missing" ]; then
+ printf ' %s\n' $missing
+fi
+
+if [ "${1:-}" = "--check" ]; then
+ if ! diff <(printf '%s\n' "$new") "$README" >/dev/null; then
+ echo "README rule counts are stale. Run: bin/rule-counts.sh" >&2
+ diff "$README" <(printf '%s\n' "$new") || true
+ exit 1
+ fi
+ echo "Rule counts up to date: ECS $ecs, blink $go_count"
+else
+ printf '%s\n' "$new" > "$README"
+ echo "Updated README: ECS $ecs, blink $go_count"
+fi
diff --git a/blink/blink.json.dist b/blink/blink.json.dist
new file mode 100644
index 0000000000..58094726e9
--- /dev/null
+++ b/blink/blink.json.dist
@@ -0,0 +1,5 @@
+{
+ "paths": ["src", "tests"],
+ "skip": ["*/Fixture/*", "*/Source/*"],
+ "sets": ["psr12"]
+}
diff --git a/blink/cmd/check-sources/main.go b/blink/cmd/check-sources/main.go
new file mode 100644
index 0000000000..f4cb6a2f2c
--- /dev/null
+++ b/blink/cmd/check-sources/main.go
@@ -0,0 +1,49 @@
+// Command check-sources verifies that every registered fixer links to a
+// reachable PHP-CS-Fixer source file on GitHub. It is run in CI so a renamed or
+// removed upstream rule fails the build.
+package main
+
+import (
+ "fmt"
+ "net/http"
+ "os"
+ "time"
+
+ "blink/internal/fixer/rules"
+)
+
+func main() {
+ client := &http.Client{Timeout: 20 * time.Second}
+ failed := false
+
+ for _, f := range rules.All() {
+ url := f.SourceURL()
+
+ req, err := http.NewRequest(http.MethodHead, url, nil)
+ if err != nil {
+ fmt.Printf("FAIL %s: %v\n", f.Name(), err)
+ failed = true
+ continue
+ }
+
+ resp, err := client.Do(req)
+ if err != nil {
+ fmt.Printf("FAIL %s: %v\n", f.Name(), err)
+ failed = true
+ continue
+ }
+ _ = resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ fmt.Printf("FAIL %s: %s -> %d\n", f.Name(), url, resp.StatusCode)
+ failed = true
+ continue
+ }
+ fmt.Printf("OK %s\n", url)
+ }
+
+ if failed {
+ os.Exit(1)
+ }
+ fmt.Println("All fixer sources reachable.")
+}
diff --git a/blink/ecs-all-fixers.txt b/blink/ecs-all-fixers.txt
new file mode 100644
index 0000000000..cf80f1b6ef
--- /dev/null
+++ b/blink/ecs-all-fixers.txt
@@ -0,0 +1,206 @@
+PhpCsFixer\Fixer\Alias\NoAliasFunctionsFixer
+PhpCsFixer\Fixer\Alias\NoAliasLanguageConstructCallFixer
+PhpCsFixer\Fixer\Alias\NoMixedEchoPrintFixer
+PhpCsFixer\Fixer\ArrayNotation\ArraySyntaxFixer
+PhpCsFixer\Fixer\ArrayNotation\NoMultilineWhitespaceAroundDoubleArrowFixer
+PhpCsFixer\Fixer\ArrayNotation\NoWhitespaceBeforeCommaInArrayFixer
+PhpCsFixer\Fixer\ArrayNotation\NoWhitespaceInEmptyArrayFixer
+PhpCsFixer\Fixer\ArrayNotation\NormalizeIndexBraceFixer
+PhpCsFixer\Fixer\ArrayNotation\TrimArraySpacesFixer
+PhpCsFixer\Fixer\ArrayNotation\WhitespaceAfterCommaInArrayFixer
+PhpCsFixer\Fixer\AttributeNotation\AttributeBlockNoSpacesFixer
+PhpCsFixer\Fixer\Basic\BracesPositionFixer
+PhpCsFixer\Fixer\Basic\EncodingFixer
+PhpCsFixer\Fixer\Basic\NoMultipleStatementsPerLineFixer
+PhpCsFixer\Fixer\Basic\NoTrailingCommaInSinglelineFixer
+PhpCsFixer\Fixer\Basic\PsrAutoloadingFixer
+PhpCsFixer\Fixer\Basic\SingleLineEmptyBodyFixer
+PhpCsFixer\Fixer\Casing\ClassReferenceNameCasingFixer
+PhpCsFixer\Fixer\Casing\ConstantCaseFixer
+PhpCsFixer\Fixer\Casing\IntegerLiteralCaseFixer
+PhpCsFixer\Fixer\Casing\LowercaseKeywordsFixer
+PhpCsFixer\Fixer\Casing\LowercaseStaticReferenceFixer
+PhpCsFixer\Fixer\Casing\MagicConstantCasingFixer
+PhpCsFixer\Fixer\Casing\MagicMethodCasingFixer
+PhpCsFixer\Fixer\Casing\NativeFunctionCasingFixer
+PhpCsFixer\Fixer\Casing\NativeTypeDeclarationCasingFixer
+PhpCsFixer\Fixer\CastNotation\CastSpacesFixer
+PhpCsFixer\Fixer\CastNotation\LowercaseCastFixer
+PhpCsFixer\Fixer\CastNotation\NoShortBoolCastFixer
+PhpCsFixer\Fixer\CastNotation\NoUnsetCastFixer
+PhpCsFixer\Fixer\CastNotation\ShortScalarCastFixer
+PhpCsFixer\Fixer\ClassNotation\ClassAttributesSeparationFixer
+PhpCsFixer\Fixer\ClassNotation\ClassDefinitionFixer
+PhpCsFixer\Fixer\ClassNotation\ModifierKeywordsFixer
+PhpCsFixer\Fixer\ClassNotation\NoBlankLinesAfterClassOpeningFixer
+PhpCsFixer\Fixer\ClassNotation\NoNullPropertyInitializationFixer
+PhpCsFixer\Fixer\ClassNotation\OrderedClassElementsFixer
+PhpCsFixer\Fixer\ClassNotation\OrderedInterfacesFixer
+PhpCsFixer\Fixer\ClassNotation\OrderedTraitsFixer
+PhpCsFixer\Fixer\ClassNotation\OrderedTypesFixer
+PhpCsFixer\Fixer\ClassNotation\ProtectedToPrivateFixer
+PhpCsFixer\Fixer\ClassNotation\SelfAccessorFixer
+PhpCsFixer\Fixer\ClassNotation\SelfStaticAccessorFixer
+PhpCsFixer\Fixer\ClassNotation\SingleClassElementPerStatementFixer
+PhpCsFixer\Fixer\ClassNotation\SingleTraitInsertPerStatementFixer
+PhpCsFixer\Fixer\ClassNotation\VisibilityRequiredFixer
+PhpCsFixer\Fixer\Comment\MultilineCommentOpeningClosingFixer
+PhpCsFixer\Fixer\Comment\NoEmptyCommentFixer
+PhpCsFixer\Fixer\Comment\NoTrailingWhitespaceInCommentFixer
+PhpCsFixer\Fixer\Comment\SingleLineCommentSpacingFixer
+PhpCsFixer\Fixer\Comment\SingleLineCommentStyleFixer
+PhpCsFixer\Fixer\ControlStructure\ControlStructureBracesFixer
+PhpCsFixer\Fixer\ControlStructure\ControlStructureContinuationPositionFixer
+PhpCsFixer\Fixer\ControlStructure\ElseifFixer
+PhpCsFixer\Fixer\ControlStructure\IncludeFixer
+PhpCsFixer\Fixer\ControlStructure\NoAlternativeSyntaxFixer
+PhpCsFixer\Fixer\ControlStructure\NoBreakCommentFixer
+PhpCsFixer\Fixer\ControlStructure\NoSuperfluousElseifFixer
+PhpCsFixer\Fixer\ControlStructure\NoUnneededBracesFixer
+PhpCsFixer\Fixer\ControlStructure\NoUnneededControlParenthesesFixer
+PhpCsFixer\Fixer\ControlStructure\NoUnneededCurlyBracesFixer
+PhpCsFixer\Fixer\ControlStructure\SwitchCaseSemicolonToColonFixer
+PhpCsFixer\Fixer\ControlStructure\SwitchCaseSpaceFixer
+PhpCsFixer\Fixer\ControlStructure\SwitchContinueToBreakFixer
+PhpCsFixer\Fixer\ControlStructure\TrailingCommaInMultilineFixer
+PhpCsFixer\Fixer\ControlStructure\YodaStyleFixer
+PhpCsFixer\Fixer\DoctrineAnnotation\DoctrineAnnotationArrayAssignmentFixer
+PhpCsFixer\Fixer\DoctrineAnnotation\DoctrineAnnotationIndentationFixer
+PhpCsFixer\Fixer\DoctrineAnnotation\DoctrineAnnotationSpacesFixer
+PhpCsFixer\Fixer\FunctionNotation\FunctionDeclarationFixer
+PhpCsFixer\Fixer\FunctionNotation\LambdaNotUsedImportFixer
+PhpCsFixer\Fixer\FunctionNotation\MethodArgumentSpaceFixer
+PhpCsFixer\Fixer\FunctionNotation\NoSpacesAfterFunctionNameFixer
+PhpCsFixer\Fixer\FunctionNotation\NoUnreachableDefaultArgumentValueFixer
+PhpCsFixer\Fixer\FunctionNotation\NullableTypeDeclarationForDefaultNullValueFixer
+PhpCsFixer\Fixer\FunctionNotation\ReturnTypeDeclarationFixer
+PhpCsFixer\Fixer\Import\FullyQualifiedStrictTypesFixer
+PhpCsFixer\Fixer\Import\NoLeadingImportSlashFixer
+PhpCsFixer\Fixer\Import\NoUnneededImportAliasFixer
+PhpCsFixer\Fixer\Import\NoUnusedImportsFixer
+PhpCsFixer\Fixer\Import\OrderedImportsFixer
+PhpCsFixer\Fixer\Import\SingleImportPerStatementFixer
+PhpCsFixer\Fixer\Import\SingleLineAfterImportsFixer
+PhpCsFixer\Fixer\LanguageConstruct\DeclareEqualNormalizeFixer
+PhpCsFixer\Fixer\LanguageConstruct\DeclareParenthesesFixer
+PhpCsFixer\Fixer\LanguageConstruct\ExplicitIndirectVariableFixer
+PhpCsFixer\Fixer\LanguageConstruct\FunctionToConstantFixer
+PhpCsFixer\Fixer\LanguageConstruct\IsNullFixer
+PhpCsFixer\Fixer\LanguageConstruct\NullableTypeDeclarationFixer
+PhpCsFixer\Fixer\LanguageConstruct\SingleSpaceAroundConstructFixer
+PhpCsFixer\Fixer\ListNotation\ListSyntaxFixer
+PhpCsFixer\Fixer\NamespaceNotation\BlankLineAfterNamespaceFixer
+PhpCsFixer\Fixer\NamespaceNotation\BlankLinesBeforeNamespaceFixer
+PhpCsFixer\Fixer\NamespaceNotation\CleanNamespaceFixer
+PhpCsFixer\Fixer\NamespaceNotation\NoLeadingNamespaceWhitespaceFixer
+PhpCsFixer\Fixer\NamespaceNotation\SingleBlankLineBeforeNamespaceFixer
+PhpCsFixer\Fixer\Operator\AssignNullCoalescingToCoalesceEqualFixer
+PhpCsFixer\Fixer\Operator\BinaryOperatorSpacesFixer
+PhpCsFixer\Fixer\Operator\ConcatSpaceFixer
+PhpCsFixer\Fixer\Operator\IncrementStyleFixer
+PhpCsFixer\Fixer\Operator\LongToShorthandOperatorFixer
+PhpCsFixer\Fixer\Operator\NewWithParenthesesFixer
+PhpCsFixer\Fixer\Operator\NoSpaceAroundDoubleColonFixer
+PhpCsFixer\Fixer\Operator\NoUselessConcatOperatorFixer
+PhpCsFixer\Fixer\Operator\NoUselessNullsafeOperatorFixer
+PhpCsFixer\Fixer\Operator\NotOperatorWithSuccessorSpaceFixer
+PhpCsFixer\Fixer\Operator\ObjectOperatorWithoutWhitespaceFixer
+PhpCsFixer\Fixer\Operator\OperatorLinebreakFixer
+PhpCsFixer\Fixer\Operator\StandardizeIncrementFixer
+PhpCsFixer\Fixer\Operator\StandardizeNotEqualsFixer
+PhpCsFixer\Fixer\Operator\TernaryOperatorSpacesFixer
+PhpCsFixer\Fixer\Operator\TernaryToNullCoalescingFixer
+PhpCsFixer\Fixer\Operator\UnaryOperatorSpacesFixer
+PhpCsFixer\Fixer\PhpTag\BlankLineAfterOpeningTagFixer
+PhpCsFixer\Fixer\PhpTag\FullOpeningTagFixer
+PhpCsFixer\Fixer\PhpTag\LinebreakAfterOpeningTagFixer
+PhpCsFixer\Fixer\PhpTag\NoClosingTagFixer
+PhpCsFixer\Fixer\PhpUnit\PhpUnitMethodCasingFixer
+PhpCsFixer\Fixer\PhpUnit\PhpUnitSetUpTearDownVisibilityFixer
+PhpCsFixer\Fixer\Phpdoc\AlignMultilineCommentFixer
+PhpCsFixer\Fixer\Phpdoc\GeneralPhpdocAnnotationRemoveFixer
+PhpCsFixer\Fixer\Phpdoc\GeneralPhpdocTagRenameFixer
+PhpCsFixer\Fixer\Phpdoc\NoBlankLinesAfterPhpdocFixer
+PhpCsFixer\Fixer\Phpdoc\NoEmptyPhpdocFixer
+PhpCsFixer\Fixer\Phpdoc\NoSuperfluousPhpdocTagsFixer
+PhpCsFixer\Fixer\Phpdoc\PhpdocAlignFixer
+PhpCsFixer\Fixer\Phpdoc\PhpdocIndentFixer
+PhpCsFixer\Fixer\Phpdoc\PhpdocInlineTagNormalizerFixer
+PhpCsFixer\Fixer\Phpdoc\PhpdocLineSpanFixer
+PhpCsFixer\Fixer\Phpdoc\PhpdocNoAccessFixer
+PhpCsFixer\Fixer\Phpdoc\PhpdocNoAliasTagFixer
+PhpCsFixer\Fixer\Phpdoc\PhpdocNoDuplicateTypesFixer
+PhpCsFixer\Fixer\Phpdoc\PhpdocNoEmptyReturnFixer
+PhpCsFixer\Fixer\Phpdoc\PhpdocNoPackageFixer
+PhpCsFixer\Fixer\Phpdoc\PhpdocNoUselessInheritdocFixer
+PhpCsFixer\Fixer\Phpdoc\PhpdocOrderByValueFixer
+PhpCsFixer\Fixer\Phpdoc\PhpdocOrderFixer
+PhpCsFixer\Fixer\Phpdoc\PhpdocReturnSelfReferenceFixer
+PhpCsFixer\Fixer\Phpdoc\PhpdocScalarFixer
+PhpCsFixer\Fixer\Phpdoc\PhpdocSeparationFixer
+PhpCsFixer\Fixer\Phpdoc\PhpdocSingleLineVarSpacingFixer
+PhpCsFixer\Fixer\Phpdoc\PhpdocSummaryFixer
+PhpCsFixer\Fixer\Phpdoc\PhpdocTagCasingFixer
+PhpCsFixer\Fixer\Phpdoc\PhpdocTagTypeFixer
+PhpCsFixer\Fixer\Phpdoc\PhpdocToCommentFixer
+PhpCsFixer\Fixer\Phpdoc\PhpdocTrimConsecutiveBlankLineSeparationFixer
+PhpCsFixer\Fixer\Phpdoc\PhpdocTrimFixer
+PhpCsFixer\Fixer\Phpdoc\PhpdocTypesFixer
+PhpCsFixer\Fixer\Phpdoc\PhpdocVarWithoutNameFixer
+PhpCsFixer\Fixer\ReturnNotation\NoUselessReturnFixer
+PhpCsFixer\Fixer\ReturnNotation\SimplifiedNullReturnFixer
+PhpCsFixer\Fixer\Semicolon\MultilineWhitespaceBeforeSemicolonsFixer
+PhpCsFixer\Fixer\Semicolon\NoEmptyStatementFixer
+PhpCsFixer\Fixer\Semicolon\NoSinglelineWhitespaceBeforeSemicolonsFixer
+PhpCsFixer\Fixer\Semicolon\SpaceAfterSemicolonFixer
+PhpCsFixer\Fixer\StringNotation\ExplicitStringVariableFixer
+PhpCsFixer\Fixer\StringNotation\HeredocToNowdocFixer
+PhpCsFixer\Fixer\StringNotation\NoBinaryStringFixer
+PhpCsFixer\Fixer\StringNotation\SingleQuoteFixer
+PhpCsFixer\Fixer\Whitespace\ArrayIndentationFixer
+PhpCsFixer\Fixer\Whitespace\BlankLineBeforeStatementFixer
+PhpCsFixer\Fixer\Whitespace\BlankLineBetweenImportGroupsFixer
+PhpCsFixer\Fixer\Whitespace\CompactNullableTypeDeclarationFixer
+PhpCsFixer\Fixer\Whitespace\IndentationTypeFixer
+PhpCsFixer\Fixer\Whitespace\LineEndingFixer
+PhpCsFixer\Fixer\Whitespace\MethodChainingIndentationFixer
+PhpCsFixer\Fixer\Whitespace\NoExtraBlankLinesFixer
+PhpCsFixer\Fixer\Whitespace\NoSpacesAroundOffsetFixer
+PhpCsFixer\Fixer\Whitespace\NoTrailingWhitespaceFixer
+PhpCsFixer\Fixer\Whitespace\NoWhitespaceInBlankLineFixer
+PhpCsFixer\Fixer\Whitespace\SingleBlankLineAtEofFixer
+PhpCsFixer\Fixer\Whitespace\SpacesInsideParenthesesFixer
+PhpCsFixer\Fixer\Whitespace\StatementIndentationFixer
+PhpCsFixer\Fixer\Whitespace\TypeDeclarationSpacesFixer
+PhpCsFixer\Fixer\Whitespace\TypesSpacesFixer
+Symplify\CodingStandard\Fixer\Annotation\RemoveEventSubscriberDescriptionFixer
+Symplify\CodingStandard\Fixer\Annotation\RemoveMethodNameDuplicateDescriptionFixer
+Symplify\CodingStandard\Fixer\Annotation\RemovePHPStormAnnotationFixer
+Symplify\CodingStandard\Fixer\Annotation\RemovePropertyVariableNameDescriptionFixer
+Symplify\CodingStandard\Fixer\ArrayNotation\ArrayListItemNewlineFixer
+Symplify\CodingStandard\Fixer\ArrayNotation\ArrayOpenerAndCloserNewlineFixer
+Symplify\CodingStandard\Fixer\ArrayNotation\StandaloneLineInMultilineArrayFixer
+Symplify\CodingStandard\Fixer\Commenting\AddMissingParamNameFixer
+Symplify\CodingStandard\Fixer\Commenting\AddMissingVarNameFixer
+Symplify\CodingStandard\Fixer\Commenting\DoubleAsteriskInlineVarFixer
+Symplify\CodingStandard\Fixer\Commenting\FixParamNameTypoFixer
+Symplify\CodingStandard\Fixer\Commenting\FixTagTypoFixer
+Symplify\CodingStandard\Fixer\Commenting\MergeDocBlockStartFixer
+Symplify\CodingStandard\Fixer\Commenting\ParamReturnAndVarTagMalformsFixer
+Symplify\CodingStandard\Fixer\Commenting\RemoveDeadParamFixer
+Symplify\CodingStandard\Fixer\Commenting\RemoveDeadVarThisFixer
+Symplify\CodingStandard\Fixer\Commenting\RemoveParamNameReferenceFixer
+Symplify\CodingStandard\Fixer\Commenting\RemoveSuperfluousReturnNameFixer
+Symplify\CodingStandard\Fixer\Commenting\RemoveSuperfluousVarNameFixer
+Symplify\CodingStandard\Fixer\Commenting\RemoveUselessDefaultCommentFixer
+Symplify\CodingStandard\Fixer\Commenting\SingleLineInlineVarDocBlockFixer
+Symplify\CodingStandard\Fixer\Commenting\SwitchedTypeAndNameFixer
+Symplify\CodingStandard\Fixer\Commenting\TypeToVarTagFixer
+Symplify\CodingStandard\Fixer\Spacing\MethodChainingNewlineFixer
+Symplify\CodingStandard\Fixer\Spacing\NoBlankLineBetweenImportsFixer
+Symplify\CodingStandard\Fixer\Spacing\SpaceAfterCommaHereNowDocFixer
+Symplify\CodingStandard\Fixer\Spacing\StandaloneLinePlainConstructorParamFixer
+Symplify\CodingStandard\Fixer\Spacing\StandaloneLinePromotedPropertyFixer
+Symplify\CodingStandard\Fixer\Spacing\StandaloneLineRequiredParamFixer
+Symplify\CodingStandard\Fixer\Spacing\StandaloneLineSymfonyAttributeParamFixer
+Symplify\CodingStandard\Fixer\Strict\BlankLineAfterStrictTypesFixer
diff --git a/blink/ecs-rules.txt b/blink/ecs-rules.txt
new file mode 100644
index 0000000000..c279bf6215
--- /dev/null
+++ b/blink/ecs-rules.txt
@@ -0,0 +1,43 @@
+ECS rules NOT yet implemented in blink
+=======================================
+
+Source: every Fixer/Sniff referenced across ecs-src config/set/* and
+src/Config/Level/*, listed in ecs-all-fixers.txt.
+
+208 rules configured in ECS. The live implemented/missing counts are in the
+README (bin/rule-counts.sh keeps them current). Regenerate the list below with:
+
+ comm -23 <(sort -u blink/ecs-all-fixers.txt) \
+ <(./blink list-checkers | sed 's/^[[:space:]]*-[[:space:]]*//' | sort -u) \
+ | sed -E 's#.*\\##'
+
+A few "missing" names below are already implemented under the fixer's newer
+PHP-CS-Fixer name and only differ by alias:
+ CurlyBracesPositionFixer -> BracesPositionFixer (done)
+ CompactNullableTypehintFixer -> CompactNullableTypeDeclarationFixer (done)
+ NoSpacesInsideParenthesisFixer -> SpacesInsideParenthesesFixer (done)
+ FunctionTypehintSpaceFixer -> TypeDeclarationSpacesFixer (done, deprecated alias)
+
+PsrAutoloadingFixer cannot be ported: blink fixers receive only a token stream,
+not the file path the rule needs.
+
+
+NOT YET IMPLEMENTED (behavior)
+------------------------------
+control structures: NoBreakComment
+functions: LambdaNotUsedImport
+class: ModifierKeywords
+operators: OperatorLinebreak
+types: FullyQualifiedStrictTypes
+phpdoc: PhpdocSeparation, PhpdocToComment, GeneralPhpdocTagRename,
+ ParamReturnAndVarTagMalforms
+doctrine annotations: DoctrineAnnotationArrayAssignment, DoctrineAnnotationIndentation
+ (need an annotation parser)
+symplify custom: StandaloneLinePlainConstructorParam, StandaloneLineRequiredParam,
+ StandaloneLineSymfonyAttributeParam
+
+
+RECENTLY IMPLEMENTED
+--------------------
+ControlStructureBraces, NoAlternativeSyntax, NoUnneededBraces
+(NoUnneededCurlyBraces), NoUnreachableDefaultArgumentValue.
diff --git a/blink/go.mod b/blink/go.mod
new file mode 100644
index 0000000000..fb5c4a990f
--- /dev/null
+++ b/blink/go.mod
@@ -0,0 +1,3 @@
+module blink
+
+go 1.26
diff --git a/blink/internal/config/config.go b/blink/internal/config/config.go
new file mode 100644
index 0000000000..60f9944989
--- /dev/null
+++ b/blink/internal/config/config.go
@@ -0,0 +1,122 @@
+// Package config mirrors ECSConfig: paths to scan, skip patterns, active rules.
+// A config can be built fluently in Go or loaded from a blink.json file.
+package config
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "runtime"
+
+ "blink/internal/fixer"
+ "blink/internal/fixer/rules"
+ "blink/internal/set"
+)
+
+type Config struct {
+ Paths []string
+ Skip []string // filepath.Match globs tested against each path
+ Rules []fixer.Fixer
+ Jobs int // parallel workers
+}
+
+// Configure returns a config seeded with the default psr12 + common sets,
+// echoing ECSConfig::configure()->withPreparedSets(psr12: true, common: true).
+func Configure() *Config {
+ return &Config{
+ Paths: []string{"."},
+ Rules: set.Default(),
+ Jobs: runtime.NumCPU(),
+ }
+}
+
+func (c *Config) WithPaths(paths ...string) *Config {
+ if len(paths) > 0 {
+ c.Paths = paths
+ }
+ return c
+}
+
+func (c *Config) WithSkip(patterns ...string) *Config {
+ c.Skip = append(c.Skip, patterns...)
+ return c
+}
+
+func (c *Config) WithRules(rs ...fixer.Fixer) *Config {
+ c.Rules = rs
+ return c
+}
+
+// file is the on-disk blink.json shape.
+type file struct {
+ Paths []string `json:"paths"`
+ Skip []string `json:"skip"`
+ Sets []string `json:"sets"`
+ Level map[string]int `json:"level"`
+ Rules []string `json:"rules"`
+}
+
+// Load reads and resolves a blink.json config file.
+func Load(path string) (*Config, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+ var f file
+ if err := json.Unmarshal(data, &f); err != nil {
+ return nil, fmt.Errorf("config %s: %w", path, err)
+ }
+ return resolve(f)
+}
+
+// resolve turns a config file into a Config, collecting rules from sets, levels
+// and explicit names, de-duplicated and kept in canonical (safest-first) order.
+func resolve(f file) (*Config, error) {
+ c := &Config{Paths: f.Paths, Skip: f.Skip, Jobs: runtime.NumCPU()}
+ if len(c.Paths) == 0 {
+ c.Paths = []string{"."}
+ }
+
+ wanted := map[string]bool{}
+ selective := false
+
+ for _, name := range f.Sets {
+ fixers, ok := set.Get(name)
+ if !ok {
+ return nil, fmt.Errorf("unknown set %q", name)
+ }
+ selective = true
+ for _, fx := range fixers {
+ wanted[fx.Name()] = true
+ }
+ }
+
+ for name, n := range f.Level {
+ if name != "spaces" {
+ return nil, fmt.Errorf("unknown level %q (only \"spaces\" is supported)", name)
+ }
+ selective = true
+ for _, fx := range set.SpacesLevel(n) {
+ wanted[fx.Name()] = true
+ }
+ }
+
+ for _, name := range f.Rules {
+ if _, ok := rules.ByName(name); !ok {
+ return nil, fmt.Errorf("unknown rule %q", name)
+ }
+ selective = true
+ wanted[name] = true
+ }
+
+ if !selective {
+ c.Rules = set.Default()
+ return c, nil
+ }
+ for _, fx := range rules.All() {
+ if wanted[fx.Name()] {
+ c.Rules = append(c.Rules, fx)
+ }
+ }
+ return c, nil
+}
diff --git a/blink/internal/config/config_test.go b/blink/internal/config/config_test.go
new file mode 100644
index 0000000000..6904d8b1f2
--- /dev/null
+++ b/blink/internal/config/config_test.go
@@ -0,0 +1,104 @@
+package config
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "blink/internal/fixer/rules"
+ "blink/internal/set"
+)
+
+func write(t *testing.T, body string) string {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), "blink.json")
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ return path
+}
+
+func TestLoadDefaultsToDefaultSet(t *testing.T) {
+ c, err := Load(write(t, `{}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(c.Rules) != len(set.Default()) {
+ t.Fatalf("empty config should enable the default psr12+common set, got %d", len(c.Rules))
+ }
+ if len(c.Paths) != 1 || c.Paths[0] != "." {
+ t.Fatalf("default path should be '.', got %v", c.Paths)
+ }
+}
+
+func TestLoadSet(t *testing.T) {
+ c, err := Load(write(t, `{"sets":["spaces"],"paths":["src"],"skip":["*/Fixture/*"]}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(c.Rules) != len(rules.SpacingFixers()) {
+ t.Fatalf("spaces set should enable the spacing rules, got %d", len(c.Rules))
+ }
+ if len(c.Paths) != 1 || c.Paths[0] != "src" {
+ t.Fatalf("paths not read: %v", c.Paths)
+ }
+ if len(c.Skip) != 1 {
+ t.Fatalf("skip not read: %v", c.Skip)
+ }
+}
+
+func TestLoadLevelIsPrefix(t *testing.T) {
+ c, err := Load(write(t, `{"level":{"spaces":3}}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(c.Rules) != 3 {
+ t.Fatalf("level 3 should give 3 rules, got %d", len(c.Rules))
+ }
+ // resolve keeps canonical (All) order; the 3 chosen must be the first 3
+ // spacing rules, appearing in All in the same relative order
+ spacing := rules.SpacingFixers()
+ wantNames := map[string]bool{spacing[0].Name(): true, spacing[1].Name(): true, spacing[2].Name(): true}
+ for _, f := range c.Rules {
+ if !wantNames[f.Name()] {
+ t.Fatalf("level 3 selected unexpected rule %s", f.Name())
+ }
+ }
+}
+
+func TestLoadExplicitRule(t *testing.T) {
+ name := rules.All()[0].Name()
+ c, err := Load(write(t, `{"rules":["`+jsonEscape(name)+`"]}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(c.Rules) != 1 || c.Rules[0].Name() != name {
+ t.Fatalf("explicit rule not selected: %v", c.Rules)
+ }
+}
+
+func TestLoadUnknownErrors(t *testing.T) {
+ cases := []string{
+ `{"sets":["nope"]}`,
+ `{"level":{"psr12":1}}`,
+ `{"rules":["Nope\\Fixer"]}`,
+ }
+ for _, body := range cases {
+ if _, err := Load(write(t, body)); err == nil {
+ t.Errorf("expected error for %s", body)
+ }
+ }
+}
+
+// jsonEscape escapes backslashes for embedding a PHP FQCN in a JSON literal.
+func jsonEscape(s string) string {
+ out := make([]rune, 0, len(s)*2)
+ for _, r := range s {
+ if r == '\\' {
+ out = append(out, '\\', '\\')
+ continue
+ }
+ out = append(out, r)
+ }
+ return string(out)
+}
diff --git a/blink/internal/config/ecsconfig.go b/blink/internal/config/ecsconfig.go
new file mode 100644
index 0000000000..57ca619f8b
--- /dev/null
+++ b/blink/internal/config/ecsconfig.go
@@ -0,0 +1,129 @@
+package config
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "runtime"
+ "sort"
+
+ "blink/internal/fixer/rules"
+)
+
+// Turbo mode: ECS dumps its resolved ecs.php configuration to JSON (paths, rules,
+// skips) with `ecs dump-config`, and blink consumes it here. Every blink fixer
+// is named by its PHP-CS-Fixer FQCN, which is exactly the class ECS reports, so
+// the rules map by name without a translation table.
+
+// ecsConfiguredRule is one ECS sniff or fixer: a fully qualified class name and
+// its configuration.
+type ecsConfiguredRule struct {
+ Class string `json:"class"`
+ Config map[string]any `json:"config"`
+}
+
+// ecsSkip is one ECS skip entry: a path to exclude, a rule to disable, or a rule
+// disabled only on some paths.
+type ecsSkip struct {
+ Path string `json:"path"`
+ Class string `json:"class"`
+ Paths []string `json:"paths"`
+}
+
+// ecsFile is the on-disk shape of `ecs dump-config` output.
+type ecsFile struct {
+ PHPVersion string `json:"php_version"`
+ Paths []string `json:"paths"`
+ Rules []ecsConfiguredRule `json:"rules"`
+ Skips []ecsSkip `json:"skips"`
+}
+
+// ECSResolution reports how an ECS config mapped onto blink, so a turbo run is
+// never silently narrower than the config it stands in for.
+type ECSResolution struct {
+ // Total is the number of ECS rules in the config.
+ Total int
+ // Mapped is the number of rules resolved to a blink fixer.
+ Mapped int
+ // Unsupported are rule classes blink has no fixer for.
+ Unsupported []string
+ // ConfigIgnored are mapped rules that carried configuration; blink applies
+ // its built-in behaviour for the fixer rather than the ECS options.
+ ConfigIgnored []string
+ // PerPathSkips are class-on-path skips applied project-wide, since blink
+ // cannot yet skip a single rule on a subset of files.
+ PerPathSkips []string
+}
+
+// LoadECS reads an `ecs dump-config` JSON file and resolves it to a blink
+// config, returning a report of what could not be mapped.
+func LoadECS(path string) (*Config, *ECSResolution, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ var f ecsFile
+ if err := json.Unmarshal(data, &f); err != nil {
+ return nil, nil, fmt.Errorf("ECS config %s: %w", path, err)
+ }
+
+ return resolveECS(f)
+}
+
+// resolveECS turns an ECS dump-config into a Config plus a mapping report.
+func resolveECS(f ecsFile) (*Config, *ECSResolution, error) {
+ config := &Config{Paths: f.Paths, Jobs: runtime.NumCPU()}
+ if len(config.Paths) == 0 {
+ config.Paths = []string{"."}
+ }
+
+ resolution := &ECSResolution{Total: len(f.Rules)}
+
+ skippedClasses := map[string]bool{}
+ for _, skip := range f.Skips {
+ switch {
+ case skip.Class != "" && skip.Path == "" && len(skip.Paths) == 0:
+ skippedClasses[skip.Class] = true
+ case skip.Class != "":
+ // A rule disabled only on some paths: blink cannot skip a single rule
+ // on a subset of files, so note it and let the rule run everywhere
+ // rather than drop it project-wide.
+ resolution.PerPathSkips = append(resolution.PerPathSkips, skip.Class)
+ default:
+ if skip.Path != "" {
+ config.Skip = append(config.Skip, skip.Path)
+ }
+ config.Skip = append(config.Skip, skip.Paths...)
+ }
+ }
+
+ seen := map[string]bool{}
+ for _, rule := range f.Rules {
+ if skippedClasses[rule.Class] {
+ continue
+ }
+
+ fixer, ok := rules.ByName(rule.Class)
+ if !ok {
+ resolution.Unsupported = append(resolution.Unsupported, rule.Class)
+ continue
+ }
+
+ if len(rule.Config) > 0 {
+ resolution.ConfigIgnored = append(resolution.ConfigIgnored, rule.Class)
+ }
+
+ if !seen[rule.Class] {
+ seen[rule.Class] = true
+ resolution.Mapped++
+ config.Rules = append(config.Rules, fixer)
+ }
+ }
+
+ sort.Strings(resolution.Unsupported)
+ sort.Strings(resolution.ConfigIgnored)
+ sort.Strings(resolution.PerPathSkips)
+
+ return config, resolution, nil
+}
diff --git a/blink/internal/config/ecsconfig_test.go b/blink/internal/config/ecsconfig_test.go
new file mode 100644
index 0000000000..5b168623f0
--- /dev/null
+++ b/blink/internal/config/ecsconfig_test.go
@@ -0,0 +1,161 @@
+package config
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// esc doubles backslashes so an FQCN can be embedded in a JSON string literal.
+func esc(s string) string {
+ return strings.ReplaceAll(s, "\\", "\\\\")
+}
+
+const (
+ knownRuleA = `PhpCsFixer\Fixer\Alias\NoMixedEchoPrintFixer`
+ knownRuleB = `PhpCsFixer\Fixer\Alias\NoAliasFunctionsFixer`
+ unknownRule = `PhpCsFixer\Fixer\Nonexistent\FooBarFixer`
+)
+
+func writeECS(t *testing.T, body string) string {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), "ecs-config.json")
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ return path
+}
+
+func TestLoadECSMapsRulesByClassName(t *testing.T) {
+ c, resolution, err := LoadECS(writeECS(t, `{
+ "paths": ["src"],
+ "rules": [
+ {"class": "`+esc(knownRuleA)+`", "config": {}},
+ {"class": "`+esc(knownRuleB)+`", "config": {}}
+ ]
+ }`))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if len(c.Rules) != 2 {
+ t.Fatalf("expected 2 mapped rules, got %d", len(c.Rules))
+ }
+ if resolution.Mapped != 2 || resolution.Total != 2 {
+ t.Fatalf("resolution mapped/total = %d/%d, want 2/2", resolution.Mapped, resolution.Total)
+ }
+ if len(c.Paths) != 1 || c.Paths[0] != "src" {
+ t.Fatalf("paths not read: %v", c.Paths)
+ }
+}
+
+func TestLoadECSReportsUnknownRule(t *testing.T) {
+ c, resolution, err := LoadECS(writeECS(t, `{
+ "rules": [
+ {"class": "`+esc(knownRuleA)+`", "config": {}},
+ {"class": "`+esc(unknownRule)+`", "config": {}}
+ ]
+ }`))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if len(c.Rules) != 1 {
+ t.Fatalf("expected 1 mapped rule, got %d", len(c.Rules))
+ }
+ if len(resolution.Unsupported) != 1 || resolution.Unsupported[0] != unknownRule {
+ t.Fatalf("unsupported = %v, want [%s]", resolution.Unsupported, unknownRule)
+ }
+}
+
+func TestLoadECSDefaultsPathToDot(t *testing.T) {
+ c, _, err := LoadECS(writeECS(t, `{"rules": []}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(c.Paths) != 1 || c.Paths[0] != "." {
+ t.Fatalf("default path should be '.', got %v", c.Paths)
+ }
+}
+
+func TestLoadECSSkips(t *testing.T) {
+ c, resolution, err := LoadECS(writeECS(t, `{
+ "rules": [{"class": "`+esc(knownRuleA)+`", "config": {}}],
+ "skips": [
+ {"path": "*/Legacy/*"},
+ {"paths": ["*/Generated/*"]},
+ {"class": "`+esc(knownRuleA)+`"}
+ ]
+ }`))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // the whole-class skip drops the only rule
+ if len(c.Rules) != 0 {
+ t.Fatalf("expected the class skip to drop the rule, got %d rules", len(c.Rules))
+ }
+ if len(c.Skip) != 2 {
+ t.Fatalf("expected 2 skip globs, got %v", c.Skip)
+ }
+ if resolution.Mapped != 0 {
+ t.Fatalf("mapped = %d, want 0 (rule was skipped)", resolution.Mapped)
+ }
+}
+
+func TestLoadECSPerPathSkipIsCaveat(t *testing.T) {
+ c, resolution, err := LoadECS(writeECS(t, `{
+ "rules": [{"class": "`+esc(knownRuleA)+`", "config": {}}],
+ "skips": [
+ {"class": "`+esc(knownRuleA)+`", "paths": ["*/tests/*"]}
+ ]
+ }`))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // per-path skip does not drop the rule; it is reported as a caveat instead
+ if len(c.Rules) != 1 {
+ t.Fatalf("expected the rule to still run, got %d rules", len(c.Rules))
+ }
+ if len(resolution.PerPathSkips) != 1 || resolution.PerPathSkips[0] != knownRuleA {
+ t.Fatalf("per-path skips = %v, want [%s]", resolution.PerPathSkips, knownRuleA)
+ }
+}
+
+func TestLoadECSConfigIgnoredNote(t *testing.T) {
+ _, resolution, err := LoadECS(writeECS(t, `{
+ "rules": [{"class": "`+esc(knownRuleA)+`", "config": {"some": "option"}}]
+ }`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(resolution.ConfigIgnored) != 1 || resolution.ConfigIgnored[0] != knownRuleA {
+ t.Fatalf("config-ignored = %v, want [%s]", resolution.ConfigIgnored, knownRuleA)
+ }
+}
+
+func TestLoadECSDeduplicatesRules(t *testing.T) {
+ c, resolution, err := LoadECS(writeECS(t, `{
+ "rules": [
+ {"class": "`+esc(knownRuleA)+`", "config": {}},
+ {"class": "`+esc(knownRuleA)+`", "config": {}}
+ ]
+ }`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(c.Rules) != 1 || resolution.Mapped != 1 {
+ t.Fatalf("expected dedup to 1 rule, got %d rules / mapped %d", len(c.Rules), resolution.Mapped)
+ }
+}
+
+func TestLoadECSErrors(t *testing.T) {
+ if _, _, err := LoadECS(filepath.Join(t.TempDir(), "missing.json")); err == nil {
+ t.Error("want error for missing file")
+ }
+ if _, _, err := LoadECS(writeECS(t, `{not json`)); err == nil {
+ t.Error("want error for invalid json")
+ }
+}
diff --git a/blink/internal/diff/diff.go b/blink/internal/diff/diff.go
new file mode 100644
index 0000000000..ae95d80b04
--- /dev/null
+++ b/blink/internal/diff/diff.go
@@ -0,0 +1,148 @@
+// Package diff produces a unified text diff in the shape PHP-CS-Fixer emits
+// (an "--- Original / +++ New" header and "@@ @@" hunks), so the reporter can
+// wrap it exactly like ECS.
+package diff
+
+import "strings"
+
+const context = 3
+
+type opKind int
+
+const (
+ equal opKind = iota
+ del
+ add
+)
+
+type op struct {
+ kind opKind
+ text string
+}
+
+// Unified returns a unified diff of before vs after, or "" if they are equal.
+func Unified(before, after string) string {
+ if before == after {
+ return ""
+ }
+ a := splitLines(before)
+ b := splitLines(after)
+ ops := diffLines(a, b)
+
+ var out strings.Builder
+ out.WriteString("--- Original\n+++ New\n")
+ for _, h := range hunks(ops) {
+ out.WriteString("@@ @@\n")
+ for _, o := range h {
+ switch o.kind {
+ case equal:
+ out.WriteString(" " + o.text + "\n")
+ case del:
+ out.WriteString("-" + o.text + "\n")
+ case add:
+ out.WriteString("+" + o.text + "\n")
+ }
+ }
+ }
+ return out.String()
+}
+
+func splitLines(s string) []string {
+ s = strings.ReplaceAll(s, "\r\n", "\n")
+ lines := strings.Split(s, "\n")
+ // a trailing newline yields a final empty element; drop it for diffing
+ if len(lines) > 0 && lines[len(lines)-1] == "" {
+ lines = lines[:len(lines)-1]
+ }
+ return lines
+}
+
+// diffLines is a straightforward LCS diff, adequate for small source files.
+func diffLines(a, b []string) []op {
+ n, m := len(a), len(b)
+ lcs := make([][]int, n+1)
+ for i := range lcs {
+ lcs[i] = make([]int, m+1)
+ }
+ for i := n - 1; i >= 0; i-- {
+ for j := m - 1; j >= 0; j-- {
+ if a[i] == b[j] {
+ lcs[i][j] = lcs[i+1][j+1] + 1
+ } else if lcs[i+1][j] >= lcs[i][j+1] {
+ lcs[i][j] = lcs[i+1][j]
+ } else {
+ lcs[i][j] = lcs[i][j+1]
+ }
+ }
+ }
+
+ var ops []op
+ i, j := 0, 0
+ for i < n && j < m {
+ switch {
+ case a[i] == b[j]:
+ ops = append(ops, op{equal, a[i]})
+ i++
+ j++
+ case lcs[i+1][j] >= lcs[i][j+1]:
+ ops = append(ops, op{del, a[i]})
+ i++
+ default:
+ ops = append(ops, op{add, b[j]})
+ j++
+ }
+ }
+ for ; i < n; i++ {
+ ops = append(ops, op{del, a[i]})
+ }
+ for ; j < m; j++ {
+ ops = append(ops, op{add, b[j]})
+ }
+ return ops
+}
+
+// hunks groups ops into change regions, each padded with up to `context` equal
+// lines, dropping long stretches of unchanged code between them.
+func hunks(ops []op) [][]op {
+ changed := make([]bool, len(ops))
+ any := false
+ for i, o := range ops {
+ if o.kind != equal {
+ changed[i] = true
+ any = true
+ }
+ }
+ if !any {
+ return nil
+ }
+
+ keep := make([]bool, len(ops))
+ for i, c := range changed {
+ if !c {
+ continue
+ }
+ lo := max(i-context, 0)
+ hi := i + context
+ if hi >= len(ops) {
+ hi = len(ops) - 1
+ }
+ for k := lo; k <= hi; k++ {
+ keep[k] = true
+ }
+ }
+
+ var result [][]op
+ var cur []op
+ for i := range ops {
+ if keep[i] {
+ cur = append(cur, ops[i])
+ } else if len(cur) > 0 {
+ result = append(result, cur)
+ cur = nil
+ }
+ }
+ if len(cur) > 0 {
+ result = append(result, cur)
+ }
+ return result
+}
diff --git a/blink/internal/diff/diff_test.go b/blink/internal/diff/diff_test.go
new file mode 100644
index 0000000000..897f6682f2
--- /dev/null
+++ b/blink/internal/diff/diff_test.go
@@ -0,0 +1,28 @@
+package diff
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestUnifiedEqual(t *testing.T) {
+ if got := Unified("a\n", "a\n"); got != "" {
+ t.Fatalf("equal inputs must yield empty diff, got %q", got)
+ }
+}
+
+func TestUnifiedChange(t *testing.T) {
+ got := Unified("= 0 && s.At(open).Kind != token.Punct {
+ open = nextSignificantIndex(s, open)
+ }
+ if open >= 0 && s.At(open).Value == "&" {
+ open = nextSignificantIndex(s, open)
+ }
+ if open < 0 || s.At(open).Value != "(" {
+ return nil
+ }
+ closeIdx := s.MatchForward(open)
+ if closeIdx < 0 {
+ return nil
+ }
+ var names []string
+ depth := 0
+ for k := open + 1; k < closeIdx; k++ {
+ if s.At(k).Kind == token.Punct {
+ switch s.At(k).Value {
+ case "(", "[", "{":
+ depth++
+ case ")", "]", "}":
+ depth--
+ }
+ continue
+ }
+ if depth == 0 && s.At(k).Kind == token.Variable {
+ names = append(names, s.At(k).Value)
+ }
+ }
+ return names
+}
+
+// addParamNames appends the positional parameter name to each "@param "
+// line that has no variable name.
+func addParamNames(d *docblock, names []string) bool {
+ changed := false
+ idx := 0
+ for i, l := range d.inner {
+ trimmed := strings.TrimLeft(l.content, " ")
+ if !strings.HasPrefix(strings.ToLower(trimmed), "@param") {
+ continue
+ }
+ rest := trimmed[len("@param"):]
+ if rest == "" || (rest[0] != ' ' && rest[0] != '\t') {
+ continue // "@paramX"
+ }
+ fields := strings.Fields(rest)
+ pos := idx
+ idx++
+ if len(fields) != 1 {
+ continue // no type, or already has a name / description
+ }
+ if pos >= len(names) {
+ continue
+ }
+ d.inner[i].content = strings.TrimRight(l.content, " \t") + " " + names[pos]
+ changed = true
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/alias.go b/blink/internal/fixer/rules/alias.go
new file mode 100644
index 0000000000..eeda9e00cc
--- /dev/null
+++ b/blink/internal/fixer/rules/alias.go
@@ -0,0 +1,162 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Alias/NoMixedEchoPrintFixer.php
+//
+// NoMixedEchoPrint rewrites the "print" language construct to "echo" when it is
+// used as a statement (the default "use: echo"). Uses of print as an expression
+// ("$x = print 'y'") are left alone.
+type NoMixedEchoPrint struct{}
+
+func (NoMixedEchoPrint) Name() string {
+ return `PhpCsFixer\Fixer\Alias\NoMixedEchoPrintFixer`
+}
+
+func (NoMixedEchoPrint) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Alias/NoMixedEchoPrintFixer.php"
+}
+
+func (NoMixedEchoPrint) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ if t.Kind != token.Keyword || strings.ToLower(t.Value) != "print" {
+ continue
+ }
+ prev, ok := prevSignificant(s, i)
+ if !ok {
+ continue
+ }
+ // "print" is a statement only after ; { } ) an open tag, or "else"
+ convertible := prev.Kind == token.OpenTag ||
+ (prev.Kind == token.Punct && (prev.Value == ";" || prev.Value == "{" || prev.Value == "}" || prev.Value == ")")) ||
+ (prev.Kind == token.Keyword && strings.ToLower(prev.Value) == "else")
+ if !convertible {
+ continue
+ }
+ s.SetValue(i, "echo")
+ changed = true
+ }
+ return changed
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Alias/NoAliasFunctionsFixer.php
+//
+// NoAliasFunctions replaces internal function aliases with their canonical name
+// ("sizeof" -> "count", "join" -> "implode"). It covers the default sets
+// (@internal, @IMAP, @pg) and only rewrites global function calls, leaving
+// methods, declarations and namespaced names alone.
+type NoAliasFunctions struct{}
+
+func (NoAliasFunctions) Name() string {
+ return `PhpCsFixer\Fixer\Alias\NoAliasFunctionsFixer`
+}
+
+func (NoAliasFunctions) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Alias/NoAliasFunctionsFixer.php"
+}
+
+// functionAliases are the default sets (@internal, @IMAP, @pg) from PHP-CS-Fixer.
+var functionAliases = map[string]string{
+ // @internal
+ "diskfreespace": "disk_free_space",
+ "dns_check_record": "checkdnsrr",
+ "dns_get_mx": "getmxrr",
+ "session_commit": "session_write_close",
+ "stream_register_wrapper": "stream_wrapper_register",
+ "set_file_buffer": "stream_set_write_buffer",
+ "socket_set_blocking": "stream_set_blocking",
+ "socket_get_status": "stream_get_meta_data",
+ "socket_set_timeout": "stream_set_timeout",
+ "socket_getopt": "socket_get_option",
+ "socket_setopt": "socket_set_option",
+ "chop": "rtrim",
+ "close": "closedir",
+ "doubleval": "floatval",
+ "fputs": "fwrite",
+ "get_required_files": "get_included_files",
+ "ini_alter": "ini_set",
+ "is_double": "is_float",
+ "is_integer": "is_int",
+ "is_long": "is_int",
+ "is_real": "is_float",
+ "is_writeable": "is_writable",
+ "join": "implode",
+ "key_exists": "array_key_exists",
+ "magic_quotes_runtime": "set_magic_quotes_runtime",
+ "pos": "current",
+ "show_source": "highlight_file",
+ "sizeof": "count",
+ "strchr": "strstr",
+ "user_error": "trigger_error",
+ // @IMAP
+ "imap_create": "imap_createmailbox",
+ "imap_fetchtext": "imap_body",
+ "imap_header": "imap_headerinfo",
+ "imap_listmailbox": "imap_list",
+ "imap_listsubscribed": "imap_lsub",
+ "imap_rename": "imap_renamemailbox",
+ "imap_scan": "imap_listscan",
+ "imap_scanmailbox": "imap_listscan",
+ // @pg
+ "pg_exec": "pg_query",
+}
+
+func (NoAliasFunctions) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ if t.Kind != token.Ident {
+ continue
+ }
+ canonical, ok := functionAliases[strings.ToLower(t.Value)]
+ if !ok {
+ continue
+ }
+ if nextSignificantValue(s, i) != "(" {
+ continue // not a call
+ }
+ if !isGlobalFunctionCall(s, i) {
+ continue
+ }
+ s.SetValue(i, canonical)
+ changed = true
+ }
+ return changed
+}
+
+// isGlobalFunctionCall reports whether the name at index i is a call to a global
+// function - not a method, a declaration, a "new", or a namespaced name.
+func isGlobalFunctionCall(s *tokens.Stream, i int) bool {
+ prev, ok := prevSignificant(s, i)
+ if !ok {
+ return true
+ }
+ if prev.Kind == token.Punct {
+ switch prev.Value {
+ case "->", "?->", "::":
+ return false // method or static call
+ case `\`:
+ // qualified name: namespaced when a name precedes the backslash,
+ // global when the backslash is leading ("\sizeof(")
+ bsIdx := prevSignificantIndex(s, i)
+ if before, ok2 := prevSignificant(s, bsIdx); ok2 && before.Kind == token.Ident {
+ return false
+ }
+ return true
+ }
+ }
+ if prev.Kind == token.Keyword {
+ switch strings.ToLower(prev.Value) {
+ case "function", "const", "new", "goto":
+ return false // declaration, constant, instantiation or label
+ }
+ }
+ return true
+}
diff --git a/blink/internal/fixer/rules/alias_lang_construct.go b/blink/internal/fixer/rules/alias_lang_construct.go
new file mode 100644
index 0000000000..bcd9052da6
--- /dev/null
+++ b/blink/internal/fixer/rules/alias_lang_construct.go
@@ -0,0 +1,53 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Alias/NoAliasLanguageConstructCallFixer.php
+//
+// NoAliasLanguageConstructCall replaces the "die" alias with the master language
+// construct "exit". Only the bare construct is touched: a member call ($x->die())
+// or a namespaced/name position is left alone.
+type NoAliasLanguageConstructCall struct{}
+
+func (NoAliasLanguageConstructCall) Name() string {
+ return `PhpCsFixer\Fixer\Alias\NoAliasLanguageConstructCallFixer`
+}
+
+func (NoAliasLanguageConstructCall) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Alias/NoAliasLanguageConstructCallFixer.php"
+}
+
+func (NoAliasLanguageConstructCall) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ // "die"/"exit" are language constructs; this lexer tokenizes them as Ident
+ if t.Kind != token.Ident || strings.ToLower(t.Value) != "die" {
+ continue
+ }
+ // a member name ($x->die, Foo::die) or a namespaced/declared name is not
+ // the language construct
+ if memberPrev(s, i) {
+ continue
+ }
+ if prev, ok := prevSignificant(s, i); ok {
+ if prev.Kind == token.Punct && prev.Value == `\` {
+ continue
+ }
+ if prev.Kind == token.Keyword {
+ switch strings.ToLower(prev.Value) {
+ case "function", "const", "class", "namespace", "use":
+ continue
+ }
+ }
+ }
+ s.SetValue(i, "exit")
+ changed = true
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/array_indentation.go b/blink/internal/fixer/rules/array_indentation.go
new file mode 100644
index 0000000000..035ffbb381
--- /dev/null
+++ b/blink/internal/fixer/rules/array_indentation.go
@@ -0,0 +1,227 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/ArrayIndentationFixer.php
+//
+// ArrayIndentation indents each element of a multi-line array exactly one level
+// past the array's own indentation, and re-indents multi-line elements by the
+// same delta. It only rewrites whitespace (never adds or removes tokens), so it
+// preserves how many items share a line and is a no-op on ECS-formatted code.
+// This is a faithful port of PHP-CS-Fixer's scope-stack algorithm.
+type ArrayIndentation struct{}
+
+func (ArrayIndentation) Name() string {
+ return `PhpCsFixer\Fixer\Whitespace\ArrayIndentationFixer`
+}
+
+func (ArrayIndentation) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/ArrayIndentationFixer.php"
+}
+
+const arrayIndentUnit = " "
+
+type aiScope struct {
+ kind string // "array" or "expression"
+ endIndex int
+ initialIndent string
+ newIndent string // expression only
+}
+
+func (ArrayIndentation) Fix(s *tokens.Stream) bool {
+ changed := false
+ lastIndent := ""
+ var scopes []aiScope
+ prevLineInitialIndent := ""
+ prevLineNewIndent := ""
+
+ for index := 0; index < s.Len(); index++ {
+ cur := len(scopes) - 1
+ t := s.At(index)
+
+ if t.Kind == token.Comment || t.Kind == token.DocComment {
+ continue
+ }
+
+ if end, ok := aiArrayOpen(s, index); ok {
+ scopes = append(scopes, aiScope{kind: "array", endIndex: end, initialIndent: lastIndent})
+ continue
+ }
+
+ if t.Kind == token.Whitespace && hasNewline(t.Value) {
+ lastIndent = aiExtractIndent(t.Value)
+ }
+
+ if cur < 0 {
+ continue
+ }
+ sc := &scopes[cur]
+
+ if t.Kind == token.Whitespace {
+ if !hasNewline(t.Value) {
+ continue
+ }
+ var content string
+ if sc.kind == "array" {
+ indent := false
+ for k := index + 1; k < sc.endIndex; k++ {
+ kt := s.At(k)
+ nonTrivia := kt.Kind != token.Whitespace && kt.Kind != token.Comment && kt.Kind != token.DocComment
+ if nonTrivia || (kt.Kind == token.Whitespace && hasNewline(kt.Value)) {
+ indent = true
+ break
+ }
+ }
+ extra := ""
+ if indent {
+ extra = arrayIndentUnit
+ }
+ content = aiReindentArray(t.Value, sc.initialIndent+extra)
+ prevLineInitialIndent = aiExtractIndent(t.Value)
+ prevLineNewIndent = aiExtractIndent(content)
+ } else {
+ content = aiReindentExpression(t.Value, sc.initialIndent, sc.newIndent)
+ }
+ if content != t.Value {
+ s.SetValue(index, content)
+ changed = true
+ }
+ lastIndent = aiExtractIndent(content)
+ continue
+ }
+
+ if index == sc.endIndex {
+ for len(scopes) > 0 && index == scopes[len(scopes)-1].endIndex {
+ scopes = scopes[:len(scopes)-1]
+ }
+ continue
+ }
+
+ if t.Kind == token.Punct && t.Value == "," {
+ continue
+ }
+
+ if sc.kind != "expression" {
+ end := aiExpressionEnd(s, index, sc.endIndex)
+ if end != index {
+ scopes = append(scopes, aiScope{
+ kind: "expression", endIndex: end,
+ initialIndent: prevLineInitialIndent, newIndent: prevLineNewIndent,
+ })
+ }
+ }
+ }
+ return changed
+}
+
+// aiArrayOpen reports whether the token at index opens an array literal - "[" as
+// a literal (not access), or "(" right after "array"/"list" - and returns its
+// matching close index.
+func aiArrayOpen(s *tokens.Stream, index int) (int, bool) {
+ t := s.At(index)
+ if t.Kind != token.Punct {
+ return 0, false
+ }
+ switch t.Value {
+ case "[":
+ if !isArrayLiteralOpen(s, index) {
+ return 0, false
+ }
+ end := s.MatchForward(index)
+ if end < 0 {
+ return 0, false
+ }
+ return end, true
+ case "(":
+ p := sigPrev(s, index)
+ if p >= 0 && s.At(p).Kind == token.Keyword {
+ lv := strings.ToLower(s.At(p).Value)
+ if lv == "array" || lv == "list" {
+ end := s.MatchForward(index)
+ if end >= 0 {
+ return end, true
+ }
+ }
+ }
+ }
+ return 0, false
+}
+
+// isArrayLiteralOpen reports whether "[" at open starts an array literal rather
+// than an array access ($a[0], foo()[0]).
+func isArrayLiteralOpen(s *tokens.Stream, open int) bool {
+ p := sigPrev(s, open)
+ if p < 0 {
+ return true
+ }
+ t := s.At(p)
+ switch t.Kind {
+ case token.Variable, token.Ident, token.String, token.Number:
+ return false
+ case token.Punct:
+ switch t.Value {
+ case ")", "]", "}":
+ return false
+ }
+ }
+ return true
+}
+
+// aiExpressionEnd finds the end of the array element expression starting at index,
+// up to the next top-level comma (or the array's end), skipping nested blocks.
+func aiExpressionEnd(s *tokens.Stream, index, parentEnd int) int {
+ end := -1
+ for k := index + 1; k < parentEnd; k++ {
+ kt := s.At(k)
+ if kt.Kind == token.Punct && (kt.Value == "(" || kt.Value == "{" || kt.Value == "[") {
+ if be := s.MatchForward(k); be >= 0 {
+ k = be
+ }
+ continue
+ }
+ if kt.Kind == token.Punct && kt.Value == "," {
+ end = sigPrev(s, k)
+ break
+ }
+ }
+ if end >= 0 {
+ return end
+ }
+ return sigPrev(s, parentEnd)
+}
+
+// aiExtractIndent returns the horizontal whitespace after the last newline.
+func aiExtractIndent(ws string) string {
+ if i := strings.LastIndexByte(ws, '\n'); i >= 0 {
+ return ws[i+1:]
+ }
+ return ws
+}
+
+// aiReindentArray keeps the newline run of a whitespace token and sets the line's
+// indentation to target.
+func aiReindentArray(ws, target string) string {
+ if i := strings.LastIndexByte(ws, '\n'); i >= 0 {
+ return ws[:i+1] + target
+ }
+ return ws
+}
+
+// aiReindentExpression shifts a continuation line: if its indent begins with the
+// expression's original indent, that prefix is swapped for the new indent.
+func aiReindentExpression(ws, initialIndent, newIndent string) string {
+ i := strings.LastIndexByte(ws, '\n')
+ if i < 0 {
+ return ws
+ }
+ line := ws[i+1:]
+ if !strings.HasPrefix(line, initialIndent) {
+ return ws
+ }
+ return ws[:i+1] + newIndent + line[len(initialIndent):]
+}
diff --git a/blink/internal/fixer/rules/array_indentation_test.go b/blink/internal/fixer/rules/array_indentation_test.go
new file mode 100644
index 0000000000..fbac89beb8
--- /dev/null
+++ b/blink/internal/fixer/rules/array_indentation_test.go
@@ -0,0 +1,29 @@
+package rules
+
+import "testing"
+
+func TestArrayIndentation(t *testing.T) {
+ f := ArrayIndentation{}
+
+ // nested array under-indented: each level indented exactly once
+ got, changed := apply(t, f, " [\n 'baz' => true,\n ],\n];\n")
+ if want := " [\n 'baz' => true,\n ],\n];\n"; !changed || got != want {
+ t.Fatalf("nested: changed=%v got=%q", changed, got)
+ }
+
+ // grouping is preserved (multiple items per line only get re-indented)
+ got, changed = apply(t, f, " 1,\n];\n"); changed {
+ t.Fatal("correctly indented array must be a no-op")
+ }
+}
diff --git a/blink/internal/fixer/rules/array_list_item_newline.go b/blink/internal/fixer/rules/array_list_item_newline.go
new file mode 100644
index 0000000000..7f97987ac7
--- /dev/null
+++ b/blink/internal/fixer/rules/array_list_item_newline.go
@@ -0,0 +1,102 @@
+package rules
+
+import (
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// Symplify: https://github.com/symplify/coding-standard/blob/main/src/Fixer/ArrayNotation/ArrayListItemNewlineFixer.php
+//
+// ArrayListItemNewline puts each item of an associative array literal (one that
+// contains a "=>") on its own line, with "[" and "]" on their own lines. Plain
+// list arrays (no "=>") are left as they are. array_indentation then aligns the
+// result, so this fixer only needs to establish the one-item-per-line structure.
+type ArrayListItemNewline struct{}
+
+func (ArrayListItemNewline) Name() string {
+ return `Symplify\CodingStandard\Fixer\ArrayNotation\ArrayListItemNewlineFixer`
+}
+
+func (ArrayListItemNewline) SourceURL() string {
+ return "https://github.com/symplify/coding-standard/blob/main/src/Fixer/ArrayNotation/ArrayListItemNewlineFixer.php"
+}
+
+func (ArrayListItemNewline) Fix(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
+ }
+ if !isArrayLiteralOpen(s, open) {
+ continue
+ }
+ closeIdx := s.MatchForward(open)
+ if closeIdx < 0 || sigNext(s, open) == closeIdx {
+ continue // empty []
+ }
+ if arrayTopLevelMultiline(s, open, closeIdx) {
+ continue // items already on their own lines: array_indentation aligns it
+ }
+ if !arrayHasTopLevelArrow(s, open, closeIdx) {
+ continue // plain list array: leave inline
+ }
+ // a multiline array carries a trailing comma; add it before reflowing so
+ // the last item ends up on its own line with the comma
+ if last := sigPrev(s, closeIdx); last > open && s.At(last).Value != "," {
+ s.InsertAt(last+1, token.Token{Kind: token.Punct, Value: ","})
+ closeIdx++
+ changed = true
+ }
+ if reflowParen(s, open, closeIdx) {
+ changed = true
+ }
+ }
+ return changed
+}
+
+// arrayTopLevelMultiline reports whether the array already has a newline at its
+// own nesting level (an item on its own line). Newlines only inside a nested
+// element (e.g. a match block) do not count.
+func arrayTopLevelMultiline(s *tokens.Stream, open, closeIdx int) bool {
+ depth := 0
+ for j := open + 1; j < closeIdx; j++ {
+ t := s.At(j)
+ if t.Kind == token.Punct {
+ switch t.Value {
+ case "(", "[", "{":
+ depth++
+ continue
+ case ")", "]", "}":
+ depth--
+ continue
+ }
+ }
+ if depth == 0 && t.Kind == token.Whitespace && hasNewline(t.Value) {
+ return true
+ }
+ }
+ return false
+}
+
+// arrayHasTopLevelArrow reports whether a "=>" appears at the array's own nesting
+// level (an associative key or an arrow-function element).
+func arrayHasTopLevelArrow(s *tokens.Stream, open, closeIdx int) bool {
+ depth := 0
+ for j := open + 1; j < closeIdx; j++ {
+ t := s.At(j)
+ if t.Kind != token.Punct {
+ continue
+ }
+ switch t.Value {
+ case "(", "[", "{":
+ depth++
+ case ")", "]", "}":
+ depth--
+ case "=>":
+ if depth == 0 {
+ return true
+ }
+ }
+ }
+ return false
+}
diff --git a/blink/internal/fixer/rules/array_list_item_newline_test.go b/blink/internal/fixer/rules/array_list_item_newline_test.go
new file mode 100644
index 0000000000..e64315ccc3
--- /dev/null
+++ b/blink/internal/fixer/rules/array_list_item_newline_test.go
@@ -0,0 +1,29 @@
+package rules
+
+import "testing"
+
+func TestArrayListItemNewline(t *testing.T) {
+ f := ArrayListItemNewline{}
+
+ // associative single-line array -> one item per line, trailing comma, [] on own lines
+ got, changed := apply(t, f, " 1, 'y' => 2];")
+ if want := " 1,\n 'y' => 2,\n];"; !changed || got != want {
+ t.Fatalf("assoc: changed=%v got=%q", changed, got)
+ }
+
+ // single-element associative array is still split
+ got, changed = apply(t, f, " 1];")
+ if want := " 1,\n];"; !changed || got != want {
+ t.Fatalf("single: changed=%v got=%q", changed, got)
+ }
+
+ // a plain list array (no "=>") stays inline
+ if _, changed := apply(t, f, " 1,\n];"); changed {
+ t.Fatal("already-multiline array must be a no-op")
+ }
+}
diff --git a/blink/internal/fixer/rules/array_notation.go b/blink/internal/fixer/rules/array_notation.go
new file mode 100644
index 0000000000..fdf98c0bae
--- /dev/null
+++ b/blink/internal/fixer/rules/array_notation.go
@@ -0,0 +1,167 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// convertLongArray rewrites a long "name(...)" construct to "[...]", where name
+// is "array" or "list". Method/constant uses (after -> ?-> ::) are skipped.
+func convertLongArray(s *tokens.Stream, name string) bool {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ if t.Kind != token.Keyword && t.Kind != token.Ident {
+ continue
+ }
+ if strings.ToLower(t.Value) != name {
+ continue
+ }
+ if prev, ok := prevSignificant(s, i); ok {
+ switch prev.Value {
+ case "->", "?->", "::":
+ continue
+ }
+ }
+ j := skipWhitespace(s, i+1)
+ if j >= s.Len() || s.At(j).Kind != token.Punct || s.At(j).Value != "(" {
+ continue
+ }
+ closeIdx := s.MatchForward(j)
+ if closeIdx < 0 {
+ continue
+ }
+ s.SetValue(closeIdx, "]")
+ s.SetValue(j, "[")
+ for k := j - 1; k >= i; k-- {
+ s.RemoveAt(k)
+ }
+ 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{}
+
+func (ArraySyntax) Name() string {
+ return `PhpCsFixer\Fixer\ArrayNotation\ArraySyntaxFixer`
+}
+
+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") }
+
+// 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{}
+
+func (ListSyntax) Name() string {
+ return `PhpCsFixer\Fixer\ListNotation\ListSyntaxFixer`
+}
+
+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") }
+
+// enclosingIsArray reports whether the innermost open bracket at index i is "[".
+func topBracketIsArray(stack []string) bool {
+ return len(stack) > 0 && stack[len(stack)-1] == "["
+}
+
+// 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{}
+
+func (NoWhitespaceBeforeCommaInArray) Name() string {
+ return `PhpCsFixer\Fixer\ArrayNotation\NoWhitespaceBeforeCommaInArrayFixer`
+}
+
+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 {
+ changed := false
+ var stack []string
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ if t.Kind == token.Punct {
+ switch t.Value {
+ case "(", "[", "{":
+ stack = append(stack, t.Value)
+ case ")", "]", "}":
+ if len(stack) > 0 {
+ stack = stack[:len(stack)-1]
+ }
+ case ",":
+ if topBracketIsArray(stack) && i > 0 &&
+ s.At(i-1).Kind == token.Whitespace && !hasNewline(s.At(i-1).Value) {
+ s.RemoveAt(i - 1)
+ i--
+ changed = true
+ }
+ }
+ }
+ }
+ return changed
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ArrayNotation/WhitespaceAfterCommaInArrayFixer.php
+//
+// 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{}
+
+func (WhitespaceAfterCommaInArray) Name() string {
+ return `PhpCsFixer\Fixer\ArrayNotation\WhitespaceAfterCommaInArrayFixer`
+}
+
+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 {
+ changed := false
+ var stack []string
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ if t.Kind != token.Punct {
+ continue
+ }
+ switch t.Value {
+ case "(", "[", "{":
+ stack = append(stack, t.Value)
+ case ")", "]", "}":
+ if len(stack) > 0 {
+ stack = stack[:len(stack)-1]
+ }
+ case ",":
+ if !topBracketIsArray(stack) || i+1 >= s.Len() {
+ continue
+ }
+ next := s.At(i + 1)
+ if next.Kind == token.Whitespace {
+ continue // already spaced or newline
+ }
+ if next.Kind == token.Punct && next.Value == "]" {
+ continue // trailing comma
+ }
+ s.InsertAt(i+1, token.Token{Kind: token.Whitespace, Value: " "})
+ i++
+ changed = true
+ }
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/array_opener_and_closer_newline.go b/blink/internal/fixer/rules/array_opener_and_closer_newline.go
new file mode 100644
index 0000000000..417f85ecf4
--- /dev/null
+++ b/blink/internal/fixer/rules/array_opener_and_closer_newline.go
@@ -0,0 +1,61 @@
+package rules
+
+import (
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// Symplify: https://github.com/ecsphp/ecs-src/blob/main/packages/coding-standard/src/Fixer/ArrayNotation/ArrayOpenerAndCloserNewlineFixer.php
+//
+// ArrayOpenerAndCloserNewline puts the "[" and "]" of an associative array (one
+// with a top-level "=>") on their own lines, leaving the items untouched. It
+// skips an array whose first element is itself an array opener.
+type ArrayOpenerAndCloserNewline struct{}
+
+func (ArrayOpenerAndCloserNewline) Name() string {
+ return `Symplify\CodingStandard\Fixer\ArrayNotation\ArrayOpenerAndCloserNewlineFixer`
+}
+
+func (ArrayOpenerAndCloserNewline) SourceURL() string {
+ return "https://github.com/ecsphp/ecs-src/blob/main/packages/coding-standard/src/Fixer/ArrayNotation/ArrayOpenerAndCloserNewlineFixer.php"
+}
+
+func (ArrayOpenerAndCloserNewline) Fix(s *tokens.Stream) bool {
+ changed := false
+ // high-to-low so inserts inside inner arrays don't shift outer indices we
+ // have yet to reach
+ for open := s.Len() - 1; open >= 0; open-- {
+ if s.At(open).Kind != token.Punct || s.At(open).Value != "[" {
+ continue
+ }
+ if !isArrayLiteralOpen(s, open) {
+ continue
+ }
+ closeIdx := s.MatchForward(open)
+ if closeIdx < 0 {
+ continue
+ }
+ first := sigNext(s, open)
+ if first < 0 || first == closeIdx {
+ continue // no items
+ }
+ // first element is itself an array opener: leave it
+ if s.At(first).Kind == token.Punct && s.At(first).Value == "[" && isArrayLiteralOpen(s, first) {
+ continue
+ }
+ if !arrayHasTopLevelArrow(s, open, closeIdx) {
+ continue // not associative
+ }
+ // closer before opener, so the opener insert does not shift the closer.
+ // only act when the border is not already on its own line.
+ closerOnOwnLine := closeIdx > 0 && s.At(closeIdx-1).Kind == token.Whitespace && hasNewline(s.At(closeIdx-1).Value)
+ if !closerOnOwnLine && editSlotBefore(s, closeIdx, "\n") {
+ changed = true
+ }
+ openerOnOwnLine := open+1 < s.Len() && s.At(open+1).Kind == token.Whitespace && hasNewline(s.At(open+1).Value)
+ if !openerOnOwnLine && editSlotAfter(s, open, "\n") {
+ changed = true
+ }
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/array_opener_and_closer_newline_test.go b/blink/internal/fixer/rules/array_opener_and_closer_newline_test.go
new file mode 100644
index 0000000000..294d694159
--- /dev/null
+++ b/blink/internal/fixer/rules/array_opener_and_closer_newline_test.go
@@ -0,0 +1,31 @@
+package rules
+
+import "testing"
+
+func TestArrayOpenerAndCloserNewline(t *testing.T) {
+ f := ArrayOpenerAndCloserNewline{}
+
+ // associative array: "[" and "]" go on their own lines, items untouched
+ got, changed := apply(t, f, " 1, \"y\" => 2];\n")
+ if want := " 1, \"y\" => 2\n];\n"; !changed || got != want {
+ t.Fatalf("assoc: changed=%v got=%q", changed, got)
+ }
+
+ // plain list (no "=>") is left inline
+ if _, changed := apply(t, f, " 1];\n$a = [1, 2, 3];\n"); changed {
+ got, _ := apply(t, f, " 3];\n"); changed {
+ t.Fatal("array whose first element is an array must be skipped")
+ }
+
+ // already on own lines: no-op
+ if _, changed := apply(t, f, " 1,\n];\n"); changed {
+ t.Fatal("already-broken array must be a no-op")
+ }
+}
diff --git a/blink/internal/fixer/rules/batch10_test.go b/blink/internal/fixer/rules/batch10_test.go
new file mode 100644
index 0000000000..33df97e624
--- /dev/null
+++ b/blink/internal/fixer/rules/batch10_test.go
@@ -0,0 +1,103 @@
+package rules
+
+import "testing"
+
+func TestTrailingCommaInMultiline(t *testing.T) {
+ got, changed := apply(t, TrailingCommaInMultiline{}, " b -> c();")
+ if want := "b->c();"; !changed || got != want {
+ t.Fatalf("changed=%v got=%q want=%q", changed, got, want)
+ }
+}
+
+func TestNativeFunctionCasing(t *testing.T) {
+ got, changed := apply(t, NativeFunctionCasing{}, "Count();"); changed {
+ t.Fatal("method call must not be lowercased")
+ }
+}
+
+func TestIntegerLiteralCase(t *testing.T) {
+ got, changed := apply(t, IntegerLiteralCase{}, "" (closing tag) is left alone
+ if _, changed := apply(t, LinebreakAfterOpeningTag{}, ""); changed {
+ t.Fatal("lone open/close tag must not change")
+ }
+}
+
+func TestNullableTypeDeclaration(t *testing.T) {
+ got, changed := apply(t, NullableTypeDeclaration{}, "foo()\n->bar()\n ->baz();\n")
+ if want := "foo()\n ->bar()\n ->baz();\n"; !changed || got != want {
+ t.Fatalf("changed=%v got=%q want=%q", changed, got, want)
+ }
+ // already aligned: no-op
+ if _, changed := apply(t, MethodChainingIndentation{}, "foo()\n ->bar();\n"); changed {
+ t.Fatal("already aligned chain must not change")
+ }
+}
diff --git a/blink/internal/fixer/rules/batchD_test.go b/blink/internal/fixer/rules/batchD_test.go
new file mode 100644
index 0000000000..3a658d5ca5
--- /dev/null
+++ b/blink/internal/fixer/rules/batchD_test.go
@@ -0,0 +1,38 @@
+package rules
+
+import "testing"
+
+func TestSimplifiedNullReturn(t *testing.T) {
+ // no return type -> simplified
+ got, changed := apply(t, SimplifiedNullReturn{}, " simplified
+ got, changed = apply(t, SimplifiedNullReturn{}, " simplified
+ got, changed = apply(t, SimplifiedNullReturn{}, " kept
+ if _, changed := apply(t, SimplifiedNullReturn{}, " kept
+ if _, changed := apply(t, SimplifiedNullReturn{}, " no-op
+ if _, changed := apply(t, SimplifiedNullReturn{}, " simplified
+ got, changed = apply(t, SimplifiedNullReturn{}, "": true,
+ "<=": true, ">=": true, "<=>": true, "<": true, ">": true,
+ "&&": true, "||": true, "??": true, "=>": 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{}
+
+func (BinaryOperatorSpaces) Name() string {
+ return `PhpCsFixer\Fixer\Operator\BinaryOperatorSpacesFixer`
+}
+
+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 {
+ return false
+ }
+ if t.Value == "=" {
+ // leave reference assignment ("=& $x") and declare(...) headers alone
+ return nextSignificantValue(s, i) != "&" && !insideDeclareArgs(s, i)
+ }
+ return binaryOperators[t.Value]
+ })
+}
diff --git a/blink/internal/fixer/rules/blank_line_after_opening_tag.go b/blink/internal/fixer/rules/blank_line_after_opening_tag.go
new file mode 100644
index 0000000000..18ec3be91b
--- /dev/null
+++ b/blink/internal/fixer/rules/blank_line_after_opening_tag.go
@@ -0,0 +1,41 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/PhpTag/BlankLineAfterOpeningTagFixer.php
+//
+// BlankLineAfterOpeningTag ensures one blank line after the opening "= s.Len() {
+ return false
+ }
+ ws := s.At(semi + 1)
+ if ws.Kind != token.Whitespace || !strings.Contains(ws.Value, "\n") {
+ return false
+ }
+ // a closing tag or EOF right after needs no blank line
+ if semi+2 >= s.Len() || s.At(semi+2).Kind == token.CloseTag {
+ return false
+ }
+ indent := ws.Value[strings.LastIndexByte(ws.Value, '\n')+1:]
+ want := "\n\n" + indent
+ if ws.Value != want {
+ s.SetValue(semi+1, want)
+ return true
+ }
+ return false
+ }
+ return false
+}
+
+func declareIsStrictTypes(s *tokens.Stream, open, close int) bool {
+ for k := open + 1; k < close; k++ {
+ if s.At(k).Kind == token.Ident && strings.EqualFold(s.At(k).Value, "strict_types") {
+ return true
+ }
+ }
+ return false
+}
diff --git a/blink/internal/fixer/rules/blank_line_before_statement.go b/blink/internal/fixer/rules/blank_line_before_statement.go
new file mode 100644
index 0000000000..b6a10f12f7
--- /dev/null
+++ b/blink/internal/fixer/rules/blank_line_before_statement.go
@@ -0,0 +1,130 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/BlankLineBeforeStatementFixer.php
+//
+// BlankLineBeforeStatement puts an empty line before each configured statement.
+// It mirrors the default set: break, continue, declare, return, throw, try. A
+// statement that opens its block (preceded by "{" or ":") keeps no blank line;
+// an attached comment above the statement is hoisted with it.
+type BlankLineBeforeStatement struct{}
+
+func (BlankLineBeforeStatement) Name() string {
+ return `PhpCsFixer\Fixer\Whitespace\BlankLineBeforeStatementFixer`
+}
+
+func (BlankLineBeforeStatement) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/BlankLineBeforeStatementFixer.php"
+}
+
+var blankLineBeforeStatementKeywords = map[string]bool{
+ "break": true, "continue": true, "declare": true,
+ "return": true, "throw": true, "try": true,
+}
+
+func (BlankLineBeforeStatement) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := s.Len() - 1; i > 0; i-- {
+ t := s.At(i)
+ if t.Kind != token.Keyword || !blankLineBeforeStatementKeywords[strings.ToLower(t.Value)] {
+ continue
+ }
+ insertIdx := blbsInsertIndex(s, i)
+ prevNW := prevNonWhitespaceIdx(s, insertIdx)
+ if prevNW >= 0 && blbsShouldAdd(s, prevNW) {
+ if blbsInsert(s, insertIdx) {
+ changed = true
+ }
+ }
+ i = prevNW
+ if i < 1 {
+ break
+ }
+ }
+ return changed
+}
+
+// prevNonWhitespaceIdx returns the previous token that is not whitespace
+// (comments included, unlike prevSignificantIndex).
+func prevNonWhitespaceIdx(s *tokens.Stream, i int) int {
+ for j := i - 1; j >= 0; j-- {
+ if s.At(j).Kind != token.Whitespace {
+ return j
+ }
+ }
+ return -1
+}
+
+func countNewlines(v string) int { return strings.Count(v, "\n") }
+
+func isCommentTok(t token.Token) bool {
+ return t.Kind == token.Comment || t.Kind == token.DocComment
+}
+
+// blbsInsertIndex hoists the insertion point above a comment that sits directly
+// on the line before the statement, matching getInsertBlankLineIndex.
+func blbsInsertIndex(s *tokens.Stream, index int) int {
+ for index > 0 {
+ if s.At(index-1).Kind == token.Whitespace && countNewlines(s.At(index-1).Value) > 1 {
+ break
+ }
+ prevIndex := prevNonWhitespaceIdx(s, index)
+ if prevIndex < 0 || !isCommentTok(s.At(prevIndex)) {
+ break
+ }
+ if prevIndex-1 < 0 || s.At(prevIndex-1).Kind != token.Whitespace {
+ break
+ }
+ if countNewlines(s.At(prevIndex-1).Value) != 1 {
+ break
+ }
+ index = prevIndex
+ }
+ return index
+}
+
+// blbsShouldAdd reports whether the statement follows another statement or a
+// block close ("}"), so an empty line belongs before it.
+func blbsShouldAdd(s *tokens.Stream, prevNonWhitespace int) bool {
+ prev := s.At(prevNonWhitespace)
+ if isCommentTok(prev) {
+ for j := prevNonWhitespace - 1; j >= 0; j-- {
+ if strings.Contains(s.At(j).Value, "\n") {
+ return false
+ }
+ if s.At(j).Kind == token.Whitespace || isCommentTok(s.At(j)) {
+ continue
+ }
+ return s.At(j).Kind == token.Punct && (s.At(j).Value == ";" || s.At(j).Value == "}")
+ }
+ return false
+ }
+ return prev.Kind == token.Punct && (prev.Value == ";" || prev.Value == "}")
+}
+
+// blbsInsert ensures exactly one empty line before index. Returns whether it
+// changed anything (keeps the fixer idempotent).
+func blbsInsert(s *tokens.Stream, index int) bool {
+ prevIndex := index - 1
+ if prevIndex >= 0 && s.At(prevIndex).Kind == token.Whitespace {
+ v := s.At(prevIndex).Value
+ switch countNewlines(v) {
+ case 0:
+ s.SetValue(prevIndex, strings.TrimRight(v, " \t")+"\n\n")
+ return true
+ case 1:
+ s.SetValue(prevIndex, "\n"+v)
+ return true
+ default:
+ return false
+ }
+ }
+ s.InsertAt(index, token.Token{Kind: token.Whitespace, Value: "\n\n"})
+ return true
+}
diff --git a/blink/internal/fixer/rules/blank_lines.go b/blink/internal/fixer/rules/blank_lines.go
new file mode 100644
index 0000000000..521748f933
--- /dev/null
+++ b/blink/internal/fixer/rules/blank_lines.go
@@ -0,0 +1,44 @@
+package rules
+
+import (
+ "regexp"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+var threeOrMoreNewlines = regexp.MustCompile(`\n{3,}`)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/NoExtraBlankLinesFixer.php
+//
+// NoExtraBlankLines collapses two or more consecutive blank lines into one. It
+// runs after no_whitespace_in_blank_line, so blank lines are already bare.
+type NoExtraBlankLines struct{}
+
+func (NoExtraBlankLines) Name() string {
+ return `PhpCsFixer\Fixer\Whitespace\NoExtraBlankLinesFixer`
+}
+
+func (NoExtraBlankLines) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/NoExtraBlankLinesFixer.php"
+}
+
+func (NoExtraBlankLines) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ if s.At(i).Kind != token.Whitespace {
+ continue
+ }
+ // merge any adjacent whitespace tokens (e.g. left by import removal) so
+ // a run of blank lines lives in one token the regex can collapse
+ for i+1 < s.Len() && s.At(i+1).Kind == token.Whitespace {
+ s.SetValue(i, s.At(i).Value+s.At(i+1).Value)
+ s.RemoveAt(i + 1)
+ }
+ if v := threeOrMoreNewlines.ReplaceAllString(s.At(i).Value, "\n\n"); v != s.At(i).Value {
+ s.SetValue(i, v)
+ changed = true
+ }
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/blocks.go b/blink/internal/fixer/rules/blocks.go
new file mode 100644
index 0000000000..03e37f4a33
--- /dev/null
+++ b/blink/internal/fixer/rules/blocks.go
@@ -0,0 +1,147 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// braceKind is the construct an opening "{" belongs to. It is the small scope
+// model the structural fixers reason over.
+type braceKind int
+
+const (
+ braceOther braceKind = iota // free block, trait adaptation, match arm, ...
+ braceClassLike // class / interface / trait / enum body
+ braceFunctionDecl // named function or method body
+ braceClosure // anonymous function body
+ braceControl // if / for / while / switch / try / ...
+)
+
+var controlKeywords = map[string]bool{
+ "if": true, "elseif": true, "else": true, "for": true, "foreach": true,
+ "while": true, "do": true, "switch": true, "try": true, "catch": true,
+ "finally": true, "match": true,
+}
+
+// classifyBrace determines what construct the "{" at brace opens, and returns
+// the index of the governing keyword (for indentation). It walks the header
+// backwards, jumping over (...) groups, until it meets a construct keyword or a
+// statement boundary.
+func classifyBrace(s *tokens.Stream, brace int) (braceKind, int) {
+ for j := brace - 1; j >= 0; j-- {
+ t := s.At(j)
+ switch t.Kind {
+ case token.Whitespace, token.Comment, token.DocComment:
+ continue
+ }
+ if t.Kind == token.Punct {
+ switch t.Value {
+ case ")":
+ open := s.MatchBackward(j)
+ if open < 0 {
+ return braceOther, -1
+ }
+ j = open // loop's j-- steps before the "("
+ continue
+ case ";", "{", "}":
+ return braceOther, -1 // statement boundary before any keyword
+ }
+ continue
+ }
+ if t.Kind == token.Keyword {
+ lw := strings.ToLower(t.Value)
+ switch {
+ case classLikeKeywords[lw]:
+ return braceClassLike, j
+ case lw == "function":
+ return functionBraceKind(s, j), j
+ case controlKeywords[lw]:
+ return braceControl, j
+ }
+ }
+ }
+ return braceOther, -1
+}
+
+// functionBraceKind tells a named function/method (has a name) from a closure
+// (a "(" directly follows "function", optionally after a "&").
+func functionBraceKind(s *tokens.Stream, fn int) braceKind {
+ k := skipWhitespace(s, fn+1)
+ if k < s.Len() && s.At(k).Kind == token.Punct && s.At(k).Value == "&" {
+ k = skipWhitespace(s, k+1)
+ }
+ if k < s.Len() && s.At(k).Kind == token.Punct && s.At(k).Value == "(" {
+ return braceClosure
+ }
+ return braceFunctionDecl
+}
+
+// classMemberStarts returns the index of the first significant token of each
+// direct member declaration in the class body opened at open. Nested bodies
+// (method bodies, defaults, argument lists) are skipped.
+func classMemberStarts(s *tokens.Stream, open int) []int {
+ closeIdx := s.MatchForward(open)
+ if closeIdx < 0 {
+ return nil
+ }
+ var starts []int
+ expect := true
+ for k := open + 1; k < closeIdx; k++ {
+ t := s.At(k)
+ if t.Kind == token.Whitespace || t.Kind == token.Comment || t.Kind == token.DocComment {
+ continue
+ }
+ if expect {
+ starts = append(starts, k)
+ expect = false
+ }
+ if t.Kind == token.Punct {
+ switch t.Value {
+ case "{", "(", "[":
+ if m := s.MatchForward(k); m > 0 {
+ wasBody := t.Value == "{"
+ k = m
+ if wasBody {
+ expect = true // a method body closed; next token starts a member
+ }
+ }
+ case ";":
+ expect = true
+ }
+ }
+ }
+ return starts
+}
+
+// braceDepthAt returns the number of "{" still open before index idx.
+func braceDepthAt(s *tokens.Stream, idx int) int {
+ d := 0
+ for j := range idx {
+ if s.At(j).Kind != token.Punct {
+ continue
+ }
+ switch s.At(j).Value {
+ case "{":
+ d++
+ case "}":
+ if d > 0 {
+ d--
+ }
+ }
+ }
+ return d
+}
+
+// lineIndent returns the leading indentation of the line containing token idx.
+func lineIndent(s *tokens.Stream, idx int) string {
+ for j := idx; j >= 0; j-- {
+ if s.At(j).Kind == token.Whitespace {
+ if v := s.At(j).Value; strings.Contains(v, "\n") {
+ return v[strings.LastIndexByte(v, '\n')+1:]
+ }
+ }
+ }
+ return ""
+}
diff --git a/blink/internal/fixer/rules/braces_position.go b/blink/internal/fixer/rules/braces_position.go
new file mode 100644
index 0000000000..f1556ce681
--- /dev/null
+++ b/blink/internal/fixer/rules/braces_position.go
@@ -0,0 +1,112 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Basic/BracesPositionFixer.php
+//
+// BracesPosition places opening braces per PSR-12: classes/interfaces/traits/
+// enums and named functions/methods get their "{" on the next line, aligned
+// with the declaration; control structures keep it on the same line after a
+// single space. Closures and free blocks are left alone.
+type BracesPosition struct{}
+
+func (BracesPosition) Name() string {
+ return `PhpCsFixer\Fixer\Basic\BracesPositionFixer`
+}
+
+func (BracesPosition) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Basic/BracesPositionFixer.php"
+}
+
+func (BracesPosition) 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 != "{" {
+ continue
+ }
+ kind, kw := classifyBrace(s, i)
+
+ // 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 := false
+ switch kind {
+ case braceClassLike:
+ // an anonymous class ("new class ... {") keeps its opening brace on
+ // the same line (PHP-CS-Fixer anonymous_classes_opening_brace default)
+ if kw >= 0 {
+ if prev, ok := prevSignificant(s, kw); ok && prev.Kind == token.Keyword && strings.EqualFold(prev.Value, "new") {
+ nextLine = false
+ break
+ }
+ }
+ nextLine = true
+ case braceFunctionDecl:
+ // PSR-12 keeps "){" on one line when the signature is multiline
+ nextLine = !funcSignatureMultiline(s, i)
+ case braceControl:
+ nextLine = false
+ default:
+ continue
+ }
+
+ want := " "
+ if nextLine {
+ // depth-based so it stays correct regardless of the header's own
+ // (possibly wrong) indentation; statement_indentation fixes the rest
+ want = "\n" + strings.Repeat(" ", braceDepthAt(s, i))
+ }
+ if i > 0 && s.At(i-1).Kind == token.Whitespace {
+ if s.At(i-1).Value != want {
+ s.SetValue(i-1, want)
+ changed = true
+ }
+ } else {
+ s.InsertAt(i, token.Token{Kind: token.Whitespace, Value: want})
+ i++
+ changed = true
+ }
+ }
+ return changed
+}
+
+// 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 {
+ closeParen := -1
+ for j := brace - 1; j >= 0; j-- {
+ t := s.At(j)
+ if t.Kind != token.Punct {
+ continue
+ }
+ if t.Value == ")" {
+ closeParen = j
+ break
+ }
+ if t.Value == "{" || t.Value == "}" || t.Value == ";" {
+ return false
+ }
+ }
+ if closeParen < 0 {
+ return false
+ }
+ open := s.MatchBackward(closeParen)
+ if open < 0 {
+ return false
+ }
+ for k := open; k <= closeParen; k++ {
+ if s.At(k).Kind == token.Whitespace && hasNewline(s.At(k).Value) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/blink/internal/fixer/rules/braces_position_test.go b/blink/internal/fixer/rules/braces_position_test.go
new file mode 100644
index 0000000000..6ee343ba55
--- /dev/null
+++ b/blink/internal/fixer/rules/braces_position_test.go
@@ -0,0 +1,90 @@
+package rules
+
+import (
+ "testing"
+
+ "blink/internal/lexer"
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+func lexStream(src string) *tokens.Stream { return tokens.New(lexer.Lex(src)) }
+
+func firstBrace(s *tokens.Stream) int {
+ for i := range s.Len() {
+ if s.At(i).Kind == token.Punct && s.At(i).Value == "{" {
+ return i
+ }
+ }
+ return -1
+}
+
+func TestBracesPositionClassAndFunction(t *testing.T) {
+ got, changed := apply(t, BracesPosition{}, "= 'A' && c <= 'Z') || c >= 0x80 {
+ return strings.ToLower(s)
+ }
+ }
+ return s
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Casing/LowercaseKeywordsFixer.php
+//
+// LowercaseKeywords lowercases PHP keywords (FUNCTION -> function).
+type LowercaseKeywords struct{}
+
+func (LowercaseKeywords) Name() string {
+ return `PhpCsFixer\Fixer\Casing\LowercaseKeywordsFixer`
+}
+
+func (LowercaseKeywords) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Casing/LowercaseKeywordsFixer.php"
+}
+
+func (LowercaseKeywords) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := range s.Len() {
+ t := s.At(i)
+ if t.Kind != token.Keyword {
+ continue
+ }
+ // a keyword-spelled class-constant name is left as written, EXCEPT when the
+ // same keyword reappears after :: in that constant's value (e.g.
+ // "const int PUBLIC = Modifiers::PUBLIC" -> "public"). PHP-CS-Fixer's
+ // LowercaseKeywordsFixer lowercases it only in that case (a transformer
+ // misfire); mirroring it keeps blink 1:1 with ECS.
+ if isClassConstName(s, i) {
+ if !sameKeywordAfterDoubleColon(s, i, lowerASCII(t.Value)) {
+ continue
+ }
+ } else {
+ // a keyword-spelled constant name in assignment position (e.g.
+ // "const string RETURN = ...") must not be lowercased
+ if nextSignificantValue(s, i) == "=" {
+ continue
+ }
+ // a keyword-spelled class/member name (e.g. "Enum" in "extends Enum",
+ // "Spatie\Enum\Enum", "class Enum", or a named argument "instanceOf:") is
+ // an identifier, not a keyword
+ if keywordUsedAsIdentifier(s, i) {
+ continue
+ }
+ }
+ if lower := lowerASCII(t.Value); lower != t.Value {
+ s.SetValue(i, lower)
+ changed = true
+ }
+ }
+ return changed
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Casing/ConstantCaseFixer.php
+//
+// ConstantCase lowercases the true, false and null constants.
+type ConstantCase struct{}
+
+func (ConstantCase) Name() string {
+ return `PhpCsFixer\Fixer\Casing\ConstantCaseFixer`
+}
+
+func (ConstantCase) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Casing/ConstantCaseFixer.php"
+}
+
+func (ConstantCase) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := range s.Len() {
+ t := s.At(i)
+ if t.Kind != token.Ident {
+ continue
+ }
+ lower := lowerASCII(t.Value)
+ if lower != "true" && lower != "false" && lower != "null" {
+ continue
+ }
+ if memberPrev(s, i) {
+ continue // $obj->true, Foo::null - a member name, not the constant
+ }
+ if lower != t.Value {
+ s.SetValue(i, lower)
+ changed = true
+ }
+ }
+ return changed
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Casing/LowercaseStaticReferenceFixer.php
+//
+// LowercaseStaticReference lowercases self, static and parent.
+type LowercaseStaticReference struct{}
+
+func (LowercaseStaticReference) Name() string {
+ return `PhpCsFixer\Fixer\Casing\LowercaseStaticReferenceFixer`
+}
+
+func (LowercaseStaticReference) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Casing/LowercaseStaticReferenceFixer.php"
+}
+
+func (LowercaseStaticReference) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := range s.Len() {
+ t := s.At(i)
+ if t.Kind != token.Ident && t.Kind != token.Keyword {
+ continue
+ }
+ lower := lowerASCII(t.Value)
+ if lower != "self" && lower != "static" && lower != "parent" {
+ continue
+ }
+ // SELF/PARENT/STATIC can be constant names: "ObjectReference::SELF" (member
+ // access) or "const string PARENT = ..." (declaration) - not the keyword
+ if memberPrev(s, i) || nextSignificantValue(s, i) == "=" {
+ continue
+ }
+ if lower != t.Value {
+ s.SetValue(i, lower)
+ changed = true
+ }
+ }
+ return changed
+}
+
+// keywordUsedAsIdentifier reports whether a keyword-spelled token is actually a
+// class or member name (the lexer marks context-sensitive words like "enum" as
+// keywords even when used as identifiers).
+func keywordUsedAsIdentifier(s *tokens.Stream, i int) bool {
+ if prev, ok := prevSignificant(s, i); ok {
+ if prev.Kind == token.Punct {
+ switch prev.Value {
+ case `\`, "->", "?->", "::":
+ return true
+ }
+ }
+ if prev.Kind == token.Keyword {
+ switch strings.ToLower(prev.Value) {
+ case "extends", "implements", "new", "instanceof",
+ "class", "interface", "trait", "enum", "function", "const",
+ "namespace", "use", "as", "goto", "insteadof":
+ return true
+ }
+ }
+ }
+ if n := nextSignificantIndex(s, i); n >= 0 && s.At(n).Kind == token.Punct {
+ switch s.At(n).Value {
+ case `\`, "::", ":":
+ return true
+ }
+ }
+ return false
+}
+
+// isClassConstName reports whether token i is the name in a class-constant
+// declaration - "const NAME" or a typed "const NAME".
+func isClassConstName(s *tokens.Stream, i int) bool {
+ j := prevSignificantIndex(s, i)
+ for steps := 0; j >= 0 && steps < 8; steps++ {
+ prev := s.At(j)
+ if prev.Kind == token.Keyword && strings.ToLower(prev.Value) == "const" {
+ return true
+ }
+ // walk back over the type between "const" and the name
+ if prev.Kind == token.Ident || prev.Kind == token.Keyword ||
+ (prev.Kind == token.Punct && (prev.Value == "?" || prev.Value == "|" || prev.Value == "&" || prev.Value == `\`)) {
+ j = prevSignificantIndex(s, j)
+ continue
+ }
+ return false
+ }
+ return false
+}
+
+// sameKeywordAfterDoubleColon reports whether the same keyword (name, already
+// lowercased) reappears after :: within the constant's value, up to the ; or {
+// that ends the declaration.
+func sameKeywordAfterDoubleColon(s *tokens.Stream, i int, name string) bool {
+ for j := i + 1; j < s.Len(); j++ {
+ v := s.At(j)
+ if v.Kind == token.Punct && (v.Value == ";" || v.Value == "{") {
+ return false
+ }
+ if v.Kind == token.Punct && v.Value == "::" {
+ if n := nextSignificantIndex(s, j); n >= 0 && lowerASCII(s.At(n).Value) == name {
+ return true
+ }
+ }
+ }
+ return false
+}
diff --git a/blink/internal/fixer/rules/casing2.go b/blink/internal/fixer/rules/casing2.go
new file mode 100644
index 0000000000..ebc5868e22
--- /dev/null
+++ b/blink/internal/fixer/rules/casing2.go
@@ -0,0 +1,127 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// nativeFunctions is a curated set of common built-in functions. It is
+// intentionally partial: names not listed are left untouched (never miscased).
+var nativeFunctions = map[string]bool{
+ "strlen": true, "count": true, "sizeof": true, "is_array": true, "is_string": true,
+ "is_int": true, "is_integer": true, "is_bool": true, "is_null": true, "is_object": true,
+ "is_callable": true, "is_numeric": true, "is_float": true, "is_a": true, "is_iterable": true,
+ "array_map": true, "array_filter": true, "array_merge": true, "array_keys": true,
+ "array_values": true, "array_key_exists": true, "in_array": true, "implode": true,
+ "explode": true, "str_replace": true, "str_repeat": true, "substr": true, "strpos": true,
+ "stripos": true, "strrpos": true, "strtolower": true, "strtoupper": true, "ucfirst": true,
+ "lcfirst": true, "ucwords": true, "trim": true, "ltrim": true, "rtrim": true,
+ "sprintf": true, "printf": true, "vsprintf": true, "number_format": true,
+ "json_encode": true, "json_decode": true, "preg_match": true, "preg_replace": true,
+ "preg_split": true, "preg_match_all": true, "preg_quote": true, "sort": true, "rsort": true,
+ "usort": true, "uasort": true, "uksort": true, "ksort": true, "krsort": true, "asort": true,
+ "arsort": true, "array_push": true, "array_pop": true, "array_shift": true,
+ "array_unshift": true, "array_slice": true, "array_splice": true, "array_reverse": true,
+ "array_unique": true, "array_flip": true, "array_combine": true, "array_column": true,
+ "array_sum": true, "array_product": true, "array_reduce": true, "array_search": true,
+ "array_fill": true, "array_diff": true, "array_intersect": true, "array_pad": true,
+ "array_chunk": true, "array_key_first": true, "array_key_last": true, "max": true,
+ "min": true, "abs": true, "ceil": true, "floor": true, "round": true, "intval": true,
+ "floatval": true, "strval": true, "boolval": true, "gettype": true, "settype": true,
+ "function_exists": true, "method_exists": true, "class_exists": true, "interface_exists": true,
+ "property_exists": true, "defined": true, "define": true, "constant": true,
+ "call_user_func": true, "call_user_func_array": true, "func_get_args": true,
+ "func_num_args": true, "compact": true, "extract": true, "print_r": true, "var_dump": true,
+ "var_export": true, "serialize": true, "unserialize": true, "base64_encode": true,
+ "base64_decode": true, "md5": true, "sha1": true, "hash": true, "dechex": true, "hexdec": true,
+ "date": true, "time": true, "mktime": true, "strtotime": true, "microtime": true,
+ "str_pad": true, "str_split": true, "str_contains": true, "str_starts_with": true,
+ "str_ends_with": true, "wordwrap": true, "nl2br": true, "htmlspecialchars": true,
+ "htmlentities": true, "strip_tags": true, "addslashes": true, "stripslashes": true,
+ "ord": true, "chr": true, "intdiv": true, "fmod": true, "pow": true,
+ "sqrt": true, "rand": true, "mt_rand": true, "random_int": true, "array_is_list": true,
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Casing/NativeFunctionCasingFixer.php
+//
+// NativeFunctionCasing lowercases calls to native PHP functions (a curated
+// subset; unknown names are left untouched, never miscased).
+type NativeFunctionCasing struct{}
+
+func (NativeFunctionCasing) Name() string {
+ return `PhpCsFixer\Fixer\Casing\NativeFunctionCasingFixer`
+}
+
+func (NativeFunctionCasing) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Casing/NativeFunctionCasingFixer.php"
+}
+
+func (NativeFunctionCasing) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := range s.Len() {
+ t := s.At(i)
+ if t.Kind != token.Ident {
+ continue
+ }
+ lower := strings.ToLower(t.Value)
+ if !nativeFunctions[lower] || t.Value == lower {
+ continue
+ }
+ // must be a function call, not a method or a namespaced name
+ if prev, ok := prevSignificant(s, i); ok {
+ switch strings.ToLower(prev.Value) {
+ case "->", "?->", "::", `\`, "function", "new":
+ continue
+ }
+ }
+ if nextSignificantValue(s, i) != "(" {
+ continue
+ }
+ s.SetValue(i, lower)
+ changed = true
+ }
+ return changed
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Casing/IntegerLiteralCaseFixer.php
+//
+// IntegerLiteralCase lowercases the prefix and digits of hex/binary/octal
+// integer literals ("0XFF" -> "0xff").
+type IntegerLiteralCase struct{}
+
+func (IntegerLiteralCase) Name() string {
+ return `PhpCsFixer\Fixer\Casing\IntegerLiteralCaseFixer`
+}
+
+func (IntegerLiteralCase) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Casing/IntegerLiteralCaseFixer.php"
+}
+
+func (IntegerLiteralCase) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := range s.Len() {
+ t := s.At(i)
+ if t.Kind != token.Number || len(t.Value) < 2 || t.Value[0] != '0' {
+ continue
+ }
+ var fixed string
+ switch t.Value[1] {
+ case 'x', 'X':
+ // prefix lowercase, hex digits uppercase ("0Xff" -> "0xFF")
+ fixed = "0x" + strings.ToUpper(t.Value[2:])
+ case 'b', 'B':
+ fixed = "0b" + t.Value[2:]
+ case 'o', 'O':
+ fixed = "0o" + t.Value[2:]
+ default:
+ continue
+ }
+ if fixed != t.Value {
+ s.SetValue(i, fixed)
+ changed = true
+ }
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/cast_casing.go b/blink/internal/fixer/rules/cast_casing.go
new file mode 100644
index 0000000000..43472eef12
--- /dev/null
+++ b/blink/internal/fixer/rules/cast_casing.go
@@ -0,0 +1,73 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/CastNotation/LowercaseCastFixer.php
+//
+// LowercaseCast lowercases the type inside a cast: "(INT)$x" -> "(int)$x".
+type LowercaseCast struct{}
+
+func (LowercaseCast) Name() string {
+ return `PhpCsFixer\Fixer\CastNotation\LowercaseCastFixer`
+}
+
+func (LowercaseCast) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/CastNotation/LowercaseCastFixer.php"
+}
+
+func (LowercaseCast) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := range s.Len() {
+ typeIdx, _, ok := castAt(s, i)
+ if !ok {
+ continue
+ }
+ v := s.At(typeIdx).Value
+ if lower := strings.ToLower(v); lower != v {
+ s.SetValue(typeIdx, lower)
+ changed = true
+ }
+ }
+ return changed
+}
+
+var shortScalarCast = map[string]string{
+ "integer": "int",
+ "boolean": "bool",
+ "double": "float",
+ "real": "float",
+ "binary": "string",
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/CastNotation/ShortScalarCastFixer.php
+//
+// ShortScalarCast rewrites long cast names to the short form: "(integer)" ->
+// "(int)", "(boolean)" -> "(bool)", "(double)"/"(real)" -> "(float)".
+type ShortScalarCast struct{}
+
+func (ShortScalarCast) Name() string {
+ return `PhpCsFixer\Fixer\CastNotation\ShortScalarCastFixer`
+}
+
+func (ShortScalarCast) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/CastNotation/ShortScalarCastFixer.php"
+}
+
+func (ShortScalarCast) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := range s.Len() {
+ typeIdx, _, ok := castAt(s, i)
+ if !ok {
+ continue
+ }
+ if short, found := shortScalarCast[strings.ToLower(s.At(typeIdx).Value)]; found {
+ s.SetValue(typeIdx, short)
+ changed = true
+ }
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/cast_spaces.go b/blink/internal/fixer/rules/cast_spaces.go
new file mode 100644
index 0000000000..d4a01d73b3
--- /dev/null
+++ b/blink/internal/fixer/rules/cast_spaces.go
@@ -0,0 +1,104 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+var castTypes = map[string]bool{
+ "int": true, "integer": true, "bool": true, "boolean": true,
+ "float": true, "double": true, "real": true, "string": true,
+ "array": true, "object": true, "unset": true, "binary": true,
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/CastNotation/CastSpacesFixer.php
+//
+// CastSpaces normalizes a type cast to no inner spaces and a single space after:
+// "(int)$x" and "( int )$x" both become "(int) $x". A flat token stream cannot
+// see PHP cast tokens, so casts are detected heuristically as "(" type ")" not
+// preceded by a call, index or value.
+type CastSpaces struct{}
+
+func (CastSpaces) Name() string {
+ return `PhpCsFixer\Fixer\CastNotation\CastSpacesFixer`
+}
+
+func (CastSpaces) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/CastNotation/CastSpacesFixer.php"
+}
+
+func (CastSpaces) Fix(s *tokens.Stream) bool {
+ changed := false
+ i := 0
+ for i < s.Len() {
+ if s.At(i).Kind != token.Punct || s.At(i).Value != "(" {
+ i++
+ continue
+ }
+
+ open := i
+ j := open + 1
+ openWS := -1
+ if j < s.Len() && s.At(j).Kind == token.Whitespace {
+ openWS = j
+ j++
+ }
+ if j >= s.Len() || !isCastType(s.At(j)) {
+ i++
+ continue
+ }
+ j++
+ closeWS := -1
+ if j < s.Len() && s.At(j).Kind == token.Whitespace {
+ closeWS = j
+ j++
+ }
+ if j >= s.Len() || s.At(j).Kind != token.Punct || s.At(j).Value != ")" {
+ i++
+ continue
+ }
+ closeIdx := j
+
+ // skip when "(" follows a call, index or value: then it is grouping, not a cast
+ if prev, ok := prevSignificant(s, open); ok {
+ if prev.Kind == token.Ident || prev.Kind == token.Variable ||
+ prev.Value == ")" || prev.Value == "]" {
+ i++
+ continue
+ }
+ }
+
+ // mutate high index to low so earlier indices stay valid
+ if closeIdx+1 < s.Len() {
+ next := s.At(closeIdx + 1)
+ if next.Kind == token.Whitespace {
+ if !hasNewline(next.Value) && next.Value != " " {
+ s.SetValue(closeIdx+1, " ")
+ changed = true
+ }
+ } else {
+ s.InsertAt(closeIdx+1, token.Token{Kind: token.Whitespace, Value: " "})
+ changed = true
+ }
+ }
+ if closeWS >= 0 {
+ s.RemoveAt(closeWS)
+ changed = true
+ }
+ if openWS >= 0 {
+ s.RemoveAt(openWS)
+ changed = true
+ }
+ i++
+ }
+ return changed
+}
+
+func isCastType(t token.Token) bool {
+ if t.Kind != token.Ident && t.Kind != token.Keyword {
+ return false
+ }
+ return castTypes[strings.ToLower(t.Value)]
+}
diff --git a/blink/internal/fixer/rules/class_definition.go b/blink/internal/fixer/rules/class_definition.go
new file mode 100644
index 0000000000..aa8810c9e3
--- /dev/null
+++ b/blink/internal/fixer/rules/class_definition.go
@@ -0,0 +1,82 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/ClassDefinitionFixer.php
+//
+// ClassDefinition normalizes spacing in a class/interface/trait/enum header:
+// exactly one space after the class-like keyword and around "extends" and
+// "implements". Newlines (multiline implements lists) are left untouched, and
+// the brace is left to braces_position.
+type ClassDefinition struct{}
+
+func (ClassDefinition) Name() string {
+ return `PhpCsFixer\Fixer\ClassNotation\ClassDefinitionFixer`
+}
+
+func (ClassDefinition) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/ClassDefinitionFixer.php"
+}
+
+func (ClassDefinition) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ if t.Kind != token.Keyword || !classLikeKeywords[strings.ToLower(t.Value)] {
+ continue
+ }
+ if memberPrev(s, i) {
+ continue // ::class
+ }
+ if prev, ok := prevSignificant(s, i); ok && strings.ToLower(prev.Value) == "new" {
+ continue // anonymous class
+ }
+ if normalizeHeaderSpacing(s, i) {
+ changed = true
+ }
+ }
+ return changed
+}
+
+// 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 {
+ changed := collapseSpace(s, kw+1)
+ for j := kw + 1; j < s.Len(); j++ {
+ t := s.At(j)
+ if t.Kind == token.Punct && t.Value == "{" {
+ break
+ }
+ if t.Kind == token.Keyword {
+ switch strings.ToLower(t.Value) {
+ case "extends", "implements":
+ if collapseSpace(s, j-1) {
+ changed = true
+ }
+ if collapseSpace(s, j+1) {
+ changed = true
+ }
+ }
+ }
+ }
+ return changed
+}
+
+// collapseSpace turns a single-line whitespace token at i into exactly one
+// space. Multiline whitespace and non-whitespace are left alone.
+func collapseSpace(s *tokens.Stream, i int) bool {
+ if i < 0 || i >= s.Len() {
+ return false
+ }
+ t := s.At(i)
+ if t.Kind != token.Whitespace || hasNewline(t.Value) || t.Value == " " {
+ return false
+ }
+ s.SetValue(i, " ")
+ return true
+}
diff --git a/blink/internal/fixer/rules/class_elements.go b/blink/internal/fixer/rules/class_elements.go
new file mode 100644
index 0000000000..80e59dad5b
--- /dev/null
+++ b/blink/internal/fixer/rules/class_elements.go
@@ -0,0 +1,161 @@
+package rules
+
+import (
+ "slices"
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/SingleClassElementPerStatementFixer.php
+//
+// SingleClassElementPerStatement splits a multi-element property or constant
+// declaration into one per line: "public $a, $b;" -> "public $a;\npublic $b;".
+type SingleClassElementPerStatement struct{}
+
+func (SingleClassElementPerStatement) Name() string {
+ return `PhpCsFixer\Fixer\ClassNotation\SingleClassElementPerStatementFixer`
+}
+
+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 {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ if s.At(i).Kind != token.Punct || s.At(i).Value != "{" {
+ continue
+ }
+ if kind, _ := classifyBrace(s, i); kind != braceClassLike {
+ continue
+ }
+ for _, m := range slices.Backward(classMemberStarts(s, i)) {
+ if splitClassElement(s, m) {
+ changed = true
+ }
+ }
+ }
+ return changed
+}
+
+func 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)] {
+ k = skipWhitespace(s, k+1)
+ }
+ if k >= s.Len() {
+ return false
+ }
+
+ isConst := false
+ if s.At(k).Kind == token.Keyword {
+ switch strings.ToLower(s.At(k).Value) {
+ case "const":
+ isConst = true
+ default:
+ return false // function / use / case / unknown
+ }
+ }
+
+ semi := memberEndSemi(s, m)
+ if semi < 0 {
+ return false
+ }
+
+ firstElem := -1
+ if isConst {
+ firstElem = skipWhitespace(s, k+1)
+ } else {
+ depth := 0
+ for x := m; x < semi; x++ {
+ t := s.At(x)
+ if t.Kind == token.Punct {
+ switch t.Value {
+ case "(", "[":
+ depth++
+ case ")", "]":
+ depth--
+ }
+ }
+ if depth == 0 && t.Kind == token.Variable {
+ firstElem = x
+ break
+ }
+ }
+ }
+ if firstElem < 0 || firstElem >= semi {
+ return false
+ }
+
+ var commas []int
+ depth := 0
+ for x := firstElem; x < semi; x++ {
+ t := s.At(x)
+ if t.Kind != token.Punct {
+ continue
+ }
+ switch t.Value {
+ case "(", "[":
+ depth++
+ case ")", "]":
+ depth--
+ case ",":
+ if depth == 0 {
+ commas = append(commas, x)
+ }
+ }
+ }
+ if len(commas) == 0 {
+ return false
+ }
+
+ prefix := make([]token.Token, 0, firstElem-m)
+ for x := m; x < firstElem; x++ {
+ prefix = append(prefix, s.At(x))
+ }
+ indent := lineIndent(s, m)
+
+ bounds := append([]int{firstElem - 1}, commas...)
+ bounds = append(bounds, semi)
+ var repl []token.Token
+ for b := 0; b+1 < len(bounds); b++ {
+ var part []token.Token
+ for x := bounds[b] + 1; x < bounds[b+1]; x++ {
+ part = append(part, s.At(x))
+ }
+ part = trimWhitespace(part)
+ if b > 0 {
+ repl = append(repl, token.Token{Kind: token.Whitespace, Value: "\n" + indent})
+ }
+ repl = append(repl, prefix...)
+ repl = append(repl, part...)
+ repl = append(repl, token.Token{Kind: token.Punct, Value: ";"})
+ }
+ s.ReplaceRange(m, semi, repl)
+ return true
+}
+
+// memberEndSemi returns the ";" that ends the member starting at m, skipping
+// nested groups. Returns -1 if a "}" is reached first (e.g. a method body).
+func memberEndSemi(s *tokens.Stream, m int) int {
+ for k := m; k < s.Len(); k++ {
+ t := s.At(k)
+ if t.Kind != token.Punct {
+ continue
+ }
+ switch t.Value {
+ case "(", "[", "{":
+ if mm := s.MatchForward(k); mm > 0 {
+ k = mm
+ }
+ case ";":
+ return k
+ case "}":
+ return -1
+ }
+ }
+ return -1
+}
diff --git a/blink/internal/fixer/rules/class_members_test.go b/blink/internal/fixer/rules/class_members_test.go
new file mode 100644
index 0000000000..4710c673aa
--- /dev/null
+++ b/blink/internal/fixer/rules/class_members_test.go
@@ -0,0 +1,52 @@
+package rules
+
+import "testing"
+
+func TestVisibilityRequired(t *testing.T) {
+ got, changed := apply(t, VisibilityRequired{}, "= 0 && i < s.Len() && s.At(i-1).Kind == token.Whitespace && s.At(i).Kind == token.Whitespace {
+ s.SetValue(i-1, s.At(i-1).Value+s.At(i).Value)
+ s.RemoveAt(i)
+ }
+ i--
+ }
+ }
+ return changed
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Comment/SingleLineCommentSpacingFixer.php
+//
+// SingleLineCommentSpacing puts a space after "//" or "#" in a single-line
+// comment ("//foo" -> "// foo"). Attributes ("#[...]") are left alone.
+type SingleLineCommentSpacing struct{}
+
+func (SingleLineCommentSpacing) Name() string {
+ return `PhpCsFixer\Fixer\Comment\SingleLineCommentSpacingFixer`
+}
+
+func (SingleLineCommentSpacing) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Comment/SingleLineCommentSpacingFixer.php"
+}
+
+func (SingleLineCommentSpacing) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := range s.Len() {
+ t := s.At(i)
+ if t.Kind != token.Comment {
+ continue
+ }
+ v := t.Value
+ // single-line block comment: ensure one space inside "/* ... */"
+ if strings.HasPrefix(v, "/*") && !strings.HasPrefix(v, "/**") &&
+ strings.HasSuffix(v, "*/") && len(v) >= 4 && !strings.ContainsAny(v, "\n\r") {
+ inner := v[2 : len(v)-2]
+ nv := inner
+ if len(nv) > 0 && nv[0] != ' ' && nv[0] != '\t' {
+ nv = " " + nv
+ }
+ if len(nv) > 0 && nv[len(nv)-1] != ' ' && nv[len(nv)-1] != '\t' {
+ nv = nv + " "
+ }
+ if nv != inner {
+ s.SetValue(i, "/*"+nv+"*/")
+ changed = true
+ }
+ continue
+ }
+ var marker string
+ switch {
+ case strings.HasPrefix(v, "//"):
+ marker = "//"
+ case strings.HasPrefix(v, "#") && !strings.HasPrefix(v, "#["):
+ marker = "#"
+ default:
+ continue
+ }
+ rest := v[len(marker):]
+ if rest == "" || rest[0] == ' ' || rest[0] == '\t' {
+ continue
+ }
+ s.SetValue(i, marker+" "+rest)
+ changed = true
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/comments_analyzer.go b/blink/internal/fixer/rules/comments_analyzer.go
new file mode 100644
index 0000000000..b867475689
--- /dev/null
+++ b/blink/internal/fixer/rules/comments_analyzer.go
@@ -0,0 +1,183 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// A partial port of PHP-CS-Fixer's CommentsAnalyzer, enough for PhpdocToComment:
+// deciding whether a doc comment is a file header or sits before a structural
+// element (a declaration, control statement or documented variable).
+
+var structuralSkipKeywords = map[string]bool{
+ "private": true, "protected": true, "public": true, "var": true,
+ "function": true, "fn": true, "abstract": true, "const": true,
+ "namespace": true, "require": true, "require_once": true,
+ "include": true, "include_once": true, "final": true, "readonly": true,
+}
+
+var classyKeywords = map[string]bool{
+ "class": true, "interface": true, "trait": true, "enum": true,
+}
+
+var assignmentOps = map[string]bool{
+ "=": true, "+=": true, "-=": true, "*=": true, "/=": true, "%=": true,
+ "**=": true, "&=": true, "|=": true, "^=": true, "<<=": true, ">>=": true,
+ "??=": true, ".=": true,
+}
+
+func docIsHeaderComment(s *tokens.Stream, index int) bool {
+ if sigNext(s, index) < 0 {
+ return false
+ }
+ prev := getPrevNonWhitespace(s, index)
+ if prev < 0 {
+ return false
+ }
+ if isPunctVal(s, prev, ";") {
+ braceClose := sigPrev(s, prev)
+ if !isPunctVal(s, braceClose, ")") {
+ return false
+ }
+ braceOpen := s.MatchBackward(braceClose)
+ if braceOpen < 0 {
+ return false
+ }
+ declare := sigPrev(s, braceOpen)
+ if declare < 0 || !kwIs(s.At(declare), "declare") {
+ return false
+ }
+ prev = getPrevNonWhitespace(s, declare)
+ if prev < 0 {
+ return false
+ }
+ }
+ return s.At(prev).Kind == token.OpenTag
+}
+
+// docNextTokenIndex mirrors CommentsAnalyzer::getNextTokenIndex - the next
+// meaningful token, skipping attributes (already comments here) and stepping
+// past a leading "(".
+func docNextTokenIndex(s *tokens.Stream, index int) int {
+ next := sigNext(s, index)
+ for next >= 0 && isPunctVal(s, next, "(") {
+ next = sigNext(s, next)
+ }
+ return next
+}
+
+func docIsBeforeStructuralElement(s *tokens.Stream, index int) bool {
+ next := docNextTokenIndex(s, index)
+ if next < 0 || isPunctVal(s, next, "}") {
+ return false
+ }
+ docContent := s.At(index).Value
+ if docIsStructuralElement(s, next) {
+ return true
+ }
+ if docIsValidControl(s, docContent, next) {
+ return true
+ }
+ if docIsValidVariable(s, next) {
+ return true
+ }
+ if docIsValidVariableAssignment(s, docContent, next) {
+ return true
+ }
+ if kwIs(s.At(next), "use") {
+ return true
+ }
+ return false
+}
+
+func docIsStructuralElement(s *tokens.Stream, index int) bool {
+ t := s.At(index)
+ if t.Kind == token.Keyword {
+ lv := strings.ToLower(t.Value)
+ if classyKeywords[lv] || structuralSkipKeywords[lv] {
+ return true
+ }
+ if lv == "case" {
+ p := prevEnumOrSwitch(s, index)
+ return p >= 0 && kwIs(s.At(p), "enum")
+ }
+ if lv == "static" {
+ n := sigNext(s, index)
+ return n < 0 || !isPunctVal(s, n, "::")
+ }
+ return false
+ }
+ if t.Kind == token.Ident {
+ c := strings.ToLower(t.Value)
+ return c == "get" || c == "set"
+ }
+ return false
+}
+
+func prevEnumOrSwitch(s *tokens.Stream, index int) int {
+ for j := index - 1; j >= 0; j-- {
+ if s.At(j).Kind == token.Keyword {
+ switch strings.ToLower(s.At(j).Value) {
+ case "enum", "switch":
+ return j
+ }
+ }
+ }
+ return -1
+}
+
+func docIsValidControl(s *tokens.Stream, docContent string, controlIndex int) bool {
+ if s.At(controlIndex).Kind != token.Keyword {
+ return false
+ }
+ switch strings.ToLower(s.At(controlIndex).Value) {
+ case "for", "foreach", "if", "switch", "while":
+ default:
+ return false
+ }
+ open := sigNext(s, controlIndex)
+ if open < 0 || !isPunctVal(s, open, "(") {
+ return false
+ }
+ closeIdx := s.MatchForward(open)
+ if closeIdx < 0 {
+ return false
+ }
+ for i := open + 1; i < closeIdx; i++ {
+ if s.At(i).Kind == token.Variable && strings.Contains(docContent, s.At(i).Value) {
+ return true
+ }
+ }
+ return false
+}
+
+func docIsValidVariable(s *tokens.Stream, index int) bool {
+ if s.At(index).Kind != token.Variable {
+ return false
+ }
+ n := sigNext(s, index)
+ return n >= 0 && s.At(n).Kind == token.Punct && assignmentOps[s.At(n).Value]
+}
+
+func docIsValidVariableAssignment(s *tokens.Stream, docContent string, lcIndex int) bool {
+ var endIdx int
+ t := s.At(lcIndex)
+ if t.Kind == token.Keyword && (strings.EqualFold(t.Value, "list") || strings.EqualFold(t.Value, "print") || strings.EqualFold(t.Value, "echo")) {
+ endIdx = nextPunctOfKind(s, lcIndex, ")")
+ } else if isPunctVal(s, lcIndex, "[") {
+ endIdx = s.MatchForward(lcIndex)
+ } else {
+ return false
+ }
+ if endIdx < 0 {
+ return false
+ }
+ for i := lcIndex + 1; i < endIdx; i++ {
+ if s.At(i).Kind == token.Variable && strings.Contains(docContent, s.At(i).Value) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/blink/internal/fixer/rules/common_batch_test.go b/blink/internal/fixer/rules/common_batch_test.go
new file mode 100644
index 0000000000..f313fc7300
--- /dev/null
+++ b/blink/internal/fixer/rules/common_batch_test.go
@@ -0,0 +1,83 @@
+package rules
+
+import "testing"
+
+func TestArraySyntax(t *testing.T) {
+ got, changed := apply(t, ArraySyntax{}, "array(1);"); changed {
+ t.Fatal("method call array() must not convert")
+ }
+ // a cast and a typehint are not the construct
+ if _, changed := apply(t, ArraySyntax{}, " $b) {}")
+ if want := " "if (", "else{" -> "else {").
+type SingleSpaceAroundConstruct struct{}
+
+func (SingleSpaceAroundConstruct) Name() string {
+ return `PhpCsFixer\Fixer\LanguageConstruct\SingleSpaceAroundConstructFixer`
+}
+
+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 {
+ changed := false
+ i := 0
+ for i < s.Len() {
+ t := s.At(i)
+ if t.Kind != token.Keyword || !constructKeywords[strings.ToLower(t.Value)] || i+1 >= s.Len() {
+ i++
+ continue
+ }
+ next := s.At(i + 1)
+ // "static" and "class" directly before "(" are a class reference, not a
+ // construct: "new static(...)", "new class(...)". They take no space.
+ kw := strings.ToLower(t.Value)
+ classRef := kw == "static" || kw == "class"
+ if next.Kind == token.Whitespace {
+ if classRef && !hasNewline(next.Value) && i+2 < s.Len() &&
+ s.At(i+2).Kind == token.Punct && s.At(i+2).Value == "(" {
+ s.RemoveAt(i + 1) // "new static (" -> "new static("
+ changed = true
+ i++
+ continue
+ }
+ if !hasNewline(next.Value) && next.Value != " " {
+ s.SetValue(i+1, " ")
+ changed = true
+ }
+ i++
+ continue
+ }
+ switch next.Value {
+ case "::", "->", "?->", ";", ",", ")", ":":
+ // member access, statement end or label - not a construct body
+ case "(":
+ if !classRef {
+ s.InsertAt(i+1, token.Token{Kind: token.Whitespace, Value: " "})
+ changed = true
+ i++
+ }
+ default:
+ s.InsertAt(i+1, token.Token{Kind: token.Whitespace, Value: " "})
+ changed = true
+ i++
+ }
+ i++
+ }
+ return changed
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/FunctionNotation/NoSpacesAfterFunctionNameFixer.php
+//
+// NoSpacesAfterFunctionName removes whitespace between a function name and its
+// opening parenthesis ("foo ()" -> "foo()").
+type NoSpacesAfterFunctionName struct{}
+
+func (NoSpacesAfterFunctionName) Name() string {
+ return `PhpCsFixer\Fixer\FunctionNotation\NoSpacesAfterFunctionNameFixer`
+}
+
+func (NoSpacesAfterFunctionName) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/FunctionNotation/NoSpacesAfterFunctionNameFixer.php"
+}
+
+func (NoSpacesAfterFunctionName) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ if s.At(i).Kind != token.Ident {
+ continue
+ }
+ if i+2 < s.Len() &&
+ s.At(i+1).Kind == token.Whitespace && !hasNewline(s.At(i+1).Value) &&
+ s.At(i+2).Kind == token.Punct && s.At(i+2).Value == "(" {
+ s.RemoveAt(i + 1)
+ changed = true
+ }
+ }
+ return changed
+}
+
+// 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{}
+
+func (NoSpacesInsideParenthesis) Name() string {
+ return `PhpCsFixer\Fixer\Whitespace\SpacesInsideParenthesesFixer`
+}
+
+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 {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ if t.Kind == token.Punct && t.Value == "(" &&
+ i+1 < s.Len() && s.At(i+1).Kind == token.Whitespace && !hasNewline(s.At(i+1).Value) {
+ s.RemoveAt(i + 1)
+ changed = true
+ }
+ if s.At(i).Kind == token.Punct && s.At(i).Value == ")" &&
+ i >= 1 && s.At(i-1).Kind == token.Whitespace && !hasNewline(s.At(i-1).Value) {
+ s.RemoveAt(i - 1)
+ i--
+ changed = true
+ }
+ }
+ return changed
+}
+
+// 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{}
+
+func (UnaryOperatorSpaces) Name() string {
+ return `PhpCsFixer\Fixer\Operator\UnaryOperatorSpacesFixer`
+}
+
+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 {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ if t.Kind != token.Punct || (t.Value != "++" && t.Value != "--") {
+ continue
+ }
+ if i >= 2 && s.At(i-1).Kind == token.Whitespace && !hasNewline(s.At(i-1).Value) && isOperand(s.At(i-2)) {
+ s.RemoveAt(i - 1)
+ i--
+ changed = true
+ }
+ if i+2 < s.Len() && s.At(i+1).Kind == token.Whitespace && !hasNewline(s.At(i+1).Value) && isOperand(s.At(i+2)) {
+ s.RemoveAt(i + 1)
+ changed = true
+ }
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/control.go b/blink/internal/fixer/rules/control.go
new file mode 100644
index 0000000000..c471a74425
--- /dev/null
+++ b/blink/internal/fixer/rules/control.go
@@ -0,0 +1,40 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/ElseifFixer.php
+//
+// Elseif merges "else if" into "elseif".
+type Elseif struct{}
+
+func (Elseif) Name() string {
+ return `PhpCsFixer\Fixer\ControlStructure\ElseifFixer`
+}
+
+func (Elseif) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/ElseifFixer.php"
+}
+
+func (Elseif) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ if t.Kind != token.Keyword || strings.ToLower(t.Value) != "else" {
+ continue
+ }
+ if i+2 < s.Len() &&
+ s.At(i+1).Kind == token.Whitespace && !hasNewline(s.At(i+1).Value) &&
+ s.At(i+2).Kind == token.Keyword && strings.ToLower(s.At(i+2).Value) == "if" {
+ s.SetValue(i, "elseif")
+ s.RemoveAt(i + 2) // remove "if" first (higher index)
+ s.RemoveAt(i + 1) // then the whitespace
+ changed = true
+ }
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/control_structure_braces.go b/blink/internal/fixer/rules/control_structure_braces.go
new file mode 100644
index 0000000000..54bd997f68
--- /dev/null
+++ b/blink/internal/fixer/rules/control_structure_braces.go
@@ -0,0 +1,248 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/ControlStructureBracesFixer.php
+//
+// ControlStructureBraces wraps the body of each control structure in braces
+// ("if (foo()) echo 'x';" -> "if (foo()) { echo 'x'; }"). It is a no-op on
+// already-braced code. Alternative syntax ("if (...): ... endif;") is left to
+// NoAlternativeSyntaxFixer, which runs first, so those forms are skipped here.
+type ControlStructureBraces struct{}
+
+func (ControlStructureBraces) Name() string {
+ return `PhpCsFixer\Fixer\ControlStructure\ControlStructureBracesFixer`
+}
+
+func (ControlStructureBraces) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/ControlStructureBracesFixer.php"
+}
+
+var csbControlKeywords = map[string]bool{
+ "declare": true, "do": true, "else": true, "elseif": true, "finally": true,
+ "for": true, "foreach": true, "if": true, "while": true, "try": true,
+ "catch": true, "switch": true,
+}
+
+var csbAltTerminators = map[string]bool{
+ "endif": true, "endwhile": true, "endfor": true,
+ "endforeach": true, "endswitch": true, "enddeclare": true,
+}
+
+func csbIsControl(t token.Token) bool {
+ return t.Kind == token.Keyword && csbControlKeywords[strings.ToLower(t.Value)]
+}
+
+// csbIsConstantName reports whether the token at index is a class or global
+// constant name - a keyword-spelled identifier declared with `const`, optionally
+// preceded by a type (e.g. "const IF", "const string IF"). Scanning back over the
+// type tokens, a `const` keyword marks it as a name rather than a control word.
+func csbIsConstantName(s *tokens.Stream, index int) bool {
+ i := prevSignificantIndex(s, index)
+ for i != -1 {
+ t := s.At(i)
+ if t.Kind == token.Keyword && strings.EqualFold(t.Value, "const") {
+ return true
+ }
+ // allow the optional type between `const` and the name (int, ?Foo, A|B, \Ns\C)
+ if t.Kind == token.Ident || t.Kind == token.Keyword ||
+ (t.Kind == token.Punct && (t.Value == "?" || t.Value == "|" || t.Value == `\`)) {
+ i = prevSignificantIndex(s, i)
+ continue
+ }
+ return false
+ }
+ return false
+}
+
+func csbIsKeyword(t token.Token, word string) bool {
+ return t.Kind == token.Keyword && strings.EqualFold(t.Value, word)
+}
+
+func csbIsPunct(t token.Token, v string) bool {
+ return t.Kind == token.Punct && t.Value == v
+}
+
+func (f ControlStructureBraces) Fix(s *tokens.Stream) bool {
+ changed := false
+ for index := s.Len() - 1; index >= 0; index-- {
+ tok := s.At(index)
+ if !csbIsControl(tok) {
+ continue
+ }
+
+ // a keyword-spelled constant name (e.g. "const string IF = ...") is an
+ // identifier, not a control structure - the lexer still tags it as a
+ // keyword, so skip it here rather than wrap it in braces
+ if csbIsConstantName(s, index) {
+ continue
+ }
+
+ // "else if" - the "if" is handled on its own, skip the "else"
+ if csbIsKeyword(tok, "else") {
+ ni := nextMeaningfulIndex(s, index)
+ if ni != -1 && csbIsKeyword(s.At(ni), "if") {
+ continue
+ }
+ }
+
+ parenEnd := csbFindParenthesisEnd(s, index)
+ nextAfter := nextMeaningfulIndex(s, parenEnd)
+ if nextAfter == -1 {
+ continue
+ }
+ ta := s.At(nextAfter)
+
+ if csbIsPunct(ta, ";") || csbIsPunct(ta, "{") || csbIsPunct(ta, ":") || ta.Kind == token.CloseTag {
+ continue
+ }
+
+ // nested control using alternative syntax is normalized elsewhere; skip
+ if csbIsControl(ta) && csbIsAltSyntax(s, nextAfter) {
+ continue
+ }
+
+ statementEnd := csbFindStatementEnd(s, parenEnd)
+ if statementEnd == -1 {
+ continue
+ }
+
+ closing := []token.Token{
+ {Kind: token.Whitespace, Value: " "},
+ {Kind: token.Punct, Value: "}"},
+ }
+ if !csbIsPunct(s.At(statementEnd), ";") && !csbIsPunct(s.At(statementEnd), "}") {
+ closing = append([]token.Token{{Kind: token.Punct, Value: ";"}}, closing...)
+ }
+ csbInsert(s, statementEnd+1, closing)
+ csbInsert(s, parenEnd+1, []token.Token{
+ {Kind: token.Whitespace, Value: " "},
+ {Kind: token.Punct, Value: "{"},
+ })
+ changed = true
+ }
+ return changed
+}
+
+// csbIsAltSyntax reports whether the control at controlIndex opens with the
+// alternative ":" syntax after its parenthesis.
+func csbIsAltSyntax(s *tokens.Stream, controlIndex int) bool {
+ pe := csbFindParenthesisEnd(s, controlIndex)
+ after := nextMeaningfulIndex(s, pe)
+ return after != -1 && csbIsPunct(s.At(after), ":")
+}
+
+// csbInsert inserts toks at pos, preserving their order.
+func csbInsert(s *tokens.Stream, pos int, toks []token.Token) {
+ for i, t := range toks {
+ s.InsertAt(pos+i, t)
+ }
+}
+
+// csbFindParenthesisEnd returns the ")" index of the control's condition, or the
+// control token index itself when it has no parenthesis (do/else/try/finally).
+func csbFindParenthesisEnd(s *tokens.Stream, controlIndex int) int {
+ ni := nextMeaningfulIndex(s, controlIndex)
+ if ni == -1 || !csbIsPunct(s.At(ni), "(") {
+ return controlIndex
+ }
+ end := s.MatchForward(ni)
+ if end == -1 {
+ return controlIndex
+ }
+ return end
+}
+
+var csbContinuation = map[string][]string{
+ "if": {"else", "elseif"},
+ "do": {"while"},
+ "try": {"catch", "finally"},
+}
+
+var csbFinalContinuation = map[string][]string{
+ "if": {"else"},
+ "do": {},
+ "try": {"finally"},
+}
+
+func csbInList(word string, list []string) bool {
+ for _, w := range list {
+ if strings.EqualFold(word, w) {
+ return true
+ }
+ }
+ return false
+}
+
+// csbFindStatementEnd returns the index of the last token of the statement that
+// follows the parenthesis, or -1 when it cannot be determined safely.
+func csbFindStatementEnd(s *tokens.Stream, parenEnd int) int {
+ nextIndex := nextMeaningfulIndex(s, parenEnd)
+ if nextIndex == -1 {
+ return -1
+ }
+ next := s.At(nextIndex)
+
+ if csbIsPunct(next, "{") {
+ return s.MatchForward(nextIndex)
+ }
+
+ if csbIsControl(next) {
+ pe := csbFindParenthesisEnd(s, nextIndex)
+ endIndex := csbFindStatementEnd(s, pe)
+ if endIndex == -1 {
+ return -1
+ }
+
+ opening := strings.ToLower(next.Value)
+ if opening == "if" || opening == "try" || opening == "do" {
+ for {
+ ni := nextMeaningfulIndex(s, endIndex)
+ if ni != -1 && s.At(ni).Kind == token.Keyword && csbInList(s.At(ni).Value, csbContinuation[opening]) {
+ pe := csbFindParenthesisEnd(s, ni)
+ endIndex = csbFindStatementEnd(s, pe)
+ if endIndex == -1 {
+ return -1
+ }
+ if csbInList(s.At(ni).Value, csbFinalContinuation[opening]) {
+ return endIndex
+ }
+ } else {
+ break
+ }
+ }
+ }
+ return endIndex
+ }
+
+ index := parenEnd
+ for {
+ index++
+ if index >= s.Len() {
+ return -1
+ }
+ tok := s.At(index)
+ if csbIsPunct(tok, "{") {
+ be := s.MatchForward(index)
+ if be == -1 {
+ return -1
+ }
+ index = be
+ continue
+ }
+ if csbIsPunct(tok, ";") {
+ return index
+ }
+ if tok.Kind == token.CloseTag {
+ return prevSignificantIndex(s, index)
+ }
+ if tok.Kind == token.Keyword && csbAltTerminators[strings.ToLower(tok.Value)] {
+ return -1
+ }
+ }
+}
diff --git a/blink/internal/fixer/rules/control_structure_braces_test.go b/blink/internal/fixer/rules/control_structure_braces_test.go
new file mode 100644
index 0000000000..8f453bd2ee
--- /dev/null
+++ b/blink/internal/fixer/rules/control_structure_braces_test.go
@@ -0,0 +1,50 @@
+package rules
+
+import "testing"
+
+func TestControlStructureBraces(t *testing.T) {
+ cases := []struct {
+ src string
+ want string
+ changed bool
+ }{
+ // braceless if body gets wrapped
+ {`for($a);`, `for($a);`, false},
+ // try/catch/finally already braced
+ {` %q changed=%v", got, again, changed2)
+ }
+ }
+}
diff --git a/blink/internal/fixer/rules/control_structure_continuation.go b/blink/internal/fixer/rules/control_structure_continuation.go
new file mode 100644
index 0000000000..7f4ce8ddca
--- /dev/null
+++ b/blink/internal/fixer/rules/control_structure_continuation.go
@@ -0,0 +1,71 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/ControlStructureContinuationPositionFixer.php
+//
+// ControlStructureContinuationPosition puts a control-structure continuation
+// keyword on the same line as the preceding "}" (the default "same_line"):
+// "}\nelse {" becomes "} else {". It covers else/elseif/catch/finally and the
+// "while" of a do-while.
+type ControlStructureContinuationPosition struct{}
+
+func (ControlStructureContinuationPosition) Name() string {
+ return `PhpCsFixer\Fixer\ControlStructure\ControlStructureContinuationPositionFixer`
+}
+
+func (ControlStructureContinuationPosition) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/ControlStructureContinuationPositionFixer.php"
+}
+
+func (ControlStructureContinuationPosition) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ if t.Kind != token.Keyword {
+ continue
+ }
+ lw := strings.ToLower(t.Value)
+ cont := lw == "else" || lw == "elseif" || lw == "catch" || lw == "finally" || lw == "while"
+ if !cont {
+ continue
+ }
+ // the keyword must directly follow a "}" with a single whitespace token
+ // carrying a newline (nothing to do when already on the same line, and a
+ // comment in between is left alone)
+ pi := prevSignificantIndex(s, i)
+ if pi < 0 || i != pi+2 {
+ continue
+ }
+ if s.At(pi).Kind != token.Punct || s.At(pi).Value != "}" {
+ continue
+ }
+ ws := s.At(pi + 1)
+ if ws.Kind != token.Whitespace || !hasNewline(ws.Value) {
+ continue
+ }
+ // a bare "while" is a loop header, not a continuation; only the "while" of
+ // a do-while (whose "}" closes a "do" block) moves up
+ if lw == "while" && !closesDoBlock(s, pi) {
+ continue
+ }
+ s.SetValue(pi+1, " ")
+ changed = true
+ }
+ return changed
+}
+
+// closesDoBlock reports whether the "}" at idx closes the body of a "do" block.
+func closesDoBlock(s *tokens.Stream, idx int) bool {
+ open := s.MatchBackward(idx)
+ if open < 0 {
+ return false
+ }
+ kind, kw := classifyBrace(s, open)
+ return kind == braceControl && kw >= 0 && strings.ToLower(s.At(kw).Value) == "do"
+}
diff --git a/blink/internal/fixer/rules/control_structure_tier1_test.go b/blink/internal/fixer/rules/control_structure_tier1_test.go
new file mode 100644
index 0000000000..e965e2bab0
--- /dev/null
+++ b/blink/internal/fixer/rules/control_structure_tier1_test.go
@@ -0,0 +1,74 @@
+package rules
+
+import "testing"
+
+func TestControlStructureContinuationPosition(t *testing.T) {
+ got, changed := apply(t, ControlStructureContinuationPosition{}, " "declare(strict_types=1)".
+type DeclareEqualNormalize struct{}
+
+func (DeclareEqualNormalize) Name() string {
+ return `PhpCsFixer\Fixer\LanguageConstruct\DeclareEqualNormalizeFixer`
+}
+
+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 {
+ 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 i+1 < s.Len() && s.At(i+1).Kind == token.Whitespace {
+ s.RemoveAt(i + 1)
+ changed = true
+ }
+ if i >= 1 && s.At(i-1).Kind == token.Whitespace {
+ s.RemoveAt(i - 1)
+ i--
+ changed = true
+ }
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/docblock.go b/blink/internal/fixer/rules/docblock.go
new file mode 100644
index 0000000000..fc9ae1b6d2
--- /dev/null
+++ b/blink/internal/fixer/rules/docblock.go
@@ -0,0 +1,71 @@
+package rules
+
+import "strings"
+
+// A docblock is modeled as its opening line ("/**"), inner content lines and
+// closing line ("*/"). Each inner line keeps its "* " prefix separate
+// from the content so fixers can rewrite content and rebuild verbatim.
+
+type docLine struct {
+ prefix string // indentation + "*" (+ " " when content follows)
+ content string
+}
+
+type docblock struct {
+ open string
+ inner []docLine
+ close string
+ single bool // "/** ... */" on one line
+}
+
+// parseDoc splits a /** ... */ value into content lines. Malformed input returns
+// ok=false. Single-line docblocks are supported.
+func parseDoc(v string) (docblock, bool) {
+ if !strings.HasPrefix(v, "/**") || !strings.HasSuffix(v, "*/") || len(v) < 5 {
+ return docblock{}, false
+ }
+ if !strings.Contains(v, "\n") {
+ body := strings.TrimSpace(v[3 : len(v)-2])
+ return docblock{open: "/**", inner: []docLine{{content: body}}, close: "*/", single: true}, true
+ }
+ lines := strings.Split(v, "\n")
+ if len(lines) < 3 {
+ return docblock{}, false
+ }
+ d := docblock{open: lines[0], close: lines[len(lines)-1]}
+ for _, line := range lines[1 : len(lines)-1] {
+ star := strings.IndexByte(line, '*')
+ if star < 0 {
+ return docblock{}, false
+ }
+ prefix := line[:star+1]
+ rest := line[star+1:]
+ if strings.HasPrefix(rest, " ") {
+ prefix += " "
+ rest = rest[1:]
+ }
+ d.inner = append(d.inner, docLine{prefix: prefix, content: rest})
+ }
+ return d, true
+}
+
+func (d docblock) render() string {
+ if d.single {
+ if len(d.inner) == 0 || d.inner[0].content == "" {
+ return "/** */"
+ }
+ return "/** " + d.inner[0].content + " */"
+ }
+ var b strings.Builder
+ b.WriteString(d.open)
+ for _, l := range d.inner {
+ b.WriteString("\n")
+ if l.content == "" {
+ b.WriteString(strings.TrimRight(l.prefix, " "))
+ } else {
+ b.WriteString(l.prefix + l.content)
+ }
+ }
+ b.WriteString("\n" + d.close)
+ return b.String()
+}
diff --git a/blink/internal/fixer/rules/docblock_lines.go b/blink/internal/fixer/rules/docblock_lines.go
new file mode 100644
index 0000000000..9d892d269a
--- /dev/null
+++ b/blink/internal/fixer/rules/docblock_lines.go
@@ -0,0 +1,131 @@
+package rules
+
+import (
+ "regexp"
+ "strings"
+)
+
+// A raw-line model of a docblock that mirrors PHP-CS-Fixer's DocBlock/Line:
+// the content is split into lines, each keeping its trailing newline, and the
+// opening "/**" and closing "*/" are lines too. Rebuilding concatenates them,
+// so a removed line (set to "") disappears and addBlank appends a blank line.
+
+var docLineSplitRe = regexp.MustCompile(`[^\n\r]+(?:\r\n|\r|\n)*`)
+
+// splitDocLines mirrors DocBlock's Preg::split('/([^\n\r]+\R*)/', ...) so each
+// line carries its own trailing newline(s).
+func splitDocLines(content string) []string {
+ return docLineSplitRe.FindAllString(content, -1)
+}
+
+var (
+ docLineTagRe = regexp.MustCompile(`\*\s*@`)
+ docLineUsefulRe = regexp.MustCompile(`\*\s*\S+`)
+ // the optional trailing newline mirrors PCRE's `$`, which also matches just
+ // before a single newline at the end of the subject
+ docLineAddBlankRe = regexp.MustCompile(`^([\t ]*\*)[^\r\n]*(\r?\n)(?:\r?\n)?$`)
+)
+
+func lineContainsTag(line string) bool {
+ return docLineTagRe.MatchString(line)
+}
+
+func lineContainsUsefulContent(line string) bool {
+ if !docLineUsefulRe.MatchString(line) {
+ return false
+ }
+ replaced := make([]rune, 0, len(line))
+ for _, r := range line {
+ if r == '/' || r == '*' {
+ replaced = append(replaced, ' ')
+ } else {
+ replaced = append(replaced, r)
+ }
+ }
+ for _, r := range replaced {
+ if r != ' ' && r != '\t' && r != '\n' && r != '\r' && r != '\v' && r != '\f' {
+ return true
+ }
+ }
+ return false
+}
+
+// lineAddBlank appends a blank doc line ("*") after line,
+// mirroring Line::addBlank.
+func lineAddBlank(line string) string {
+ m := docLineAddBlankRe.FindStringSubmatch(line)
+ if m == nil {
+ return line
+ }
+ return line + m[1] + m[2]
+}
+
+// docAnnotation is a span of lines beginning at a tag line.
+type docAnnotation struct {
+ start int
+ end int
+ name string
+}
+
+// docAnnotations mirrors DocBlock::getAnnotations - each annotation runs from
+// its "@tag" line through its continuation lines.
+func docAnnotations(lines []string) []docAnnotation {
+ var anns []docAnnotation
+ total := len(lines)
+ for index := 0; index < total; index++ {
+ if !lineContainsTag(lines[index]) {
+ continue
+ }
+ length := findAnnotationLength(lines, index)
+ anns = append(anns, docAnnotation{
+ start: index,
+ end: index + length - 1,
+ name: annotationTagName(lines[index : index+length]),
+ })
+ index += length - 1
+ }
+ return anns
+}
+
+// findAnnotationLength mirrors DocBlock::findAnnotationLength.
+func findAnnotationLength(lines []string, start int) int {
+ index := start
+ for {
+ index++
+ if index >= len(lines) {
+ break
+ }
+ line := lines[index]
+ if lineContainsTag(line) {
+ break
+ }
+ if !lineContainsUsefulContent(line) {
+ if index+1 >= len(lines) || !lineContainsUsefulContent(lines[index+1]) || lineContainsTag(lines[index+1]) {
+ break
+ }
+ }
+ }
+ return index - start
+}
+
+// tagNameRe matches the @tag name; the trailing boundary (whitespace, end or
+// "(") is checked separately since Go's regexp has no lookahead.
+var tagNameRe = regexp.MustCompile(`@([a-zA-Z0-9_\-]+)`)
+
+func annotationTagName(lines []string) string {
+ content := strings.Join(lines, "")
+ loc := tagNameRe.FindStringSubmatchIndex(content)
+ if loc == nil {
+ return ""
+ }
+ name := content[loc[2]:loc[3]]
+ after := loc[3]
+ if after >= len(content) {
+ return name
+ }
+ c := content[after]
+ if c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\v' || c == '\f' || c == '(' {
+ return name
+ }
+ return ""
+}
diff --git a/blink/internal/fixer/rules/doctrine_annotation.go b/blink/internal/fixer/rules/doctrine_annotation.go
new file mode 100644
index 0000000000..ee32582852
--- /dev/null
+++ b/blink/internal/fixer/rules/doctrine_annotation.go
@@ -0,0 +1,258 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/DoctrineAnnotation/DoctrineAnnotationSpacesFixer.php
+//
+// DoctrineAnnotationSpaces normalises whitespace in a Doctrine annotation:
+// no space before the "(", none just inside "(" / ")", none around an argument
+// "=" and none before a ",", while an array-assignment "=" (inside "{ }") gets a
+// single space on each side. String contents are left untouched.
+//
+// Only single-line annotations whose name starts uppercase or is namespaced
+// (e.g. @ORM\Column, @Route, @Assert\NotBlank) are handled - that keeps plain
+// phpdoc tags (@param, @return, ...) and multi-line annotations untouched, which
+// is a safe under-fire rather than a risky guess.
+type DoctrineAnnotationSpaces struct{}
+
+func (DoctrineAnnotationSpaces) Name() string {
+ return `PhpCsFixer\Fixer\DoctrineAnnotation\DoctrineAnnotationSpacesFixer`
+}
+
+func (DoctrineAnnotationSpaces) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/DoctrineAnnotation/DoctrineAnnotationSpacesFixer.php"
+}
+
+func (DoctrineAnnotationSpaces) Fix(s *tokens.Stream) bool {
+ return applyToDocblocks(s, func(d *docblock) bool {
+ changed := false
+ for i := range d.inner {
+ fixed, ok := fixDoctrineSpacesLine(d.inner[i].content)
+ if ok && fixed != d.inner[i].content {
+ d.inner[i].content = fixed
+ changed = true
+ }
+ }
+ return changed
+ })
+}
+
+// fixDoctrineSpacesLine rewrites a single doc line that holds one complete
+// Doctrine annotation. It returns ok=false when the line is not such an
+// annotation (leaving it untouched).
+func fixDoctrineSpacesLine(content string) (string, bool) {
+ lead := content[:len(content)-len(strings.TrimLeft(content, " \t"))]
+ rest := content[len(lead):]
+ if !strings.HasPrefix(rest, "@") {
+ return content, false
+ }
+ // annotation name: @Name or @Ns\Name
+ j := 1
+ for j < len(rest) && isAnnotationNameByte(rest[j]) {
+ j++
+ }
+ name := rest[1:j]
+ if name == "" || !isDoctrineAnnotationName(name) {
+ return content, false
+ }
+ // optional spaces, then the opening "("
+ k := j
+ for k < len(rest) && (rest[k] == ' ' || rest[k] == '\t') {
+ k++
+ }
+ if k >= len(rest) || rest[k] != '(' {
+ return content, false // no argument list - nothing to space
+ }
+ closeIdx := matchAnnotationParen(rest, k)
+ if closeIdx < 0 {
+ return content, false // unbalanced - multi-line annotation, skip
+ }
+ // only trailing whitespace may follow the closing ")"
+ if strings.TrimRight(rest[closeIdx+1:], " \t") != "" {
+ return content, false
+ }
+ inner := normalizeDoctrineInner(rest[k+1 : closeIdx])
+ rebuilt := lead + "@" + name + "(" + inner + ")" + rest[closeIdx+1:]
+ return rebuilt, true
+}
+
+func isAnnotationNameByte(b byte) bool {
+ return b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' || b == '_' || b == '\\'
+}
+
+// isDoctrineAnnotationName reports whether name looks like a Doctrine annotation
+// (starts uppercase or is namespaced) rather than a lowercase phpdoc tag.
+func isDoctrineAnnotationName(name string) bool {
+ if strings.Contains(name, "\\") {
+ return true
+ }
+ return name[0] >= 'A' && name[0] <= 'Z'
+}
+
+// matchAnnotationParen returns the index of the ")" matching the "(" at open,
+// honouring double-quoted strings, or -1 when unbalanced within the string.
+func matchAnnotationParen(s string, open int) int {
+ depth := 0
+ inStr := false
+ for i := open; i < len(s); i++ {
+ c := s[i]
+ if inStr {
+ if c == '\\' {
+ i++
+ continue
+ }
+ if c == '"' {
+ inStr = false
+ }
+ continue
+ }
+ switch c {
+ case '"':
+ inStr = true
+ case '(':
+ depth++
+ case ')':
+ depth--
+ if depth == 0 {
+ return i
+ }
+ }
+ }
+ return -1
+}
+
+// doctrineToken is a non-space unit of annotation content plus the original
+// whitespace that followed it.
+type doctrineToken struct {
+ text string
+ isPunct bool
+ gap string // whitespace following this token
+}
+
+// normalizeDoctrineInner applies the spacing rules to the content between the
+// outermost parentheses.
+func normalizeDoctrineInner(inner string) string {
+ toks := tokenizeDoctrine(inner)
+ if len(toks) == 0 {
+ return ""
+ }
+ // brace depth in front of each token (used to tell an array "=" apart from
+ // an argument "=")
+ depth := make([]int, len(toks))
+ d := 0
+ for i, t := range toks {
+ depth[i] = d
+ if t.isPunct && t.text == "{" {
+ d++
+ } else if t.isPunct && t.text == "}" {
+ if d > 0 {
+ d--
+ }
+ }
+ }
+ var b strings.Builder
+ for i, t := range toks {
+ b.WriteString(t.text)
+ if i == len(toks)-1 {
+ break
+ }
+ next := toks[i+1]
+ b.WriteString(gapBetween(t, next, depth[i], depth[i+1]))
+ }
+ return b.String()
+}
+
+// gapBetween decides the whitespace between two adjacent tokens.
+func gapBetween(a, next doctrineToken, aDepth, nextDepth int) string {
+ // no space just inside parentheses, nor before a comma
+ if a.isPunct && a.text == "(" {
+ return ""
+ }
+ if next.isPunct && next.text == ")" {
+ return ""
+ }
+ if next.isPunct && next.text == "," {
+ return ""
+ }
+ // one space after a comma (add when missing; existing spacing is kept)
+ if a.isPunct && a.text == "," {
+ if a.gap == "" {
+ return " "
+ }
+ return a.gap
+ }
+ // an "=" is an array assignment when inside "{ }" (one space each side),
+ // otherwise an argument assignment (no space)
+ if a.isPunct && a.text == "=" {
+ if aDepth > 0 {
+ return " "
+ }
+ return ""
+ }
+ if next.isPunct && next.text == "=" {
+ if nextDepth > 0 {
+ return " "
+ }
+ return ""
+ }
+ return a.gap
+}
+
+// tokenizeDoctrine splits annotation content into string-aware tokens, trimming
+// the leading and trailing whitespace.
+func tokenizeDoctrine(inner string) []doctrineToken {
+ var toks []doctrineToken
+ i := 0
+ n := len(inner)
+ for i < n {
+ c := inner[i]
+ if c == ' ' || c == '\t' {
+ // whitespace belongs to the previous token's gap
+ start := i
+ for i < n && (inner[i] == ' ' || inner[i] == '\t') {
+ i++
+ }
+ if len(toks) > 0 {
+ toks[len(toks)-1].gap = inner[start:i]
+ }
+ continue
+ }
+ if c == '"' {
+ start := i
+ i++
+ for i < n {
+ if inner[i] == '\\' {
+ i += 2
+ continue
+ }
+ if inner[i] == '"' {
+ i++
+ break
+ }
+ i++
+ }
+ toks = append(toks, doctrineToken{text: inner[start:i]})
+ continue
+ }
+ if strings.IndexByte("(){},=", c) >= 0 {
+ toks = append(toks, doctrineToken{text: string(c), isPunct: true})
+ i++
+ continue
+ }
+ // a plain run: identifier, number, ::, etc.
+ start := i
+ for i < n {
+ ch := inner[i]
+ if ch == ' ' || ch == '\t' || ch == '"' || strings.IndexByte("(){},=", ch) >= 0 {
+ break
+ }
+ i++
+ }
+ toks = append(toks, doctrineToken{text: inner[start:i]})
+ }
+ return toks
+}
diff --git a/blink/internal/fixer/rules/doctrine_annotation_array_assignment.go b/blink/internal/fixer/rules/doctrine_annotation_array_assignment.go
new file mode 100644
index 0000000000..30573f26bb
--- /dev/null
+++ b/blink/internal/fixer/rules/doctrine_annotation_array_assignment.go
@@ -0,0 +1,43 @@
+package rules
+
+import "blink/internal/tokens"
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/DoctrineAnnotation/DoctrineAnnotationArrayAssignmentFixer.php
+//
+// DoctrineAnnotationArrayAssignment makes array assignments inside a Doctrine
+// annotation use the configured operator ("=" by default), so `{bar : "baz"}`
+// becomes `{bar = "baz"}`.
+type DoctrineAnnotationArrayAssignment struct{}
+
+func (DoctrineAnnotationArrayAssignment) Name() string {
+ return `PhpCsFixer\Fixer\DoctrineAnnotation\DoctrineAnnotationArrayAssignmentFixer`
+}
+
+func (DoctrineAnnotationArrayAssignment) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/DoctrineAnnotation/DoctrineAnnotationArrayAssignmentFixer.php"
+}
+
+func (DoctrineAnnotationArrayAssignment) Fix(s *tokens.Stream) bool {
+ return applyToDoctrineAnnotations(s, func(toks []daToken) bool {
+ changed := false
+ var scopes []string
+ for i := range toks {
+ switch toks[i].typ {
+ case daTOpenParen:
+ scopes = append(scopes, "annotation")
+ case daTOpenCurly:
+ scopes = append(scopes, "array")
+ case daTCloseParen, daTCloseCurly:
+ if len(scopes) > 0 {
+ scopes = scopes[:len(scopes)-1]
+ }
+ case daTEquals, daTColon:
+ if len(scopes) > 0 && scopes[len(scopes)-1] == "array" && toks[i].content != "=" {
+ toks[i].content = "="
+ changed = true
+ }
+ }
+ }
+ return changed
+ })
+}
diff --git a/blink/internal/fixer/rules/doctrine_annotation_array_assignment_test.go b/blink/internal/fixer/rules/doctrine_annotation_array_assignment_test.go
new file mode 100644
index 0000000000..e17e2336ba
--- /dev/null
+++ b/blink/internal/fixer/rules/doctrine_annotation_array_assignment_test.go
@@ -0,0 +1,28 @@
+package rules
+
+import "testing"
+
+func TestDoctrineAnnotationArrayAssignment(t *testing.T) {
+ f := DoctrineAnnotationArrayAssignment{}
+
+ // a colon inside an annotation array becomes an equals
+ got, changed := apply(t, f, " 0
+
+ if indentLevel > 0 && (delta < 0 || mixedBraces) {
+ indentLevel--
+ }
+
+ repl := "${1}" + strings.Repeat(" ", 4*indentLevel+1)
+ if daIndentRe.MatchString(toks[index].content) {
+ fixed := daIndentRe.ReplaceAllString(toks[index].content, repl)
+ if fixed != toks[index].content {
+ toks[index].content = fixed
+ changed = true
+ }
+ }
+
+ if delta > 0 || mixedBraces {
+ indentLevel++
+ }
+ }
+ return changed
+ })
+}
+
+func daLineBracesCount(toks []daToken, index int) (int, int) {
+ opening, closing := 0, 0
+ for i := index + 1; i < len(toks); i++ {
+ if toks[i].typ == daTNone && strings.Contains(toks[i].content, "\n") {
+ break
+ }
+ switch toks[i].typ {
+ case daTOpenParen, daTOpenCurly:
+ opening++
+ case daTCloseParen, daTCloseCurly:
+ if opening > 0 {
+ opening--
+ } else {
+ closing++
+ }
+ }
+ }
+ return opening, closing
+}
+
+type daSpan struct{ start, end int }
+
+func daIndentationCanBeFixed(toks []daToken, newLineTokenIndex int, positions []daSpan) bool {
+ for _, p := range positions {
+ if newLineTokenIndex >= p.start && newLineTokenIndex <= p.end {
+ return true
+ }
+ }
+ if newLineTokenIndex+1 < len(toks) {
+ if strings.Contains(toks[newLineTokenIndex+1].content, "\n") {
+ return false
+ }
+ return toks[newLineTokenIndex+1].typ == daTAt
+ }
+ return false
+}
diff --git a/blink/internal/fixer/rules/doctrine_annotation_indentation_test.go b/blink/internal/fixer/rules/doctrine_annotation_indentation_test.go
new file mode 100644
index 0000000000..0fb9bc77c3
--- /dev/null
+++ b/blink/internal/fixer/rules/doctrine_annotation_indentation_test.go
@@ -0,0 +1,18 @@
+package rules
+
+import "testing"
+
+func TestDoctrineAnnotationIndentation(t *testing.T) {
+ f := DoctrineAnnotationIndentation{}
+
+ // annotation continuation lines get four-space indentation
+ got, changed := apply(t, f, "= 2 {
+ value = strings.ReplaceAll(value[1:len(value)-1], `""`, `"`)
+ } else {
+ value = ""
+ }
+ }
+ toks = append(toks, daToken{typ: typ, content: value, pos: gs})
+ break
+ }
+ }
+ return toks
+}
+
+// daGetType mirrors DocLexer::getType; for a string it also returns the
+// unescaped content (outer quotes removed, "" -> ").
+func daGetType(value string) int {
+ if value == "" {
+ return daTNone
+ }
+ if value[0] == '"' {
+ return daTString
+ }
+ if t, ok := daNoCase[value]; ok {
+ return t
+ }
+ if value[0] == '_' || value[0] == '\\' || isAsciiLetter(value[0]) {
+ return daTIdentifier
+ }
+ if daIsNumeric(value) {
+ if strings.ContainsAny(value, ".eE") {
+ return daTFloat
+ }
+ return daTInteger
+ }
+ return daTNone
+}
+
+func isAsciiLetter(b byte) bool {
+ return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z')
+}
+
+func daIsNumeric(v string) bool {
+ if v == "" {
+ return false
+ }
+ i := 0
+ if v[0] == '+' || v[0] == '-' {
+ i++
+ }
+ digits, dot, e := false, false, false
+ for ; i < len(v); i++ {
+ c := v[i]
+ switch {
+ case c >= '0' && c <= '9':
+ digits = true
+ case c == '.' && !dot && !e:
+ dot = true
+ case (c == 'e' || c == 'E') && !e && digits:
+ e = true
+ if i+1 < len(v) && (v[i+1] == '+' || v[i+1] == '-') {
+ i++
+ }
+ default:
+ return false
+ }
+ }
+ return digits
+}
+
+// daStringPos scans "@" occurrences preceded by whitespace or start, the same
+// filter Tokens::createFromDocComment applies.
+func daIsSpaceByte(b byte) bool {
+ return b == ' ' || b == '\t' || b == '\n' || b == '\r' || b == '\v' || b == '\f'
+}
+
+// createFromDocComment mirrors Tokens::createFromDocComment.
+func daCreateFromDocComment(content string, ignoredTags map[string]bool) []daToken {
+ var toks []daToken
+ ignoredTextPosition := 0
+ currentPosition := 0
+ for {
+ nextAt := strings.IndexByte(content[currentPosition:], '@')
+ if nextAt < 0 {
+ break
+ }
+ nextAt += currentPosition
+ if nextAt != 0 && !daIsSpaceByte(content[nextAt-1]) {
+ currentPosition = nextAt + 1
+ continue
+ }
+
+ scanned := daScan(content[nextAt:])
+ var used []daToken
+ nbScannedTokensToUse := 0
+ nbScopes := 0
+ var lastToken daToken
+ for index := range scanned {
+ tk := scanned[index]
+ lastToken = tk
+ if index == 0 && tk.typ != daTAt {
+ break
+ }
+ if index == 1 {
+ if tk.typ != daTIdentifier || ignoredTags[tk.content] {
+ break
+ }
+ nbScannedTokensToUse = 2
+ }
+ if index >= 2 && nbScopes == 0 && tk.typ != daTNone && tk.typ != daTOpenParen {
+ break
+ }
+ used = append(used, tk)
+ if tk.typ == daTOpenParen {
+ nbScopes++
+ } else if tk.typ == daTCloseParen {
+ nbScopes--
+ if nbScopes == 0 {
+ nbScannedTokensToUse = len(used)
+ break
+ }
+ }
+ }
+
+ if nbScopes != 0 {
+ break
+ }
+
+ if nbScannedTokensToUse != 0 {
+ if ignoredTextLength := nextAt - ignoredTextPosition; ignoredTextLength != 0 {
+ toks = append(toks, daToken{typ: daTNone, content: content[ignoredTextPosition : ignoredTextPosition+ignoredTextLength]})
+ }
+ lastTokenEndIndex := 0
+ var last daToken
+ for _, st := range used[:nbScannedTokensToUse] {
+ tk := st
+ if st.typ == daTString {
+ tk = daToken{typ: st.typ, content: `"` + strings.ReplaceAll(st.content, `"`, `""`) + `"`, pos: st.pos}
+ }
+ if missing := tk.pos - lastTokenEndIndex; missing > 0 {
+ toks = append(toks, daToken{typ: daTNone, content: content[nextAt+lastTokenEndIndex : nextAt+lastTokenEndIndex+missing]})
+ }
+ toks = append(toks, daToken{typ: tk.typ, content: tk.content})
+ lastTokenEndIndex = tk.pos + len(tk.content)
+ last = tk
+ }
+ currentPosition = nextAt + last.pos + len(last.content)
+ ignoredTextPosition = currentPosition
+ } else {
+ _ = lastToken
+ currentPosition = nextAt + 1
+ }
+ }
+
+ if ignoredTextPosition < len(content) {
+ toks = append(toks, daToken{typ: daTNone, content: content[ignoredTextPosition:]})
+ }
+ return toks
+}
+
+func daGetCode(toks []daToken) string {
+ var b strings.Builder
+ for _, t := range toks {
+ b.WriteString(t.content)
+ }
+ return b.String()
+}
+
+var daAnnotationEndRe = regexp.MustCompile(`^(\r?\n\s*\*\s*)*\s*$`)
+
+// daGetAnnotationEnd mirrors Tokens::getAnnotationEnd; returns -1 for "null".
+func daGetAnnotationEnd(toks []daToken, index int) int {
+ current := -1
+ if index+2 < len(toks) {
+ if toks[index+2].typ == daTOpenParen {
+ current = index + 2
+ } else if index+3 < len(toks) && toks[index+2].typ == daTNone &&
+ toks[index+3].typ == daTOpenParen && daAnnotationEndRe.MatchString(toks[index+2].content) {
+ current = index + 3
+ }
+ }
+ if current >= 0 {
+ level := 0
+ for ; current < len(toks); current++ {
+ switch toks[current].typ {
+ case daTOpenParen:
+ level++
+ case daTCloseParen:
+ level--
+ }
+ if level == 0 {
+ return current
+ }
+ }
+ return -1
+ }
+ return index + 1
+}
+
+// doctrineIgnoredTags is the default ignored_tags list shared by the fixers.
+var doctrineIgnoredTags = buildStringSet([]string{
+ "abstract", "access", "code", "deprec", "encode", "exception", "final", "ingroup",
+ "inheritdoc", "inheritDoc", "magic", "name", "toc", "tutorial", "private", "static",
+ "staticvar", "staticVar", "throw", "api", "author", "category", "copyright", "deprecated",
+ "example", "filesource", "global", "ignore", "internal", "license", "link", "method",
+ "package", "param", "property", "property-read", "property-write", "return", "see", "since",
+ "source", "subpackage", "throws", "todo", "TODO", "usedBy", "uses", "var", "version",
+ "after", "afterClass", "backupGlobals", "backupStaticAttributes", "before", "beforeClass",
+ "codeCoverageIgnore", "codeCoverageIgnoreStart", "codeCoverageIgnoreEnd", "covers",
+ "coversDefaultClass", "coversNothing", "dataProvider", "depends", "expectedException",
+ "expectedExceptionCode", "expectedExceptionMessage", "expectedExceptionMessageRegExp",
+ "group", "large", "medium", "preserveGlobalState", "requires", "runTestsInSeparateProcesses",
+ "runInSeparateProcess", "small", "test", "testdox", "ticket", "SuppressWarnings",
+ "noinspection", "package_version", "enduml", "startuml", "psalm", "phpstan", "template",
+ "fix", "FIXME", "fixme", "override",
+})
+
+func buildStringSet(items []string) map[string]bool {
+ m := make(map[string]bool, len(items))
+ for _, it := range items {
+ m[it] = true
+ }
+ return m
+}
+
+// classModifierKw are the class modifiers skipped before a "class" keyword.
+var classModifierKw = map[string]bool{"abstract": true, "final": true, "readonly": true}
+
+// memberModifierKw mirrors MODIFIER_KINDS for skipping to a class member.
+var memberModifierKw = map[string]bool{
+ "public": true, "protected": true, "private": true, "final": true,
+ "abstract": true, "readonly": true,
+}
+
+// docAnnotationEligible mirrors nextElementAcceptsDoctrineAnnotations: the
+// docblock at index must precede a class or a member of a class (not interface,
+// trait or enum).
+func docAnnotationEligible(s *tokens.Stream, index int) bool {
+ i := sigNext(s, index)
+ for i >= 0 && s.At(i).Kind == token.Keyword && classModifierKw[strings.ToLower(s.At(i).Value)] {
+ i = sigNext(s, i)
+ }
+ if i < 0 {
+ return false
+ }
+ if s.At(i).Kind == token.Keyword && strings.ToLower(s.At(i).Value) == "class" {
+ return true
+ }
+ // skip member modifiers / type tokens, then require an enclosing "class" body
+ for i >= 0 && s.At(i).Kind == token.Keyword && memberModifierKw[strings.ToLower(s.At(i).Value)] {
+ i = sigNext(s, i)
+ }
+ if i < 0 {
+ return false
+ }
+ return enclosingClassIsClass(s, i)
+}
+
+// enclosingClassIsClass reports whether the nearest enclosing "{ }" block around
+// idx is a class body (not interface/trait/enum).
+func enclosingClassIsClass(s *tokens.Stream, idx int) bool {
+ depth := 0
+ for j := idx - 1; j >= 0; j-- {
+ if s.At(j).Kind != token.Punct {
+ continue
+ }
+ switch s.At(j).Value {
+ case "}":
+ depth++
+ case "{":
+ if depth == 0 {
+ kind, kw := classifyBrace(s, j)
+ return kind == braceClassLike && kw >= 0 && strings.ToLower(s.At(kw).Value) == "class"
+ }
+ depth--
+ }
+ }
+ return false
+}
+
+// applyToDoctrineAnnotations runs fn over each eligible docblock's parsed
+// annotation tokens, replacing the docblock when fn mutates them.
+func applyToDoctrineAnnotations(s *tokens.Stream, fn func(toks []daToken) bool) bool {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ if s.At(i).Kind != token.DocComment {
+ continue
+ }
+ if !docAnnotationEligible(s, i) {
+ continue
+ }
+ toks := daCreateFromDocComment(s.At(i).Value, doctrineIgnoredTags)
+ if fn(toks) {
+ rebuilt := daGetCode(toks)
+ if rebuilt != s.At(i).Value {
+ s.SetValue(i, rebuilt)
+ changed = true
+ }
+ }
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/doctrine_annotation_test.go b/blink/internal/fixer/rules/doctrine_annotation_test.go
new file mode 100644
index 0000000000..7ea2561b45
--- /dev/null
+++ b/blink/internal/fixer/rules/doctrine_annotation_test.go
@@ -0,0 +1,49 @@
+package rules
+
+import "testing"
+
+func TestDoctrineAnnotationSpaces(t *testing.T) {
+ cases := []struct{ in, want string }{
+ // space before "(" and just inside "(" / ")" removed
+ {` "{$a}",
+// "$a->b" -> "{$a->b}", "$a[key]" -> "{$a['key']}". Already-explicit ("{$a}",
+// "${a}"), single-quoted strings and nowdocs are left untouched.
+type ExplicitStringVariable struct{}
+
+func (ExplicitStringVariable) Name() string {
+ return `PhpCsFixer\Fixer\StringNotation\ExplicitStringVariableFixer`
+}
+
+func (ExplicitStringVariable) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/StringNotation/ExplicitStringVariableFixer.php"
+}
+
+func (ExplicitStringVariable) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := range s.Len() {
+ if s.At(i).Kind != token.String {
+ continue
+ }
+ v := s.At(i).Value
+ start, ok := interpolatedBody(v)
+ if !ok {
+ continue
+ }
+ end := len(v) - suffixLen(v, start)
+ if end < start {
+ continue
+ }
+ body, hit := wrapInterpolations(v[start:end])
+ if hit {
+ s.SetValue(i, v[:start]+body+v[end:])
+ changed = true
+ }
+ }
+ return changed
+}
+
+// interpolatedBody returns the offset where the interpolated body begins for a
+// double-quoted string or a heredoc (not a nowdoc or single-quoted string), and
+// whether the token is one that interpolates at all.
+func interpolatedBody(v string) (int, bool) {
+ if len(v) > 0 && v[0] == '"' {
+ return 1, true
+ }
+ if len(v) >= 3 && v[0] == '<' && v[1] == '<' && v[2] == '<' {
+ p := 3
+ for p < len(v) && (v[p] == ' ' || v[p] == '\t') {
+ p++
+ }
+ if p < len(v) && v[p] == '\'' {
+ return 0, false // nowdoc
+ }
+ // heredoc: body starts after the opener line
+ for p < len(v) && v[p] != '\n' {
+ p++
+ }
+ if p < len(v) {
+ return p + 1, true
+ }
+ }
+ return 0, false
+}
+
+// suffixLen is the length of the trailing delimiter after the body: 1 for the
+// closing quote of a double-quoted string, or the closing heredoc label line.
+func suffixLen(v string, bodyStart int) int {
+ if v[0] == '"' {
+ return 1
+ }
+ // heredoc: the closing label sits on the last line; the body ends at the
+ // last newline before it.
+ last := -1
+ for i := bodyStart; i < len(v); i++ {
+ if v[i] == '\n' {
+ last = i
+ }
+ }
+ if last < 0 {
+ return 0
+ }
+ return len(v) - last // include the newline and the closing label
+}
+
+func wrapInterpolations(b string) (string, bool) {
+ out := make([]byte, 0, len(b)+8)
+ changed := false
+ i := 0
+ for i < len(b) {
+ c := b[i]
+ if c == '\\' && i+1 < len(b) {
+ out = append(out, c, b[i+1])
+ i += 2
+ continue
+ }
+ // already-explicit "{$...}" - copy the balanced block verbatim
+ if c == '{' && i+1 < len(b) && b[i+1] == '$' {
+ depth := 0
+ j := i
+ for j < len(b) {
+ if b[j] == '{' {
+ depth++
+ } else if b[j] == '}' {
+ depth--
+ if depth == 0 {
+ j++
+ break
+ }
+ }
+ j++
+ }
+ out = append(out, b[i:j]...)
+ i = j
+ continue
+ }
+ if c == '$' && i+1 < len(b) {
+ // "${...}" complex syntax - leave as is
+ if b[i+1] == '{' {
+ out = append(out, '$', '{')
+ i += 2
+ continue
+ }
+ // "$$x" - first $ was literal; do not start an interpolation here
+ if i > 0 && b[i-1] == '$' {
+ out = append(out, c)
+ i++
+ continue
+ }
+ if isNameStart(b[i+1]) {
+ expr, end := parseSimpleVar(b, i)
+ out = append(out, '{')
+ out = append(out, expr...)
+ out = append(out, '}')
+ i = end
+ changed = true
+ continue
+ }
+ }
+ out = append(out, c)
+ i++
+ }
+ return string(out), changed
+}
+
+// parseSimpleVar reads a PHP simple-syntax interpolation starting at the "$" at
+// index i ("$name", "$name->prop", "$name[index]") and returns the expression
+// to place inside braces, quoting a bareword array key, plus the end offset.
+func parseSimpleVar(b string, i int) ([]byte, int) {
+ j := i + 1
+ for j < len(b) && isNameByte(b[j]) {
+ j++
+ }
+ expr := make([]byte, 0, j-i+4)
+ expr = append(expr, b[i:j]...) // "$name"
+
+ switch {
+ case j+2 < len(b) && b[j] == '-' && b[j+1] == '>' && isNameStart(b[j+2]):
+ k := j + 2
+ for k < len(b) && isNameByte(b[k]) {
+ k++
+ }
+ expr = append(expr, b[j:k]...) // "->prop"
+ return expr, k
+ case j < len(b) && b[j] == '[':
+ k := j + 1
+ for k < len(b) && b[k] != ']' {
+ k++
+ }
+ if k < len(b) && b[k] == ']' {
+ idx := b[j+1 : k]
+ expr = append(expr, '[')
+ expr = append(expr, quoteIndex(idx)...)
+ expr = append(expr, ']')
+ return expr, k + 1
+ }
+ }
+ return expr, j
+}
+
+// quoteIndex mirrors PHP's simple-syntax array access inside interpolation: a
+// numeric or variable index is kept, a bareword key is single-quoted.
+func quoteIndex(idx string) []byte {
+ if len(idx) == 0 {
+ return []byte(idx)
+ }
+ if idx[0] == '$' {
+ return []byte(idx)
+ }
+ numeric := true
+ s := idx
+ if s[0] == '-' {
+ s = s[1:]
+ }
+ if len(s) == 0 {
+ numeric = false
+ }
+ for k := 0; k < len(s); k++ {
+ if s[k] < '0' || s[k] > '9' {
+ numeric = false
+ break
+ }
+ }
+ if numeric {
+ return []byte(idx)
+ }
+ out := make([]byte, 0, len(idx)+2)
+ out = append(out, '\'')
+ out = append(out, idx...)
+ out = append(out, '\'')
+ return out
+}
+
+func isNameStart(c byte) bool {
+ return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c >= 0x80
+}
+
+func isNameByte(c byte) bool {
+ return isNameStart(c) || (c >= '0' && c <= '9')
+}
diff --git a/blink/internal/fixer/rules/explicit_string_variable_test.go b/blink/internal/fixer/rules/explicit_string_variable_test.go
new file mode 100644
index 0000000000..0670e62ad1
--- /dev/null
+++ b/blink/internal/fixer/rules/explicit_string_variable_test.go
@@ -0,0 +1,39 @@
+package rules
+
+import "testing"
+
+func TestExplicitStringVariable(t *testing.T) {
+ f := ExplicitStringVariable{}
+
+ cases := []struct {
+ src, want string
+ changed bool
+ }{
+ {`b";`, `b}";`, true},
+ {`b->c";`, `b}->c";`, true}, // one level only
+ {`` types)
+// rather than a full recursive TypeExpression parser; it matches ECS on the
+// verified corpora, but a sufficiently exotic unparseable type construct
+// could still differ.
+type FullyQualifiedStrictTypes struct{}
+
+func (FullyQualifiedStrictTypes) Name() string {
+ return `PhpCsFixer\Fixer\Import\FullyQualifiedStrictTypesFixer`
+}
+
+func (FullyQualifiedStrictTypes) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Import/FullyQualifiedStrictTypesFixer.php"
+}
+
+var fqReservedTypes = map[string]bool{
+ "array": true, "bool": true, "callable": true, "false": true, "float": true,
+ "int": true, "iterable": true, "list": true, "mixed": true, "never": true,
+ "null": true, "object": true, "parent": true, "resource": true, "self": true,
+ "static": true, "string": true, "true": true, "void": true,
+}
+
+// fqUses holds the class-import map for one namespace. Like ECS's $uses it is
+// keyed by full name, so importing the same class twice keeps only the last
+// alias; the caches are then derived from that (build).
+type fqUses struct {
+ longs []string // full names in first-occurrence order
+ longToShort map[string]string
+ nameByShortLower map[string]string // lower(short) -> long
+ shortByName map[string]string // long -> short
+ shortByNormalized map[string]string // normalizeFqcn(long) -> short
+}
+
+func fqNewUses() *fqUses {
+ return &fqUses{
+ longToShort: map[string]string{},
+ nameByShortLower: map[string]string{},
+ shortByName: map[string]string{},
+ shortByNormalized: map[string]string{},
+ }
+}
+
+func (u *fqUses) add(long, short string) {
+ if _, ok := u.longToShort[long]; !ok {
+ u.longs = append(u.longs, long)
+ }
+ u.longToShort[long] = short
+}
+
+// build derives the caches from the (full-name-keyed) import map.
+func (u *fqUses) build() {
+ for _, long := range u.longs {
+ short := u.longToShort[long]
+ u.nameByShortLower[strings.ToLower(short)] = long
+ u.shortByName[long] = short
+ u.shortByNormalized[fqNormalize(long)] = short
+ }
+}
+
+func fqNormalize(input string) string {
+ bs := strings.LastIndexByte(input, '\\')
+ if bs < 0 {
+ return strings.ToLower(input)
+ }
+ return input[:bs+1] + strings.ToLower(input[bs+1:])
+}
+
+func fqIsReserved(symbol string, reserved map[string]bool) bool {
+ if strings.Contains(symbol, "\\") {
+ return false
+ }
+ if fqReservedTypes[strings.ToLower(symbol)] {
+ return true
+ }
+ return reserved[symbol]
+}
+
+// resolveSymbol resolves an absolute or relative symbol to a normalized FQCN.
+func fqResolveSymbol(symbol string, u *fqUses, ns string, reserved map[string]bool) string {
+ if strings.HasPrefix(symbol, "\\") {
+ return symbol[1:]
+ }
+ if fqIsReserved(symbol, reserved) {
+ return symbol
+ }
+ first, rest, hasRest := strings.Cut(symbol, "\\")
+ if long, ok := u.nameByShortLower[strings.ToLower(first)]; ok {
+ if hasRest {
+ return long + "\\" + rest
+ }
+ return long
+ }
+ if ns != "" {
+ return ns + "\\" + symbol
+ }
+ return symbol
+}
+
+// shortenSymbol shortens a normalized FQCN as much as the namespace + uses allow.
+func fqShortenSymbol(fqcn string, u *fqUses, ns string, reserved map[string]bool) string {
+ if fqIsReserved(fqcn, reserved) {
+ return fqcn
+ }
+ res := ""
+ found := false
+ iMin := 0
+
+ if ns != "" && strings.HasPrefix(fqcn, ns+"\\") {
+ tmpRes := fqcn[len(ns)+1:]
+ firstSeg, _, _ := strings.Cut(tmpRes, "\\")
+ if _, collide := u.nameByShortLower[strings.ToLower(firstSeg)]; !collide && !fqIsReserved(tmpRes, reserved) {
+ res = tmpRes
+ found = true
+ iMin = strings.Count(ns, "\\") + 1
+ }
+ }
+
+ tmp := fqcn
+ for i := strings.Count(fqcn, "\\"); i >= iMin; i-- {
+ if short, ok := u.shortByName[tmp]; ok {
+ tmpRes := short + fqcn[len(tmp):]
+ if !fqIsReserved(tmpRes, reserved) {
+ res = tmpRes
+ found = true
+ break
+ }
+ }
+ if i > 0 {
+ if bs := strings.LastIndexByte(tmp, '\\'); bs >= 0 {
+ tmp = tmp[:bs]
+ }
+ }
+ }
+
+ if !found {
+ if short, ok := u.shortByNormalized[fqNormalize(fqcn)]; ok && !fqIsReserved(short, reserved) {
+ res = short
+ found = true
+ }
+ }
+
+ if !found {
+ res = fqcn
+ firstSeg, _, _ := strings.Cut(res, "\\")
+ _, collide := u.nameByShortLower[strings.ToLower(firstSeg)]
+ if ns != "" || collide {
+ res = "\\" + res
+ }
+ }
+ return res
+}
+
+// determineShortType returns the shortened rendering of typeName, or "" + false
+// when it is already shortest (no change).
+func fqDetermineShort(typeName string, u *fqUses, ns string, reserved map[string]bool) (string, bool) {
+ fqcn := fqResolveSymbol(typeName, u, ns, reserved)
+ shortened := fqShortenSymbol(fqcn, u, ns, reserved)
+ if shortened == typeName {
+ return "", false
+ }
+ return shortened, true
+}
+
+type fqReplace struct {
+ start, end int // inclusive token range of the name run
+ repl string // rendered replacement (name-run splice)
+ raw *token.Token // when set, replace [start,end] with this single token
+}
+
+func (FullyQualifiedStrictTypes) Fix(s *tokens.Stream) bool {
+ regions := fqNamespaceRegions(s)
+ var repls []fqReplace
+ for _, reg := range regions {
+ repls = append(repls, fqProcessNamespace(s, reg)...)
+ }
+ if len(repls) == 0 {
+ return false
+ }
+ for _, r := range slices.Backward(repls) {
+ fqApply(s, r)
+ }
+ return true
+}
+
+func fqApply(s *tokens.Stream, r fqReplace) {
+ if r.raw != nil {
+ s.ReplaceRange(r.start, r.end, []token.Token{*r.raw})
+ return
+ }
+ s.ReplaceRange(r.start, r.end, fqStringToTokens(r.repl))
+}
+
+// fqStringToTokens mirrors namespacedStringToTokens.
+func fqStringToTokens(input string) []token.Token {
+ var out []token.Token
+ if strings.HasPrefix(input, "\\") {
+ out = append(out, token.Token{Kind: token.Punct, Value: "\\"})
+ input = input[1:]
+ }
+ parts := strings.Split(input, "\\")
+ for i, p := range parts {
+ out = append(out, token.Token{Kind: token.Ident, Value: p})
+ if i != len(parts)-1 {
+ out = append(out, token.Token{Kind: token.Punct, Value: "\\"})
+ }
+ }
+ return out
+}
+
+type fqRegion struct {
+ name string
+ start, end int // token range [start, end) of the namespace scope
+}
+
+// fqNamespaceRegions returns the namespace scopes, mirroring
+// getNamespaceDeclarations: one global region when there is no namespace.
+func fqNamespaceRegions(s *tokens.Stream) []fqRegion {
+ var decls []int
+ for i := 0; i < s.Len(); i++ {
+ if kwIs(s.At(i), "namespace") {
+ // `namespace\foo(...)` relative call is not a declaration
+ n := sigNext(s, i)
+ if n >= 0 && s.At(n).Kind == token.Punct && s.At(n).Value == "\\" {
+ continue
+ }
+ decls = append(decls, i)
+ }
+ }
+ if len(decls) == 0 {
+ return []fqRegion{{name: "", start: 0, end: s.Len()}}
+ }
+ var regions []fqRegion
+ for k, d := range decls {
+ name, bodyStart, semiOrBrace := fqReadNamespaceName(s, d)
+ if semiOrBrace < 0 {
+ continue
+ }
+ if s.At(semiOrBrace).Value == "{" {
+ end := s.MatchForward(semiOrBrace)
+ if end < 0 {
+ end = s.Len()
+ }
+ regions = append(regions, fqRegion{name: name, start: bodyStart, end: end})
+ } else {
+ end := s.Len()
+ if k+1 < len(decls) {
+ end = decls[k+1]
+ }
+ regions = append(regions, fqRegion{name: name, start: bodyStart, end: end})
+ }
+ }
+ return regions
+}
+
+// fqReadNamespaceName reads the name after a `namespace` keyword, returning the
+// name, the index just after the `;`/`{`, and the index of that `;`/`{`.
+func fqReadNamespaceName(s *tokens.Stream, kw int) (name string, bodyStart, term int) {
+ var b strings.Builder
+ i := sigNext(s, kw)
+ for i >= 0 && i < s.Len() {
+ t := s.At(i)
+ if t.Kind == token.Ident {
+ b.WriteString(t.Value)
+ i = sigNext(s, i)
+ continue
+ }
+ if t.Kind == token.Punct && t.Value == "\\" {
+ b.WriteByte('\\')
+ i = sigNext(s, i)
+ continue
+ }
+ if t.Kind == token.Punct && (t.Value == ";" || t.Value == "{") {
+ return b.String(), i + 1, i
+ }
+ break
+ }
+ return "", -1, -1
+}
diff --git a/blink/internal/fixer/rules/fully_qualified_strict_types_phpdoc.go b/blink/internal/fixer/rules/fully_qualified_strict_types_phpdoc.go
new file mode 100644
index 0000000000..b8c4a8e270
--- /dev/null
+++ b/blink/internal/fixer/rules/fully_qualified_strict_types_phpdoc.go
@@ -0,0 +1,274 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// Default phpdoc_tags whose type expression is shortened.
+var fqPhpdocTags = map[string]bool{
+ "param": true, "phpstan-param": true, "phpstan-property": true,
+ "phpstan-property-read": true, "phpstan-property-write": true,
+ "phpstan-return": true, "phpstan-var": true, "property": true,
+ "property-read": true, "property-write": true, "psalm-param": true,
+ "psalm-property": true, "psalm-property-read": true, "psalm-property-write": true,
+ "psalm-return": true, "psalm-var": true, "return": true, "see": true,
+ "throws": true, "var": true,
+}
+
+var fqDocKeywords = map[string]bool{
+ "min": true, "max": true, "class-string": true, "int": true, "positive-int": true,
+ "negative-int": true, "non-empty-string": true, "numeric-string": true,
+ "array-key": true, "scalar": true, "non-empty-array": true, "non-empty-list": true,
+ "key-of": true, "value-of": true, "literal-string": true, "callable-string": true,
+ "double": true, "boolean": true, "integer": true, "this": true, "$this": true,
+ "lowercase-string": true, "non-falsy-string": true, "truthy-string": true,
+}
+
+func fqHSpace(c byte) bool { return c == ' ' || c == '\t' }
+func fqWS(c byte) bool {
+ return c == '\t' || c == '\n' || c == '\f' || c == '\r' || c == ' '
+}
+func fqTagChar(c byte) bool {
+ return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-'
+}
+func fqNameStart(c byte) bool {
+ return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_' || c >= 0x80
+}
+func fqNameChar(c byte) bool {
+ return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c >= 0x80
+}
+
+// fqDocTemplateNames collects @template/@type declared identifiers (reserved).
+func fqDocTemplateNames(content string) []string {
+ var out []string
+ b := []byte(content)
+ n := len(b)
+ prefixes := [][]byte{[]byte("psalm-"), []byte("phpstan-")}
+ kws := [][]byte{
+ []byte("template-covariant"), []byte("template-contravariant"),
+ []byte("template"), []byte("import-type"), []byte("type"),
+ }
+ for i := range n {
+ if b[i] != '*' {
+ continue
+ }
+ j := i + 1
+ for j < n && fqHSpace(b[j]) {
+ j++
+ }
+ if j >= n || b[j] != '@' {
+ continue
+ }
+ rest := b[j+1:]
+ lower := []byte(strings.ToLower(string(rest)))
+ off := 0
+ for _, pfx := range prefixes {
+ if fqHasPrefix(lower, pfx) {
+ off = len(pfx)
+ break
+ }
+ }
+ after := lower[off:]
+ var matched []byte
+ for _, kw := range kws {
+ if fqHasPrefix(after, kw) {
+ matched = kw
+ break
+ }
+ }
+ if matched == nil {
+ continue
+ }
+ p := j + 1 + off + len(matched)
+ hs := false
+ for p < n && fqHSpace(b[p]) {
+ p++
+ hs = true
+ }
+ if hs && p < n && fqNameStart(b[p]) {
+ st := p
+ p++
+ for p < n && fqNameChar(b[p]) {
+ p++
+ }
+ out = append(out, string(b[st:p]))
+ }
+ }
+ return out
+}
+
+func fqHasPrefix(b, pfx []byte) bool {
+ if len(b) < len(pfx) {
+ return false
+ }
+ for i := range pfx {
+ if b[i] != pfx[i] {
+ return false
+ }
+ }
+ return true
+}
+
+// fqPhpDoc shortens class names inside each allowed docblock tag's type.
+func fqPhpDoc(s *tokens.Stream, idx int, uses *fqUses, ns string, reserved map[string]bool) (fqReplace, bool) {
+ content := s.At(idx).Value
+ newContent := fqPhpDocContent(content, uses, ns, reserved)
+ if newContent == content {
+ return fqReplace{}, false
+ }
+ tok := token.Token{Kind: token.DocComment, Value: newContent}
+ return fqReplace{start: idx, end: idx, raw: &tok}, true
+}
+
+// fqPhpDocContent mirrors ECS's `([*{]\h*@)(tag)(\h+)(type)` replace over the
+// allowed tags, using an explicit scanner (identical in go and rust).
+func fqPhpDocContent(content string, uses *fqUses, ns string, reserved map[string]bool) string {
+ b := []byte(content)
+ n := len(b)
+ var out []byte
+ i := 0
+ for i < n {
+ if b[i] == '*' || b[i] == '{' {
+ j := i + 1
+ for j < n && fqHSpace(b[j]) {
+ j++
+ }
+ if j < n && b[j] == '@' {
+ g1End := j + 1
+ t := g1End
+ for t < n && fqTagChar(b[t]) {
+ t++
+ }
+ if t > g1End {
+ tag := b[g1End:t]
+ h := t
+ for h < n && fqHSpace(b[h]) {
+ h++
+ }
+ if h > t && h < n && !fqWS(b[h]) && b[h] != '*' {
+ typeStart := h
+ te := h + 1
+ for te < n && !fqWS(b[te]) {
+ te++
+ }
+ out = append(out, b[i:g1End]...)
+ out = append(out, tag...)
+ out = append(out, b[t:h]...)
+ if fqPhpdocTags[strings.ToLower(string(tag))] {
+ out = append(out, []byte(fqShortenDocType(string(b[typeStart:te]), uses, ns, reserved))...)
+ } else {
+ out = append(out, b[typeStart:te]...)
+ }
+ i = te
+ continue
+ }
+ }
+ }
+ }
+ out = append(out, b[i])
+ i++
+ }
+ return string(out)
+}
+
+// fqShortenDocType shortens each class-name atom in a type expression, skipping
+// variables ($x), string keys, array-shape keys and `::` members.
+func fqShortenDocType(typeStr string, uses *fqUses, ns string, reserved map[string]bool) string {
+ // a wildcard type argument (`*`, e.g. `Foo<*>` or `Rel`) makes the
+ // whole type expression unparseable for ECS's TypeExpression, which then
+ // leaves it entirely fully qualified.
+ if strings.Contains(typeStr, "*") {
+ return typeStr
+ }
+ b := []byte(typeStr)
+ n := len(b)
+ var out []byte
+ i := 0
+ inString := byte(0)
+ for i < n {
+ c := b[i]
+ if inString != 0 {
+ out = append(out, c)
+ if c == inString {
+ inString = 0
+ }
+ i++
+ continue
+ }
+ if c == '\'' || c == '"' {
+ inString = c
+ out = append(out, c)
+ i++
+ continue
+ }
+ // try to read an atom at i
+ j := i
+ if b[j] == '\\' && j+1 < n && fqNameStart(b[j+1]) {
+ j++
+ }
+ if j < n && fqNameStart(b[j]) {
+ start := i
+ j++
+ for j < n && fqNameChar(b[j]) {
+ j++
+ }
+ for j < n && b[j] == '\\' && j+1 < n && fqNameStart(b[j+1]) {
+ j++
+ for j < n && fqNameChar(b[j]) {
+ j++
+ }
+ }
+ atom := string(b[start:j])
+ if fqDocAtomShortenable(b, start, j, n) {
+ out = append(out, []byte(fqMapDocAtom(atom, uses, ns, reserved))...)
+ } else {
+ out = append(out, b[start:j]...)
+ }
+ i = j
+ continue
+ }
+ out = append(out, c)
+ i++
+ }
+ return string(out)
+}
+
+// fqDocAtomShortenable guards against non-type atoms.
+func fqDocAtomShortenable(b []byte, start, end, n int) bool {
+ // preceded by `$` (variable) or `:` (::member or value separator artefact)
+ if start > 0 {
+ p := b[start-1]
+ if p == '$' || p == ':' {
+ return false
+ }
+ }
+ // array-shape / object-shape key: atom immediately followed by a single `:`
+ if end < n && b[end] == ':' && (end+1 >= n || b[end+1] != ':') {
+ return false
+ }
+ // generic base (`Foo<...>`): ECS shortens some but keeps others (wildcards,
+ // multi-arg spaced generics its parser rejects); leaving every generic base
+ // fully qualified is byte-safe and never over-shortens. (Valid callable
+ // bases `Closure(...)` are not guarded - ECS shortens those.)
+ if end < n && b[end] == '<' {
+ return false
+ }
+ return true
+}
+
+func fqMapDocAtom(atom string, uses *fqUses, ns string, reserved map[string]bool) string {
+ if !strings.Contains(atom, "\\") {
+ lower := strings.ToLower(atom)
+ if fqReservedTypes[lower] || fqDocKeywords[lower] {
+ return atom
+ }
+ }
+ repl, changed := fqDetermineShort(atom, uses, ns, reserved)
+ if !changed {
+ return atom
+ }
+ return repl
+}
diff --git a/blink/internal/fixer/rules/fully_qualified_strict_types_test.go b/blink/internal/fixer/rules/fully_qualified_strict_types_test.go
new file mode 100644
index 0000000000..8d5027204f
--- /dev/null
+++ b/blink/internal/fixer/rules/fully_qualified_strict_types_test.go
@@ -0,0 +1,36 @@
+package rules
+
+import "testing"
+
+func TestFullyQualifiedStrictTypes(t *testing.T) {
+ f := FullyQualifiedStrictTypes{}
+
+ // code positions: extends/new/instanceof/::/catch, aliased + unimported
+ got, changed := apply(t, f, "= 0 && s.At(p).Kind == token.Ident {
+ if r, ok := fqPrevName(s, i, uses, reg.name, reserved); ok {
+ add([]fqReplace{r})
+ }
+ }
+ case t.Kind == token.Punct && t.Value == "::":
+ if r, ok := fqPrevName(s, i, uses, reg.name, reserved); ok {
+ add([]fqReplace{r})
+ }
+ case t.Kind == token.Keyword:
+ switch strings.ToLower(t.Value) {
+ case "function", "fn":
+ add(fqFunction(s, i, reg.end, uses, reg.name, reserved))
+ case "catch":
+ add(fqCatch(s, i, uses, reg.name, reserved))
+ case "extends", "implements":
+ add(fqExtendsImplements(s, i, uses, reg.name, reserved))
+ case "new", "instanceof":
+ if r, ok := fqNextName(s, i, uses, reg.name, reserved); ok {
+ add([]fqReplace{r})
+ }
+ case "use":
+ // only trait-use (inside a class body) shortens; a top-level
+ // `use` is an import declaration and must never be touched
+ if depth >= 1 {
+ if r, ok := fqNextName(s, i, uses, reg.name, reserved); ok {
+ add([]fqReplace{r})
+ }
+ }
+ }
+ case t.Kind == token.DocComment:
+ for _, id := range fqDocTemplateNames(t.Value) {
+ reserved[id] = true
+ }
+ if r, ok := fqPhpDoc(s, i, uses, reg.name, reserved); ok {
+ add([]fqReplace{r})
+ }
+ }
+ }
+ return repls
+}
+
+// fqCollectUsesRegion builds the class-import map for a namespace region: `use`
+// imports at the region base depth (not `use function`/`use const`, closure use
+// or grouped use).
+func fqCollectUsesRegion(s *tokens.Stream, reg fqRegion) *fqUses {
+ u := fqNewUses()
+ depth := 0
+ for i := reg.start; i < reg.end && i < s.Len(); i++ {
+ t := s.At(i)
+ if t.Kind == token.Punct {
+ switch t.Value {
+ case "{":
+ depth++
+ case "}":
+ depth--
+ }
+ continue
+ }
+ if depth != 0 || !kwIs(t, "use") {
+ continue
+ }
+ n := sigNext(s, i)
+ if n < 0 {
+ continue
+ }
+ if s.At(n).Kind == token.Keyword {
+ lv := strings.ToLower(s.At(n).Value)
+ if lv == "function" || lv == "const" {
+ continue
+ }
+ }
+ fqcn, alias, end, ok := fqReadImport(s, n)
+ if !ok {
+ continue
+ }
+ i = end
+ short := alias
+ if short == "" {
+ if idx := strings.LastIndexByte(fqcn, '\\'); idx >= 0 {
+ short = fqcn[idx+1:]
+ } else {
+ short = fqcn
+ }
+ }
+ u.add(fqcn, short)
+ }
+ u.build()
+ return u
+}
+
+// fqReadImport reads `Name\Space[ as Alias];` from the first name token.
+func fqReadImport(s *tokens.Stream, start int) (fqcn, alias string, end int, ok bool) {
+ var b strings.Builder
+ i := start
+ if isPunctVal(s, i, "\\") {
+ i++
+ }
+ expectName := true
+ for i < s.Len() {
+ t := s.At(i)
+ switch t.Kind {
+ case token.Whitespace, token.Comment, token.DocComment:
+ i++
+ continue
+ }
+ if expectName {
+ if t.Kind != token.Ident {
+ return "", "", 0, false
+ }
+ b.WriteString(t.Value)
+ expectName = false
+ i++
+ continue
+ }
+ if t.Kind == token.Punct && t.Value == "\\" {
+ b.WriteByte('\\')
+ expectName = true
+ i++
+ continue
+ }
+ if t.Kind == token.Punct && t.Value == ";" {
+ return b.String(), "", i, true
+ }
+ if kwIs(t, "as") {
+ a := sigNext(s, i)
+ if a < 0 || s.At(a).Kind != token.Ident {
+ return "", "", 0, false
+ }
+ semi := sigNext(s, a)
+ if semi < 0 || !isPunctVal(s, semi, ";") {
+ return "", "", 0, false
+ }
+ return b.String(), s.At(a).Value, semi, true
+ }
+ return "", "", 0, false
+ }
+ return "", "", 0, false
+}
+
+// fqReadRunForward reads the contiguous Ident/`\` name run starting at start.
+func fqReadRunForward(s *tokens.Stream, start int) (content string, end int, ok bool) {
+ if start < 0 || start >= s.Len() {
+ return "", 0, false
+ }
+ t := s.At(start)
+ if t.Kind != token.Ident && (t.Kind != token.Punct || t.Value != "\\") {
+ return "", 0, false
+ }
+ var b strings.Builder
+ i := start
+ last := start
+ for i < s.Len() {
+ ct := s.At(i)
+ if ct.Kind == token.Ident {
+ b.WriteString(ct.Value)
+ last = i
+ i++
+ continue
+ }
+ if ct.Kind == token.Punct && ct.Value == "\\" {
+ b.WriteByte('\\')
+ last = i
+ i++
+ continue
+ }
+ break
+ }
+ return b.String(), last, true
+}
+
+// fqReadRunBackward reads the contiguous Ident/`\` name run ending at end.
+func fqReadRunBackward(s *tokens.Stream, end int) (content string, start int, ok bool) {
+ if end < 0 || end >= s.Len() {
+ return "", 0, false
+ }
+ i := end
+ first := end
+ for i >= 0 {
+ ct := s.At(i)
+ if ct.Kind == token.Ident || (ct.Kind == token.Punct && ct.Value == "\\") {
+ first = i
+ i--
+ continue
+ }
+ break
+ }
+ var b strings.Builder
+ for j := first; j <= end; j++ {
+ b.WriteString(s.At(j).Value)
+ }
+ if b.Len() == 0 {
+ return "", 0, false
+ }
+ return b.String(), first, true
+}
+
+// fqNextName shortens the name run immediately after a keyword.
+func fqNextName(s *tokens.Stream, kw int, uses *fqUses, ns string, reserved map[string]bool) (fqReplace, bool) {
+ n := sigNext(s, kw)
+ if n < 0 {
+ return fqReplace{}, false
+ }
+ content, end, ok := fqReadRunForward(s, n)
+ if !ok {
+ return fqReplace{}, false
+ }
+ repl, changed := fqDetermineShort(content, uses, ns, reserved)
+ if !changed {
+ return fqReplace{}, false
+ }
+ return fqReplace{start: n, end: end, repl: repl}, true
+}
+
+// fqPrevName shortens the name run immediately before a token (a variable or `::`).
+func fqPrevName(s *tokens.Stream, idx int, uses *fqUses, ns string, reserved map[string]bool) (fqReplace, bool) {
+ p := sigPrev(s, idx)
+ if p < 0 || s.At(p).Kind != token.Ident {
+ return fqReplace{}, false
+ }
+ content, start, ok := fqReadRunBackward(s, p)
+ if !ok {
+ return fqReplace{}, false
+ }
+ // a name after an object operator (`$this->grammar::`) is a member, not a class
+ if b := sigPrev(s, start); b >= 0 && s.At(b).Kind == token.Punct && (s.At(b).Value == "->" || s.At(b).Value == "?->") {
+ return fqReplace{}, false
+ }
+ repl, changed := fqDetermineShort(content, uses, ns, reserved)
+ if !changed {
+ return fqReplace{}, false
+ }
+ return fqReplace{start: start, end: p, repl: repl}, true
+}
+
+// fqExtendsImplements shortens each type in an extends/implements list.
+func fqExtendsImplements(s *tokens.Stream, kw int, uses *fqUses, ns string, reserved map[string]bool) []fqReplace {
+ var out []fqReplace
+ i := sigNext(s, kw)
+ for i >= 0 && i < s.Len() {
+ t := s.At(i)
+ if t.Kind == token.Punct && t.Value == "{" {
+ break
+ }
+ if t.Kind == token.Keyword {
+ break // extends X implements Y - the other keyword handles its list
+ }
+ if t.Kind == token.Ident || (t.Kind == token.Punct && t.Value == "\\") {
+ content, end, ok := fqReadRunForward(s, i)
+ if ok {
+ if repl, changed := fqDetermineShort(content, uses, ns, reserved); changed {
+ out = append(out, fqReplace{start: i, end: end, repl: repl})
+ }
+ i = sigNext(s, end)
+ continue
+ }
+ }
+ i = sigNext(s, i)
+ }
+ return out
+}
+
+// fqCatch shortens each exception type in a catch clause.
+func fqCatch(s *tokens.Stream, kw int, uses *fqUses, ns string, reserved map[string]bool) []fqReplace {
+ open := sigNext(s, kw)
+ if open < 0 || !isPunctVal(s, open, "(") {
+ return nil
+ }
+ var out []fqReplace
+ i := sigNext(s, open)
+ for i >= 0 && i < s.Len() {
+ t := s.At(i)
+ if (t.Kind == token.Punct && t.Value == ")") || t.Kind == token.Variable {
+ break
+ }
+ if t.Kind == token.Ident || (t.Kind == token.Punct && t.Value == "\\") {
+ content, end, ok := fqReadRunForward(s, i)
+ if ok {
+ if repl, changed := fqDetermineShort(content, uses, ns, reserved); changed {
+ out = append(out, fqReplace{start: i, end: end, repl: repl})
+ }
+ i = sigNext(s, end)
+ continue
+ }
+ }
+ i = sigNext(s, i)
+ }
+ return out
+}
+
+// fqFunction shortens parameter and return types of a function/closure/arrow fn.
+func fqFunction(s *tokens.Stream, kw, regionEnd int, uses *fqUses, ns string, reserved map[string]bool) []fqReplace {
+ open := fqNextPunct(s, kw, "(")
+ if open < 0 {
+ return nil
+ }
+ closeIdx := s.MatchForward(open)
+ if closeIdx < 0 {
+ return nil
+ }
+ var out []fqReplace
+ // parameters: each type run inside (...) that precedes a $var, & or ...
+ out = append(out, fqTypeRunsIn(s, open+1, closeIdx-1, uses, ns, reserved, true)...)
+ // return type: after `)` an optional `:` then a type run up to `{` or `;`
+ c := sigNext(s, closeIdx)
+ if c >= 0 && isPunctVal(s, c, ":") {
+ te := fqReturnTypeEnd(s, c, regionEnd)
+ if te > c {
+ out = append(out, fqTypeRunsIn(s, sigNext(s, c), te, uses, ns, reserved, false)...)
+ }
+ }
+ return out
+}
+
+func fqReturnTypeEnd(s *tokens.Stream, colon, regionEnd int) int {
+ for i := colon + 1; i < s.Len() && i < regionEnd; i++ {
+ t := s.At(i)
+ if t.Kind == token.Punct && (t.Value == "{" || t.Value == ";") {
+ return sigPrev(s, i)
+ }
+ }
+ return -1
+}
+
+// fqTypeRunsIn scans a range for class-name atoms in type position: name runs
+// that are part of a type (skip runs used as default values, constants, etc.).
+// A name run counts when it is preceded by a type boundary (`(` `,` `|` `&` `?`
+// `:` start) and, for parameters, followed by `$var`/`&`/`...`/`|`/`&`/`)`.
+func fqTypeRunsIn(s *tokens.Stream, from, to int, uses *fqUses, ns string, reserved map[string]bool, params bool) []fqReplace {
+ var out []fqReplace
+ i := from
+ for i >= 0 && i <= to && i < s.Len() {
+ t := s.At(i)
+ if t.Kind == token.Ident || (t.Kind == token.Punct && t.Value == "\\") {
+ // must be a type: previous meaningful token is a type boundary
+ p := sigPrev(s, i)
+ if p >= 0 && fqIsTypeBoundaryBefore(s, p) {
+ content, end, ok := fqReadRunForward(s, i)
+ if ok && fqIsTypeBoundaryAfter(s, end, params) {
+ if repl, changed := fqDetermineShort(content, uses, ns, reserved); changed {
+ out = append(out, fqReplace{start: i, end: end, repl: repl})
+ }
+ i = sigNext(s, end)
+ continue
+ }
+ }
+ }
+ i = sigNext(s, i)
+ }
+ return out
+}
+
+func fqIsTypeBoundaryBefore(s *tokens.Stream, p int) bool {
+ t := s.At(p)
+ if t.Kind == token.Punct {
+ switch t.Value {
+ case "(", ",", "|", "&", "?", ":":
+ return true
+ }
+ }
+ if t.Kind == token.Keyword {
+ switch strings.ToLower(t.Value) {
+ case "public", "protected", "private", "readonly", "static", "var", "const", "function", "fn":
+ return true
+ }
+ }
+ return false
+}
+
+func fqIsTypeBoundaryAfter(s *tokens.Stream, end int, params bool) bool {
+ n := sigNext(s, end)
+ if n < 0 {
+ return true
+ }
+ t := s.At(n)
+ if t.Kind == token.Variable {
+ return true
+ }
+ if t.Kind == token.Punct {
+ switch t.Value {
+ case "|", "&", ")", "{", ";", ",":
+ return true
+ case "...":
+ return true
+ }
+ }
+ // `&` variadic/reference before var
+ return false
+}
+
+func fqNextPunct(s *tokens.Stream, from int, v string) int {
+ for i := from + 1; i < s.Len(); i++ {
+ if s.At(i).Kind == token.Punct && s.At(i).Value == v {
+ return i
+ }
+ if s.At(i).Kind == token.Punct && (s.At(i).Value == ";" || s.At(i).Value == "{") {
+ return -1
+ }
+ }
+ return -1
+}
diff --git a/blink/internal/fixer/rules/function_to_constant.go b/blink/internal/fixer/rules/function_to_constant.go
new file mode 100644
index 0000000000..54b7c542f5
--- /dev/null
+++ b/blink/internal/fixer/rules/function_to_constant.go
@@ -0,0 +1,103 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/LanguageConstruct/FunctionToConstantFixer.php
+//
+// FunctionToConstant replaces a configured function call with its constant
+// equivalent: pi() -> M_PI, phpversion() -> PHP_VERSION, php_sapi_name() ->
+// PHP_SAPI, get_called_class() -> static::class, get_class($this) ->
+// static::class.
+type FunctionToConstant struct{}
+
+func (FunctionToConstant) Name() string {
+ return `PhpCsFixer\Fixer\LanguageConstruct\FunctionToConstantFixer`
+}
+
+func (FunctionToConstant) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/LanguageConstruct/FunctionToConstantFixer.php"
+}
+
+var noArgConstants = map[string]string{
+ "pi": "M_PI",
+ "phpversion": "PHP_VERSION",
+ "php_sapi_name": "PHP_SAPI",
+}
+
+func (FunctionToConstant) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ if s.At(i).Kind != token.Ident {
+ continue
+ }
+ name := strings.ToLower(s.At(i).Value)
+ _, isNoArg := noArgConstants[name]
+ if !isNoArg && name != "get_called_class" && name != "get_class" {
+ continue
+ }
+ // a plain function call: not a method/static call, not a declaration,
+ // and optionally prefixed by a single leading "\"
+ start := i
+ if prev, ok := prevSignificant(s, i); ok {
+ switch {
+ case prev.Kind == token.Punct && (prev.Value == "->" || prev.Value == "?->" || prev.Value == "::"):
+ continue
+ case prev.Kind == token.Keyword && strings.EqualFold(prev.Value, "function"):
+ continue
+ case prev.Kind == token.Punct && prev.Value == `\`:
+ pj := prevSignificantIndex(s, i)
+ if before, ok := prevSignificant(s, pj); ok && (before.Kind == token.Ident || before.Value == `\`) {
+ continue // namespaced call, not the global function
+ }
+ start = pj
+ }
+ }
+ open := nextSignificantIndex(s, i)
+ if open < 0 || s.At(open).Kind != token.Punct || s.At(open).Value != "(" {
+ continue
+ }
+ closeIdx := s.MatchForward(open)
+ if closeIdx < 0 {
+ continue
+ }
+ var repl []token.Token
+ switch {
+ case isNoArg:
+ if nextSignificantIndex(s, open) != closeIdx {
+ continue // has arguments
+ }
+ repl = []token.Token{{Kind: token.Ident, Value: noArgConstants[name]}}
+ case name == "get_called_class":
+ if nextSignificantIndex(s, open) != closeIdx {
+ continue
+ }
+ repl = staticClassTokens()
+ case name == "get_class":
+ arg := nextSignificantIndex(s, open)
+ if arg < 0 || s.At(arg).Kind != token.Variable || !strings.EqualFold(s.At(arg).Value, "$this") {
+ continue
+ }
+ if nextSignificantIndex(s, arg) != closeIdx {
+ continue // more than just $this
+ }
+ repl = staticClassTokens()
+ }
+ s.ReplaceRange(start, closeIdx, repl)
+ changed = true
+ i = start
+ }
+ return changed
+}
+
+func staticClassTokens() []token.Token {
+ return []token.Token{
+ {Kind: token.Keyword, Value: "static"},
+ {Kind: token.Punct, Value: "::"},
+ {Kind: token.Ident, Value: "class"},
+ }
+}
diff --git a/blink/internal/fixer/rules/gen2_casing.go b/blink/internal/fixer/rules/gen2_casing.go
new file mode 100644
index 0000000000..368ff85943
--- /dev/null
+++ b/blink/internal/fixer/rules/gen2_casing.go
@@ -0,0 +1,196 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// builtinClassNames maps the lowercased name of a well-known internal PHP class
+// to its canonical casing. Only reserved/built-in names appear here: a user
+// class cannot be declared with any of these names, so a fully-qualified
+// reference to one is unambiguous.
+var builtinClassNames = map[string]string{
+ // core
+ "stdclass": "stdClass",
+ "closure": "Closure",
+ "generator": "Generator",
+ "fiber": "Fiber",
+ "weakmap": "WeakMap",
+ "weakreference": "WeakReference",
+ "stringable": "Stringable",
+ "throwable": "Throwable",
+ "traversable": "Traversable",
+ "iterator": "Iterator",
+ "iteratoraggregate": "IteratorAggregate",
+ "arrayaccess": "ArrayAccess",
+ "countable": "Countable",
+ "jsonserializable": "JsonSerializable",
+ "unitenum": "UnitEnum",
+ "backedenum": "BackedEnum",
+ "attribute": "Attribute",
+ // exceptions / errors
+ "exception": "Exception",
+ "errorexception": "ErrorException",
+ "error": "Error",
+ "typeerror": "TypeError",
+ "valueerror": "ValueError",
+ "argumentcounterror": "ArgumentCountError",
+ "arithmeticerror": "ArithmeticError",
+ "divisionbyzeroerror": "DivisionByZeroError",
+ "unhandledmatcherror": "UnhandledMatchError",
+ "jsonexception": "JsonException",
+ "logicexception": "LogicException",
+ "badfunctioncallexception": "BadFunctionCallException",
+ "badmethodcallexception": "BadMethodCallException",
+ "domainexception": "DomainException",
+ "invalidargumentexception": "InvalidArgumentException",
+ "lengthexception": "LengthException",
+ "outofrangeexception": "OutOfRangeException",
+ "runtimeexception": "RuntimeException",
+ "outofboundsexception": "OutOfBoundsException",
+ "overflowexception": "OverflowException",
+ "rangeexception": "RangeException",
+ "underflowexception": "UnderflowException",
+ "unexpectedvalueexception": "UnexpectedValueException",
+ // SPL data structures / iterators
+ "arrayobject": "ArrayObject",
+ "arrayiterator": "ArrayIterator",
+ "splstack": "SplStack",
+ "splqueue": "SplQueue",
+ "spldoublylinkedlist": "SplDoublyLinkedList",
+ "splfixedarray": "SplFixedArray",
+ "splheap": "SplHeap",
+ "splminheap": "SplMinHeap",
+ "splmaxheap": "SplMaxHeap",
+ "splpriorityqueue": "SplPriorityQueue",
+ "splobjectstorage": "SplObjectStorage",
+ "splfileinfo": "SplFileInfo",
+ "splfileobject": "SplFileObject",
+ "spltempfileobject": "SplTempFileObject",
+ // date / time
+ "datetime": "DateTime",
+ "datetimeimmutable": "DateTimeImmutable",
+ "datetimeinterface": "DateTimeInterface",
+ "dateinterval": "DateInterval",
+ "dateperiod": "DatePeriod",
+ "datetimezone": "DateTimeZone",
+ // reflection
+ "reflectionclass": "ReflectionClass",
+ "reflectionmethod": "ReflectionMethod",
+ "reflectionproperty": "ReflectionProperty",
+ "reflectionfunction": "ReflectionFunction",
+ "reflectionparameter": "ReflectionParameter",
+ "reflectionnamedtype": "ReflectionNamedType",
+ "reflectionexception": "ReflectionException",
+ "reflectionenum": "ReflectionEnum",
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Casing/ClassReferenceNameCasingFixer.php
+//
+// ClassReferenceNameCasing normalises the casing of a reference to an internal
+// PHP class to its canonical spelling ("\exception" -> "\Exception"). To stay
+// provable on flat tokens it only touches a fully-qualified reference ("\Name")
+// to a known built-in class, which is global and unaffected by namespace or use
+// imports, so the correct casing is unambiguous.
+type ClassReferenceNameCasing struct{}
+
+func (ClassReferenceNameCasing) Name() string {
+ return `PhpCsFixer\Fixer\Casing\ClassReferenceNameCasingFixer`
+}
+
+func (ClassReferenceNameCasing) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Casing/ClassReferenceNameCasingFixer.php"
+}
+
+func (ClassReferenceNameCasing) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ if t.Kind != token.Ident {
+ continue
+ }
+ canonical, ok := builtinClassNames[strings.ToLower(t.Value)]
+ if !ok || canonical == t.Value {
+ continue
+ }
+ // only a fully-qualified reference "\Name" is unambiguously the global
+ // built-in class
+ bs := prevSignificantIndex(s, i)
+ if bs < 0 || s.At(bs).Kind != token.Punct || s.At(bs).Value != `\` {
+ continue
+ }
+ // "\Name\..." - the name heads a namespace, it is not the class itself
+ if n := nextSignificantIndex(s, i); n >= 0 && s.At(n).Kind == token.Punct && s.At(n).Value == `\` {
+ continue
+ }
+ // "Foo\Name" - part of a namespaced name, not a global built-in
+ before := prevSignificantIndex(s, bs)
+ if before >= 0 && s.At(before).Kind == token.Ident {
+ continue
+ }
+ if !isClassReferencePosition(s, before, i) {
+ continue
+ }
+ s.SetValue(i, canonical)
+ changed = true
+ }
+ return changed
+}
+
+// isClassReferencePosition mirrors the remaining guards of the PHP fixer for the
+// name at nameIdx, given the significant token before its leading "\" (before,
+// or -1). It rejects positions where a same-spelled function call, constant or
+// assignment target - rather than a class name - would sit.
+func isClassReferencePosition(s *tokens.Stream, before, nameIdx int) bool {
+ next := nextSignificantIndex(s, nameIdx)
+
+ // a name wrapped by matching block edges (e.g. "[\Name]") is ambiguous
+ if before >= 0 && next >= 0 {
+ if isBlockOpenOrComma(s.At(before)) && isBlockCloseOrComma(s.At(next)) {
+ return false
+ }
+ }
+
+ // "new \Name" is always a class reference
+ if before >= 0 && s.At(before).Kind == token.Keyword && strings.EqualFold(s.At(before).Value, "new") {
+ return true
+ }
+
+ // "\Name(" / "\Name;" / "\Name=" / "\Name?>" reads as a call, constant or
+ // target, never a class name
+ if next >= 0 {
+ nt := s.At(next)
+ if nt.Kind == token.CloseTag {
+ return false
+ }
+ switch nt.Value {
+ case "(", ";", "=":
+ return false
+ }
+ }
+ return true
+}
+
+func isBlockOpenOrComma(t token.Token) bool {
+ if t.Kind != token.Punct {
+ return false
+ }
+ switch t.Value {
+ case ",", "(", "[", "{":
+ return true
+ }
+ return false
+}
+
+func isBlockCloseOrComma(t token.Token) bool {
+ if t.Kind != token.Punct {
+ return false
+ }
+ switch t.Value {
+ case ",", ")", "]", "}":
+ return true
+ }
+ return false
+}
diff --git a/blink/internal/fixer/rules/gen2_casing_test.go b/blink/internal/fixer/rules/gen2_casing_test.go
new file mode 100644
index 0000000000..1cd69abc32
--- /dev/null
+++ b/blink/internal/fixer/rules/gen2_casing_test.go
@@ -0,0 +1,62 @@
+package rules
+
+import (
+ "testing"
+
+ "blink/internal/fixer"
+)
+
+func TestGen2CasingClassReferenceNameCasing(t *testing.T) {
+ cases := []struct {
+ src, want string
+ changed bool
+ }{
+ // fully-qualified built-in class references are recased
+ {"exception;", "exception;", false},
+ {" %q", got, again)
+ }
+ }
+}
+
+func TestGen2CasingSourceURLs(t *testing.T) {
+ fixers := []fixer.Fixer{
+ ClassReferenceNameCasing{},
+ }
+ for _, f := range fixers {
+ if want := fixer.SourceURLFor(f.Name()); f.SourceURL() != want {
+ t.Fatalf("%T SourceURL()=%q want %q", f, f.SourceURL(), want)
+ }
+ }
+}
diff --git a/blink/internal/fixer/rules/gen2_comment.go b/blink/internal/fixer/rules/gen2_comment.go
new file mode 100644
index 0000000000..a97d3ecd0e
--- /dev/null
+++ b/blink/internal/fixer/rules/gen2_comment.go
@@ -0,0 +1,82 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/AlignMultilineCommentFixer.php
+//
+// AlignMultilineComment re-indents the continuation "*" lines of a multi-line
+// block comment or docblock so each "*" sits one space to the right of the
+// opening "/*". Only the leading whitespace of lines whose trimmed content
+// starts with "*" is rewritten; free-form lines are left untouched.
+type AlignMultilineComment struct{}
+
+func (AlignMultilineComment) Name() string {
+ return `PhpCsFixer\Fixer\Phpdoc\AlignMultilineCommentFixer`
+}
+
+func (AlignMultilineComment) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/AlignMultilineCommentFixer.php"
+}
+
+func (AlignMultilineComment) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ if t.Kind != token.Comment && t.Kind != token.DocComment {
+ continue
+ }
+ v := t.Value
+ if !strings.Contains(v, "\n") {
+ continue
+ }
+ indent, ok := commentOpeningIndent(s, i)
+ if !ok {
+ continue
+ }
+ nv := realignCommentLines(v, indent)
+ if nv != v {
+ s.SetValue(i, nv)
+ changed = true
+ }
+ }
+ return changed
+}
+
+// commentOpeningIndent returns the indentation of the comment's opening line,
+// taken from the whitespace token right before it. ok is false when the comment
+// is not preceded by a line break (an inline comment), which must be left alone.
+func commentOpeningIndent(s *tokens.Stream, i int) (string, bool) {
+ whitespace := ""
+ if prev := i - 1; prev >= 0 && s.At(prev).Kind == token.Whitespace {
+ whitespace = s.At(prev).Value
+ }
+ cut := -1
+ for j := 0; j < len(whitespace); j++ {
+ if whitespace[j] == '\n' || whitespace[j] == '\r' {
+ cut = j
+ }
+ }
+ if cut < 0 {
+ return "", false
+ }
+ return whitespace[cut+1:], true
+}
+
+// realignCommentLines rewrites the leading whitespace of every interior line
+// whose trimmed content starts with "*", aligning it to indent + one space.
+func realignCommentLines(v, indent string) string {
+ lines := strings.Split(v, "\n")
+ for k := 1; k < len(lines); k++ {
+ trimmed := strings.TrimLeft(lines[k], " \t")
+ if !strings.HasPrefix(trimmed, "*") {
+ continue
+ }
+ lines[k] = indent + " " + trimmed
+ }
+ return strings.Join(lines, "\n")
+}
diff --git a/blink/internal/fixer/rules/gen2_comment_test.go b/blink/internal/fixer/rules/gen2_comment_test.go
new file mode 100644
index 0000000000..70ccbb5026
--- /dev/null
+++ b/blink/internal/fixer/rules/gen2_comment_test.go
@@ -0,0 +1,60 @@
+package rules
+
+import (
+ "testing"
+
+ "blink/internal/fixer"
+)
+
+func TestGen2Comment(t *testing.T) {
+ // misaligned docblock at column 0 -> aligned under the first "*"
+ got, changed := apply(t, AlignMultilineComment{}, "= 0 && lvaluePrefix(s.At(p).Value) {
+ continue
+ }
+
+ // Right side must start with the same variable.
+ rv := sigNext(s, i)
+ if rv < 0 || s.At(rv).Kind != token.Variable || s.At(rv).Value != s.At(lv).Value {
+ continue
+ }
+
+ // Then the null-coalescing operator (single Punct token).
+ opIdx := sigNext(s, rv)
+ if opIdx < 0 || s.At(opIdx).Kind != token.Punct || s.At(opIdx).Value != "??" {
+ continue
+ }
+
+ // Then a single operand and a terminating ';'.
+ operandIdx := sigNext(s, opIdx)
+ if operandIdx < 0 || !shortOperand(s.At(operandIdx)) {
+ continue
+ }
+ endIdx := sigNext(s, operandIdx)
+ if endIdx < 0 || s.At(endIdx).Value != ";" {
+ continue
+ }
+
+ // Refuse if a comment sits inside the rewritten span.
+ if !spanClean(s, i, opIdx) {
+ continue
+ }
+
+ // `= $a ??` becomes `??=`: retag the `=` and drop the duplicate operand
+ // and operator.
+ s.SetValue(i, "??=")
+ for k := opIdx; k >= i+1; k-- {
+ s.RemoveAt(k)
+ }
+ changed = true
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/gen2_ops_test.go b/blink/internal/fixer/rules/gen2_ops_test.go
new file mode 100644
index 0000000000..80140b2571
--- /dev/null
+++ b/blink/internal/fixer/rules/gen2_ops_test.go
@@ -0,0 +1,72 @@
+package rules
+
+import (
+ "testing"
+
+ "blink/internal/fixer"
+)
+
+func TestGen2OpsAssignNullCoalescing(t *testing.T) {
+ cases := []struct {
+ src, want string
+ changed bool
+ }{
+ {"x = $o->x ?? $b;", "x = $o->x ?? $b;", false},
+ // plain assignment, no coalescing
+ {"x = $o->x ?? $b;\n",
+ }
+ for _, f := range fixers {
+ for _, src := range corpus {
+ once, _ := apply(t, f, src)
+ twice, changed := apply(t, f, once)
+ if changed || once != twice {
+ t.Errorf("%s not idempotent: src=%q once=%q twice=%q", f.Name(), src, once, twice)
+ }
+ }
+ }
+}
diff --git a/blink/internal/fixer/rules/gen2_phpdoc.go b/blink/internal/fixer/rules/gen2_phpdoc.go
new file mode 100644
index 0000000000..4d4a5a276d
--- /dev/null
+++ b/blink/internal/fixer/rules/gen2_phpdoc.go
@@ -0,0 +1,197 @@
+package rules
+
+import (
+ "regexp"
+ "sort"
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocIndentFixer.php
+//
+// PhpdocIndent aligns a docblock's "*" continuation lines and closing "*/" to
+// the indentation of the code the docblock documents, one space after the
+// opening "/**"'s column.
+type PhpdocIndent struct{}
+
+func (PhpdocIndent) Name() string {
+ return `PhpCsFixer\Fixer\Phpdoc\PhpdocIndentFixer`
+}
+
+func (PhpdocIndent) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocIndentFixer.php"
+}
+
+func (PhpdocIndent) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := range s.Len() {
+ t := s.At(i)
+ if t.Kind != token.DocComment {
+ continue
+ }
+ if !docblockAtLineStart(s, i) {
+ continue
+ }
+ // align to the element the docblock documents (the next line), not the
+ // docblock's own possibly-wrong indent
+ indent, ok := docblockTargetIndent(s, i)
+ if !ok {
+ continue
+ }
+ d, ok := parseDoc(t.Value)
+ if !ok || d.single {
+ continue
+ }
+ if reindentDocblock(&d, indent) {
+ s.SetValue(i, d.render())
+ changed = true
+ }
+ // move the opening "/**" line to the same indent
+ if s.At(i-1).Kind == token.Whitespace {
+ v := s.At(i - 1).Value
+ nl := strings.LastIndexByte(v, '\n')
+ if nl >= 0 && v[nl+1:] != indent {
+ s.SetValue(i-1, v[:nl+1]+indent)
+ changed = true
+ }
+ }
+ }
+ return changed
+}
+
+// docblockAtLineStart reports whether the docblock at i begins its own line.
+func docblockAtLineStart(s *tokens.Stream, i int) bool {
+ if i == 0 {
+ return false
+ }
+ prev := s.At(i - 1)
+ return prev.Kind == token.Whitespace && strings.IndexByte(prev.Value, '\n') >= 0
+}
+
+// docblockTargetIndent returns the indentation of the structural element the
+// docblock at i documents - the indent of the line holding the next significant
+// token. ok is false when there is none.
+func docblockTargetIndent(s *tokens.Stream, i int) (string, bool) {
+ if i+1 >= s.Len() || s.At(i+1).Kind != token.Whitespace {
+ return "", false
+ }
+ v := s.At(i + 1).Value
+ nl := strings.LastIndexByte(v, '\n')
+ if nl < 0 {
+ return "", false
+ }
+ return v[nl+1:], true
+}
+
+// docblockLineIndent returns the indentation of the line the docblock at index i
+// sits on, taken from the trailing whitespace of the preceding whitespace token.
+// ok is false when the docblock is not at the start of its line.
+func docblockLineIndent(s *tokens.Stream, i int) (string, bool) {
+ if i == 0 {
+ return "", false
+ }
+ prev := s.At(i - 1)
+ if prev.Kind != token.Whitespace {
+ return "", false
+ }
+ nl := strings.LastIndexByte(prev.Value, '\n')
+ if nl < 0 {
+ return "", false
+ }
+ return prev.Value[nl+1:], true
+}
+
+// reindentDocblock rewrites every interior line's "*" and the closing "*/" so
+// the star sits one column past the indent. Reports whether anything changed.
+func reindentDocblock(d *docblock, indent string) bool {
+ changed := false
+ for k, l := range d.inner {
+ star := strings.IndexByte(l.prefix, '*')
+ if star < 0 {
+ continue
+ }
+ newPrefix := indent + " *" + l.prefix[star+1:]
+ if newPrefix != l.prefix {
+ d.inner[k].prefix = newPrefix
+ changed = true
+ }
+ }
+ if strings.TrimSpace(d.close) == "*/" {
+ newClose := indent + " */"
+ if newClose != d.close {
+ d.close = newClose
+ changed = true
+ }
+ }
+ return changed
+}
+
+// coversRe matches a "@covers" annotation line, capturing the value after the
+// tag. The value is required so "@coversNothing"/"@coversDefaultClass" and a
+// bare "@covers" are left untouched.
+var coversRe = regexp.MustCompile(`^@covers\s+(\S.*?)\s*$`)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocOrderByValueFixer.php
+//
+// PhpdocOrderByValue sorts the annotations of the configured tag by their value.
+// The default tag set is ["covers"]; a contiguous run of "@covers" lines is
+// stable-sorted alphabetically (case-insensitively) by the value after the tag.
+type PhpdocOrderByValue struct{}
+
+func (PhpdocOrderByValue) Name() string {
+ return `PhpCsFixer\Fixer\Phpdoc\PhpdocOrderByValueFixer`
+}
+
+func (PhpdocOrderByValue) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocOrderByValueFixer.php"
+}
+
+func (PhpdocOrderByValue) Fix(s *tokens.Stream) bool {
+ return applyToDocblocks(s, func(d *docblock) bool {
+ changed := false
+ n := len(d.inner)
+ for start := 0; start < n; {
+ if coversValue(d.inner[start]) == "" {
+ start++
+ continue
+ }
+ end := start + 1
+ for end < n && coversValue(d.inner[end]) != "" {
+ end++
+ }
+ if end-start > 1 && sortCoversRun(d.inner[start:end]) {
+ changed = true
+ }
+ start = end
+ }
+ return changed
+ })
+}
+
+// coversValue returns the lowercased value of a "@covers" annotation line, or ""
+// when the line is not a "@covers" annotation with a value.
+func coversValue(l docLine) string {
+ m := coversRe.FindStringSubmatch(strings.TrimSpace(l.content))
+ if m == nil {
+ return ""
+ }
+ return strings.ToLower(m[1])
+}
+
+// sortCoversRun stable-sorts a run of "@covers" lines by their value and reports
+// whether the order changed.
+func sortCoversRun(run []docLine) bool {
+ keys := make([]string, len(run))
+ for i, l := range run {
+ keys[i] = coversValue(l)
+ }
+ if sort.StringsAreSorted(keys) {
+ return false
+ }
+ sort.SliceStable(run, func(i, j int) bool {
+ return coversValue(run[i]) < coversValue(run[j])
+ })
+ return true
+}
diff --git a/blink/internal/fixer/rules/gen2_phpdoc_test.go b/blink/internal/fixer/rules/gen2_phpdoc_test.go
new file mode 100644
index 0000000000..e5e5890006
--- /dev/null
+++ b/blink/internal/fixer/rules/gen2_phpdoc_test.go
@@ -0,0 +1,78 @@
+package rules
+
+import (
+ "testing"
+
+ "blink/internal/fixer"
+)
+
+func TestGen2PhpdocIndent(t *testing.T) {
+ f := PhpdocIndent{}
+
+ // misaligned continuation "*" and closing "*/" inside a class (4-space indent)
+ assertFix(t, f,
+ "= 0 {
+ if st.Kind == token.Punct && (st.Value == "(" || st.Value == ",") {
+ return true
+ }
+ return st.Kind == token.Keyword && isVisibilityModifier(st.Value)
+ }
+ // typed property: the type run starts after a visibility/modifier keyword
+ return st.Kind == token.Keyword && isVisibilityModifier(st.Value)
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/TypesSpacesFixer.php
+//
+// TypesSpaces removes the single-line whitespace around a "|"/"&" that joins type
+// names in a union/intersection ("int | string" -> "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{}
+
+func (TypesSpaces) Name() string {
+ return `PhpCsFixer\Fixer\Whitespace\TypesSpacesFixer`
+}
+
+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 {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ 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)
+ changed = true
+ }
+ if i-1 >= 0 && s.At(i-1).Kind == token.Whitespace && !hasNewline(s.At(i-1).Value) {
+ s.RemoveAt(i - 1)
+ i--
+ changed = true
+ }
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/gen2_types_test.go b/blink/internal/fixer/rules/gen2_types_test.go
new file mode 100644
index 0000000000..1ea3b118b4
--- /dev/null
+++ b/blink/internal/fixer/rules/gen2_types_test.go
@@ -0,0 +1,66 @@
+package rules
+
+import (
+ "testing"
+
+ "blink/internal/fixer"
+)
+
+func TestGen2TypesTypesSpaces(t *testing.T) {
+ cases := []struct {
+ src, want string
+ changed bool
+ }{
+ // parameter union / intersection
+ {" %q", got, again)
+ }
+ }
+}
+
+func TestGen2TypesSourceURLs(t *testing.T) {
+ fixers := []fixer.Fixer{
+ TypesSpaces{},
+ }
+ for _, f := range fixers {
+ if want := fixer.SourceURLFor(f.Name()); f.SourceURL() != want {
+ t.Fatalf("%T SourceURL()=%q want %q", f, f.SourceURL(), want)
+ }
+ }
+}
diff --git a/blink/internal/fixer/rules/gen3_class_sep.go b/blink/internal/fixer/rules/gen3_class_sep.go
new file mode 100644
index 0000000000..cdba02094f
--- /dev/null
+++ b/blink/internal/fixer/rules/gen3_class_sep.go
@@ -0,0 +1,219 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// class member kinds for the separation rule, mirroring PHP-CS-Fixer's element
+// types (const|method|property|trait_import|case).
+const (
+ sepUnknown = iota
+ sepConst
+ sepMethod
+ sepProperty
+ sepTraitImport
+ sepCase
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/ClassAttributesSeparationFixer.php
+//
+// ClassAttributesSeparation normalizes the blank lines between two consecutive
+// top-level class members per the fixer's DEFAULT config: one blank line between
+// methods, properties and consts; none between trait imports or enum cases. It
+// is conservative - see the caveats on Fix.
+type ClassAttributesSeparation struct{}
+
+func (ClassAttributesSeparation) Name() string {
+ return `PhpCsFixer\Fixer\ClassNotation\ClassAttributesSeparationFixer`
+}
+
+func (ClassAttributesSeparation) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/ClassAttributesSeparationFixer.php"
+}
+
+// Fix only ever ADJUSTS the newline count inside an existing whitespace token
+// between two classified members; it never inserts, removes or moves code. It
+// covers two gap shapes and skips everything else:
+// - a plain whitespace gap (already spanning a line) between two members;
+// - a member whose leading trivia is only docblocks/attributes: the blank line
+// is placed above that block, leaving the block attached.
+//
+// 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 {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ if s.At(i).Kind != token.Punct || s.At(i).Value != "{" {
+ continue
+ }
+ if kind, _ := classifyBrace(s, i); kind != braceClassLike {
+ continue
+ }
+ starts := classMemberStarts(s, i)
+ for k := 0; k+1 < len(starts); k++ {
+ if fixMemberGap(s, starts[k], starts[k+1]) {
+ changed = true
+ }
+ }
+ }
+ return changed
+}
+
+// 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 {
+ prevEnd := memberSpanEnd(s, prevStart)
+ if prevEnd < 0 || prevEnd >= nextStart {
+ return false
+ }
+ nextType := classSepMemberType(s, nextStart)
+ if nextType == sepUnknown {
+ return false
+ }
+
+ firstTrivia := -1
+ wsCount, wsIdx := 0, -1
+ for j := prevEnd + 1; j < nextStart; j++ {
+ t := s.At(j)
+ switch {
+ case t.Kind == token.Whitespace:
+ wsCount++
+ wsIdx = j
+ case t.Kind == token.DocComment || isAttribute(t):
+ if firstTrivia < 0 {
+ firstTrivia = j
+ }
+ case t.Kind == token.Comment:
+ return false // plain comment: ambiguous ownership, leave alone
+ default:
+ return false
+ }
+ }
+
+ if firstTrivia < 0 {
+ // pure whitespace gap
+ if wsCount != 1 || !hasNewline(s.At(wsIdx).Value) {
+ return false
+ }
+ prevType := classSepMemberType(s, prevStart)
+ return setNewlineCount(s, wsIdx, requiredNewlines(s, prevStart, prevType, nextType))
+ }
+
+ // docblock/attribute block leading the next member: blank line goes above it.
+ // Require exactly one whitespace token (ws0) between prevEnd and the block, and
+ // the block itself contiguous (single-newline whitespace throughout) so it is
+ // unambiguously attached to nextStart.
+ if firstTrivia != prevEnd+2 {
+ return false
+ }
+ ws0 := prevEnd + 1
+ if s.At(ws0).Kind != token.Whitespace || !hasNewline(s.At(ws0).Value) {
+ return false
+ }
+ for j := firstTrivia + 1; j < nextStart; j++ {
+ if t := s.At(j); t.Kind == token.Whitespace && strings.Count(t.Value, "\n") != 1 {
+ return false
+ }
+ }
+ return setNewlineCount(s, ws0, 2)
+}
+
+// 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.
+ 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*
+// kinds, or sepUnknown when it cannot be told apart safely.
+func classSepMemberType(s *tokens.Stream, m int) int {
+ end := memberSpanEnd(s, m)
+ if end < 0 {
+ return sepUnknown
+ }
+ k := m
+ for k <= end && s.At(k).Kind == token.Keyword && memberModifiers[strings.ToLower(s.At(k).Value)] {
+ k = skipWhitespace(s, k+1)
+ }
+ if k > end {
+ return sepUnknown
+ }
+ if s.At(k).Kind == token.Keyword {
+ switch strings.ToLower(s.At(k).Value) {
+ case "use":
+ return sepTraitImport
+ case "const":
+ return sepConst
+ case "function":
+ return sepMethod
+ case "case":
+ return sepCase
+ default:
+ return sepUnknown
+ }
+ }
+ for x := k; x <= end; x++ {
+ t := s.At(x)
+ if t.Kind == token.Punct {
+ switch t.Value {
+ case "(", "[", "{":
+ if mm := s.MatchForward(x); mm > 0 {
+ x = mm
+ continue
+ }
+ }
+ }
+ if t.Kind == token.Variable {
+ return sepProperty
+ }
+ }
+ return sepUnknown
+}
+
+// hasDocOrAttrAbove reports whether the token directly above start (skipping
+// whitespace) is a docblock or an attribute.
+func hasDocOrAttrAbove(s *tokens.Stream, start int) bool {
+ j := start - 1
+ for j >= 0 && s.At(j).Kind == token.Whitespace {
+ j--
+ }
+ if j < 0 {
+ return false
+ }
+ t := s.At(j)
+ return t.Kind == token.DocComment || isAttribute(t)
+}
+
+func isAttribute(t token.Token) bool {
+ return t.Kind == token.Comment && strings.HasPrefix(t.Value, "#[")
+}
+
+// setNewlineCount rewrites the whitespace token at idx to hold exactly req
+// newlines while keeping the trailing line indentation. Returns whether it
+// changed the value.
+func setNewlineCount(s *tokens.Stream, idx, req int) bool {
+ v := s.At(idx).Value
+ indent := ""
+ if nl := strings.LastIndexByte(v, '\n'); nl >= 0 {
+ indent = v[nl+1:]
+ }
+ want := strings.Repeat("\n", req) + indent
+ if v == want {
+ return false
+ }
+ s.SetValue(idx, want)
+ return true
+}
diff --git a/blink/internal/fixer/rules/gen3_class_sep_test.go b/blink/internal/fixer/rules/gen3_class_sep_test.go
new file mode 100644
index 0000000000..96ed4ba5cc
--- /dev/null
+++ b/blink/internal/fixer/rules/gen3_class_sep_test.go
@@ -0,0 +1,179 @@
+package rules
+
+import (
+ "testing"
+
+ "blink/internal/fixer"
+)
+
+func TestGen3ClassSepMethods(t *testing.T) {
+ src := "= s.Len() {
+ return false
+ }
+ n := s.At(i + 1)
+ if n.Kind == token.Whitespace {
+ if hasNewline(n.Value) || n.Value == " " {
+ return false
+ }
+ s.SetValue(i+1, " ")
+ return true
+ }
+ s.InsertAt(i+1, token.Token{Kind: token.Whitespace, Value: " "})
+ return true
+}
+
+// fnEnsureSingleSpaceBefore forces exactly one single-line space between the token
+// at i and the one before it. A newline between them is left intact.
+func fnEnsureSingleSpaceBefore(s *tokens.Stream, i int) bool {
+ if i-1 < 0 {
+ return false
+ }
+ p := s.At(i - 1)
+ if p.Kind == token.Whitespace {
+ if hasNewline(p.Value) || p.Value == " " {
+ return false
+ }
+ s.SetValue(i-1, " ")
+ return true
+ }
+ s.InsertAt(i, token.Token{Kind: token.Whitespace, Value: " "})
+ return true
+}
+
+// fnGlueAfter removes a single-line whitespace token immediately after i, gluing
+// the token at i to the following significant token. A newline is left intact.
+func fnGlueAfter(s *tokens.Stream, i int) bool {
+ if i+1 < s.Len() && s.At(i+1).Kind == token.Whitespace && !hasNewline(s.At(i+1).Value) {
+ s.RemoveAt(i + 1)
+ return true
+ }
+ return false
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/FunctionNotation/FunctionDeclarationFixer.php
+//
+// FunctionDeclaration normalizes the spacing of a function, method or closure
+// declaration (DEFAULT config, a @PSR12/@PhpCsFixer rule): one space after the
+// "function" keyword, no space between a function name and its "(", one space
+// between "function" and "(" for a closure, and one space around a closure's
+// "use". A leading by-reference "&" is glued to the name ("function &foo()"), and
+// a static closure keeps one space after "static". Only "function" declarations
+// are touched, never a call; arrow "fn" and the "{"/"=>" and inner-parenthesis
+// spacing (owned by other fixers) are left alone.
+type FunctionDeclaration struct{}
+
+func (FunctionDeclaration) Name() string {
+ return `PhpCsFixer\Fixer\FunctionNotation\FunctionDeclarationFixer`
+}
+
+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 {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ if t.Kind != token.Keyword {
+ continue
+ }
+ switch strings.ToLower(t.Value) {
+ case "function":
+ if fixFunctionDeclaration(s, i) {
+ changed = true
+ }
+ case "fn":
+ // arrow function: one space 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
+ }
+ }
+ }
+ return changed
+}
+
+// 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 {
+ startParen := findFunctionParamOpen(s, fi)
+ if startParen < 0 {
+ return false
+ }
+
+ // classify: optional leading "&", optional name directly before "("
+ a := nextSignificantIndex(s, fi)
+ ampIndex := -1
+ if a >= 0 && s.At(a).Kind == token.Punct && s.At(a).Value == "&" {
+ ampIndex = a
+ a = nextSignificantIndex(s, a)
+ }
+ nameIndex := -1
+ if a >= 0 && a < startParen {
+ if s.At(a).Kind == token.Ident && nextSignificantIndex(s, a) == startParen {
+ nameIndex = a
+ } else {
+ return false // unexpected token between "function" and "("
+ }
+ }
+ 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 {
+ u := nextSignificantIndex(s, closeParen)
+ if u >= 0 && s.At(u).Kind == token.Keyword && strings.ToLower(s.At(u).Value) == "use" {
+ if fnEnsureSingleSpaceAfter(s, u) {
+ changed = true
+ }
+ if fnEnsureSingleSpaceBefore(s, u) {
+ changed = true
+ }
+ }
+ }
+ }
+
+ // named declaration: glue name (and any "&") to the "("
+ if named {
+ if fnGlueAfter(s, nameIndex) {
+ changed = true
+ }
+ if ampIndex >= 0 && fnGlueAfter(s, ampIndex) {
+ changed = true
+ }
+ }
+
+ // one space after the "function" keyword (before name, "&" or "(")
+ if fnEnsureSingleSpaceAfter(s, fi) {
+ changed = true
+ }
+
+ // static closure: one space between "static" and "function"
+ if !named {
+ if p := prevSignificantIndex(s, fi); p >= 0 &&
+ s.At(p).Kind == token.Keyword && strings.ToLower(s.At(p).Value) == "static" {
+ if fi-1 >= 0 && s.At(fi-1).Kind == token.Whitespace &&
+ !hasNewline(s.At(fi-1).Value) && s.At(fi-1).Value != " " {
+ s.SetValue(fi-1, " ")
+ changed = true
+ }
+ }
+ }
+
+ return changed
+}
+
+// findFunctionParamOpen returns the index of the "(" that opens the parameter
+// list of the "function" declaration at fi, or -1 when this "function" is not a
+// declaration (e.g. a "use function" import) or has no parameter list. Only
+// whitespace, a name and a leading "&" may appear before the "(".
+func findFunctionParamOpen(s *tokens.Stream, fi int) int {
+ for j := fi + 1; j < s.Len(); j++ {
+ t := s.At(j)
+ switch t.Kind {
+ case token.Whitespace, token.Ident:
+ continue
+ case token.Punct:
+ switch t.Value {
+ case "(":
+ return j
+ case "&":
+ continue
+ default:
+ return -1
+ }
+ default:
+ return -1
+ }
+ }
+ return -1
+}
diff --git a/blink/internal/fixer/rules/gen3_func_test.go b/blink/internal/fixer/rules/gen3_func_test.go
new file mode 100644
index 0000000000..c86eb0f1e1
--- /dev/null
+++ b/blink/internal/fixer/rules/gen3_func_test.go
@@ -0,0 +1,65 @@
+package rules
+
+import (
+ "testing"
+
+ "blink/internal/fixer"
+)
+
+func TestGen3FuncFunctionDeclaration(t *testing.T) {
+ f := FunctionDeclaration{}
+ cases := []struct {
+ src, want string
+ changed bool
+ }{
+ // named function: collapse space after keyword, glue name to "("
+ {" $x * 2;", " $x * 2;", true},
+ {" $x * 2;", " $x * 2;", false},
+ {" $x;", " $x;", false},
+ // newline between keyword and name is kept intact
+ {" %q (changed=%v)", got, again, ch2)
+ }
+ }
+}
+
+func TestGen3FuncSourceURL(t *testing.T) {
+ f := FunctionDeclaration{}
+ if want := fixer.SourceURLFor(f.Name()); f.SourceURL() != want {
+ t.Fatalf("%T SourceURL()=%q want %q", f, f.SourceURL(), want)
+ }
+}
diff --git a/blink/internal/fixer/rules/gen3_types.go b/blink/internal/fixer/rules/gen3_types.go
new file mode 100644
index 0000000000..0d766e3850
--- /dev/null
+++ b/blink/internal/fixer/rules/gen3_types.go
@@ -0,0 +1,192 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// isTypeDeclarationVariable reports whether the Variable at v is preceded by a
+// type declaration: either a function/method/closure parameter type, or a typed
+// property (a type run that begins after a visibility/modifier keyword). p is the
+// significant token before v and must already be a type-name token.
+func isTypeDeclarationVariable(s *tokens.Stream, v, p int) bool {
+ if enclosingFuncParamOpen(s, v) >= 0 {
+ return true
+ }
+ boundary := typeRunBoundaryPrev(s, p)
+ if boundary < 0 {
+ return false
+ }
+ bt := s.At(boundary)
+ return bt.Kind == token.Keyword && isVisibilityModifier(bt.Value)
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/TypeDeclarationSpacesFixer.php
+//
+// TypeDeclarationSpaces forces exactly one space between a type declaration and
+// the "$variable" it declares, in function parameters and typed properties
+// ("int$x" and "int $x" -> "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{}
+
+func (TypeDeclarationSpaces) Name() string {
+ return `PhpCsFixer\Fixer\Whitespace\TypeDeclarationSpacesFixer`
+}
+
+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 {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ if s.At(i).Kind != token.Variable {
+ continue
+ }
+ p := prevSignificantIndex(s, i)
+ if p < 0 || !isTypeNameToken(s.At(p)) {
+ continue
+ }
+ if !isTypeDeclarationVariable(s, i, p) {
+ continue
+ }
+ // type token abuts the variable: insert a single space
+ if p == i-1 {
+ s.InsertAt(i, token.Token{Kind: token.Whitespace, Value: " "})
+ changed = true
+ continue
+ }
+ // type, single whitespace, variable: collapse to a single space
+ if p == i-2 && s.At(i-1).Kind == token.Whitespace {
+ ws := s.At(i - 1).Value
+ if ws != " " && !hasNewline(ws) {
+ s.SetValue(i-1, " ")
+ changed = true
+ }
+ }
+ }
+ return changed
+}
+
+// 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 {
+ eq := nextSignificantIndex(s, v)
+ if eq < 0 || s.At(eq).Kind != token.Punct || s.At(eq).Value != "=" {
+ return false
+ }
+ nv := nextSignificantIndex(s, eq)
+ if nv < 0 || s.At(nv).Kind != token.Ident || strings.ToLower(s.At(nv).Value) != "null" {
+ return false
+ }
+ after := nextSignificantIndex(s, nv)
+ if after < 0 {
+ return false
+ }
+ at := s.At(after)
+ return at.Kind == token.Punct && (at.Value == "," || at.Value == ")")
+}
+
+// typeRunStart walks left from the type's last token at end across the type parts
+// ("\", names, and the operators "?", "|", "&") and returns the index of the
+// type's first token together with whether the run contains a union/intersection
+// ("|" or "&") or a nullable marker ("?").
+func typeRunStart(s *tokens.Stream, end int) (start int, hasUnion, hasNullable bool) {
+ start = end
+ for {
+ pp := prevSignificantIndex(s, start)
+ if pp < 0 {
+ break
+ }
+ pt := s.At(pp)
+ if pt.Kind == token.Punct {
+ switch pt.Value {
+ case "|", "&":
+ hasUnion = true
+ start = pp
+ continue
+ case "?":
+ hasNullable = true
+ start = pp
+ continue
+ case `\`:
+ start = pp
+ continue
+ }
+ break
+ }
+ if isTypeNameToken(pt) {
+ start = pp
+ continue
+ }
+ break
+ }
+ return start, hasUnion, hasNullable
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/FunctionNotation/NullableTypeDeclarationForDefaultNullValueFixer.php
+//
+// NullableTypeDeclarationForDefaultNullValue prepends "?" to a non-nullable
+// parameter type whose default value is null ("function f(int $x = null)" ->
+// "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{}
+
+func (NullableTypeDeclarationForDefaultNullValue) Name() string {
+ return `PhpCsFixer\Fixer\FunctionNotation\NullableTypeDeclarationForDefaultNullValueFixer`
+}
+
+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 {
+ changed := false
+ // Right-to-left so an inserted "?" (always left of the current index) never
+ // disturbs positions still to be scanned.
+ for i := s.Len() - 1; i >= 0; i-- {
+ if s.At(i).Kind != token.Variable {
+ continue
+ }
+ if enclosingFuncParamOpen(s, i) < 0 {
+ continue
+ }
+ if !hasNullDefault(s, i) {
+ continue
+ }
+ p := prevSignificantIndex(s, i)
+ if p < 0 {
+ continue
+ }
+ // step over a by-reference "&" to reach the type's last token
+ if s.At(p).Kind == token.Punct && s.At(p).Value == "&" {
+ p = prevSignificantIndex(s, p)
+ if p < 0 {
+ continue
+ }
+ }
+ if !isTypeNameToken(s.At(p)) {
+ 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) {
+ case "mixed", "null":
+ continue
+ }
+ }
+ s.InsertAt(start, token.Token{Kind: token.Punct, Value: "?"})
+ changed = true
+ i = start
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/gen3_types_test.go b/blink/internal/fixer/rules/gen3_types_test.go
new file mode 100644
index 0000000000..72d750615b
--- /dev/null
+++ b/blink/internal/fixer/rules/gen3_types_test.go
@@ -0,0 +1,105 @@
+package rules
+
+import (
+ "testing"
+
+ "blink/internal/fixer"
+)
+
+func TestGen3TypesTypeDeclarationSpaces(t *testing.T) {
+ cases := []struct {
+ src, want string
+ changed bool
+ }{
+ // parameters
+ {" $x;", " $x;", true},
+ // typed properties
+ {" %q", got, again)
+ }
+ }
+}
+
+func TestGen3TypesNullableTypeDeclarationForDefaultNullValue(t *testing.T) {
+ cases := []struct {
+ src, want string
+ changed bool
+ }{
+ // happy paths
+ {" %q", got, again)
+ }
+ }
+}
+
+func TestGen3TypesSourceURLs(t *testing.T) {
+ fixers := []fixer.Fixer{
+ TypeDeclarationSpaces{},
+ NullableTypeDeclarationForDefaultNullValue{},
+ }
+ for _, f := range fixers {
+ if want := fixer.SourceURLFor(f.Name()); f.SourceURL() != want {
+ t.Fatalf("%T SourceURL()=%q want %q", f, f.SourceURL(), want)
+ }
+ }
+}
diff --git a/blink/internal/fixer/rules/gen4_a.go b/blink/internal/fixer/rules/gen4_a.go
new file mode 100644
index 0000000000..5cbf621aef
--- /dev/null
+++ b/blink/internal/fixer/rules/gen4_a.go
@@ -0,0 +1,238 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Comment/SingleLineCommentStyleFixer.php
+//
+// SingleLineCommentStyle normalizes single-line comments to the "//" form. A "#"
+// line comment becomes "//" (an "#[" attribute is never touched), and a genuinely
+// single-line "/* ... */" block comment at the end of its line becomes "// ...".
+// Multi-line "/* */" blocks and doc comments are left alone.
+type SingleLineCommentStyle struct{}
+
+func (SingleLineCommentStyle) Name() string {
+ return `PhpCsFixer\Fixer\Comment\SingleLineCommentStyleFixer`
+}
+
+func (SingleLineCommentStyle) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Comment/SingleLineCommentStyleFixer.php"
+}
+
+func (SingleLineCommentStyle) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ // only plain comments (T_COMMENT); doc blocks keep their "/** */" form
+ if s.At(i).Kind != token.Comment {
+ continue
+ }
+ v := s.At(i).Value
+
+ // hash style: "# foo" -> "// foo"; "#[...]" is an attribute, not a comment
+ if strings.HasPrefix(v, "#") {
+ if len(v) >= 2 && v[1] == '[' {
+ continue
+ }
+ s.SetValue(i, "//"+v[1:])
+ changed = true
+ continue
+ }
+
+ // asterisk style: only a genuinely single-line "/* ... */" block
+ if !strings.HasPrefix(v, "/*") || len(v) < 4 {
+ continue
+ }
+ if strings.ContainsAny(v, "\n\r") {
+ continue // conservative: never touch a multi-line block comment
+ }
+ content := v[2 : len(v)-2]
+ if strings.Contains(content, "?>") {
+ continue
+ }
+ // the comment must sit at the end of its line, otherwise turning it into
+ // a "//" comment would swallow the code that follows on the same line
+ if next := i + 1; next < s.Len() {
+ nt := s.At(next)
+ if nt.Kind != token.Whitespace || !strings.ContainsAny(nt.Value, "\n\r") {
+ continue
+ }
+ }
+
+ inner := strings.Trim(content, " \t\r\n\f\v*")
+ nv := "//"
+ if inner != "" {
+ nv = "// " + inner
+ }
+ s.SetValue(i, nv)
+ changed = true
+
+ // drop the leading indentation the following whitespace inherited from the
+ // now-removed closing "*/", matching PHP-CS-Fixer
+ if next := i + 1; next < s.Len() {
+ nt := s.At(next)
+ if trimmed := strings.TrimLeft(nt.Value, " \t"); trimmed != nt.Value {
+ s.SetValue(next, trimmed)
+ }
+ }
+ }
+ return changed
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/NoNullPropertyInitializationFixer.php
+//
+// NoNullPropertyInitialization removes an explicit "= null" default from an
+// untyped class or trait property: "public $x = null;" -> "public $x;". Typed
+// properties, constants and parameter defaults are left untouched.
+type NoNullPropertyInitialization struct{}
+
+func (NoNullPropertyInitialization) Name() string {
+ return `PhpCsFixer\Fixer\ClassNotation\NoNullPropertyInitializationFixer`
+}
+
+func (NoNullPropertyInitialization) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/NoNullPropertyInitializationFixer.php"
+}
+
+// propertyModifiers are the keywords that may lead a property declaration. A type
+// declaration is not among them, so an untyped property is one whose modifiers are
+// followed directly by the variable.
+var propertyModifiers = map[string]bool{
+ "public": true, "protected": true, "private": true,
+ "static": true, "var": true, "final": true,
+ "abstract": true, "readonly": true,
+}
+
+func (NoNullPropertyInitialization) Fix(s *tokens.Stream) bool {
+ var remove []int
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ if t.Kind != token.Keyword {
+ continue
+ }
+ // only class and trait bodies hold properties; enums and interfaces do not
+ lw := strings.ToLower(t.Value)
+ if lw != "class" && lw != "trait" {
+ continue
+ }
+ open := classBodyBrace(s, i)
+ if open < 0 {
+ continue
+ }
+ for _, m := range classMemberStarts(s, open) {
+ remove = append(remove, nullDefaultsInProperty(s, m)...)
+ }
+ }
+ if len(remove) == 0 {
+ return false
+ }
+ // remove high-to-low so the lower indices stay valid; ranges never overlap
+ for a := 0; a < len(remove); a++ {
+ for b := a + 1; b < len(remove); b++ {
+ if remove[b] > remove[a] {
+ remove[a], remove[b] = remove[b], remove[a]
+ }
+ }
+ }
+ for _, idx := range remove {
+ s.RemoveAt(idx)
+ }
+ return true
+}
+
+// classBodyBrace returns the index of the "{" opening the body of the class-like
+// keyword at kw, or -1 when there is none before the statement ends.
+func classBodyBrace(s *tokens.Stream, kw int) int {
+ for j := kw + 1; j < s.Len(); j++ {
+ if s.At(j).Kind != token.Punct {
+ continue
+ }
+ switch s.At(j).Value {
+ case "{":
+ return j
+ case ";":
+ return -1
+ }
+ }
+ return -1
+}
+
+// nullDefaultsInProperty returns the token indices to drop so that the untyped
+// property declaration starting at member loses its "= null" defaults. It returns
+// nil for anything that is not an untyped property (a method, const, or a typed
+// property whose type sits between the modifiers and the variable).
+func nullDefaultsInProperty(s *tokens.Stream, member int) []int {
+ i := member
+ // skip leading modifier keywords; a type declaration is not a modifier, so
+ // reaching a non-modifier that is not the variable means this is not an
+ // untyped property (method, const, typed property, ...)
+ for i >= 0 && i < s.Len() {
+ t := s.At(i)
+ if (t.Kind == token.Keyword || t.Kind == token.Ident) && propertyModifiers[strings.ToLower(t.Value)] {
+ i = nextMeaningfulIndex(s, i)
+ continue
+ }
+ break
+ }
+ if i < 0 || s.At(i).Kind != token.Variable {
+ return nil
+ }
+
+ var remove []int
+ for {
+ varIdx := i
+ eq := nextMeaningfulIndex(s, varIdx)
+ if eq < 0 || s.At(eq).Kind != token.Punct || s.At(eq).Value != "=" {
+ return remove
+ }
+ valIdx := nextMeaningfulIndex(s, eq)
+ if valIdx >= 0 && s.At(valIdx).Kind == token.Punct && s.At(valIdx).Value == `\` {
+ valIdx = nextMeaningfulIndex(s, valIdx)
+ }
+ if valIdx < 0 || !isNullLiteral(s.At(valIdx)) {
+ return remove
+ }
+ // clear everything between the variable and the null, keeping newline
+ // whitespace and comments so surrounding layout survives
+ for k := varIdx + 1; k <= valIdx; k++ {
+ tk := s.At(k)
+ if tk.Kind == token.Comment || tk.Kind == token.DocComment {
+ continue
+ }
+ if tk.Kind == token.Whitespace && strings.Contains(tk.Value, "\n") {
+ continue
+ }
+ remove = append(remove, k)
+ }
+ after := nextMeaningfulIndex(s, valIdx)
+ if after < 0 || s.At(after).Kind != token.Punct || s.At(after).Value != "," {
+ return remove
+ }
+ next := nextMeaningfulIndex(s, after)
+ if next < 0 || s.At(next).Kind != token.Variable {
+ return remove
+ }
+ i = next
+ }
+}
+
+// isNullLiteral reports whether t is the null constant (case-insensitive).
+func isNullLiteral(t token.Token) bool {
+ return (t.Kind == token.Ident || t.Kind == token.Keyword) && strings.EqualFold(t.Value, "null")
+}
+
+// nextMeaningfulIndex returns the index of the first token after i that is not
+// whitespace or a comment, or -1 if there is none.
+func nextMeaningfulIndex(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
+}
diff --git a/blink/internal/fixer/rules/gen4_a_test.go b/blink/internal/fixer/rules/gen4_a_test.go
new file mode 100644
index 0000000000..83deae4864
--- /dev/null
+++ b/blink/internal/fixer/rules/gen4_a_test.go
@@ -0,0 +1,134 @@
+package rules
+
+import (
+ "testing"
+
+ "blink/internal/fixer"
+)
+
+func TestGen4ASingleLineCommentStyle(t *testing.T) {
+ f := SingleLineCommentStyle{}
+
+ // hash comment -> // comment
+ assertFix(t, f, " // comment
+ assertFix(t, f, " b */\n"); changed {
+ t.Fatalf("block comment with ?> must not change: got=%q", got)
+ }
+}
+
+func TestGen4ANoNullPropertyInitialization(t *testing.T) {
+ f := NoNullPropertyInitialization{}
+
+ // untyped property loses its null default
+ assertFix(t, f,
+ " 1
+}
+
+// isDynamicVarPrefix reports whether the significant token at p makes the
+// variable after it an indirect (dynamic) access that should be braced: the bare
+// "$" of a variable-variable, or an object operator ("->" / "?->") introducing a
+// dynamic property/method name.
+func isDynamicVarPrefix(t token.Token) bool {
+ if isLoneDollar(t) {
+ return true
+ }
+ return t.Kind == token.Punct && (t.Value == "->" || t.Value == "?->")
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/LanguageConstruct/ExplicitIndirectVariableFixer.php
+//
+// ExplicitIndirectVariable braces indirect variables so their meaning is
+// explicit: "$$foo" -> "${$foo}", "$$foo['bar']" -> "${$foo}['bar']",
+// "$foo->$bar" -> "$foo->{$bar}". Only the "$name" directly after a bare "$" or
+// an object operator is wrapped; any following "[...]" or "->x" stays outside the
+// braces, so the rewrite is a pure clarification with no change in behaviour.
+type ExplicitIndirectVariable struct{}
+
+func (ExplicitIndirectVariable) Name() string {
+ return `PhpCsFixer\Fixer\LanguageConstruct\ExplicitIndirectVariableFixer`
+}
+
+func (ExplicitIndirectVariable) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/LanguageConstruct/ExplicitIndirectVariableFixer.php"
+}
+
+func (ExplicitIndirectVariable) Fix(s *tokens.Stream) bool {
+ changed := false
+ // Right-to-left so the two inserted braces (always at/after the current
+ // index) never disturb positions still to be scanned.
+ for i := s.Len() - 1; i >= 0; i-- {
+ if !isRealVariable(s.At(i)) {
+ continue
+ }
+ p := prevSignificantIndex(s, i)
+ if p < 0 || !isDynamicVarPrefix(s.At(p)) {
+ continue
+ }
+ s.InsertAt(i+1, token.Token{Kind: token.Punct, Value: "}"})
+ s.InsertAt(i, token.Token{Kind: token.Punct, Value: "{"})
+ changed = true
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/gen4_b_test.go b/blink/internal/fixer/rules/gen4_b_test.go
new file mode 100644
index 0000000000..cb23b72cd5
--- /dev/null
+++ b/blink/internal/fixer/rules/gen4_b_test.go
@@ -0,0 +1,63 @@
+package rules
+
+import (
+ "testing"
+
+ "blink/internal/fixer"
+)
+
+func TestGen4BExplicitIndirectVariable(t *testing.T) {
+ cases := []struct {
+ src, want string
+ changed bool
+ }{
+ // core variable-variable
+ {"bar;", "bar;", true},
+ // dynamic property access via object operator
+ {"$bar;", "{$bar};", true},
+ {"$bar['baz'];", "{$bar}['baz'];", true},
+ {"$cb($x);", "{$cb}($x);", true},
+ // nullsafe operator
+ {"$bar;", "{$bar};", true},
+ // nested variable-variable: only the concrete "$name" is braced
+ {"{$bar};", "{$bar};", false},
+ // a plain variable is untouched
+ {"bar;", "bar;", false},
+ // static/method calls with plain names are untouched
+ {"bar();", "bar();", false},
+ }
+ for _, c := range cases {
+ got, changed := apply(t, ExplicitIndirectVariable{}, c.src)
+ if got != c.want || changed != c.changed {
+ t.Fatalf("src=%q changed=%v got=%q want=%q", c.src, changed, got, c.want)
+ }
+ if again, _ := apply(t, ExplicitIndirectVariable{}, got); again != got {
+ t.Fatalf("not idempotent: %q -> %q", got, again)
+ }
+ }
+}
+
+func TestGen4BSourceURLs(t *testing.T) {
+ fixers := []fixer.Fixer{
+ ExplicitIndirectVariable{},
+ }
+ for _, f := range fixers {
+ if want := fixer.SourceURLFor(f.Name()); f.SourceURL() != want {
+ t.Fatalf("%T SourceURL()=%q want %q", f, f.SourceURL(), want)
+ }
+ }
+}
diff --git a/blink/internal/fixer/rules/gen4_phpdoc.go b/blink/internal/fixer/rules/gen4_phpdoc.go
new file mode 100644
index 0000000000..af2d3b84c2
--- /dev/null
+++ b/blink/internal/fixer/rules/gen4_phpdoc.go
@@ -0,0 +1,80 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocLineSpanFixer.php
+//
+// PhpdocLineSpan expands a single-line docblock that documents a class member
+// into a multi-line one, matching the fixer's default config (const, method and
+// property all default to "multi").
+type PhpdocLineSpan struct{}
+
+func (PhpdocLineSpan) Name() string {
+ return `PhpCsFixer\Fixer\Phpdoc\PhpdocLineSpanFixer`
+}
+
+func (PhpdocLineSpan) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocLineSpanFixer.php"
+}
+
+func (PhpdocLineSpan) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := range s.Len() {
+ t := s.At(i)
+ if t.Kind != token.DocComment {
+ continue
+ }
+ d, ok := parseDoc(t.Value)
+ if !ok || !d.single {
+ continue
+ }
+ if !documentsMember(s, i) {
+ continue
+ }
+ indent, ok := docblockLineIndent(s, i)
+ if !ok {
+ continue
+ }
+ expandDocblock(&d, indent)
+ s.SetValue(i, d.render())
+ changed = true
+ }
+ return changed
+}
+
+// documentsMember reports whether the token after the docblock at index i is a
+// visibility keyword (public/private/protected) or "var", which unambiguously
+// introduce a class member (property or method). Only whitespace is skipped, so
+// an intervening attribute is a no-op. Bare const/function without visibility is
+// intentionally excluded - on flat tokens it cannot be told from a top-level
+// const or a free function, which the real fixer (class-scope only) leaves alone.
+func documentsMember(s *tokens.Stream, i int) bool {
+ j := skipWhitespace(s, i+1)
+ if j >= s.Len() {
+ return false
+ }
+ next := s.At(j)
+ if next.Kind != token.Keyword {
+ return false
+ }
+ lw := strings.ToLower(next.Value)
+ return visibilityModifiers[lw] || lw == "var"
+}
+
+// expandDocblock rewrites a single-line docblock into a multi-line one, keeping
+// the content verbatim and aligning the "*" one column past the given indent.
+func expandDocblock(d *docblock, indent string) {
+ content := ""
+ if len(d.inner) > 0 {
+ content = d.inner[0].content
+ }
+ d.single = false
+ d.open = "/**"
+ d.inner = []docLine{{prefix: indent + " * ", content: content}}
+ d.close = indent + " */"
+}
diff --git a/blink/internal/fixer/rules/gen4_phpdoc_test.go b/blink/internal/fixer/rules/gen4_phpdoc_test.go
new file mode 100644
index 0000000000..b1ead0b2f3
--- /dev/null
+++ b/blink/internal/fixer/rules/gen4_phpdoc_test.go
@@ -0,0 +1,61 @@
+package rules
+
+import (
+ "testing"
+
+ "blink/internal/fixer"
+)
+
+func TestGen4PhpdocLineSpan(t *testing.T) {
+ f := PhpdocLineSpan{}
+
+ // single-line property docblock is expanded to multi-line (4-space indent)
+ assertFix(t, f,
+ "include", "Foo::for") rather than a language
+// keyword.
+func isMemberAccessPrev(s *tokens.Stream, i int) bool {
+ p := sigPrev(s, i)
+ if p < 0 {
+ return false
+ }
+ pt := s.At(p)
+ return pt.Kind == token.Punct && (pt.Value == "->" || pt.Value == "?->" || pt.Value == "::")
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/IncludeFixer.php
+//
+// Include normalizes the whitespace between an include/require construct and its
+// argument to exactly one space ("require \"x\"" -> "require \"x\""). Only the
+// spacing part of the upstream fixer is implemented; the paren-form
+// ("include(\"x\")") is left untouched because removing those parentheses cannot
+// be proven safe on a flat token stream.
+type Include struct{}
+
+func (Include) Name() string {
+ return `PhpCsFixer\Fixer\ControlStructure\IncludeFixer`
+}
+
+func (Include) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/IncludeFixer.php"
+}
+
+func (Include) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ if t.Kind != token.Keyword || !includeKeywords[strings.ToLower(t.Value)] {
+ continue
+ }
+ if isMemberAccessPrev(s, i) {
+ continue
+ }
+ n := i + 1
+ if n >= s.Len() {
+ continue
+ }
+ nt := s.At(n)
+ if nt.Kind == token.Whitespace {
+ if hasNewline(nt.Value) {
+ continue // keep multi-line spacing (alignment) untouched
+ }
+ j := sigNext(s, i)
+ if j < 0 || (s.At(j).Kind == token.Punct && s.At(j).Value == "(") {
+ continue // paren form: leave the parentheses alone
+ }
+ if nt.Value != " " {
+ s.SetValue(n, " ")
+ changed = true
+ }
+ continue
+ }
+ // Directly adjacent argument: insert a single space. Only the token kinds
+ // that can legally abut the keyword are handled ("include$x", "include'x'",
+ // "include\\Foo"); the paren form is skipped.
+ switch {
+ case nt.Kind == token.Variable || nt.Kind == token.String:
+ case nt.Kind == token.Punct && nt.Value == `\`:
+ default:
+ continue
+ }
+ s.InsertAt(n, token.Token{Kind: token.Whitespace, Value: " "})
+ changed = true
+ }
+ return changed
+}
+
+// loopBodyKeywords are the loops EmptyLoopBody acts on (for/foreach/while).
+var loopBodyKeywords = map[string]bool{
+ "for": true, "foreach": true, "while": true,
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/EmptyLoopBodyFixer.php
+//
+// EmptyLoopBody rewrites an empty braced loop body to a semicolon, matching the
+// upstream default style "semicolon" ("while ($x) {}" -> "while ($x);"). A
+// do-while is naturally excluded (its "while (...)" is followed by ";", not
+// "{"), and a body holding a comment is kept.
+type EmptyLoopBody struct{}
+
+func (EmptyLoopBody) Name() string {
+ return `PhpCsFixer\Fixer\ControlStructure\EmptyLoopBodyFixer`
+}
+
+func (EmptyLoopBody) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/EmptyLoopBodyFixer.php"
+}
+
+func (EmptyLoopBody) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ if t.Kind != token.Keyword || !loopBodyKeywords[strings.ToLower(t.Value)] {
+ continue
+ }
+ if isMemberAccessPrev(s, i) {
+ continue
+ }
+ open := sigNext(s, i)
+ if open < 0 || s.At(open).Kind != token.Punct || s.At(open).Value != "(" {
+ continue
+ }
+ closeParen := s.MatchForward(open)
+ if closeParen < 0 {
+ continue
+ }
+ braceOpen := sigNext(s, closeParen)
+ if braceOpen < 0 || s.At(braceOpen).Kind != token.Punct || s.At(braceOpen).Value != "{" {
+ continue
+ }
+ // The body is empty only when the next non-whitespace token (comments are
+ // NOT skipped, so a commented body is preserved) is the closing brace.
+ braceClose := nextSignificantIndex(s, braceOpen)
+ if braceClose < 0 || s.At(braceClose).Kind != token.Punct || s.At(braceClose).Value != "}" {
+ continue
+ }
+ // Replace "{" with ";" and drop the "}" plus the whitespace-only interior,
+ // so "{ }" collapses cleanly to ";".
+ s.SetValue(braceOpen, ";")
+ for k := braceClose; k > braceOpen; k-- {
+ s.RemoveAt(k)
+ }
+ changed = true
+ }
+ return changed
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/EmptyLoopConditionFixer.php
+//
+// EmptyLoopCondition rewrites an empty for-loop header to while(true), matching
+// the upstream default style "while" ("for (;;)" -> "while (true)"). Only this
+// for-form is implemented; the do-while conversion of the upstream fixer is not,
+// and a header containing a comment is skipped to avoid reordering it.
+type EmptyLoopCondition struct{}
+
+func (EmptyLoopCondition) Name() string {
+ return `PhpCsFixer\Fixer\ControlStructure\EmptyLoopConditionFixer`
+}
+
+func (EmptyLoopCondition) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/EmptyLoopConditionFixer.php"
+}
+
+func (EmptyLoopCondition) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ if t.Kind != token.Keyword || strings.ToLower(t.Value) != "for" {
+ continue
+ }
+ if isMemberAccessPrev(s, i) {
+ continue
+ }
+ open := sigNext(s, i)
+ if open < 0 || s.At(open).Kind != token.Punct || s.At(open).Value != "(" {
+ continue
+ }
+ closeParen := s.MatchForward(open)
+ if closeParen < 0 {
+ continue
+ }
+ // Empty condition: "( ; ; )" - exactly two semicolons and nothing else.
+ a := sigNext(s, open)
+ if a < 0 || s.At(a).Kind != token.Punct || s.At(a).Value != ";" {
+ continue
+ }
+ b := sigNext(s, a)
+ if b < 0 || s.At(b).Kind != token.Punct || s.At(b).Value != ";" {
+ continue
+ }
+ if c := sigNext(s, b); c != closeParen {
+ continue
+ }
+ // Only rewrite when the header holds no comment, so nothing is dropped or
+ // reordered by the replacement.
+ if !spanClean(s, i, closeParen) {
+ continue
+ }
+ s.ReplaceRange(i, closeParen, []token.Token{
+ {Kind: token.Keyword, Value: "while"},
+ {Kind: token.Whitespace, Value: " "},
+ {Kind: token.Punct, Value: "("},
+ {Kind: token.Ident, Value: "true"},
+ {Kind: token.Punct, Value: ")"},
+ })
+ changed = true
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/gen5_lang_test.go b/blink/internal/fixer/rules/gen5_lang_test.go
new file mode 100644
index 0000000000..e37a701126
--- /dev/null
+++ b/blink/internal/fixer/rules/gen5_lang_test.go
@@ -0,0 +1,122 @@
+package rules
+
+import (
+ "testing"
+
+ "blink/internal/fixer"
+)
+
+func TestGen5LangInclude(t *testing.T) {
+ cases := []struct {
+ src string
+ want string
+ changed bool
+ }{
+ // collapse multiple spaces to one
+ {`include ("x");`, `include ("x");`, false},
+ }
+ for _, c := range cases {
+ got, changed := apply(t, Include{}, c.src)
+ if got != c.want || changed != c.changed {
+ t.Fatalf("src=%q changed=%v got=%q want=%q", c.src, changed, got, c.want)
+ }
+ if again, changed2 := apply(t, Include{}, got); changed2 || again != got {
+ t.Fatalf("not idempotent: %q -> %q changed=%v", got, again, changed2)
+ }
+ }
+}
+
+func TestGen5LangEmptyLoopBody(t *testing.T) {
+ cases := []struct {
+ src string
+ want string
+ changed bool
+ }{
+ // empty braced body -> semicolon (upstream default "semicolon")
+ {`while();`, `while();`, false},
+ }
+ for _, c := range cases {
+ got, changed := apply(t, EmptyLoopBody{}, c.src)
+ if got != c.want || changed != c.changed {
+ t.Fatalf("src=%q changed=%v got=%q want=%q", c.src, changed, got, c.want)
+ }
+ if again, changed2 := apply(t, EmptyLoopBody{}, got); changed2 || again != got {
+ t.Fatalf("not idempotent: %q -> %q changed=%v", got, again, changed2)
+ }
+ }
+}
+
+func TestGen5LangEmptyLoopCondition(t *testing.T) {
+ cases := []struct {
+ src string
+ want string
+ changed bool
+ }{
+ // empty for header -> while (true) (upstream default "while")
+ {`for(;;);`, `for(;;);`, false},
+ }
+ for _, c := range cases {
+ got, changed := apply(t, EmptyLoopCondition{}, c.src)
+ if got != c.want || changed != c.changed {
+ t.Fatalf("src=%q changed=%v got=%q want=%q", c.src, changed, got, c.want)
+ }
+ if again, changed2 := apply(t, EmptyLoopCondition{}, got); changed2 || again != got {
+ t.Fatalf("not idempotent: %q -> %q changed=%v", got, again, changed2)
+ }
+ }
+}
+
+// TestGen5LangSourceURLs verifies each SourceURL matches SourceURLFor(Name()).
+func TestGen5LangSourceURLs(t *testing.T) {
+ for _, f := range []fixer.Fixer{Include{}, EmptyLoopBody{}, EmptyLoopCondition{}} {
+ if got, want := f.SourceURL(), fixer.SourceURLFor(f.Name()); got != want {
+ t.Errorf("%s: SourceURL %q != SourceURLFor %q", f.Name(), got, want)
+ }
+ }
+}
diff --git a/blink/internal/fixer/rules/gen5_phpdoc_misc.go b/blink/internal/fixer/rules/gen5_phpdoc_misc.go
new file mode 100644
index 0000000000..cd86ea9a67
--- /dev/null
+++ b/blink/internal/fixer/rules/gen5_phpdoc_misc.go
@@ -0,0 +1,76 @@
+package rules
+
+import (
+ "regexp"
+ "strings"
+
+ "blink/internal/tokens"
+)
+
+// phpdocReturnSelfRefMap is the fixer's default "replacements" map, verbatim from
+// source. Keys are matched case-insensitively (the fixer lowercases the type).
+var phpdocReturnSelfRefMap = map[string]string{
+ "this": "$this",
+ "@this": "$this",
+ "$self": "self",
+ "@self": "self",
+ "$static": "static",
+ "@static": "static",
+}
+
+var phpdocReturnSelfRefRe = regexp.MustCompile(`(?i)^(@return\s+)(\S+)(.*)$`)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocReturnSelfReferenceFixer.php
+//
+// PhpdocReturnSelfReference normalizes self-reference return types in "@return"
+// (this -> $this, @self -> self, $static -> static, ...). Each union member that
+// exactly matches an alias (case-insensitive) is replaced; the description after
+// the type is preserved.
+type PhpdocReturnSelfReference struct{}
+
+func (PhpdocReturnSelfReference) Name() string {
+ return `PhpCsFixer\Fixer\Phpdoc\PhpdocReturnSelfReferenceFixer`
+}
+
+func (PhpdocReturnSelfReference) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocReturnSelfReferenceFixer.php"
+}
+
+func (PhpdocReturnSelfReference) Fix(s *tokens.Stream) bool {
+ return applyToDocblocks(s, func(d *docblock) bool {
+ changed := false
+ for i, l := range d.inner {
+ trimmed := strings.TrimLeft(l.content, " ")
+ m := phpdocReturnSelfRefRe.FindStringSubmatch(trimmed)
+ if m == nil {
+ continue
+ }
+ newType, ok := normalizeReturnSelfType(m[2])
+ if !ok {
+ continue
+ }
+ lead := l.content[:len(l.content)-len(trimmed)]
+ d.inner[i].content = lead + m[1] + newType + m[3]
+ changed = true
+ }
+ return changed
+ })
+}
+
+// normalizeReturnSelfType replaces each union member equal (case-insensitive) to
+// a configured alias. Rejoining with "|" is lossless, so non-matching or complex
+// types round-trip unchanged; ok is false when nothing was replaced.
+func normalizeReturnSelfType(typ string) (string, bool) {
+ parts := strings.Split(typ, "|")
+ changed := false
+ for i, p := range parts {
+ if repl, ok := phpdocReturnSelfRefMap[strings.ToLower(p)]; ok {
+ parts[i] = repl
+ changed = true
+ }
+ }
+ if !changed {
+ return "", false
+ }
+ return strings.Join(parts, "|"), true
+}
diff --git a/blink/internal/fixer/rules/gen5_phpdoc_misc_test.go b/blink/internal/fixer/rules/gen5_phpdoc_misc_test.go
new file mode 100644
index 0000000000..045cd30cfa
--- /dev/null
+++ b/blink/internal/fixer/rules/gen5_phpdoc_misc_test.go
@@ -0,0 +1,74 @@
+package rules
+
+import (
+ "testing"
+
+ "blink/internal/fixer"
+)
+
+func TestGen5PhpdocMiscReturnSelfReference(t *testing.T) {
+ f := PhpdocReturnSelfReference{}
+
+ // this -> $this
+ assertFix(t, f,
+ " self
+ assertFix(t, f,
+ " static
+ assertFix(t, f,
+ "', ')', ']', '}':
+ if depth > 0 {
+ depth--
+ }
+ case '|':
+ if depth == 0 {
+ parts = append(parts, typ[start:i])
+ start = i + 1
+ }
+ }
+ }
+ return append(parts, typ[start:])
+}
+
+// normalizePhpdocCompare mirrors the fixer's /^\(*\??\\\?/ normalization used
+// only to detect a null member; the member's original spelling is preserved.
+func normalizePhpdocCompare(t string) string {
+ t = strings.TrimLeft(t, "(")
+ t = strings.TrimPrefix(t, "?")
+ return strings.TrimPrefix(t, "\\")
+}
+
+// Swaps a `$var`-before-type @var/@type into type-before-`$var`, ported from the
+// fixer's Preg replace (\h -> [ \t] for RE2).
+var phpdocVarOrderRe = regexp.MustCompile(`(?i)(@(?:type|var)\s*)(\$\S+)([ \t]+)([^$](?:[^<\s]|<[^>]*>)*)(\s|\*)`)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocVarAnnotationCorrectOrderFixer.php
+//
+// PhpdocVarAnnotationCorrectOrder fixes swapped @var/@type order, rewriting
+// "@var $x int" to "@var int $x". Already-correct "@var Type $x" is a no-op.
+type PhpdocVarAnnotationCorrectOrder struct{}
+
+func (PhpdocVarAnnotationCorrectOrder) Name() string {
+ return `PhpCsFixer\Fixer\Phpdoc\PhpdocVarAnnotationCorrectOrderFixer`
+}
+
+func (PhpdocVarAnnotationCorrectOrder) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocVarAnnotationCorrectOrderFixer.php"
+}
+
+func (PhpdocVarAnnotationCorrectOrder) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := range s.Len() {
+ t := s.At(i)
+ if t.Kind != token.DocComment {
+ continue
+ }
+ lower := strings.ToLower(t.Value)
+ if !strings.Contains(lower, "@var") && !strings.Contains(lower, "@type") {
+ continue
+ }
+ newVal := phpdocVarOrderRe.ReplaceAllString(t.Value, "${1}${4}${3}${2}${5}")
+ if newVal != t.Value {
+ s.SetValue(i, newVal)
+ changed = true
+ }
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/gen5_phpdoc_order_test.go b/blink/internal/fixer/rules/gen5_phpdoc_order_test.go
new file mode 100644
index 0000000000..4dc4de06cd
--- /dev/null
+++ b/blink/internal/fixer/rules/gen5_phpdoc_order_test.go
@@ -0,0 +1,98 @@
+package rules
+
+import (
+ "testing"
+
+ "blink/internal/fixer"
+)
+
+func TestGen5PhpdocOrderTypesOrder(t *testing.T) {
+ f := PhpdocTypesOrder{}
+
+ // null moves to the end, other members keep order: string|null|int -> string|int|null
+ assertFix(t, f,
+ "|null $x\n */\nfunction f($x){}",
+ "|null $x\n */\nfunction f($x){}", false)
+
+ // DNF is skipped (contains '(')
+ assertFix(t, f,
+ " */\n$foo = [];",
+ " $foo */\n$foo = [];", true)
+
+ // already correct - no-op
+ assertFix(t, f,
+ "= 0 {
+ break
+ }
+ continue
+ }
+ if strings.HasPrefix(c, "@") {
+ break
+ }
+ if first < 0 {
+ first = i
+ }
+ last = i
+ }
+ return first, last
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocTagTypeFixer.php
+//
+// PhpdocTagType normalizes a standalone inline "{@inheritDoc}" to its annotation
+// form "@inheritDoc" (the default tags => ['inheritDoc' => 'annotation']). Inline
+// occurrences inside descriptive text are left as-is.
+type PhpdocTagType struct{}
+
+func (PhpdocTagType) Name() string {
+ return `PhpCsFixer\Fixer\Phpdoc\PhpdocTagTypeFixer`
+}
+
+func (PhpdocTagType) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocTagTypeFixer.php"
+}
+
+var inlineInheritDocRe = regexp.MustCompile(`^\{@([a-zA-Z]+)\}$`)
+
+func (PhpdocTagType) Fix(s *tokens.Stream) bool {
+ return applyToDocblocks(s, func(d *docblock) bool {
+ changed := false
+ for i, l := range d.inner {
+ trimmed := strings.TrimLeft(l.content, " ")
+ m := inlineInheritDocRe.FindStringSubmatch(strings.TrimRight(trimmed, " "))
+ if m == nil || !strings.EqualFold(m[1], "inheritDoc") {
+ continue
+ }
+ lead := l.content[:len(l.content)-len(strings.TrimLeft(l.content, " "))]
+ d.inner[i].content = lead + "@" + m[1]
+ changed = true
+ }
+ return changed
+ })
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocOrderFixer.php
+//
+// PhpdocOrder orders @param, @throws and @return tags into that sequence (the
+// build's default). Only those tag blocks are permuted; summary, blank lines and
+// other tags keep their positions.
+type PhpdocOrder struct{}
+
+func (PhpdocOrder) Name() string {
+ return `PhpCsFixer\Fixer\Phpdoc\PhpdocOrderFixer`
+}
+
+func (PhpdocOrder) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocOrderFixer.php"
+}
+
+var phpdocTagNameRe = regexp.MustCompile(`^@([a-zA-Z][a-zA-Z0-9_-]*)`)
+
+// phpdocOrderRank returns the sort rank of a tag and whether it is one of the
+// reordered tags. phpstan-/psalm- prefixed variants share their base rank.
+func phpdocOrderRank(tag string) (int, bool) {
+ base := strings.ToLower(tag)
+ base = strings.TrimPrefix(base, "phpstan-")
+ base = strings.TrimPrefix(base, "psalm-")
+ switch base {
+ case "param":
+ return 0, true
+ case "throws":
+ return 1, true
+ case "return":
+ return 2, true
+ }
+ return 0, false
+}
+
+type phpdocBlock struct {
+ lines []docLine
+ rank int
+ target bool
+}
+
+func (PhpdocOrder) Fix(s *tokens.Stream) bool {
+ return applyToDocblocks(s, func(d *docblock) bool {
+ if d.single {
+ return false
+ }
+ blocks := splitPhpdocBlocks(d.inner)
+
+ var targetPos []int
+ var targetBlocks []phpdocBlock
+ for i, b := range blocks {
+ if b.target {
+ targetPos = append(targetPos, i)
+ targetBlocks = append(targetBlocks, b)
+ }
+ }
+ if len(targetBlocks) < 2 {
+ return false
+ }
+ sorted := make([]phpdocBlock, len(targetBlocks))
+ copy(sorted, targetBlocks)
+ stableSortByRank(sorted)
+ if sameBlockOrder(targetBlocks, sorted) {
+ return false
+ }
+ for k, pos := range targetPos {
+ blocks[pos] = sorted[k]
+ }
+ var inner []docLine
+ for _, b := range blocks {
+ inner = append(inner, b.lines...)
+ }
+ d.inner = inner
+ return true
+ })
+}
+
+// splitPhpdocBlocks groups inner lines: each "@tag" line plus its continuation
+// lines forms one block; every other line is its own block.
+func splitPhpdocBlocks(inner []docLine) []phpdocBlock {
+ var blocks []phpdocBlock
+ for i := 0; i < len(inner); {
+ c := strings.TrimSpace(inner[i].content)
+ if m := phpdocTagNameRe.FindStringSubmatch(c); m != nil {
+ rank, target := phpdocOrderRank(m[1])
+ j := i + 1
+ for j < len(inner) {
+ cj := strings.TrimSpace(inner[j].content)
+ if cj == "" || strings.HasPrefix(cj, "@") {
+ break
+ }
+ j++
+ }
+ blocks = append(blocks, phpdocBlock{lines: inner[i:j], rank: rank, target: target})
+ i = j
+ continue
+ }
+ blocks = append(blocks, phpdocBlock{lines: inner[i : i+1]})
+ i++
+ }
+ return blocks
+}
+
+// stableSortByRank orders blocks by rank, keeping equal-rank blocks in place.
+func stableSortByRank(blocks []phpdocBlock) {
+ for i := 1; i < len(blocks); i++ {
+ for j := i; j > 0 && blocks[j-1].rank > blocks[j].rank; j-- {
+ blocks[j-1], blocks[j] = blocks[j], blocks[j-1]
+ }
+ }
+}
+
+func sameBlockOrder(a, b []phpdocBlock) bool {
+ for i := range a {
+ if len(a[i].lines) != len(b[i].lines) {
+ return false
+ }
+ for k := range a[i].lines {
+ if a[i].lines[k] != b[i].lines[k] {
+ return false
+ }
+ }
+ }
+ return true
+}
diff --git a/blink/internal/fixer/rules/gen6_phpdoc_test.go b/blink/internal/fixer/rules/gen6_phpdoc_test.go
new file mode 100644
index 0000000000..9e422daa38
--- /dev/null
+++ b/blink/internal/fixer/rules/gen6_phpdoc_test.go
@@ -0,0 +1,68 @@
+package rules
+
+import "testing"
+
+func TestNoAliasLanguageConstructCall(t *testing.T) {
+ got, changed := apply(t, NoAliasLanguageConstructCall{}, "die(); Foo::die();"); changed {
+ t.Fatal("exit and member die() must not change")
+ }
+}
+
+func TestBlankLineBeforeStatement(t *testing.T) {
+ got, changed := apply(t, BlankLineBeforeStatement{}, " "[]"). Only "[]" is handled; "array()" is left alone.
+type NoWhitespaceInEmptyArray struct{}
+
+func (NoWhitespaceInEmptyArray) Name() string {
+ return `PhpCsFixer\Fixer\ArrayNotation\NoWhitespaceInEmptyArrayFixer`
+}
+
+func (NoWhitespaceInEmptyArray) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ArrayNotation/NoWhitespaceInEmptyArrayFixer.php"
+}
+
+func (NoWhitespaceInEmptyArray) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i+2 < s.Len(); i++ {
+ if s.At(i).Kind != token.Punct || s.At(i).Value != "[" {
+ continue
+ }
+ if s.At(i+1).Kind != token.Whitespace {
+ continue
+ }
+ if s.At(i+2).Kind != token.Punct || s.At(i+2).Value != "]" {
+ continue
+ }
+ s.RemoveAt(i + 1)
+ changed = true
+ }
+ return changed
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ArrayNotation/NormalizeIndexBraceFixer.php
+//
+// NormalizeIndexBrace converts the deprecated curly-brace array index access
+// ("$a{0}" -> "$a[0]"). To never touch a real block "{", it only converts a
+// brace pair that directly follows a value that can be indexed: a variable, a
+// string literal or a closing "]".
+type NormalizeIndexBrace struct{}
+
+func (NormalizeIndexBrace) Name() string {
+ return `PhpCsFixer\Fixer\ArrayNotation\NormalizeIndexBraceFixer`
+}
+
+func (NormalizeIndexBrace) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ArrayNotation/NormalizeIndexBraceFixer.php"
+}
+
+func (NormalizeIndexBrace) 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 != "{" {
+ continue
+ }
+ if !isIndexBraceTarget(s, i) {
+ continue
+ }
+ closeIdx := s.MatchForward(i)
+ if closeIdx < 0 {
+ continue
+ }
+ s.SetValue(closeIdx, "]")
+ s.SetValue(i, "[")
+ changed = true
+ }
+ return changed
+}
+
+// isIndexBraceTarget reports whether the "{" at i is an array index brace, based
+// on the preceding significant token. A variable name (not the "$" of the "${}"
+// variable-variable form), a string literal or a "]" mean an index access.
+func isIndexBraceTarget(s *tokens.Stream, i int) bool {
+ prev, ok := prevSignificant(s, i)
+ if !ok {
+ return false
+ }
+ switch {
+ case prev.Kind == token.Variable && prev.Value != "$":
+ return true
+ case prev.Kind == token.String:
+ return true
+ case prev.Kind == token.Punct && prev.Value == "]":
+ return true
+ }
+ return false
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ArrayNotation/NoMultilineWhitespaceAroundDoubleArrowFixer.php
+//
+// NoMultilineWhitespaceAroundDoubleArrow collapses newline-containing whitespace
+// directly around "=>" to a single space. Single-line spacing is left alone, and
+// a line comment before, or any comment after, the arrow is respected.
+type NoMultilineWhitespaceAroundDoubleArrow struct{}
+
+func (NoMultilineWhitespaceAroundDoubleArrow) Name() string {
+ return `PhpCsFixer\Fixer\ArrayNotation\NoMultilineWhitespaceAroundDoubleArrowFixer`
+}
+
+func (NoMultilineWhitespaceAroundDoubleArrow) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ArrayNotation/NoMultilineWhitespaceAroundDoubleArrowFixer.php"
+}
+
+func (NoMultilineWhitespaceAroundDoubleArrow) 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 != "=>" {
+ continue
+ }
+ // before: skip when a line comment precedes the whitespace
+ if i-2 < 0 || !isLineComment(s.At(i-2)) {
+ changed = collapseArrowWhitespace(s, i-1) || changed
+ }
+ // after: skip when any comment follows the whitespace
+ if i+2 >= s.Len() || !isComment(s.At(i+2)) {
+ changed = collapseArrowWhitespace(s, i+1) || changed
+ }
+ }
+ return changed
+}
+
+// collapseArrowWhitespace turns a newline-containing whitespace token into a
+// single space, matching PHP-CS-Fixer's rtrim(...)." " on the arrow neighbour.
+func collapseArrowWhitespace(s *tokens.Stream, i int) bool {
+ if i < 0 || i >= s.Len() {
+ return false
+ }
+ t := s.At(i)
+ if t.Kind != token.Whitespace || !hasNewline(t.Value) {
+ return false
+ }
+ s.SetValue(i, " ")
+ return true
+}
+
+func isComment(t token.Token) bool {
+ return t.Kind == token.Comment || t.Kind == token.DocComment
+}
+
+func isLineComment(t token.Token) bool {
+ return t.Kind == token.Comment && (strings.HasPrefix(t.Value, "//") || strings.HasPrefix(t.Value, "#"))
+}
diff --git a/blink/internal/fixer/rules/gen_arrays_test.go b/blink/internal/fixer/rules/gen_arrays_test.go
new file mode 100644
index 0000000000..8732532e27
--- /dev/null
+++ b/blink/internal/fixer/rules/gen_arrays_test.go
@@ -0,0 +1,126 @@
+package rules
+
+import (
+ "testing"
+
+ fixerpkg "blink/internal/fixer"
+)
+
+func TestGenArraysNoWhitespaceInEmptyArray(t *testing.T) {
+ got, changed := apply(t, NoWhitespaceInEmptyArray{}, "\n 1,\n];")
+ if want := " 1,\n];"; !changed || got != want {
+ t.Fatalf("after: changed=%v got=%q want=%q", changed, got, want)
+ }
+ // newline before the arrow collapses to a single space
+ got, changed = apply(t, NoMultilineWhitespaceAroundDoubleArrow{}, " 1,\n];")
+ if want := " 1,\n];"; !changed || got != want {
+ t.Fatalf("before: changed=%v got=%q want=%q", changed, got, want)
+ }
+ // single-line spacing is left alone (that is another fixer's job)
+ if _, changed := apply(t, NoMultilineWhitespaceAroundDoubleArrow{}, " 1];"); changed {
+ t.Fatal("single-line spacing must not change")
+ }
+ // already normalized: no-op
+ if _, changed := apply(t, NoMultilineWhitespaceAroundDoubleArrow{}, " 1];"); changed {
+ t.Fatal("normalized arrow must not change")
+ }
+ // a comment right after the arrow is respected
+ if _, changed := apply(t, NoMultilineWhitespaceAroundDoubleArrow{}, "\n // c\n 1];"); changed {
+ t.Fatal("comment after arrow must be preserved")
+ }
+}
+
+// TestGenArraysSourceURLs verifies every SourceURL matches SourceURLFor(Name()).
+func TestGenArraysSourceURLs(t *testing.T) {
+ fixers := []fixerpkg.Fixer{
+ NoWhitespaceInEmptyArray{},
+ NormalizeIndexBrace{},
+ NoMultilineWhitespaceAroundDoubleArrow{},
+ }
+ for _, f := range fixers {
+ if got, want := f.SourceURL(), fixerpkg.SourceURLFor(f.Name()); got != want {
+ t.Errorf("%s: SourceURL %q != SourceURLFor %q", f.Name(), got, want)
+ }
+ }
+}
+
+// TestGenArraysIdempotent guarantees a second pass is a no-op.
+func TestGenArraysIdempotent(t *testing.T) {
+ fixers := []fixerRule{
+ NoWhitespaceInEmptyArray{},
+ NormalizeIndexBrace{},
+ NoMultilineWhitespaceAroundDoubleArrow{},
+ }
+ corpus := []string{
+ "\n 1,\n 'y'\n => 2,\n];\n",
+ }
+ for _, f := range fixers {
+ for _, src := range corpus {
+ once, _ := apply(t, f, src)
+ twice, changed := apply(t, f, once)
+ if changed || once != twice {
+ t.Errorf("%T not idempotent\n src: %q\n once: %q\n twice:%q", f, src, once, twice)
+ }
+ }
+ }
+}
diff --git a/blink/internal/fixer/rules/gen_control.go b/blink/internal/fixer/rules/gen_control.go
new file mode 100644
index 0000000000..01d157ad6d
--- /dev/null
+++ b/blink/internal/fixer/rules/gen_control.go
@@ -0,0 +1,121 @@
+package rules
+
+import (
+ "strconv"
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/SwitchContinueToBreakFixer.php
+//
+// SwitchContinueToBreak rewrites a "continue" that targets a switch into
+// "break". A "continue" targeting an enclosing loop is left untouched.
+type SwitchContinueToBreak struct{}
+
+func (SwitchContinueToBreak) Name() string {
+ return `PhpCsFixer\Fixer\ControlStructure\SwitchContinueToBreakFixer`
+}
+
+func (SwitchContinueToBreak) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/SwitchContinueToBreakFixer.php"
+}
+
+func (SwitchContinueToBreak) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ if t.Kind != token.Keyword || strings.ToLower(t.Value) != "continue" {
+ continue
+ }
+ level, ok := continueLevel(s, i)
+ if !ok {
+ continue
+ }
+ targets := enclosingLoopsAndSwitches(s, i)
+ if level <= len(targets) && targets[level-1] == "switch" {
+ s.SetValue(i, "break")
+ changed = true
+ }
+ }
+ return changed
+}
+
+// continueLevel returns the loop level a "continue" at i targets: 1 for a bare
+// "continue;" (or "continue 1;"/"continue 0;"), n for "continue n;". ok is
+// false when i is not a plain continue statement (e.g. an unparsable level, or a
+// level not immediately followed by ";").
+func continueLevel(s *tokens.Stream, i int) (int, bool) {
+ j := meaningfulAfter(s, i)
+ if j < 0 {
+ return 0, false
+ }
+ tj := s.At(j)
+ if tj.Kind == token.Punct && tj.Value == ";" {
+ return 1, true // bare "continue" == "continue 1"
+ }
+ if tj.Kind != token.Number {
+ return 0, false
+ }
+ if k := meaningfulAfter(s, j); k < 0 || s.At(k).Kind != token.Punct || s.At(k).Value != ";" {
+ return 0, false
+ }
+ n, err := strconv.ParseInt(strings.ReplaceAll(tj.Value, "_", ""), 0, 64)
+ if err != nil || n < 0 {
+ return 0, false
+ }
+ if n == 0 {
+ n = 1 // "continue 0" behaves like "continue 1"
+ }
+ return int(n), true
+}
+
+// enclosingLoopsAndSwitches lists the loop and switch constructs enclosing token
+// i, innermost first (each entry is "loop" or "switch"). "if"/"try"/"catch" and
+// free blocks are transparent to continue and skipped. The walk stops at a
+// function, closure or class boundary a continue cannot cross.
+func enclosingLoopsAndSwitches(s *tokens.Stream, i int) []string {
+ var out []string
+ 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 {
+ depth--
+ continue
+ }
+ kind, kw := classifyBrace(s, j)
+ switch kind {
+ case braceFunctionDecl, braceClosure, braceClassLike:
+ return out
+ case braceControl:
+ switch strings.ToLower(s.At(kw).Value) {
+ case "switch":
+ out = append(out, "switch")
+ case "for", "foreach", "while", "do":
+ out = append(out, "loop")
+ }
+ }
+ }
+ }
+ return out
+}
+
+// meaningfulAfter returns the first index after i that is not whitespace or a
+// comment, or -1 if there is none.
+func meaningfulAfter(s *tokens.Stream, i int) int {
+ for j := i + 1; j < s.Len(); j++ {
+ switch s.At(j).Kind {
+ case token.Whitespace, token.Comment, token.DocComment:
+ continue
+ }
+ return j
+ }
+ return -1
+}
diff --git a/blink/internal/fixer/rules/gen_control_test.go b/blink/internal/fixer/rules/gen_control_test.go
new file mode 100644
index 0000000000..1f64e81fd3
--- /dev/null
+++ b/blink/internal/fixer/rules/gen_control_test.go
@@ -0,0 +1,114 @@
+package rules
+
+import (
+ "testing"
+
+ "blink/internal/fixer"
+)
+
+func TestGenControlSwitchContinueToBreak(t *testing.T) {
+ cases := []struct {
+ src string
+ want string
+ changed bool
+ }{
+ // bare continue directly in a switch case -> break
+ {
+ " break 2
+ {
+ "continue(); break; }",
+ "continue(); break; }",
+ false,
+ },
+ // no switch anywhere: plain loop continue untouched
+ {
+ " %q changed=%v", got, again, changed2)
+ }
+ }
+}
+
+// TestGenControlSourceURLs verifies each SourceURL matches SourceURLFor(Name()).
+func TestGenControlSourceURLs(t *testing.T) {
+ for _, f := range []fixer.Fixer{SwitchContinueToBreak{}} {
+ if got, want := f.SourceURL(), fixer.SourceURLFor(f.Name()); got != want {
+ t.Errorf("%s: SourceURL %q != SourceURLFor %q", f.Name(), got, want)
+ }
+ }
+}
diff --git a/blink/internal/fixer/rules/gen_imports.go b/blink/internal/fixer/rules/gen_imports.go
new file mode 100644
index 0000000000..7e5b2c417d
--- /dev/null
+++ b/blink/internal/fixer/rules/gen_imports.go
@@ -0,0 +1,167 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Import/NoUnneededImportAliasFixer.php
+//
+// NoUnneededImportAlias drops an alias that equals the imported name's last
+// segment: "use App\Foo as Foo;" -> "use App\Foo;" (comparison is case-sensitive).
+type NoUnneededImportAlias struct{}
+
+func (NoUnneededImportAlias) Name() string {
+ return `PhpCsFixer\Fixer\Import\NoUnneededImportAliasFixer`
+}
+
+func (NoUnneededImportAlias) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Import/NoUnneededImportAliasFixer.php"
+}
+
+func (NoUnneededImportAlias) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ if t.Kind != token.Keyword || strings.ToLower(t.Value) != "use" {
+ continue
+ }
+ j := skipWhitespace(s, i+1)
+ if j < s.Len() && s.At(j).Kind == token.Punct && s.At(j).Value == "(" {
+ continue // closure use
+ }
+ semi := -1
+ for k := i + 1; k < s.Len(); k++ {
+ if s.At(k).Kind == token.Punct && s.At(k).Value == ";" {
+ semi = k
+ break
+ }
+ }
+ if semi < 0 {
+ continue
+ }
+ if removeUnneededAliases(s, i, semi) {
+ changed = true
+ }
+ }
+ return changed
+}
+
+// removeUnneededAliases strips "as X" occurrences in the use statement spanning
+// (from, semi) where X equals the preceding path segment. Returns true on change.
+func removeUnneededAliases(s *tokens.Stream, from, semi int) bool {
+ changed := false
+ k := from + 1
+ end := semi
+ for k < end {
+ if s.At(k).Kind == token.Keyword && strings.ToLower(s.At(k).Value) == "as" {
+ seg := k - 1
+ for seg > from && s.At(seg).Kind == token.Whitespace {
+ seg--
+ }
+ alias := skipWhitespace(s, k+1)
+ if seg > from && alias < end &&
+ s.At(seg).Kind == token.Ident && s.At(alias).Kind == token.Ident &&
+ s.At(seg).Value == s.At(alias).Value {
+ for r := alias; r >= seg+1; r-- {
+ s.RemoveAt(r)
+ }
+ end -= alias - seg
+ k = seg + 1
+ changed = true
+ continue
+ }
+ }
+ k++
+ }
+ return changed
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/NamespaceNotation/CleanNamespaceFixer.php
+//
+// CleanNamespace removes whitespace and comments between the parts of a
+// namespace or use path: "A \ B" -> "A\B".
+type CleanNamespace struct{}
+
+func (CleanNamespace) Name() string {
+ return `PhpCsFixer\Fixer\NamespaceNotation\CleanNamespaceFixer`
+}
+
+func (CleanNamespace) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/NamespaceNotation/CleanNamespaceFixer.php"
+}
+
+func (CleanNamespace) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ if t.Kind != token.Keyword {
+ continue
+ }
+ lw := strings.ToLower(t.Value)
+ if lw != "namespace" && lw != "use" {
+ continue
+ }
+ j := skipWhitespace(s, i+1)
+ if lw == "use" && j < s.Len() && s.At(j).Kind == token.Punct && s.At(j).Value == "(" {
+ continue // closure use
+ }
+ end := s.Len()
+ for k := i + 1; k < s.Len(); k++ {
+ if s.At(k).Kind == token.Punct && (s.At(k).Value == ";" || s.At(k).Value == "{") {
+ end = k
+ break
+ }
+ }
+ if collapseNamespaceSpaces(s, i, end) {
+ changed = true
+ }
+ }
+ return changed
+}
+
+// collapseNamespaceSpaces removes whitespace/comment tokens in (from, end) that
+// sit directly between two path tokens (an identifier or a "\").
+func collapseNamespaceSpaces(s *tokens.Stream, from, end int) bool {
+ changed := false
+ k := from + 1
+ for k < end {
+ t := s.At(k)
+ if t.Kind == token.Whitespace || t.Kind == token.Comment || t.Kind == token.DocComment {
+ p := prevPathToken(s, k, from)
+ n := nextPathToken(s, k, end)
+ if p >= 0 && n < end && isPathToken(s.At(p)) && isPathToken(s.At(n)) {
+ s.RemoveAt(k)
+ end--
+ changed = true
+ continue
+ }
+ }
+ k++
+ }
+ return changed
+}
+
+func isPathToken(t token.Token) bool {
+ return t.Kind == token.Ident || (t.Kind == token.Punct && t.Value == `\`)
+}
+
+func prevPathToken(s *tokens.Stream, i, from int) int {
+ for i--; i > from; i-- {
+ if k := s.At(i).Kind; k != token.Whitespace && k != token.Comment && k != token.DocComment {
+ return i
+ }
+ }
+ return -1
+}
+
+func nextPathToken(s *tokens.Stream, i, end int) int {
+ for i++; i < end; i++ {
+ if k := s.At(i).Kind; k != token.Whitespace && k != token.Comment && k != token.DocComment {
+ return i
+ }
+ }
+ return end
+}
diff --git a/blink/internal/fixer/rules/gen_imports_test.go b/blink/internal/fixer/rules/gen_imports_test.go
new file mode 100644
index 0000000000..d522770a32
--- /dev/null
+++ b/blink/internal/fixer/rules/gen_imports_test.go
@@ -0,0 +1,87 @@
+package rules
+
+import (
+ "testing"
+
+ "blink/internal/fixer"
+)
+
+func TestGenImportsNoUnneededImportAlias(t *testing.T) {
+ got, changed := apply(t, NoUnneededImportAlias{}, " %q", once, twice)
+ }
+}
+
+func TestGenImportsCleanNamespace(t *testing.T) {
+ got, changed := apply(t, CleanNamespace{}, " %q", once, twice)
+ }
+}
+
+// TestGenImportsSourceURLs verifies each SourceURL matches SourceURLFor(Name()).
+func TestGenImportsSourceURLs(t *testing.T) {
+ for _, f := range []fixer.Fixer{NoUnneededImportAlias{}, CleanNamespace{}} {
+ if got, want := f.SourceURL(), fixer.SourceURLFor(f.Name()); got != want {
+ t.Errorf("%s: SourceURL %q != SourceURLFor %q", f.Name(), got, want)
+ }
+ }
+}
diff --git a/blink/internal/fixer/rules/gen_ops_cast.go b/blink/internal/fixer/rules/gen_ops_cast.go
new file mode 100644
index 0000000000..f5dd839662
--- /dev/null
+++ b/blink/internal/fixer/rules/gen_ops_cast.go
@@ -0,0 +1,104 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/CastNotation/NoShortBoolCastFixer.php
+//
+// NoShortBoolCast rewrites the double-not short bool cast to an explicit cast:
+// "!!$x" -> "(bool) $x". Two adjacent "!" Punct tokens (only whitespace between)
+// are the short cast; a comment between them is left untouched.
+type NoShortBoolCast struct{}
+
+func (NoShortBoolCast) Name() string {
+ return `PhpCsFixer\Fixer\CastNotation\NoShortBoolCastFixer`
+}
+
+func (NoShortBoolCast) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/CastNotation/NoShortBoolCastFixer.php"
+}
+
+func (NoShortBoolCast) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := s.Len() - 1; i >= 1; i-- {
+ if !isBang(s.At(i)) {
+ continue
+ }
+ j := prevSignificantIndex(s, i)
+ if j < 0 || !isBang(s.At(j)) {
+ continue
+ }
+
+ s.ReplaceRange(j, i, []token.Token{
+ {Kind: token.Punct, Value: "("},
+ {Kind: token.Ident, Value: "bool"},
+ {Kind: token.Punct, Value: ")"},
+ })
+ // ensure a single space after the new ")"
+ after := j + 3
+ if after < s.Len() && s.At(after).Kind != token.Whitespace {
+ s.InsertAt(after, token.Token{Kind: token.Whitespace, Value: " "})
+ }
+ changed = true
+ i = j
+ }
+ return changed
+}
+
+func isBang(t token.Token) bool {
+ return t.Kind == token.Punct && t.Value == "!"
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/CastNotation/NoUnsetCastFixer.php
+//
+// NoUnsetCast replaces an "(unset)" cast assignment with null: "$a = (unset) $b;"
+// -> "$a = null;". It applies only to the exact shape "= (unset) $var ;" (or a
+// closing tag in place of ";"), since "(unset) $x" always evaluates to null.
+type NoUnsetCast struct{}
+
+func (NoUnsetCast) Name() string {
+ return `PhpCsFixer\Fixer\CastNotation\NoUnsetCastFixer`
+}
+
+func (NoUnsetCast) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/CastNotation/NoUnsetCastFixer.php"
+}
+
+func (NoUnsetCast) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ typeIdx, closeIdx, ok := castAt(s, i)
+ if !ok || strings.ToLower(s.At(typeIdx).Value) != "unset" {
+ continue
+ }
+
+ assignIdx := prevSignificantIndex(s, i)
+ if assignIdx < 0 || s.At(assignIdx).Kind != token.Punct || s.At(assignIdx).Value != "=" {
+ continue
+ }
+ varIdx := nextSignificantIndex(s, closeIdx)
+ if varIdx < 0 || s.At(varIdx).Kind != token.Variable {
+ continue
+ }
+ afterVar := nextSignificantIndex(s, varIdx)
+ if afterVar < 0 {
+ continue
+ }
+ if s.At(afterVar).Kind != token.CloseTag &&
+ (s.At(afterVar).Kind != token.Punct || s.At(afterVar).Value != ";") {
+ continue
+ }
+
+ repl := []token.Token{{Kind: token.Ident, Value: "null"}}
+ if s.At(assignIdx+1).Kind != token.Whitespace {
+ repl = append([]token.Token{{Kind: token.Whitespace, Value: " "}}, repl...)
+ }
+ s.ReplaceRange(i, varIdx, repl)
+ changed = true
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/gen_ops_cast_test.go b/blink/internal/fixer/rules/gen_ops_cast_test.go
new file mode 100644
index 0000000000..2970b0fd9e
--- /dev/null
+++ b/blink/internal/fixer/rules/gen_ops_cast_test.go
@@ -0,0 +1,72 @@
+package rules
+
+import (
+ "testing"
+
+ "blink/internal/fixer"
+)
+
+func TestGenOpsCastNoShortBoolCast(t *testing.T) {
+ cases := []struct {
+ src string
+ want string
+ changed bool
+ }{
+ {" %q changed=%v", got, again, changed2)
+ }
+ }
+}
+
+func TestGenOpsCastNoUnsetCast(t *testing.T) {
+ cases := []struct {
+ src string
+ want string
+ changed bool
+ }{
+ {"", "", true},
+ // only "= (unset) $var ;" is rewritten
+ {" %q changed=%v", got, again, changed2)
+ }
+ }
+}
+
+func TestGenOpsCastSourceURLs(t *testing.T) {
+ for _, f := range []fixer.Fixer{NoShortBoolCast{}, NoUnsetCast{}} {
+ if want := fixer.SourceURLFor(f.Name()); f.SourceURL() != want {
+ t.Errorf("%s: SourceURL %q inconsistent with name, want %q", f.Name(), f.SourceURL(), want)
+ }
+ }
+}
diff --git a/blink/internal/fixer/rules/gen_ops_inc.go b/blink/internal/fixer/rules/gen_ops_inc.go
new file mode 100644
index 0000000000..ef7da34888
--- /dev/null
+++ b/blink/internal/fixer/rules/gen_ops_inc.go
@@ -0,0 +1,302 @@
+package rules
+
+import (
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// This file implements three PHP-CS-Fixer Operator rules on the flat token
+// stream. Each is deliberately narrower than the upstream fixer: only the
+// token-adjacency cases that are provably semantics-preserving are rewritten,
+// everything else is left untouched. Correctness (never corrupt, no-op on
+// already-correct code, idempotent) is preferred over completeness.
+
+// sigPrev returns the index of the first meaningful token before i (skipping
+// whitespace and comments), or -1.
+func sigPrev(s *tokens.Stream, i int) int {
+ for j := i - 1; j >= 0; j-- {
+ switch s.At(j).Kind {
+ case token.Whitespace, token.Comment, token.DocComment:
+ continue
+ }
+ return j
+ }
+ return -1
+}
+
+// sigNext returns the index of the first meaningful token after i, or -1.
+func sigNext(s *tokens.Stream, i int) int {
+ for j := i + 1; j < s.Len(); j++ {
+ switch s.At(j).Kind {
+ case token.Whitespace, token.Comment, token.DocComment:
+ continue
+ }
+ return j
+ }
+ return -1
+}
+
+// spanClean reports whether the inclusive range [a, b] holds no comments, so a
+// rewrite of that span cannot silently drop one.
+func spanClean(s *tokens.Stream, a, b int) bool {
+ for j := a; j <= b && j < s.Len(); j++ {
+ if k := s.At(j).Kind; k == token.Comment || k == token.DocComment {
+ return false
+ }
+ }
+ return true
+}
+
+// inlineWSBetween reports whether every token strictly between a and b is
+// whitespace with no newline (so the two are on the same line, gap removable).
+func inlineWSBetween(s *tokens.Stream, a, b int) bool {
+ for j := a + 1; j < b; j++ {
+ t := s.At(j)
+ if t.Kind != token.Whitespace || hasNewline(t.Value) {
+ return false
+ }
+ }
+ return true
+}
+
+// lvaluePrefix reports whether v attaches the following variable to a larger
+// lvalue (member/static/dynamic/reference access), meaning it is not a plain
+// standalone variable.
+func lvaluePrefix(v string) bool {
+ switch v {
+ case "->", "?->", "::", "$", "&":
+ return true
+ }
+ return false
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/StandardizeIncrementFixer.php
+//
+// StandardizeIncrement rewrites `$i += 1` to `++$i` and `$i -= 1` to `--$i`.
+// The pre-increment form is value-equivalent to the compound assignment, so the
+// rewrite is safe in any expression position. Only a single plain variable on
+// the left is handled; complex lvalues are left to the upstream fixer.
+type StandardizeIncrement struct{}
+
+func (StandardizeIncrement) Name() string {
+ return `PhpCsFixer\Fixer\Operator\StandardizeIncrementFixer`
+}
+
+func (StandardizeIncrement) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/StandardizeIncrementFixer.php"
+}
+
+// incExprEnd matches the tokens that may terminate the `1` operand.
+func incExprEnd(v string) bool {
+ switch v {
+ case ";", ")", "]", ",", ":":
+ return true
+ }
+ return false
+}
+
+func (StandardizeIncrement) 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 != "+=" && t.Value != "-=") {
+ continue
+ }
+
+ // Left side: exactly one plain variable.
+ lv := sigPrev(s, i)
+ if lv < 0 || s.At(lv).Kind != token.Variable {
+ continue
+ }
+ if p := sigPrev(s, lv); p >= 0 && lvaluePrefix(s.At(p).Value) {
+ continue
+ }
+
+ // Right side: the literal 1, then an expression-end token.
+ numIdx := sigNext(s, i)
+ if numIdx < 0 || s.At(numIdx).Kind != token.Number || s.At(numIdx).Value != "1" {
+ continue
+ }
+ endIdx := sigNext(s, numIdx)
+ if endIdx < 0 || s.At(endIdx).Kind != token.Punct || !incExprEnd(s.At(endIdx).Value) {
+ continue
+ }
+
+ // Refuse if a comment sits inside the rewritten span.
+ if !spanClean(s, lv, numIdx) {
+ continue
+ }
+
+ op := "++"
+ if t.Value == "-=" {
+ op = "--"
+ }
+
+ // Drop ` 1` (operator..number), then the inline gap before it, then
+ // prepend the increment operator to the variable.
+ for k := numIdx; k >= i; k-- {
+ s.RemoveAt(k)
+ }
+ if lv+1 < s.Len() && s.At(lv+1).Kind == token.Whitespace && !hasNewline(s.At(lv+1).Value) {
+ s.RemoveAt(lv + 1)
+ }
+ s.InsertAt(lv, token.Token{Kind: token.Punct, Value: op})
+ changed = true
+ i = lv
+ }
+ return changed
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/IncrementStyleFixer.php
+//
+// IncrementStyle converts a post-increment statement to pre-increment
+// (`$i++;` -> `++$i;`, `$i--;` -> `--$i;`) using the default `pre` style. Only
+// standalone statements on a single plain variable are rewritten, where the
+// return value is discarded and post/pre are equivalent.
+type IncrementStyle struct{}
+
+func (IncrementStyle) Name() string {
+ return `PhpCsFixer\Fixer\Operator\IncrementStyleFixer`
+}
+
+func (IncrementStyle) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/IncrementStyleFixer.php"
+}
+
+func (IncrementStyle) 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 != "++" && t.Value != "--") {
+ continue
+ }
+
+ // Operand: exactly one plain variable, on the same line as the operator.
+ lv := sigPrev(s, i)
+ if lv < 0 || s.At(lv).Kind != token.Variable {
+ continue
+ }
+ if !inlineWSBetween(s, lv, i) {
+ continue
+ }
+
+ // The operand must open a statement (previous meaningful token is a
+ // boundary) and not be part of a larger lvalue.
+ p := sigPrev(s, lv)
+ if p < 0 {
+ continue
+ }
+ pv := s.At(p)
+ if lvaluePrefix(pv.Value) {
+ continue
+ }
+ if pv.Value != ";" && pv.Value != "{" && pv.Value != "}" && pv.Kind != token.OpenTag {
+ continue
+ }
+
+ // The operator must be a standalone statement, terminated by ';'.
+ endIdx := sigNext(s, i)
+ if endIdx < 0 || s.At(endIdx).Value != ";" {
+ continue
+ }
+
+ // Move the operator in front of the variable, dropping the inline gap.
+ for k := i; k >= lv+1; k-- {
+ s.RemoveAt(k)
+ }
+ s.InsertAt(lv, token.Token{Kind: token.Punct, Value: t.Value})
+ changed = true
+ i = lv
+ }
+ return changed
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/LongToShorthandOperatorFixer.php
+//
+// LongToShorthandOperator rewrites `$a = $a ;` to
+// `$a = ;`. Only the single-variable, single-operand,
+// semicolon-terminated form is handled; a lone right operand plus the `;`
+// terminator removes any operator-precedence ambiguity. Anything with a chained
+// or complex right side is left to the upstream fixer.
+type LongToShorthandOperator struct{}
+
+func (LongToShorthandOperator) Name() string {
+ return `PhpCsFixer\Fixer\Operator\LongToShorthandOperatorFixer`
+}
+
+func (LongToShorthandOperator) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/LongToShorthandOperatorFixer.php"
+}
+
+// shortOp reports whether v is an operator with a compound-assignment form.
+func shortOp(v string) bool {
+ switch v {
+ case "+", "-", "*", "/", ".", "%", "&", "|", "^":
+ return true
+ }
+ return false
+}
+
+// shortOperand reports whether t is a single self-contained right operand.
+func shortOperand(t token.Token) bool {
+ switch t.Kind {
+ case token.Variable, token.Number, token.String, token.Ident:
+ return true
+ }
+ return false
+}
+
+func (LongToShorthandOperator) 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 != "=" {
+ continue
+ }
+
+ // Left side: a single plain variable.
+ lv := sigPrev(s, i)
+ if lv < 0 || s.At(lv).Kind != token.Variable {
+ continue
+ }
+ if p := sigPrev(s, lv); p >= 0 && lvaluePrefix(s.At(p).Value) {
+ continue
+ }
+
+ // Right side must start with the same variable.
+ rv := sigNext(s, i)
+ if rv < 0 || s.At(rv).Kind != token.Variable || s.At(rv).Value != s.At(lv).Value {
+ continue
+ }
+
+ // Then a compound-capable operator.
+ opIdx := sigNext(s, rv)
+ if opIdx < 0 || s.At(opIdx).Kind != token.Punct || !shortOp(s.At(opIdx).Value) {
+ continue
+ }
+
+ // Then a single operand and a terminating ';'.
+ operandIdx := sigNext(s, opIdx)
+ if operandIdx < 0 || !shortOperand(s.At(operandIdx)) {
+ continue
+ }
+ endIdx := sigNext(s, operandIdx)
+ if endIdx < 0 || s.At(endIdx).Value != ";" {
+ continue
+ }
+
+ // Refuse if a comment sits inside the rewritten span.
+ if !spanClean(s, i, opIdx) {
+ continue
+ }
+
+ // `= $a ` becomes `=`: retag the `=` and drop the duplicate
+ // operand and operator.
+ s.SetValue(i, s.At(opIdx).Value+"=")
+ for k := opIdx; k >= i+1; k-- {
+ s.RemoveAt(k)
+ }
+ changed = true
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/gen_ops_inc_test.go b/blink/internal/fixer/rules/gen_ops_inc_test.go
new file mode 100644
index 0000000000..3585a69eae
--- /dev/null
+++ b/blink/internal/fixer/rules/gen_ops_inc_test.go
@@ -0,0 +1,134 @@
+package rules
+
+import (
+ "testing"
+
+ "blink/internal/fixer"
+)
+
+func TestGenOpsIncStandardizeIncrement(t *testing.T) {
+ cases := []struct {
+ src, want string
+ changed bool
+ }{
+ {"x += 1;", "x += 1;", false},
+ {" not a standalone statement, must not move
+ {"x++;", "x++;", false},
+ {"x += 1;\n$a = $a - $b - $c;\n",
+ }
+ for _, f := range fixers {
+ for _, src := range corpus {
+ once, _ := apply(t, f, src)
+ twice, changed := apply(t, f, once)
+ if changed || once != twice {
+ t.Errorf("%s not idempotent: src=%q once=%q twice=%q", f.Name(), src, once, twice)
+ }
+ }
+ }
+}
diff --git a/blink/internal/fixer/rules/gen_phpdoc_tags.go b/blink/internal/fixer/rules/gen_phpdoc_tags.go
new file mode 100644
index 0000000000..63bdc0b376
--- /dev/null
+++ b/blink/internal/fixer/rules/gen_phpdoc_tags.go
@@ -0,0 +1,150 @@
+package rules
+
+import (
+ "regexp"
+ "strings"
+
+ "blink/internal/tokens"
+)
+
+// inheritDocRe matches an "@inheritdoc" tag token (any case), whether standalone
+// ("@inheritdoc") or inline ("{@inheritdoc}"). The trailing \b keeps
+// "@inheritdocs" untouched, mirroring the default ['inheritDoc'] tag set.
+var inheritDocRe = regexp.MustCompile(`(?i)@inheritdoc\b`)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocTagCasingFixer.php
+//
+// PhpdocTagCasing fixes the casing of the @inheritDoc tag (the default tag set),
+// in both annotation and inline positions.
+type PhpdocTagCasing struct{}
+
+func (PhpdocTagCasing) Name() string {
+ return `PhpCsFixer\Fixer\Phpdoc\PhpdocTagCasingFixer`
+}
+
+func (PhpdocTagCasing) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocTagCasingFixer.php"
+}
+
+func (PhpdocTagCasing) Fix(s *tokens.Stream) bool {
+ return applyToDocblocks(s, func(d *docblock) bool {
+ changed := false
+ for i, l := range d.inner {
+ fixed := inheritDocRe.ReplaceAllString(l.content, "@inheritDoc")
+ if fixed != l.content {
+ d.inner[i].content = fixed
+ changed = true
+ }
+ }
+ return changed
+ })
+}
+
+// inlineTagRe ports PhpdocInlineTagNormalizerFixer's pattern: it matches an inline
+// tag written as "@{tag}" or "{@tag}" (with stray braces or spaces) for a known
+// inline tag, capturing the tag word (case preserved) and its inner text.
+var inlineTagRe = regexp.MustCompile(`(?i)(?:@\{+|\{+[ \t]*@)[ \t]*(example|id|internal|inheritdoc|inheritdocs|link|source|toc|tutorial|see)\b([^}]*)\}+`)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocInlineTagNormalizerFixer.php
+//
+// PhpdocInlineTagNormalizer normalizes inline tags: it moves "@" inside the
+// braces, collapses repeated braces and trims inner spaces ("{ @see X }" ->
+// "{@see X}"). The tag word's casing is left untouched (that is PhpdocTagCasing's
+// job).
+type PhpdocInlineTagNormalizer struct{}
+
+func (PhpdocInlineTagNormalizer) Name() string {
+ return `PhpCsFixer\Fixer\Phpdoc\PhpdocInlineTagNormalizerFixer`
+}
+
+func (PhpdocInlineTagNormalizer) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocInlineTagNormalizerFixer.php"
+}
+
+func (PhpdocInlineTagNormalizer) Fix(s *tokens.Stream) bool {
+ return applyToDocblocks(s, func(d *docblock) bool {
+ changed := false
+ for i, l := range d.inner {
+ fixed := inlineTagRe.ReplaceAllStringFunc(l.content, func(m string) string {
+ sub := inlineTagRe.FindStringSubmatch(m)
+ tag := sub[1]
+ doc := strings.TrimSpace(sub[2])
+ if doc == "" {
+ return "{@" + tag + "}"
+ }
+ return "{@" + tag + " " + doc + "}"
+ })
+ if fixed != l.content {
+ d.inner[i].content = fixed
+ changed = true
+ }
+ }
+ return changed
+ })
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocNoDuplicateTypesFixer.php
+//
+// PhpdocNoDuplicateTypes removes duplicate members from a phpdoc type union
+// ("int|int|string" -> "int|string"), comparing case-insensitively and keeping
+// the first occurrence.
+type PhpdocNoDuplicateTypes struct{}
+
+func (PhpdocNoDuplicateTypes) Name() string {
+ return `PhpCsFixer\Fixer\Phpdoc\PhpdocNoDuplicateTypesFixer`
+}
+
+func (PhpdocNoDuplicateTypes) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocNoDuplicateTypesFixer.php"
+}
+
+func (PhpdocNoDuplicateTypes) Fix(s *tokens.Stream) bool {
+ return applyToDocblocks(s, func(d *docblock) bool {
+ changed := false
+ for i, l := range d.inner {
+ trimmed := strings.TrimLeft(l.content, " ")
+ m := phpdocTypeTagRe.FindStringSubmatch(trimmed)
+ if m == nil {
+ continue
+ }
+ newType := dedupeUnionTypes(m[2])
+ if newType == m[2] {
+ continue
+ }
+ lead := l.content[:len(l.content)-len(trimmed)]
+ d.inner[i].content = lead + m[1] + newType + m[3]
+ changed = true
+ }
+ return changed
+ })
+}
+
+// dedupeUnionTypes removes case-insensitive duplicate members from a "|" union,
+// keeping the first spelling. Types with nested brackets (generics, DNF) are left
+// untouched, since a "|" inside them is not a top-level separator.
+func dedupeUnionTypes(typ string) string {
+ nullable := strings.HasPrefix(typ, "?")
+ body := strings.TrimPrefix(typ, "?")
+ if strings.ContainsAny(body, "<>(){}[]") {
+ return typ
+ }
+ parts := strings.Split(body, "|")
+ if len(parts) < 2 {
+ return typ
+ }
+ seen := make(map[string]bool, len(parts))
+ kept := parts[:0:0]
+ for _, p := range parts {
+ key := strings.ToLower(p)
+ if seen[key] {
+ continue
+ }
+ seen[key] = true
+ kept = append(kept, p)
+ }
+ out := strings.Join(kept, "|")
+ if nullable {
+ out = "?" + out
+ }
+ return out
+}
diff --git a/blink/internal/fixer/rules/gen_phpdoc_tags_test.go b/blink/internal/fixer/rules/gen_phpdoc_tags_test.go
new file mode 100644
index 0000000000..78452c1680
--- /dev/null
+++ b/blink/internal/fixer/rules/gen_phpdoc_tags_test.go
@@ -0,0 +1,96 @@
+package rules
+
+import (
+ "testing"
+
+ "blink/internal/fixer"
+)
+
+func TestGenPhpdocTagsTagCasing(t *testing.T) {
+ src := " $a\n */\nfunction f($a) {}"); changed {
+ t.Fatal("nested generic type must not change")
+ }
+}
+
+// TestGenPhpdocTagsSourceURLs verifies every SourceURL matches SourceURLFor(Name()).
+func TestGenPhpdocTagsSourceURLs(t *testing.T) {
+ fixers := []fixer.Fixer{
+ PhpdocTagCasing{},
+ PhpdocInlineTagNormalizer{},
+ PhpdocNoDuplicateTypes{},
+ }
+ for _, f := range fixers {
+ if got, want := f.SourceURL(), fixer.SourceURLFor(f.Name()); got != want {
+ t.Errorf("%s: SourceURL %q != SourceURLFor %q", f.Name(), got, want)
+ }
+ }
+}
diff --git a/blink/internal/fixer/rules/gen_phpdoc_var.go b/blink/internal/fixer/rules/gen_phpdoc_var.go
new file mode 100644
index 0000000000..6195be48e5
--- /dev/null
+++ b/blink/internal/fixer/rules/gen_phpdoc_var.go
@@ -0,0 +1,121 @@
+package rules
+
+import (
+ "regexp"
+ "slices"
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// nextCodeIndex returns the first non-whitespace, non-comment token after i, or
+// -1. It mirrors PHP-CS-Fixer's getNextMeaningfulToken.
+func nextCodeIndex(s *tokens.Stream, i int) int {
+ for j := i + 1; j < s.Len(); j++ {
+ switch s.At(j).Kind {
+ case token.Whitespace, token.Comment, token.DocComment:
+ continue
+ }
+ return j
+ }
+ return -1
+}
+
+func isKeywordValue(t token.Token, vals ...string) bool {
+ if t.Kind != token.Keyword {
+ return false
+ }
+ return slices.Contains(vals, strings.ToLower(t.Value))
+}
+
+// isPropertyModifier reports whether t is an access/property modifier keyword,
+// i.e. the docblock in front of it documents a class property.
+func isPropertyModifier(t token.Token) bool {
+ return isKeywordValue(t, "private", "protected", "public", "var", "readonly")
+}
+
+var (
+ varWithoutNameTagRe = regexp.MustCompile(`(?i)^@(?:var|type)(?:\s|$)`)
+ varNameRe = regexp.MustCompile(` \$[A-Za-z_\x{0080}-\x{00FF}][A-Za-z0-9_\x{0080}-\x{00FF}]*`)
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocVarWithoutNameFixer.php
+//
+// PhpdocVarWithoutName removes the variable name from @var/@type tags in a docblock
+// documenting a class property, e.g. "@var int $bar" -> "@var int". Conservative:
+// docblocks containing array-shape braces are left untouched.
+type PhpdocVarWithoutName struct{}
+
+func (PhpdocVarWithoutName) Name() string {
+ return `PhpCsFixer\Fixer\Phpdoc\PhpdocVarWithoutNameFixer`
+}
+
+func (PhpdocVarWithoutName) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocVarWithoutNameFixer.php"
+}
+
+func (PhpdocVarWithoutName) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ if s.At(i).Kind != token.DocComment {
+ continue
+ }
+ next := nextCodeIndex(s, i)
+ if next < 0 {
+ continue
+ }
+ // "static public $foo" - skip the leading static.
+ if isKeywordValue(s.At(next), "static") {
+ next = nextCodeIndex(s, next)
+ if next < 0 {
+ continue
+ }
+ }
+ if !isPropertyModifier(s.At(next)) {
+ continue
+ }
+ d, ok := parseDoc(s.At(i).Value)
+ if !ok {
+ continue
+ }
+ if docblockHasBraces(d) {
+ continue
+ }
+ if fixVarNames(&d) {
+ s.SetValue(i, d.render())
+ changed = true
+ }
+ }
+ return changed
+}
+
+func docblockHasBraces(d docblock) bool {
+ for _, l := range d.inner {
+ if strings.ContainsAny(l.content, "{}") {
+ return true
+ }
+ }
+ return false
+}
+
+// fixVarNames strips " $name" (but never " $this") from @var/@type lines.
+func fixVarNames(d *docblock) bool {
+ changed := false
+ for i, l := range d.inner {
+ if !varWithoutNameTagRe.MatchString(strings.TrimSpace(l.content)) {
+ continue
+ }
+ newContent := varNameRe.ReplaceAllStringFunc(l.content, func(m string) string {
+ if m == " $this" {
+ return m
+ }
+ return ""
+ })
+ if newContent != l.content {
+ d.inner[i].content = newContent
+ changed = true
+ }
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/gen_phpdoc_var_test.go b/blink/internal/fixer/rules/gen_phpdoc_var_test.go
new file mode 100644
index 0000000000..0b03641959
--- /dev/null
+++ b/blink/internal/fixer/rules/gen_phpdoc_var_test.go
@@ -0,0 +1,76 @@
+package rules
+
+import (
+ "testing"
+
+ "blink/internal/fixer"
+)
+
+// assertFix runs r once and checks the output and changed flag.
+func assertFix(t *testing.T, r fixerRule, src, want string, wantChanged bool) {
+ t.Helper()
+ got, changed := apply(t, r, src)
+ if changed != wantChanged || got != want {
+ t.Fatalf("changed=%v (want %v)\n got: %q\nwant: %q", changed, wantChanged, got, want)
+ }
+ // idempotent: a second pass over the output must be a no-op
+ if got2, changed2 := apply(t, r, got); changed2 || got2 != got {
+ t.Fatalf("not idempotent: changed=%v got=%q", changed2, got2)
+ }
+}
+
+func TestGenPhpdocVarWithoutName(t *testing.T) {
+ f := PhpdocVarWithoutName{}
+
+ // single-line property docblock
+ assertFix(t, f,
+ "= 0 && s.At(q).Kind == token.Punct && s.At(q).Value == "&" {
+ q = prevSignificantIndex(s, q)
+ }
+ return q >= 0 && s.At(q).Kind == token.Keyword && strings.ToLower(s.At(q).Value) == "function"
+ }
+ return false
+}
+
+// enclosingFuncParamOpen returns the index of the "(" of the function parameter
+// list that directly encloses i, or -1. It stops at a "{", "}" or ";" reached at
+// paren depth zero so it never crosses out of the signature into a body.
+func enclosingFuncParamOpen(s *tokens.Stream, i int) int {
+ depth := 0
+ for j := i - 1; j >= 0; j-- {
+ t := s.At(j)
+ if t.Kind != token.Punct {
+ continue
+ }
+ switch t.Value {
+ case ")":
+ depth++
+ case "(":
+ if depth == 0 {
+ if isFunctionParamOpen(s, j) {
+ return j
+ }
+ return -1
+ }
+ depth--
+ case "{", "}", ";":
+ if depth == 0 {
+ return -1
+ }
+ }
+ }
+ return -1
+}
+
+// isNullableTypePos reports whether the "?" at i is a nullable-type marker rather
+// than a ternary. A nullable "?" follows a type-position token (":", "(", ",",
+// "|" or a visibility/modifier keyword); a ternary "?" follows an expression.
+func isNullableTypePos(s *tokens.Stream, i int) bool {
+ p := prevSignificantIndex(s, i)
+ if p < 0 {
+ return false
+ }
+ pt := s.At(p)
+ if pt.Kind == token.Punct {
+ switch pt.Value {
+ case ":", "(", ",", "|":
+ return true
+ }
+ return false
+ }
+ if pt.Kind == token.Keyword {
+ switch strings.ToLower(pt.Value) {
+ case "public", "private", "protected", "readonly", "static", "var":
+ return true
+ }
+ }
+ return false
+}
+
+// isTypeNameToken reports whether t can name a type (a class name / Ident, or a
+// native type that happens to lex as a Keyword).
+func isTypeNameToken(t token.Token) bool {
+ if t.Kind == token.Ident {
+ return true
+ }
+ if t.Kind == token.Keyword {
+ return nativeTypeNames[strings.ToLower(t.Value)]
+ }
+ return false
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/CompactNullableTypeDeclarationFixer.php
+//
+// CompactNullableTypeDeclaration removes the whitespace between a nullable "?"
+// and its type ("? int" -> "?int"). It only touches a "?" in a type position, so
+// ternary, elvis ("?:"), null-coalesce ("??") and nullsafe ("?->") are left alone.
+type CompactNullableTypeDeclaration struct{}
+
+func (CompactNullableTypeDeclaration) Name() string {
+ return `PhpCsFixer\Fixer\Whitespace\CompactNullableTypeDeclarationFixer`
+}
+
+func (CompactNullableTypeDeclaration) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/CompactNullableTypeDeclarationFixer.php"
+}
+
+func (CompactNullableTypeDeclaration) 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 != "?" {
+ continue
+ }
+ if !isNullableTypePos(s, i) {
+ continue
+ }
+ n := nextSignificantIndex(s, i)
+ if n < 0 || !isTypeNameToken(s.At(n)) {
+ continue
+ }
+ // drop the single-line whitespace between "?" and the type
+ for j := n - 1; j > i; j-- {
+ if s.At(j).Kind == token.Whitespace && !hasNewline(s.At(j).Value) {
+ s.RemoveAt(j)
+ changed = true
+ }
+ }
+ }
+ return changed
+}
+
+// inReturnType reports whether the type token at i sits in a return-type
+// position, i.e. scanning left across type parts ("?", "|", "&", "\", names)
+// reaches a return-type colon.
+func inReturnType(s *tokens.Stream, i int) bool {
+ j := i
+ for {
+ p := prevSignificantIndex(s, j)
+ if p < 0 {
+ return false
+ }
+ pt := s.At(p)
+ if pt.Kind == token.Punct && pt.Value == ":" {
+ return isReturnTypeColon(s, p)
+ }
+ if pt.Kind == token.Ident ||
+ (pt.Kind == token.Punct && (pt.Value == "?" || pt.Value == "|" || pt.Value == "&" || pt.Value == `\`)) {
+ j = p
+ continue
+ }
+ return false
+ }
+}
+
+// isFunctionSignatureType reports whether the token at i is a type in a function
+// signature: a parameter type inside the parameter list, or a return type.
+func isFunctionSignatureType(s *tokens.Stream, i int) bool {
+ if enclosingFuncParamOpen(s, i) >= 0 && typeContextPrev(s, i) && typeContextNext(s, i) {
+ return true
+ }
+ return inReturnType(s, i)
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Casing/NativeFunctionTypeDeclarationCasingFixer.php
+//
+// NativeFunctionTypeDeclarationCasing lowercases native types used in function
+// parameter and return type declarations ("function f(INT $x): STRING" -> "int",
+// "string"). It only rewrites reserved native type names, which cannot name a
+// class, and only in a function-signature type position.
+type NativeFunctionTypeDeclarationCasing struct{}
+
+func (NativeFunctionTypeDeclarationCasing) Name() string {
+ return `PhpCsFixer\Fixer\Casing\NativeFunctionTypeDeclarationCasingFixer`
+}
+
+func (NativeFunctionTypeDeclarationCasing) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Casing/NativeFunctionTypeDeclarationCasingFixer.php"
+}
+
+func (NativeFunctionTypeDeclarationCasing) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ if t.Kind != token.Ident && t.Kind != token.Keyword {
+ continue
+ }
+ lower := strings.ToLower(t.Value)
+ if !nativeTypeNames[lower] || t.Value == lower {
+ continue
+ }
+ if !isFunctionSignatureType(s, i) {
+ continue
+ }
+ s.SetValue(i, lower)
+ changed = true
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/gen_types_test.go b/blink/internal/fixer/rules/gen_types_test.go
new file mode 100644
index 0000000000..9de7e66685
--- /dev/null
+++ b/blink/internal/fixer/rules/gen_types_test.go
@@ -0,0 +1,73 @@
+package rules
+
+import (
+ "testing"
+
+ "blink/internal/fixer"
+)
+
+func TestGenTypesCompactNullableTypeDeclaration(t *testing.T) {
+ cases := []struct {
+ src, want string
+ changed bool
+ }{
+ {"c;", "c;", false},
+ }
+ for _, c := range cases {
+ got, changed := apply(t, CompactNullableTypeDeclaration{}, c.src)
+ if got != c.want || changed != c.changed {
+ t.Fatalf("src=%q changed=%v got=%q want=%q", c.src, changed, got, c.want)
+ }
+ if again, _ := apply(t, CompactNullableTypeDeclaration{}, got); again != got {
+ t.Fatalf("not idempotent: %q -> %q", got, again)
+ }
+ }
+}
+
+func TestGenTypesNativeFunctionTypeDeclarationCasing(t *testing.T) {
+ cases := []struct {
+ src, want string
+ changed bool
+ }{
+ {" %q", got, again)
+ }
+ }
+}
+
+func TestGenTypesSourceURLs(t *testing.T) {
+ fixers := []fixer.Fixer{
+ CompactNullableTypeDeclaration{},
+ NativeFunctionTypeDeclarationCasing{},
+ }
+ for _, f := range fixers {
+ if want := fixer.SourceURLFor(f.Name()); f.SourceURL() != want {
+ t.Fatalf("%T SourceURL()=%q want %q", f, f.SourceURL(), want)
+ }
+ }
+}
diff --git a/blink/internal/fixer/rules/gen_ws.go b/blink/internal/fixer/rules/gen_ws.go
new file mode 100644
index 0000000000..91bcc71874
--- /dev/null
+++ b/blink/internal/fixer/rules/gen_ws.go
@@ -0,0 +1,198 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Basic/EncodingFixer.php
+//
+// Encoding removes a leading UTF-8 BOM (EF BB BF) from the start of the file.
+type Encoding struct{}
+
+const utf8BOM = "\uFEFF"
+
+func (Encoding) Name() string {
+ return `PhpCsFixer\Fixer\Basic\EncodingFixer`
+}
+
+func (Encoding) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Basic/EncodingFixer.php"
+}
+
+func (Encoding) Fix(s *tokens.Stream) bool {
+ if s.Len() == 0 {
+ return false
+ }
+ v := s.At(0).Value
+ if !strings.HasPrefix(v, utf8BOM) {
+ return false
+ }
+ rest := v[len(utf8BOM):]
+ if rest == "" {
+ s.RemoveAt(0)
+ } else {
+ s.SetValue(0, rest)
+ }
+ return true
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/LanguageConstruct/DeclareParenthesesFixer.php
+//
+// DeclareParentheses removes whitespace around a declare() header's parentheses:
+// "declare ( strict_types=1 )" -> "declare(strict_types=1)".
+type DeclareParentheses struct{}
+
+func (DeclareParentheses) Name() string {
+ return `PhpCsFixer\Fixer\LanguageConstruct\DeclareParenthesesFixer`
+}
+
+func (DeclareParentheses) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/LanguageConstruct/DeclareParenthesesFixer.php"
+}
+
+func (DeclareParentheses) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ if t.Kind != token.Keyword || !strings.EqualFold(t.Value, "declare") {
+ continue
+ }
+ op := i + 1
+ wsBetween := -1
+ if op < s.Len() && s.At(op).Kind == token.Whitespace {
+ wsBetween = op
+ op++
+ }
+ if op >= s.Len() || s.At(op).Kind != token.Punct || s.At(op).Value != "(" {
+ continue
+ }
+ cp := s.MatchForward(op)
+ if cp < 0 {
+ continue
+ }
+ // collect whitespace to drop, delete high-to-low to keep indices valid
+ var drop []int
+ if cp-1 > op && s.At(cp-1).Kind == token.Whitespace {
+ drop = append(drop, cp-1)
+ }
+ if op+1 < cp && s.At(op+1).Kind == token.Whitespace {
+ drop = append(drop, op+1)
+ }
+ if wsBetween >= 0 {
+ drop = append(drop, wsBetween)
+ }
+ for j := 0; j < len(drop); j++ {
+ for k := j + 1; k < len(drop); k++ {
+ if drop[k] > drop[j] {
+ drop[j], drop[k] = drop[k], drop[j]
+ }
+ }
+ }
+ for _, idx := range drop {
+ s.RemoveAt(idx)
+ changed = true
+ }
+ }
+ return changed
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Comment/MultilineCommentOpeningClosingFixer.php
+//
+// MultilineCommentOpeningClosing normalizes block-comment delimiters: an opening
+// with extra asterisks ("/***") collapses to "/*" (doc blocks keep their "/**"),
+// and a closing with extra asterisks ("***/") collapses to "*/".
+type MultilineCommentOpeningClosing struct{}
+
+func (MultilineCommentOpeningClosing) Name() string {
+ return `PhpCsFixer\Fixer\Comment\MultilineCommentOpeningClosingFixer`
+}
+
+func (MultilineCommentOpeningClosing) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Comment/MultilineCommentOpeningClosingFixer.php"
+}
+
+func (MultilineCommentOpeningClosing) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := range s.Len() {
+ t := s.At(i)
+ if t.Kind != token.Comment && t.Kind != token.DocComment {
+ continue
+ }
+ v := t.Value
+ if !strings.HasPrefix(v, "/*") {
+ continue
+ }
+ nv := v
+ // the opening fix applies to plain comments only, never to doc blocks
+ if !isDocBlockOpening(nv) {
+ nv = fixCommentOpening(nv)
+ }
+ nv = fixCommentClosing(nv)
+ if nv != v {
+ s.SetValue(i, nv)
+ changed = true
+ }
+ }
+ return changed
+}
+
+// isDocBlockOpening mirrors PHP's tokenizer: "/**" followed by a non-asterisk,
+// non-slash character is a doc block; "/***" or "/**/" is a plain comment.
+func isDocBlockOpening(v string) bool {
+ return len(v) >= 4 && v[:3] == "/**" && v[3] != '*' && v[3] != '/'
+}
+
+// fixCommentOpening implements /^\/\*{2,}(?!\/)/ -> "/*".
+func fixCommentOpening(v string) string {
+ if !strings.HasPrefix(v, "/*") {
+ return v
+ }
+ a := 0
+ for 1+a < len(v) && v[1+a] == '*' {
+ a++
+ }
+ if a < 2 {
+ return v
+ }
+ var next byte
+ if 1+a < len(v) {
+ next = v[1+a]
+ }
+ if next != '/' {
+ return "/*" + v[1+a:]
+ }
+ if a >= 3 {
+ return "/*" + v[a:]
+ }
+ return v
+}
+
+// fixCommentClosing implements /(? "*/".
+func fixCommentClosing(v string) string {
+ if !strings.HasSuffix(v, "/") {
+ return v
+ }
+ k := len(v) - 2
+ b := 0
+ for k >= 0 && v[k] == '*' {
+ b++
+ k--
+ }
+ if b < 2 {
+ return v
+ }
+ var prev byte
+ if k >= 0 {
+ prev = v[k]
+ }
+ if prev != '/' {
+ return v[:k+1] + "*/"
+ }
+ if b >= 3 {
+ return v[:k+2] + "*/"
+ }
+ return v
+}
diff --git a/blink/internal/fixer/rules/gen_ws_test.go b/blink/internal/fixer/rules/gen_ws_test.go
new file mode 100644
index 0000000000..1e8da7ce49
--- /dev/null
+++ b/blink/internal/fixer/rules/gen_ws_test.go
@@ -0,0 +1,69 @@
+package rules
+
+import "testing"
+
+func TestGenWsEncoding(t *testing.T) {
+ got, changed := apply(t, Encoding{}, "\uFEFFdeclare ( 1 );"); changed {
+ t.Fatal("method call declare() must not change")
+ }
+ idempotent(t, DeclareParentheses{}, " "use Foo\Bar;", including "use function" / "use const".
+type NoLeadingImportSlash struct{}
+
+func (NoLeadingImportSlash) Name() string {
+ return `PhpCsFixer\Fixer\Import\NoLeadingImportSlashFixer`
+}
+
+func (NoLeadingImportSlash) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Import/NoLeadingImportSlashFixer.php"
+}
+
+func (NoLeadingImportSlash) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ if t.Kind != token.Keyword || strings.ToLower(t.Value) != "use" {
+ continue
+ }
+ j := skipWhitespace(s, i+1)
+ if j < s.Len() && s.At(j).Kind == token.Keyword {
+ if lw := strings.ToLower(s.At(j).Value); lw == "function" || lw == "const" {
+ j = skipWhitespace(s, j+1)
+ }
+ }
+ if j < s.Len() && s.At(j).Kind == token.Punct && s.At(j).Value == `\` {
+ s.RemoveAt(j)
+ changed = true
+ }
+ }
+ return changed
+}
+
+func skipWhitespace(s *tokens.Stream, i int) int {
+ for i < s.Len() && s.At(i).Kind == token.Whitespace {
+ i++
+ }
+ return i
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Import/SingleImportPerStatementFixer.php
+//
+// SingleImportPerStatement splits a grouped import into one per line:
+// "use A, B;" -> "use A;\nuse B;". Group imports ("use A\{B, C};") and closure
+// "use (...)" are left alone.
+type SingleImportPerStatement struct{}
+
+func (SingleImportPerStatement) Name() string {
+ return `PhpCsFixer\Fixer\Import\SingleImportPerStatementFixer`
+}
+
+func (SingleImportPerStatement) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Import/SingleImportPerStatementFixer.php"
+}
+
+func (SingleImportPerStatement) Fix(s *tokens.Stream) bool {
+ return splitUseStatements(s, false)
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/SingleTraitInsertPerStatementFixer.php
+//
+// SingleTraitInsertPerStatement splits a grouped trait use inside a class body
+// into one per line: "use A, B;" -> "use A;\nuse B;".
+type SingleTraitInsertPerStatement struct{}
+
+func (SingleTraitInsertPerStatement) Name() string {
+ return `PhpCsFixer\Fixer\ClassNotation\SingleTraitInsertPerStatementFixer`
+}
+
+func (SingleTraitInsertPerStatement) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/SingleTraitInsertPerStatementFixer.php"
+}
+
+func (SingleTraitInsertPerStatement) Fix(s *tokens.Stream) bool {
+ return splitUseStatements(s, true)
+}
+
+// splitUseStatements splits grouped "use" statements one per line. inClass picks
+// the scope: false handles top-level imports, true handles in-class trait use.
+func splitUseStatements(s *tokens.Stream, inClass bool) bool {
+ changed := false
+ i := 0
+ for i < s.Len() {
+ if s.At(i).Kind != token.Keyword || strings.ToLower(s.At(i).Value) != "use" {
+ i++
+ continue
+ }
+ next, c := splitUseAt(s, i, inClass)
+ i = next
+ if c {
+ changed = true
+ }
+ }
+ return changed
+}
+
+// splitUseAt splits the "use" statement at index i when its scope matches
+// inClass. It returns the index to continue from and whether it changed.
+func splitUseAt(s *tokens.Stream, i int, inClass bool) (int, bool) {
+ if inClassLikeBody(s, i) != inClass {
+ return i + 1, false
+ }
+
+ j := skipWhitespace(s, i+1)
+ modifier := ""
+ if j < s.Len() && s.At(j).Kind == token.Keyword {
+ if lw := strings.ToLower(s.At(j).Value); lw == "function" || lw == "const" {
+ modifier = s.At(j).Value
+ j = skipWhitespace(s, j+1)
+ }
+ }
+ if j < s.Len() && s.At(j).Kind == token.Punct && s.At(j).Value == "(" {
+ return i + 1, false // closure "use (...)"
+ }
+
+ semi, group := -1, false
+ var commas []int
+ for k := j; k < s.Len(); k++ {
+ if s.At(k).Kind != token.Punct {
+ continue
+ }
+ switch s.At(k).Value {
+ case "{":
+ group = true
+ case ";":
+ semi = k
+ case ",":
+ commas = append(commas, k)
+ }
+ if group || semi >= 0 {
+ break
+ }
+ }
+ if group || semi < 0 || len(commas) == 0 {
+ return i + 1, false
+ }
+
+ indent := indentBefore(s, i)
+ parts := splitImportParts(s, j, semi, commas)
+
+ var repl []token.Token
+ for p, part := range parts {
+ if p > 0 {
+ repl = append(repl, token.Token{Kind: token.Whitespace, Value: "\n" + indent})
+ }
+ repl = append(repl, token.Token{Kind: token.Keyword, Value: "use"}, token.Token{Kind: token.Whitespace, Value: " "})
+ if modifier != "" {
+ repl = append(repl, token.Token{Kind: token.Keyword, Value: modifier}, token.Token{Kind: token.Whitespace, Value: " "})
+ }
+ repl = append(repl, part...)
+ repl = append(repl, token.Token{Kind: token.Punct, Value: ";"})
+ }
+ s.ReplaceRange(i, semi, repl)
+ return i + len(repl), true
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Import/SingleLineAfterImportsFixer.php
+//
+// SingleLineAfterImports ensures one blank line after the last import in a block
+// of consecutive top-level use statements.
+type SingleLineAfterImports struct{}
+
+func (SingleLineAfterImports) Name() string {
+ return `PhpCsFixer\Fixer\Import\SingleLineAfterImportsFixer`
+}
+
+func (SingleLineAfterImports) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Import/SingleLineAfterImportsFixer.php"
+}
+
+func (SingleLineAfterImports) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ if t.Kind != token.Keyword || strings.ToLower(t.Value) != "use" {
+ continue
+ }
+ if inClassLikeBody(s, i) {
+ continue // trait use
+ }
+ j := skipWhitespace(s, i+1)
+ if j < s.Len() && s.At(j).Kind == token.Punct && s.At(j).Value == "(" {
+ continue // closure use
+ }
+ semi := -1
+ for k := i + 1; k < s.Len(); k++ {
+ if s.At(k).Kind == token.Punct && s.At(k).Value == ";" {
+ semi = k
+ break
+ }
+ }
+ if semi < 0 || semi+1 >= s.Len() {
+ continue
+ }
+ nx := skipWhitespace(s, semi+1)
+ if nx >= s.Len() {
+ continue
+ }
+ next := s.At(nx)
+ // only the last import in a block, and only when code follows
+ if next.Kind == token.Keyword && strings.ToLower(next.Value) == "use" {
+ continue
+ }
+ if next.Kind == token.Punct && next.Value == "}" {
+ continue
+ }
+ if s.At(semi+1).Kind == token.Whitespace &&
+ hasNewline(s.At(semi+1).Value) && s.At(semi+1).Value != "\n\n" {
+ s.SetValue(semi+1, "\n\n")
+ changed = true
+ }
+ }
+ return changed
+}
+
+// indentBefore returns the indentation (spaces/tabs after the last newline) of
+// the token preceding index i.
+func indentBefore(s *tokens.Stream, i int) string {
+ if i == 0 || s.At(i-1).Kind != token.Whitespace {
+ return ""
+ }
+ v := s.At(i - 1).Value
+ if idx := strings.LastIndexByte(v, '\n'); idx >= 0 {
+ return v[idx+1:]
+ }
+ return ""
+}
+
+// splitImportParts slices tokens (j, semi) at the given comma indices, trimming
+// surrounding whitespace from each part.
+func splitImportParts(s *tokens.Stream, j, semi int, commas []int) [][]token.Token {
+ bounds := append([]int{j - 1}, commas...)
+ bounds = append(bounds, semi)
+ var parts [][]token.Token
+ for b := 0; b+1 < len(bounds); b++ {
+ var part []token.Token
+ for k := bounds[b] + 1; k < bounds[b+1]; k++ {
+ part = append(part, s.At(k))
+ }
+ parts = append(parts, trimWhitespace(part))
+ }
+ return parts
+}
+
+// inClassLikeBody reports whether index i is directly inside a class, interface,
+// trait or enum body (as opposed to file scope or a bracketed namespace).
+func inClassLikeBody(s *tokens.Stream, i int) bool {
+ depth := 0
+ for j := i - 1; j >= 0; j-- {
+ t := s.At(j)
+ if t.Kind != token.Punct {
+ continue
+ }
+ switch t.Value {
+ case "}":
+ depth++
+ case "{":
+ if depth == 0 {
+ return braceOpensClassLike(s, j)
+ }
+ depth--
+ }
+ }
+ return false
+}
+
+// braceOpensClassLike reports whether the "{" at index brace opens a class-like
+// body, by finding a class/interface/trait/enum keyword in its header.
+func braceOpensClassLike(s *tokens.Stream, brace int) bool {
+ for j := brace - 1; j >= 0; j-- {
+ t := s.At(j)
+ if t.Kind == token.Punct && (t.Value == ";" || t.Value == "{" || t.Value == "}") {
+ return false
+ }
+ if t.Kind == token.Keyword && classLikeKeywords[strings.ToLower(t.Value)] {
+ return true
+ }
+ }
+ return false
+}
+
+func trimWhitespace(toks []token.Token) []token.Token {
+ for len(toks) > 0 && toks[0].Kind == token.Whitespace {
+ toks = toks[1:]
+ }
+ for len(toks) > 0 && toks[len(toks)-1].Kind == token.Whitespace {
+ toks = toks[:len(toks)-1]
+ }
+ return toks
+}
diff --git a/blink/internal/fixer/rules/imports_order.go b/blink/internal/fixer/rules/imports_order.go
new file mode 100644
index 0000000000..c26667b968
--- /dev/null
+++ b/blink/internal/fixer/rules/imports_order.go
@@ -0,0 +1,238 @@
+package rules
+
+import (
+ "sort"
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+type importStmt struct {
+ start, semi int
+ kind string // "class", "function" or "const"
+ pathKey string // lowercased imported path, for alphabetical ordering
+}
+
+// importSortKey returns the lowercased imported path of a use statement (after an
+// optional "function"/"const", up to ";" or " as "), used as the alphabetical
+// sort key by ECS's default OrderedImports (sort_algorithm: alpha).
+func importSortKey(s *tokens.Stream, useIdx, semi int) string {
+ j := skipWhitespace(s, useIdx+1)
+ if j < s.Len() && s.At(j).Kind == token.Keyword {
+ if lw := strings.ToLower(s.At(j).Value); lw == "function" || lw == "const" {
+ j = skipWhitespace(s, j+1)
+ }
+ }
+ var b strings.Builder
+ for k := j; k < semi; k++ {
+ t := s.At(k)
+ if t.Kind == token.Keyword && strings.ToLower(t.Value) == "as" {
+ break
+ }
+ if t.Kind == token.Whitespace {
+ continue
+ }
+ b.WriteString(t.Value)
+ }
+ // compare segment by segment: "\" must sort before any other character so
+ // "Rector\Php\X" precedes "Rector\Php71\Y" (ECS's alpha comparator)
+ return strings.ReplaceAll(strings.ToLower(b.String()), `\`, "\x00")
+}
+
+// importKindRank orders use statements by kind for ECS's default imports_order
+// (['class', 'function', 'const']).
+func importKindRank(kind string) int {
+ switch kind {
+ case "function":
+ return 1
+ case "const":
+ return 2
+ default: // class
+ return 0
+ }
+}
+
+func useKind(s *tokens.Stream, useIdx int) string {
+ j := skipWhitespace(s, useIdx+1)
+ if j < s.Len() && s.At(j).Kind == token.Keyword {
+ switch strings.ToLower(s.At(j).Value) {
+ case "function":
+ return "function"
+ case "const":
+ return "const"
+ }
+ }
+ return "class"
+}
+
+// collectImportRun gathers a maximal run of consecutive top-level use statements
+// starting at start, separated only by whitespace. Group imports and closure use
+// stop the run.
+func collectImportRun(s *tokens.Stream, start int) []importStmt {
+ var stmts []importStmt
+ k := start
+ for k < s.Len() {
+ t := s.At(k)
+ if t.Kind != token.Keyword || strings.ToLower(t.Value) != "use" || inClassLikeBody(s, k) {
+ break
+ }
+ j := skipWhitespace(s, k+1)
+ if j < s.Len() && s.At(j).Kind == token.Punct && s.At(j).Value == "(" {
+ break // closure use
+ }
+ semi, group := -1, false
+ for m := k + 1; m < s.Len(); m++ {
+ if s.At(m).Kind != token.Punct {
+ continue
+ }
+ if s.At(m).Value == "{" {
+ group = true
+ break
+ }
+ if s.At(m).Value == ";" {
+ semi = m
+ break
+ }
+ }
+ if group || semi < 0 {
+ break
+ }
+ stmts = append(stmts, importStmt{start: k, semi: semi, kind: useKind(s, k), pathKey: importSortKey(s, k, semi)})
+ n := skipWhitespace(s, semi+1)
+ if n < s.Len() && s.At(n).Kind == token.Keyword && strings.ToLower(s.At(n).Value) == "use" {
+ k = n
+ continue
+ }
+ break
+ }
+ return stmts
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Import/OrderedImportsFixer.php
+//
+// OrderedImports sorts a run of use statements by kind (class, then function,
+// then const) and alphabetically by imported path within each kind
+// (case-insensitive), matching ECS's psr12 config (sort_algorithm: alpha,
+// imports_order: ['class', 'function', 'const']).
+type OrderedImports struct{}
+
+func (OrderedImports) Name() string {
+ return `PhpCsFixer\Fixer\Import\OrderedImportsFixer`
+}
+
+func (OrderedImports) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Import/OrderedImportsFixer.php"
+}
+
+func (OrderedImports) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i < s.Len(); {
+ t := s.At(i)
+ if t.Kind != token.Keyword || strings.ToLower(t.Value) != "use" || inClassLikeBody(s, i) {
+ i++
+ continue
+ }
+ run := collectImportRun(s, i)
+ if len(run) < 2 {
+ i++
+ continue
+ }
+ newEnd, c := reorderImports(s, run)
+ if c {
+ changed = true
+ }
+ i = newEnd
+ }
+ return changed
+}
+
+func reorderImports(s *tokens.Stream, run []importStmt) (int, bool) {
+ first := run[0].start
+ last := run[len(run)-1].semi
+ indent := indentBefore(s, first)
+
+ ordered := append([]importStmt(nil), run...)
+ sort.SliceStable(ordered, func(a, b int) bool {
+ // ECS default imports_order: class, then function, then const; alpha within
+ if ra, rb := importKindRank(ordered[a].kind), importKindRank(ordered[b].kind); ra != rb {
+ return ra < rb
+ }
+ return ordered[a].pathKey < ordered[b].pathKey
+ })
+
+ var repl []token.Token
+ for p, st := range ordered {
+ if p > 0 {
+ repl = append(repl, token.Token{Kind: token.Whitespace, Value: "\n" + indent})
+ }
+ for k := st.start; k <= st.semi; k++ {
+ repl = append(repl, s.At(k))
+ }
+ }
+
+ orig := make([]token.Token, 0, last-first+1)
+ for k := first; k <= last; k++ {
+ orig = append(orig, s.At(k))
+ }
+ changed := !tokensEqual(orig, repl)
+ s.ReplaceRange(first, last, repl)
+ return first + len(repl), changed
+}
+
+func tokensEqual(a, b []token.Token) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for i := range a {
+ if a[i].Kind != b[i].Kind || a[i].Value != b[i].Value {
+ return false
+ }
+ }
+ return true
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/BlankLineBetweenImportGroupsFixer.php
+//
+// BlankLineBetweenImportGroups puts a blank line between class, function and
+// const import groups.
+type BlankLineBetweenImportGroups struct{}
+
+func (BlankLineBetweenImportGroups) Name() string {
+ return `PhpCsFixer\Fixer\Whitespace\BlankLineBetweenImportGroupsFixer`
+}
+
+func (BlankLineBetweenImportGroups) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/BlankLineBetweenImportGroupsFixer.php"
+}
+
+func (BlankLineBetweenImportGroups) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i < s.Len(); {
+ t := s.At(i)
+ if t.Kind != token.Keyword || strings.ToLower(t.Value) != "use" || inClassLikeBody(s, i) {
+ i++
+ continue
+ }
+ run := collectImportRun(s, i)
+ if len(run) >= 2 {
+ for p := 1; p < len(run); p++ {
+ if run[p].kind == run[p-1].kind {
+ continue
+ }
+ ws := run[p].start - 1
+ if ws >= 0 && s.At(ws).Kind == token.Whitespace &&
+ hasNewline(s.At(ws).Value) && s.At(ws).Value != "\n\n" {
+ s.SetValue(ws, "\n\n")
+ changed = true
+ }
+ }
+ }
+ if len(run) > 0 {
+ i = run[len(run)-1].semi + 1
+ } else {
+ i++
+ }
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/imports_unused.go b/blink/internal/fixer/rules/imports_unused.go
new file mode 100644
index 0000000000..f40e84956e
--- /dev/null
+++ b/blink/internal/fixer/rules/imports_unused.go
@@ -0,0 +1,295 @@
+package rules
+
+import (
+ "slices"
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+type importInfo struct {
+ start, semi int
+ shortLower string
+ kind string // "class", "function" or "const"
+ fullLower string // full imported path, lowercased, no leading "\"
+ aliased bool
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Import/NoUnusedImportsFixer.php
+//
+// NoUnusedImports removes a top-level "use" import that is never referenced. It
+// matches usage by import kind (class/function/const), treats a name in a comment
+// as used (word-boundary), and removes imports of the current namespace.
+type NoUnusedImports struct{}
+
+func (NoUnusedImports) Name() string {
+ return `PhpCsFixer\Fixer\Import\NoUnusedImportsFixer`
+}
+
+func (NoUnusedImports) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Import/NoUnusedImportsFixer.php"
+}
+
+func (NoUnusedImports) Fix(s *tokens.Stream) bool {
+ var imports []importInfo
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ if t.Kind != token.Keyword || strings.ToLower(t.Value) != "use" || inClassLikeBody(s, i) {
+ continue
+ }
+ j := skipWhitespace(s, i+1)
+ if j < s.Len() && s.At(j).Kind == token.Punct && s.At(j).Value == "(" {
+ continue // closure use
+ }
+ kind := "class"
+ if j < s.Len() && s.At(j).Kind == token.Keyword {
+ switch strings.ToLower(s.At(j).Value) {
+ case "function":
+ kind = "function"
+ case "const":
+ kind = "const"
+ }
+ }
+
+ semi, group := -1, false
+ for k := i + 1; k < s.Len(); k++ {
+ if s.At(k).Kind != token.Punct {
+ continue
+ }
+ if s.At(k).Value == "{" {
+ group = true
+ break
+ }
+ if s.At(k).Value == ";" {
+ semi = k
+ break
+ }
+ }
+ if group || semi < 0 {
+ continue
+ }
+
+ short, full, aliased := importParts(s, i, semi, kind)
+ if short == "" {
+ continue
+ }
+ imports = append(imports, importInfo{
+ start: i, semi: semi, shortLower: strings.ToLower(short),
+ kind: kind, fullLower: strings.ToLower(full), aliased: aliased,
+ })
+ i = semi
+ }
+ if len(imports) == 0 {
+ return false
+ }
+
+ nsLower := strings.ToLower(fileNamespace(s))
+
+ inImport := func(idx int) bool {
+ for _, im := range imports {
+ if idx >= im.start && idx <= im.semi {
+ return true
+ }
+ }
+ return false
+ }
+
+ changed := false
+ for _, im := range slices.Backward(imports) {
+ // an import of the current namespace ("namespace\Name") is redundant
+ redundant := !im.aliased && nsLower != "" &&
+ strings.HasPrefix(im.fullLower, nsLower+`\`) &&
+ !strings.Contains(im.fullLower[len(nsLower)+1:], `\`)
+ if !redundant && importIsUsed(s, im, inImport) {
+ continue
+ }
+ removeImport(s, im)
+ changed = true
+ }
+ return changed
+}
+
+// importIsUsed reports whether the import's short name appears as a matching-kind
+// reference or in a comment (word-boundary).
+func importIsUsed(s *tokens.Stream, im importInfo, inImport func(int) bool) bool {
+ for k := 0; k < s.Len(); k++ {
+ t := s.At(k)
+ if t.Kind == token.Ident && !inImport(k) && strings.ToLower(t.Value) == im.shortLower {
+ if usageMatchesKind(s, k, im.kind) {
+ return true
+ }
+ continue
+ }
+ // a class name colliding with a PHP keyword (enum, list, ...) is lexed as a
+ // keyword; count it when used in a class-reference position
+ if t.Kind == token.Keyword && im.kind == "class" && !inImport(k) &&
+ strings.ToLower(t.Value) == im.shortLower && keywordIsClassRef(s, k) {
+ return true
+ }
+ if (t.Kind == token.Comment || t.Kind == token.DocComment) && commentReferences(t.Value, im.shortLower) {
+ return true
+ }
+ }
+ return false
+}
+
+// usageMatchesKind reports whether the identifier at k is a reference of the
+// given import kind.
+func usageMatchesKind(s *tokens.Stream, k int, kind string) bool {
+ prev, hasPrev := prevSignificant(s, k)
+ if hasPrev {
+ if prev.Kind == token.Punct {
+ switch prev.Value {
+ case `\`, "->", "?->", "::":
+ return false
+ }
+ }
+ if prev.Kind == token.Keyword {
+ switch strings.ToLower(prev.Value) {
+ case "namespace", "function":
+ return false
+ case "const":
+ if nextSignificantValue(s, k) == "=" {
+ return false
+ }
+ }
+ }
+ }
+ prevNew := hasPrev && prev.Kind == token.Keyword && strings.EqualFold(prev.Value, "new")
+ fnCall := nextSignificantValue(s, k) == "(" && !prevNew
+ if kind == "function" {
+ return fnCall
+ }
+ // class and const references are any matching identifier that is not a call
+ return !fnCall
+}
+
+// commentReferences reports whether short appears in v as a whole word, not
+// preceded by an identifier char, "$" or "\".
+func commentReferences(v, short string) bool {
+ lv := strings.ToLower(v)
+ from := 0
+ for {
+ idx := strings.Index(lv[from:], short)
+ if idx < 0 {
+ return false
+ }
+ p := from + idx
+ before := byte(' ')
+ if p > 0 {
+ before = lv[p-1]
+ }
+ after := byte(' ')
+ if p+len(short) < len(lv) {
+ after = lv[p+len(short)]
+ }
+ if !isIdentByte(before) && before != '$' && before != '\\' && !isIdentByte(after) {
+ return true
+ }
+ from = p + 1
+ }
+}
+
+func keywordIsClassRef(s *tokens.Stream, k int) bool {
+ if prev, ok := prevSignificant(s, k); ok {
+ if prev.Kind == token.Keyword {
+ switch strings.ToLower(prev.Value) {
+ case "extends", "implements", "new", "instanceof":
+ return true
+ }
+ }
+ if prev.Kind == token.Punct {
+ switch prev.Value {
+ case "(", ",", ":", "|", "&", "?":
+ return true
+ }
+ }
+ }
+ if n := nextSignificantIndex(s, k); n >= 0 {
+ if s.At(n).Kind == token.Punct && s.At(n).Value == "::" {
+ return true
+ }
+ if s.At(n).Kind == token.Variable {
+ return true
+ }
+ }
+ return false
+}
+
+func isIdentByte(c byte) bool {
+ return c == '_' || (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
+}
+
+// importParts returns the imported short name, full path and whether it is
+// aliased, for the use statement spanning (useIdx, semi).
+func importParts(s *tokens.Stream, useIdx, semi int, kind string) (short, full string, aliased bool) {
+ j := skipWhitespace(s, useIdx+1)
+ if kind != "class" {
+ j = skipWhitespace(s, j+1) // skip "function"/"const"
+ }
+ var path strings.Builder
+ alias := ""
+ inAlias := false
+ for k := j; k < semi; k++ {
+ t := s.At(k)
+ if t.Kind == token.Keyword && strings.ToLower(t.Value) == "as" {
+ inAlias = true
+ aliased = true
+ continue
+ }
+ if t.Kind == token.Whitespace {
+ continue
+ }
+ if inAlias {
+ if t.Kind == token.Ident {
+ alias = t.Value
+ }
+ continue
+ }
+ path.WriteString(t.Value)
+ }
+ full = strings.TrimPrefix(path.String(), `\`)
+ if alias != "" {
+ short = alias
+ } else if i := strings.LastIndex(full, `\`); i >= 0 {
+ short = full[i+1:]
+ } else {
+ short = full
+ }
+ return short, full, aliased
+}
+
+// fileNamespace returns the first top-level namespace name, or "".
+func fileNamespace(s *tokens.Stream) string {
+ for i := 0; i < s.Len(); i++ {
+ if s.At(i).Kind != token.Keyword || strings.ToLower(s.At(i).Value) != "namespace" || memberPrev(s, i) {
+ continue
+ }
+ var b strings.Builder
+ for k := i + 1; k < s.Len(); k++ {
+ t := s.At(k)
+ if t.Kind == token.Punct && (t.Value == ";" || t.Value == "{") {
+ break
+ }
+ if t.Kind == token.Whitespace {
+ continue
+ }
+ b.WriteString(t.Value)
+ }
+ return strings.TrimPrefix(b.String(), `\`)
+ }
+ return ""
+}
+
+func removeImport(s *tokens.Stream, im importInfo) {
+ for r := im.semi; r >= im.start; r-- {
+ s.RemoveAt(r)
+ }
+ // drop just the statement's own line break, keeping any blank line
+ if im.start < s.Len() && s.At(im.start).Kind == token.Whitespace {
+ if v := s.At(im.start).Value; strings.HasPrefix(v, "\n") {
+ s.SetValue(im.start, v[1:])
+ }
+ }
+}
diff --git a/blink/internal/fixer/rules/indentation_braces_test.go b/blink/internal/fixer/rules/indentation_braces_test.go
new file mode 100644
index 0000000000..3a2b1f6e9a
--- /dev/null
+++ b/blink/internal/fixer/rules/indentation_braces_test.go
@@ -0,0 +1,33 @@
+package rules
+
+import "testing"
+
+func TestStatementIndentation(t *testing.T) {
+ got, changed := apply(t, StatementIndentation{}, "foo()\n ->bar();\n"
+ if _, changed := apply(t, StatementIndentation{}, chain); changed {
+ t.Fatal("method chain must not be reindented")
+ }
+ // multiline arguments are continuations - left alone
+ args := "= 0 {
+ pv := s.At(pj)
+ if pv.Kind == token.Punct && (pv.Value == "->" || pv.Value == "?->" || pv.Value == "::" || pv.Value == `\`) {
+ continue
+ }
+ if pv.Kind == token.Keyword && strings.EqualFold(pv.Value, "function") {
+ continue
+ }
+ }
+ op := nextSignificantIndex(s, i)
+ if op < 0 || s.At(op).Kind != token.Punct || s.At(op).Value != "(" {
+ continue
+ }
+ cl := s.MatchForward(op)
+ if cl < 0 {
+ continue
+ }
+ start, neg := i, false
+ if pj >= 0 && s.At(pj).Kind == token.Punct && s.At(pj).Value == "!" {
+ neg, start = true, pj
+ }
+ sites = append(sites, site{start, op, cl, neg})
+ }
+ if len(sites) == 0 {
+ return false
+ }
+ for _, st := range slices.Backward(sites) {
+ a, b := st.argOpen+1, st.argClose-1
+ for a <= b && s.At(a).Kind == token.Whitespace {
+ a++
+ }
+ for b >= a && s.At(b).Kind == token.Whitespace {
+ b--
+ }
+ var arg []token.Token
+ for x := a; x <= b; x++ {
+ arg = append(arg, s.At(x))
+ }
+ opTok := "==="
+ if st.neg {
+ opTok = "!=="
+ }
+ repl := []token.Token{
+ {Kind: token.Ident, Value: "null"},
+ {Kind: token.Whitespace, Value: " "},
+ {Kind: token.Punct, Value: opTok},
+ {Kind: token.Whitespace, Value: " "},
+ }
+ if argHasDepth0Operator(arg) {
+ repl = append(repl, token.Token{Kind: token.Punct, Value: "("})
+ repl = append(repl, arg...)
+ repl = append(repl, token.Token{Kind: token.Punct, Value: ")"})
+ } else {
+ repl = append(repl, arg...)
+ }
+ s.ReplaceRange(st.start, st.argClose, repl)
+ }
+ return true
+}
+
+// argHasDepth0Operator reports whether the argument expression contains a
+// top-level operator (anything but a postfix member/chain), meaning it must keep
+// its parentheses to preserve precedence.
+func argHasDepth0Operator(arg []token.Token) bool {
+ depth := 0
+ for _, t := range arg {
+ if t.Kind == token.Punct {
+ switch t.Value {
+ case "(", "[", "{":
+ depth++
+ continue
+ case ")", "]", "}":
+ depth--
+ continue
+ }
+ if depth == 0 {
+ switch t.Value {
+ case "->", "?->", "::", `\`, "...":
+ default:
+ return true
+ }
+ }
+ }
+ if depth == 0 && t.Kind == token.Keyword {
+ switch strings.ToLower(t.Value) {
+ case "and", "or", "xor", "instanceof":
+ return true
+ }
+ }
+ }
+ return false
+}
diff --git a/blink/internal/fixer/rules/is_null_test.go b/blink/internal/fixer/rules/is_null_test.go
new file mode 100644
index 0000000000..061c95d91a
--- /dev/null
+++ b/blink/internal/fixer/rules/is_null_test.go
@@ -0,0 +1,33 @@
+package rules
+
+import (
+ "testing"
+
+ "blink/internal/fixer"
+)
+
+func TestIsNull(t *testing.T) {
+ f := IsNull{}
+
+ assertFix(t, f, "b());", "b();", true)
+ assertFix(t, f, "is_null($x);", "is_null($x);", false)
+ assertFix(t, f, " 0; i-- {
+ useIdx := lambdaUseIndex(s, i)
+ if useIdx >= 0 {
+ fixLambdaImports(s, useIdx)
+ }
+ }
+ compactEmptyTokens(s)
+ return s.Render() != before
+}
+
+// compactEmptyTokens drops the empty placeholder tokens left by clearing, so the
+// rest of the pipeline sees a normal stream. It never changes the rendered bytes.
+func compactEmptyTokens(s *tokens.Stream) {
+ for i := s.Len() - 1; i >= 0; i-- {
+ if s.At(i).Kind == token.Whitespace && s.At(i).Value == "" {
+ s.RemoveAt(i)
+ }
+ }
+}
+
+// lambdaUseIndex returns the "use" keyword index of the closure declared at
+// index, or -1 when index is not a closure with a use-import list.
+func lambdaUseIndex(s *tokens.Stream, index int) int {
+ if !kwIs(s.At(index), "function") || !isLambdaFunction(s, index) {
+ return -1
+ }
+ u := sigNext(s, index)
+ if u < 0 {
+ return -1
+ }
+ if isPunctVal(s, u, "&") { // by-reference return
+ u = sigNext(s, u)
+ }
+ if u < 0 || !isPunctVal(s, u, "(") {
+ return -1
+ }
+ closeIdx := s.MatchForward(u)
+ if closeIdx < 0 {
+ return -1
+ }
+ useIdx := sigNext(s, closeIdx)
+ if useIdx < 0 || !kwIs(s.At(useIdx), "use") {
+ return -1
+ }
+ return useIdx
+}
+
+// isLambdaFunction reports whether the "function" at index is anonymous.
+func isLambdaFunction(s *tokens.Stream, index int) bool {
+ n := sigNext(s, index)
+ return n >= 0 && (isPunctVal(s, n, "(") || isPunctVal(s, n, "&"))
+}
+
+type lambdaImport struct {
+ name string
+ idx int
+}
+
+func fixLambdaImports(s *tokens.Stream, useIdx int) {
+ open := nextPunctOfKind(s, useIdx, "(")
+ if open < 0 {
+ return
+ }
+ closeIdx := s.MatchForward(open)
+ if closeIdx < 0 {
+ return
+ }
+ args := docArgumentSpans(s, open, closeIdx)
+ imports := filterLambdaImports(s, args)
+ if len(imports) == 0 {
+ return
+ }
+ notUsed := findNotUsedLambdaImports(s, imports, closeIdx)
+ if len(notUsed) == 0 {
+ return
+ }
+ if len(notUsed) == len(args) {
+ clearImportsAndUse(s, useIdx, closeIdx)
+ return
+ }
+ // remove highest index first so lower indices stay valid
+ for _, im := range slices.Backward(notUsed) {
+ clearLambdaImport(s, im.idx)
+ }
+}
+
+// docArgumentSpans splits the paren (open..closeIdx) into top-level argument
+// spans (inclusive start/end of the meaningful content between commas).
+func docArgumentSpans(s *tokens.Stream, open, closeIdx int) [][2]int {
+ var spans [][2]int
+ depth := 0
+ start := -1
+ flush := func(end int) {
+ if start < 0 {
+ return
+ }
+ // trim trailing whitespace/comments
+ e := end
+ for e >= start && isTrivia(s, e) {
+ e--
+ }
+ st := start
+ for st <= e && isTrivia(s, st) {
+ st++
+ }
+ if st <= e {
+ spans = append(spans, [2]int{st, e})
+ }
+ start = -1
+ }
+ for j := open + 1; j < closeIdx; j++ {
+ t := s.At(j)
+ if t.Kind == token.Punct {
+ switch t.Value {
+ case "(", "[", "{":
+ depth++
+ case ")", "]", "}":
+ depth--
+ case ",":
+ if depth == 0 {
+ flush(j - 1)
+ continue
+ }
+ }
+ }
+ if start < 0 && !isTrivia(s, j) {
+ start = j
+ }
+ }
+ flush(closeIdx - 1)
+ return spans
+}
+
+func isTrivia(s *tokens.Stream, i int) bool {
+ k := s.At(i).Kind
+ return k == token.Whitespace || k == token.Comment || k == token.DocComment
+}
+
+func filterLambdaImports(s *tokens.Stream, args [][2]int) []lambdaImport {
+ var imports []lambdaImport
+ for _, sp := range args {
+ varIdx := -1
+ for j := sp[0]; j <= sp[1]; j++ {
+ if s.At(j).Kind == token.Variable {
+ varIdx = j
+ break
+ }
+ }
+ if varIdx < 0 {
+ continue
+ }
+ if p := sigPrev(s, varIdx); p >= 0 && isPunctVal(s, p, "&") {
+ continue // imported by reference
+ }
+ name := s.At(varIdx).Value
+ if name == "$this" || superGlobalNames[name] {
+ continue
+ }
+ imports = append(imports, lambdaImport{name: name, idx: varIdx})
+ }
+ return imports
+}
+
+func findNotUsedLambdaImports(s *tokens.Stream, imports []lambdaImport, useCloseBrace int) []lambdaImport {
+ lambdaOpen := nextPunctOfKind(s, useCloseBrace, "{")
+ if lambdaOpen < 0 {
+ return nil
+ }
+ remaining := make(map[string]bool, len(imports))
+ for _, im := range imports {
+ remaining[im.name] = true
+ }
+ level := 0
+ for i := lambdaOpen; i < s.Len(); i++ {
+ t := s.At(i)
+ if isPunctVal(s, i, "{") {
+ level++
+ continue
+ }
+ if isPunctVal(s, i, "}") {
+ level--
+ if level == 0 {
+ break
+ }
+ continue
+ }
+ if t.Kind == token.Ident && strings.EqualFold(t.Value, "compact") && lambdaIsGlobalCall(s, i) {
+ return nil
+ }
+ if isLambdaBailoutKeyword(t) {
+ return nil
+ }
+ if isPunctVal(s, i, "$") {
+ n := sigNext(s, i)
+ if n >= 0 && (s.At(n).Kind == token.Variable || isPunctVal(s, n, "{")) {
+ return nil // "$$a" or "${...}"
+ }
+ }
+ if t.Kind == token.Variable && remaining[t.Value] {
+ delete(remaining, t.Value)
+ if len(remaining) == 0 {
+ return nil
+ }
+ }
+ if isClassyKeyword(t) { // anonymous class
+ j := nextPunctOfKind(s, i, "(", "{")
+ if j < 0 {
+ break
+ }
+ if isPunctVal(s, j, "(") {
+ cb := s.MatchForward(j)
+ if cb < 0 {
+ break
+ }
+ countImportsUsedAsArgument(s, remaining, docArgumentSpans(s, j, cb))
+ j = nextPunctOfKind(s, cb, "{")
+ if j < 0 {
+ break
+ }
+ }
+ end := s.MatchForward(j)
+ if end < 0 {
+ break
+ }
+ i = end
+ continue
+ }
+ if kwIs(t, "function") { // nested closure
+ o := nextPunctOfKind(s, i, "(")
+ if o < 0 {
+ break
+ }
+ c := s.MatchForward(o)
+ if c < 0 {
+ break
+ }
+ countImportsUsedAsArgument(s, remaining, docArgumentSpans(s, o, c))
+ j := nextUseOrBrace(s, i)
+ if j < 0 {
+ break
+ }
+ if kwIs(s.At(j), "use") {
+ o2 := nextPunctOfKind(s, j, "(")
+ if o2 < 0 {
+ break
+ }
+ c2 := s.MatchForward(o2)
+ if c2 < 0 {
+ break
+ }
+ countImportsUsedAsArgument(s, remaining, docArgumentSpans(s, o2, c2))
+ j = nextPunctOfKind(s, c2, "{")
+ if j < 0 {
+ break
+ }
+ }
+ end := s.MatchForward(j)
+ if end < 0 {
+ break
+ }
+ i = end
+ continue
+ }
+ }
+ var notUsed []lambdaImport
+ for _, im := range imports {
+ if remaining[im.name] {
+ notUsed = append(notUsed, im)
+ }
+ }
+ return notUsed
+}
+
+func countImportsUsedAsArgument(s *tokens.Stream, remaining map[string]bool, args [][2]int) {
+ for _, sp := range args {
+ name := lambdaArgName(s, sp)
+ if name != "" && remaining[name] {
+ delete(remaining, name)
+ }
+ }
+}
+
+// lambdaArgName returns the argument's variable name when the span reduces to a
+// plain (optionally typed / by-ref / defaulted) variable, else "".
+func lambdaArgName(s *tokens.Stream, sp [2]int) string {
+ varIdx := -1
+ for j := sp[0]; j <= sp[1]; j++ {
+ if s.At(j).Kind == token.Variable {
+ if varIdx >= 0 {
+ return "" // more than one variable: not a plain name
+ }
+ varIdx = j
+ }
+ }
+ if varIdx < 0 {
+ return ""
+ }
+ // anything after the variable other than "= default" disqualifies it
+ n := sigNext(s, varIdx)
+ if n >= 0 && n <= sp[1] && !isPunctVal(s, n, "=") {
+ return ""
+ }
+ return s.At(varIdx).Value
+}
+
+func isLambdaBailoutKeyword(t token.Token) bool {
+ if t.Kind != token.Keyword {
+ return false
+ }
+ switch strings.ToLower(t.Value) {
+ case "eval", "include", "include_once", "require", "require_once":
+ return true
+ }
+ return false
+}
+
+func isClassyKeyword(t token.Token) bool {
+ return t.Kind == token.Keyword && classyKeywords[strings.ToLower(t.Value)]
+}
+
+// lambdaIsGlobalCall reports whether the name at i is a global function call
+// (reusing isGlobalFunctionCall from the alias fixer) with a following "(".
+func lambdaIsGlobalCall(s *tokens.Stream, i int) bool {
+ n := sigNext(s, i)
+ return n >= 0 && isPunctVal(s, n, "(") && isGlobalFunctionCall(s, i)
+}
+
+func nextUseOrBrace(s *tokens.Stream, idx int) int {
+ for j := idx + 1; j < s.Len(); j++ {
+ if kwIs(s.At(j), "use") {
+ return j
+ }
+ if isPunctVal(s, j, "{") {
+ return j
+ }
+ }
+ return -1
+}
+
+// clearLambdaImport removes one imported variable token and its adjoining comma,
+// mirroring PhpdocToComment's clearImports.
+func clearLambdaImport(s *tokens.Stream, removeIdx int) {
+ clearTokenAndMergeWS(s, removeIdx)
+ prev := sigPrev(s, removeIdx)
+ switch {
+ case prev >= 0 && isPunctVal(s, prev, ","):
+ clearTokenAndMergeWS(s, prev)
+ case prev >= 0 && isPunctVal(s, prev, "("):
+ clearTokenAndMergeWS(s, sigNext(s, removeIdx)) // the following ","
+ }
+}
+
+// clearImportsAndUse removes the whole "use (...)" clause.
+func clearImportsAndUse(s *tokens.Stream, useIdx, useCloseBrace int) {
+ for i := useCloseBrace; i >= useIdx; i-- {
+ if s.At(i).Kind == token.Comment || s.At(i).Kind == token.DocComment {
+ continue
+ }
+ if s.At(i).Kind == token.Whitespace {
+ pv := getPrevNonWhitespace(s, i)
+ if pv >= 0 && (s.At(pv).Kind == token.Comment || s.At(pv).Kind == token.DocComment) {
+ continue
+ }
+ }
+ clearTokenAndMergeWS(s, i)
+ }
+}
+
+// clearTokenAndMergeWS mirrors Tokens::clearTokenAndMergeSurroundingWhitespace:
+// the token becomes an empty whitespace token (renders as nothing) and adjacent
+// whitespace is merged, so token indices stay stable.
+func clearTokenAndMergeWS(s *tokens.Stream, index int) {
+ if index < 0 || index >= s.Len() {
+ return
+ }
+ count := s.Len()
+ clearTokenAt(s, index)
+ if index == count-1 {
+ return
+ }
+ nextIdx := nonEmptySibling(s, index, 1)
+ if nextIdx < 0 || s.At(nextIdx).Kind != token.Whitespace {
+ return
+ }
+ prevIdx := nonEmptySibling(s, index, -1)
+ if prevIdx >= 0 && s.At(prevIdx).Kind == token.Whitespace {
+ s.SetValue(prevIdx, s.At(prevIdx).Value+s.At(nextIdx).Value)
+ } else if prevIdx+1 < s.Len() && isEmptyToken(s, prevIdx+1) {
+ s.Set(prevIdx+1, token.Token{Kind: token.Whitespace, Value: s.At(nextIdx).Value})
+ }
+ clearTokenAt(s, nextIdx)
+}
+
+func clearTokenAt(s *tokens.Stream, i int) {
+ s.Set(i, token.Token{Kind: token.Whitespace, Value: ""})
+}
+
+func isEmptyToken(s *tokens.Stream, i int) bool {
+ return i >= 0 && i < s.Len() && s.At(i).Value == ""
+}
+
+// nonEmptySibling returns the nearest non-empty token index in the given
+// direction, skipping empty (cleared) tokens.
+func nonEmptySibling(s *tokens.Stream, i, dir int) int {
+ j := i + dir
+ for j >= 0 && j < s.Len() {
+ if s.At(j).Value != "" {
+ return j
+ }
+ j += dir
+ }
+ return -1
+}
diff --git a/blink/internal/fixer/rules/lambda_not_used_import_test.go b/blink/internal/fixer/rules/lambda_not_used_import_test.go
new file mode 100644
index 0000000000..eda893d4f0
--- /dev/null
+++ b/blink/internal/fixer/rules/lambda_not_used_import_test.go
@@ -0,0 +1,40 @@
+package rules
+
+import "testing"
+
+func TestLambdaNotUsedImport(t *testing.T) {
+ f := LambdaNotUsedImport{}
+
+ // a single unused import removes the whole use()
+ got, changed := apply(t, f, "" is left untouched.
+type LinebreakAfterOpeningTag struct{}
+
+func (LinebreakAfterOpeningTag) Name() string {
+ return `PhpCsFixer\Fixer\PhpTag\LinebreakAfterOpeningTagFixer`
+}
+
+func (LinebreakAfterOpeningTag) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/PhpTag/LinebreakAfterOpeningTagFixer.php"
+}
+
+func (LinebreakAfterOpeningTag) Fix(s *tokens.Stream) bool {
+ if s.Len() < 3 {
+ return false
+ }
+ if s.At(0).Kind != token.OpenTag || s.At(0).Value != " __LINE__).
+type MagicConstantCasing struct{}
+
+func (MagicConstantCasing) Name() string {
+ return `PhpCsFixer\Fixer\Casing\MagicConstantCasingFixer`
+}
+
+func (MagicConstantCasing) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Casing/MagicConstantCasingFixer.php"
+}
+
+func (MagicConstantCasing) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := range s.Len() {
+ t := s.At(i)
+ if t.Kind != token.Ident {
+ continue
+ }
+ if canonical, ok := magicConstants[strings.ToLower(t.Value)]; ok && canonical != t.Value {
+ s.SetValue(i, canonical)
+ changed = true
+ }
+ }
+ return changed
+}
+
+var magicMethods = map[string]string{
+ "__construct": "__construct",
+ "__destruct": "__destruct",
+ "__call": "__call",
+ "__callstatic": "__callStatic",
+ "__get": "__get",
+ "__set": "__set",
+ "__isset": "__isset",
+ "__unset": "__unset",
+ "__sleep": "__sleep",
+ "__wakeup": "__wakeup",
+ "__serialize": "__serialize",
+ "__unserialize": "__unserialize",
+ "__tostring": "__toString",
+ "__invoke": "__invoke",
+ "__set_state": "__set_state",
+ "__clone": "__clone",
+ "__debuginfo": "__debugInfo",
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Casing/MagicMethodCasingFixer.php
+//
+// MagicMethodCasing fixes the casing of magic methods (__CONSTRUCT ->
+// __construct, __tostring -> __toString).
+type MagicMethodCasing struct{}
+
+func (MagicMethodCasing) Name() string {
+ return `PhpCsFixer\Fixer\Casing\MagicMethodCasingFixer`
+}
+
+func (MagicMethodCasing) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Casing/MagicMethodCasingFixer.php"
+}
+
+func (MagicMethodCasing) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := range s.Len() {
+ t := s.At(i)
+ if t.Kind != token.Ident {
+ continue
+ }
+ canonical, ok := magicMethods[strings.ToLower(t.Value)]
+ if !ok || canonical == t.Value {
+ continue
+ }
+ // a magic method is always a declaration or call ("__set("); a constant
+ // named "__SET" (followed by "=") must not be recased
+ if nextSignificantValue(s, i) != "(" {
+ continue
+ }
+ s.SetValue(i, canonical)
+ changed = true
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/method_argument_space.go b/blink/internal/fixer/rules/method_argument_space.go
new file mode 100644
index 0000000000..7e9d3a6b7c
--- /dev/null
+++ b/blink/internal/fixer/rules/method_argument_space.go
@@ -0,0 +1,249 @@
+package rules
+
+import (
+ "slices"
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/FunctionNotation/MethodArgumentSpaceFixer.php
+//
+// MethodArgumentSpace normalizes single-line spacing around commas inside
+// parentheses (calls and signatures): no space before a comma, exactly one
+// space after it. Commas inside arrays "[...]", commas followed by a newline
+// (multiline alignment) and a trailing comma right before ")" are left alone.
+type MethodArgumentSpace struct{}
+
+func (MethodArgumentSpace) Name() string {
+ return `PhpCsFixer\Fixer\FunctionNotation\MethodArgumentSpaceFixer`
+}
+
+func (MethodArgumentSpace) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/FunctionNotation/MethodArgumentSpaceFixer.php"
+}
+
+func (MethodArgumentSpace) Fix(s *tokens.Stream) bool {
+ // ensure_fully_multiline: a call/declaration argument list that already spans
+ // lines gets one argument per line, "(" and ")" on their own lines.
+ changed := reflowMultilineArgs(s)
+ var stack []string
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ if t.Kind != token.Punct {
+ continue
+ }
+ switch t.Value {
+ case "(", "[", "{":
+ stack = append(stack, t.Value)
+ continue
+ case ")", "]", "}":
+ if len(stack) > 0 {
+ stack = stack[:len(stack)-1]
+ }
+ continue
+ case ",":
+ if len(stack) == 0 || stack[len(stack)-1] != "(" {
+ continue
+ }
+ // Multiline arg list: leave alignment untouched.
+ if i+1 < s.Len() && s.At(i+1).Kind == token.Whitespace && hasNewline(s.At(i+1).Value) {
+ continue
+ }
+ // No space before the comma (single-line only).
+ if i > 0 && s.At(i-1).Kind == token.Whitespace && !hasNewline(s.At(i-1).Value) {
+ 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 != " " {
+ s.SetValue(i+1, " ")
+ changed = true
+ }
+ } else if next.Kind != token.Punct || next.Value != ")" {
+ s.InsertAt(i+1, token.Token{Kind: token.Whitespace, Value: " "})
+ i++
+ 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.
+func reflowMultilineArgs(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 sigNext(s, open) == closeIdx {
+ continue // empty ()
+ }
+ if reflowParen(s, open, closeIdx) {
+ changed = true
+ }
+ }
+ return changed
+}
+
+// reflowParen puts each top-level argument of the paren at open on its own line,
+// with "(" and ")" on their own lines, indented one level past the call.
+func reflowParen(s *tokens.Stream, open, closeIdx int) bool {
+ changed := false
+ base := lineIndentBefore(s, open)
+ argNL := "\n" + base + " "
+
+ var commas []int
+ depth := 0
+ for j := open + 1; j < closeIdx; j++ {
+ t := s.At(j)
+ if t.Kind != token.Punct {
+ continue
+ }
+ switch t.Value {
+ case "(", "[", "{":
+ depth++
+ case ")", "]", "}":
+ depth--
+ case ",":
+ // skip a trailing comma (nothing but the closer follows it); decided
+ // now, before edits shift indices
+ if depth == 0 && sigNext(s, j) != closeIdx {
+ commas = append(commas, j)
+ }
+ }
+ }
+
+ // apply right-to-left so indices stay valid: ")" first, then commas, then "("
+ if editSlotBefore(s, closeIdx, "\n"+base) {
+ changed = true
+ }
+ for _, c := range slices.Backward(commas) {
+ if editSlotAfter(s, c, argNLAfterComma(s, c, base)) {
+ changed = true
+ }
+ }
+ if editSlotAfter(s, open, argNL) {
+ changed = true
+ }
+ return changed
+}
+
+// argListIsMultiline reports whether the argument list is split at the top level
+// - a newline right after "(" or right after a top-level comma. Newlines that
+// only occur inside a nested array/closure argument do not count, matching ECS's
+// ensure_fully_multiline trigger.
+func argListIsMultiline(s *tokens.Stream, open, closeIdx int) bool {
+ if n := open + 1; n < closeIdx && s.At(n).Kind == token.Whitespace && hasNewline(s.At(n).Value) {
+ return true
+ }
+ depth := 0
+ for j := open + 1; j < closeIdx; j++ {
+ t := s.At(j)
+ if t.Kind != token.Punct {
+ continue
+ }
+ switch t.Value {
+ case "(", "[", "{":
+ depth++
+ case ")", "]", "}":
+ depth--
+ case ",":
+ if depth == 0 && j+1 < closeIdx && s.At(j+1).Kind == token.Whitespace && hasNewline(s.At(j+1).Value) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// isCallOrDeclParen reports whether the "(" at open opens a function/method call
+// or a function/closure declaration (not a control structure, array or grouping).
+func isCallOrDeclParen(s *tokens.Stream, open int) bool {
+ p := sigPrev(s, open)
+ if p < 0 {
+ return false
+ }
+ t := s.At(p)
+ switch t.Kind {
+ case token.Ident, token.Variable:
+ return true
+ case token.Punct:
+ return t.Value == ")" || t.Value == "]"
+ case token.Keyword:
+ lv := strings.ToLower(t.Value)
+ return lv == "function" || lv == "fn"
+ }
+ return false
+}
+
+// lineIndentBefore returns the indentation of the line containing token idx.
+func lineIndentBefore(s *tokens.Stream, idx int) string {
+ for i := idx - 1; i >= 0; i-- {
+ if t := s.At(i); t.Kind == token.Whitespace && strings.Contains(t.Value, "\n") {
+ if nl := strings.LastIndexByte(t.Value, '\n'); nl >= 0 {
+ return t.Value[nl+1:]
+ }
+ }
+ }
+ return ""
+}
+
+// argNLAfterComma returns the newline+indent to place after a top-level comma
+// in a multiline argument list. A blank line an author left between two
+// arguments is preserved (ECS keeps blank lines in a multiline argument list and
+// only normalizes the indentation), so a comma whose following whitespace holds a
+// blank line keeps that blank rather than collapsing to a single newline.
+func argNLAfterComma(s *tokens.Stream, comma int, base string) string {
+ if comma+1 < s.Len() {
+ ws := s.At(comma + 1)
+ if ws.Kind == token.Whitespace {
+ if newlines := strings.Count(ws.Value, "\n"); newlines >= 2 {
+ return strings.Repeat("\n", newlines) + base + " "
+ }
+ }
+ }
+ return "\n" + base + " "
+}
+
+// editSlotAfter sets the whitespace immediately after token idx to val.
+func editSlotAfter(s *tokens.Stream, idx int, val string) bool {
+ if idx+1 < s.Len() && s.At(idx+1).Kind == token.Whitespace {
+ if s.At(idx+1).Value != val {
+ s.SetValue(idx+1, val)
+ return true
+ }
+ return false
+ }
+ s.InsertAt(idx+1, token.Token{Kind: token.Whitespace, Value: val})
+ return true
+}
+
+// editSlotBefore sets the whitespace immediately before token idx to val.
+func editSlotBefore(s *tokens.Stream, idx int, val string) bool {
+ if idx > 0 && s.At(idx-1).Kind == token.Whitespace {
+ if s.At(idx-1).Value != val {
+ s.SetValue(idx-1, val)
+ return true
+ }
+ return false
+ }
+ s.InsertAt(idx, token.Token{Kind: token.Whitespace, Value: val})
+ return true
+}
diff --git a/blink/internal/fixer/rules/method_argument_space_test.go b/blink/internal/fixer/rules/method_argument_space_test.go
new file mode 100644
index 0000000000..10e13d4354
--- /dev/null
+++ b/blink/internal/fixer/rules/method_argument_space_test.go
@@ -0,0 +1,48 @@
+package rules
+
+import (
+ "testing"
+
+ fixerpkg "blink/internal/fixer"
+)
+
+func TestMethodArgumentSpace(t *testing.T) {
+ cases := []struct {
+ name string
+ src string
+ want string
+ changed bool
+ }{
+ {"already correct", ""/"?->" that already sits at the
+// start of a line to one indentation level (four spaces) past the line the chain
+// starts on. It only adjusts the indentation of an existing line break, so it
+// never joins or splits calls and is a no-op once the chain is aligned.
+type MethodChainingIndentation struct{}
+
+func (MethodChainingIndentation) Name() string {
+ return `PhpCsFixer\Fixer\Whitespace\MethodChainingIndentationFixer`
+}
+
+func (MethodChainingIndentation) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/MethodChainingIndentationFixer.php"
+}
+
+func (MethodChainingIndentation) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 1; i < s.Len(); i++ {
+ t := s.At(i)
+ if t.Kind != token.Punct || (t.Value != "->" && t.Value != "?->") {
+ continue
+ }
+ ws := s.At(i - 1)
+ if ws.Kind != token.Whitespace || !strings.Contains(ws.Value, "\n") {
+ continue // only operators that already begin a line
+ }
+ want := chainFirstLineIndent(s, i) + " "
+ nl := strings.LastIndexByte(ws.Value, '\n')
+ got := ws.Value[nl+1:]
+ if got == want {
+ continue
+ }
+ s.SetValue(i-1, ws.Value[:nl+1]+want)
+ changed = true
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/method_chaining_newline.go b/blink/internal/fixer/rules/method_chaining_newline.go
new file mode 100644
index 0000000000..928ee85b33
--- /dev/null
+++ b/blink/internal/fixer/rules/method_chaining_newline.go
@@ -0,0 +1,160 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// Symplify: https://github.com/symplify/coding-standard/blob/main/src/Fixer/Spacing/MethodChainingNewlineFixer.php
+//
+// MethodChainingNewline puts each chained method call on its own line. A "->"
+// that directly follows a method call's ")" starts a new line, indented one
+// level past the chain. The first call after the chain root (a variable or a
+// grouped/constructor expression) stays inline; chains that are call/array
+// arguments on the same line, that follow a "::"/"["/"." on the line, that
+// follow a multi-line call, or that are already split are left untouched - so it
+// is a no-op on code ECS has already formatted.
+type MethodChainingNewline struct{}
+
+func (MethodChainingNewline) Name() string {
+ return `Symplify\CodingStandard\Fixer\Spacing\MethodChainingNewlineFixer`
+}
+
+func (MethodChainingNewline) SourceURL() string {
+ return "https://github.com/symplify/coding-standard/blob/main/src/Fixer/Spacing/MethodChainingNewlineFixer.php"
+}
+
+func (MethodChainingNewline) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 1; i < s.Len(); i++ {
+ if s.At(i).Kind != token.Punct || s.At(i).Value != "->" {
+ continue
+ }
+ prev := sigPrev(s, i)
+ if prev < 0 || s.At(prev).Kind != token.Punct || s.At(prev).Value != ")" {
+ continue // "->" must follow a ")"
+ }
+ // the ")" must close a method call, not a grouping/constructor paren -
+ // this keeps the first call after "(new X)" or "(expr)" inline
+ if !chainIsCallClose(s, prev) {
+ continue
+ }
+ // already split here, or the preceding call spans multiple lines: leave it
+ if rangeHasNewline(s, prev, i) {
+ continue
+ }
+ if open := s.MatchBackward(prev); open >= 0 && rangeHasNewline(s, open, prev) {
+ continue
+ }
+ // "::", "[", "." or an enclosing "(" earlier on the line means the chain is
+ // part of a call/array (symplify's isPartOfMethodCallOrArray) - leave it
+ if chainLineHasBreakingChar(s, prev) {
+ continue
+ }
+ nl := "\n" + chainFirstLineIndent(s, i) + " "
+ if s.At(i-1).Kind == token.Whitespace {
+ s.SetValue(i-1, nl)
+ } else {
+ s.InsertAt(i, token.Token{Kind: token.Whitespace, Value: nl})
+ i++
+ }
+ changed = true
+ }
+ return changed
+}
+
+// chainIsCallClose reports whether the ")" at closeIdx closes a function/method
+// call (its "(" follows a name, variable or another call/subscript) rather than
+// a grouping or constructor paren.
+func chainIsCallClose(s *tokens.Stream, closeIdx int) bool {
+ open := s.MatchBackward(closeIdx)
+ if open < 0 {
+ return false
+ }
+ p := sigPrev(s, open)
+ if p < 0 {
+ return false
+ }
+ t := s.At(p)
+ if t.Kind == token.Ident || t.Kind == token.Variable {
+ return true
+ }
+ return t.Kind == token.Punct && (t.Value == ")" || t.Value == "]")
+}
+
+// chainFirstLineIndent returns the indentation of the line the chain starts on,
+// so every continuation aligns to the same column. It walks back over the whole
+// chain expression (jumping across balanced brackets) to its root.
+func chainFirstLineIndent(s *tokens.Stream, opIdx int) string {
+ i := opIdx
+ root := opIdx
+ for i > 0 {
+ t := s.At(i - 1)
+ switch {
+ case t.Kind == token.Whitespace || t.Kind == token.Comment || t.Kind == token.DocComment:
+ i--
+ case t.Kind == token.Ident || t.Kind == token.Variable:
+ root = i - 1
+ i--
+ case t.Kind == token.Punct && (t.Value == "->" || t.Value == "?->" || t.Value == "::"):
+ i--
+ case t.Kind == token.Keyword && strings.ToLower(t.Value) == "new":
+ root = i - 1
+ i--
+ case t.Kind == token.Punct && (t.Value == ")" || t.Value == "]" || t.Value == "}"):
+ m := s.MatchBackward(i - 1)
+ if m < 0 {
+ return chainBaseIndent(s, root)
+ }
+ root = m
+ i = m
+ default:
+ return chainBaseIndent(s, root) // boundary before the chain root
+ }
+ }
+ return chainBaseIndent(s, root)
+}
+
+// chainBaseIndent returns the indentation after the nearest newline before index.
+func chainBaseIndent(s *tokens.Stream, index int) string {
+ for i := index - 1; i >= 0; i-- {
+ if t := s.At(i); t.Kind == token.Whitespace && strings.Contains(t.Value, "\n") {
+ if nl := strings.LastIndexByte(t.Value, '\n'); nl >= 0 {
+ return t.Value[nl+1:]
+ }
+ }
+ }
+ return ""
+}
+
+// chainLineHasBreakingChar mirrors symplify's isPartOfMethodCallOrArray: walking
+// back from the ")" at pos to the start of the line, a "[", "::", "." or "array"
+// (or an unmatched enclosing "(") means the chain is treated as part of a call or
+// array and is left inline.
+func chainLineHasBreakingChar(s *tokens.Stream, pos int) bool {
+ nesting := 0
+ for i := pos; i >= 0; i-- {
+ t := s.At(i)
+ if t.Kind == token.Whitespace && strings.Contains(t.Value, "\n") {
+ return false
+ }
+ if t.Kind == token.Punct && (t.Value == "[" || t.Value == "::" || t.Value == ".") {
+ return true
+ }
+ if t.Kind == token.Keyword && strings.ToLower(t.Value) == "array" {
+ return true
+ }
+ if t.Kind == token.Punct && t.Value == ")" {
+ nesting--
+ } else if t.Kind == token.Punct && t.Value == "(" {
+ if nesting != 0 {
+ nesting++
+ } else {
+ return true
+ }
+ }
+ }
+ return false
+}
diff --git a/blink/internal/fixer/rules/method_chaining_newline_test.go b/blink/internal/fixer/rules/method_chaining_newline_test.go
new file mode 100644
index 0000000000..e1daf9bebb
--- /dev/null
+++ b/blink/internal/fixer/rules/method_chaining_newline_test.go
@@ -0,0 +1,44 @@
+package rules
+
+import "testing"
+
+func TestMethodChainingNewline(t *testing.T) {
+ f := MethodChainingNewline{}
+
+ cases := []struct {
+ src, want string
+ changed bool
+ }{
+ // plain variable-rooted chain at statement level: split
+ {"one()->two();", "one()\n ->two();", true},
+ {"a()->b()->c();", "a()\n ->b()\n ->c();", true},
+ {"one()->two();", "one()\n ->two();", true},
+
+ // single method call: nothing to split
+ {"one();", "one();", false},
+
+ // a "::" earlier on the line suppresses the split (symplify heuristic)
+ {"one()->two();", "one()->two();", false},
+ // a "[" earlier on the line likewise
+ {"b($m[2])->c();", "b($m[2])->c();", false},
+
+ // a chain inline inside a call's arguments is left alone
+ {"a()->b());", "a()->b());", false},
+ // grouped root: the first call stays inline, later calls split
+ {"bar()->baz();", "bar()\n ->baz();", true},
+ {"bar();", "bar();", false},
+
+ // a chain that starts its own line (inside a multi-line expression) splits
+ {"a($x)->b() ||\n $this->c()->d()\n);",
+ "a($x)\n ->b() ||\n $this->c()\n ->d()\n);", true},
+
+ // already multi-line: no-op
+ {"one()\n ->two();", "one()\n ->two();", false},
+ }
+ for _, c := range cases {
+ got, changed := apply(t, f, c.src)
+ if got != c.want || changed != c.changed {
+ t.Errorf("src=%q\n got=%q changed=%v\nwant=%q changed=%v", c.src, got, changed, c.want, c.changed)
+ }
+ }
+}
diff --git a/blink/internal/fixer/rules/misc_rules.go b/blink/internal/fixer/rules/misc_rules.go
new file mode 100644
index 0000000000..5854cbca0c
--- /dev/null
+++ b/blink/internal/fixer/rules/misc_rules.go
@@ -0,0 +1,102 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/StandardizeNotEqualsFixer.php
+//
+// StandardizeNotEquals rewrites the "<>" operator to "!=".
+type StandardizeNotEquals struct{}
+
+func (StandardizeNotEquals) Name() string {
+ return `PhpCsFixer\Fixer\Operator\StandardizeNotEqualsFixer`
+}
+
+func (StandardizeNotEquals) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/StandardizeNotEqualsFixer.php"
+}
+
+func (StandardizeNotEquals) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := range s.Len() {
+ if s.At(i).Kind == token.Punct && s.At(i).Value == "<>" {
+ s.SetValue(i, "!=")
+ changed = true
+ }
+ }
+ return changed
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Semicolon/NoEmptyStatementFixer.php
+//
+// NoEmptyStatement removes redundant semicolons ("$a = 1;;" -> "$a = 1;"). The
+// empty statements of a for-header (inside "(...)") are left alone.
+type NoEmptyStatement struct{}
+
+func (NoEmptyStatement) Name() string {
+ return `PhpCsFixer\Fixer\Semicolon\NoEmptyStatementFixer`
+}
+
+func (NoEmptyStatement) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Semicolon/NoEmptyStatementFixer.php"
+}
+
+func (NoEmptyStatement) Fix(s *tokens.Stream) bool {
+ changed := false
+ depth := 0
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ if t.Kind != token.Punct {
+ continue
+ }
+ switch t.Value {
+ case "(", "[":
+ depth++
+ case ")", "]":
+ depth--
+ case ";":
+ if depth != 0 {
+ continue
+ }
+ if prev, ok := prevSignificant(s, i); ok && prev.Value == ";" {
+ s.RemoveAt(i)
+ i--
+ changed = true
+ }
+ }
+ }
+ return changed
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/LineEndingFixer.php
+//
+// LineEnding normalizes CRLF line endings to LF in whitespace and comments.
+// String contents (including heredoc) are left untouched.
+type LineEnding struct{}
+
+func (LineEnding) Name() string {
+ return `PhpCsFixer\Fixer\Whitespace\LineEndingFixer`
+}
+
+func (LineEnding) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/LineEndingFixer.php"
+}
+
+func (LineEnding) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := range s.Len() {
+ t := s.At(i)
+ if t.Kind != token.Whitespace && t.Kind != token.Comment && t.Kind != token.DocComment {
+ continue
+ }
+ if v := strings.ReplaceAll(t.Value, "\r\n", "\n"); v != t.Value {
+ s.SetValue(i, v)
+ changed = true
+ }
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/modifier_keywords.go b/blink/internal/fixer/rules/modifier_keywords.go
new file mode 100644
index 0000000000..6eeb61abaf
--- /dev/null
+++ b/blink/internal/fixer/rules/modifier_keywords.go
@@ -0,0 +1,143 @@
+package rules
+
+import (
+ "slices"
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/ModifierKeywordsFixer.php
+//
+// ModifierKeywords gives class constants, properties and methods an explicit
+// visibility and orders the modifier keywords canonically:
+// inheritance (abstract/final), visibility, scope (static), mutation (readonly),
+// then the type declaration and name.
+//
+// This handles class-body const/property/method members. Constructor promoted
+// properties and PHP 8.4 asymmetric set-visibility (public(set)) are left
+// untouched; members whose modifier run contains a comment are skipped.
+type ModifierKeywords struct{}
+
+func (ModifierKeywords) Name() string {
+ return `PhpCsFixer\Fixer\ClassNotation\ModifierKeywordsFixer`
+}
+
+func (ModifierKeywords) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/ModifierKeywordsFixer.php"
+}
+
+func (ModifierKeywords) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ if s.At(i).Kind != token.Punct || s.At(i).Value != "{" {
+ continue
+ }
+ if kind, _ := classifyBrace(s, i); kind != braceClassLike {
+ continue
+ }
+ for _, m := range slices.Backward(classMemberStarts(s, i)) {
+ if orderModifierKeywords(s, m) {
+ changed = true
+ }
+ }
+ }
+ return changed
+}
+
+func orderModifierKeywords(s *tokens.Stream, m int) bool {
+ var absFinal []string
+ visibility := ""
+ static := ""
+ readOnly := ""
+ hasVisibility := false
+
+ k := m
+ for k < s.Len() {
+ t := s.At(k)
+ if t.Kind == token.Whitespace {
+ k++
+ continue
+ }
+ if t.Kind == token.Comment || t.Kind == token.DocComment {
+ return false // cannot safely reorder across a comment
+ }
+ if t.Kind != token.Keyword {
+ break
+ }
+ lw := strings.ToLower(t.Value)
+ if lw == "use" || lw == "case" {
+ return false // trait use / enum case is not a member with modifiers
+ }
+ if !memberModifiers[lw] {
+ break // "const", "function" or a type keyword: modifier run ends
+ }
+ // set-visibility "public(set)" is not representable on the flat lexer
+ if next := skipWhitespace(s, k+1); next < s.Len() && s.At(next).Kind == token.Punct && s.At(next).Value == "(" {
+ return false
+ }
+ switch {
+ case lw == "abstract" || lw == "final":
+ absFinal = append(absFinal, t.Value)
+ case lw == "static":
+ static = t.Value
+ case lw == "readonly":
+ readOnly = t.Value
+ case lw == "var":
+ visibility = "public"
+ hasVisibility = true
+ case visibilityModifiers[lw]:
+ visibility = t.Value
+ hasVisibility = true
+ }
+ k++
+ }
+ after := k
+ if after < s.Len() && (s.At(after).Kind == token.Comment || s.At(after).Kind == token.DocComment) {
+ return false
+ }
+ if visibility == "" {
+ visibility = "public"
+ }
+
+ ordered := make([]string, 0, len(absFinal)+3)
+ ordered = append(ordered, absFinal...)
+ ordered = append(ordered, visibility)
+ if static != "" {
+ ordered = append(ordered, static)
+ }
+ if readOnly != "" {
+ ordered = append(ordered, readOnly)
+ }
+
+ // desired rendering: modifiers single-spaced, one trailing space before the type/name
+ var want strings.Builder
+ for _, mod := range ordered {
+ want.WriteString(mod)
+ want.WriteString(" ")
+ }
+
+ // current rendering of the run [m, after)
+ var cur strings.Builder
+ for x := m; x < after; x++ {
+ cur.WriteString(s.At(x).Value)
+ }
+ if hasVisibility && cur.String() == want.String() {
+ return false
+ }
+
+ repl := make([]token.Token, 0, len(ordered)*2)
+ for _, mod := range ordered {
+ repl = append(repl, token.Token{Kind: token.Keyword, Value: mod})
+ repl = append(repl, token.Token{Kind: token.Whitespace, Value: " "})
+ }
+ if after == m {
+ for _, tk := range slices.Backward(repl) {
+ s.InsertAt(m, tk)
+ }
+ return true
+ }
+ s.ReplaceRange(m, after-1, repl)
+ return true
+}
diff --git a/blink/internal/fixer/rules/modifier_keywords_test.go b/blink/internal/fixer/rules/modifier_keywords_test.go
new file mode 100644
index 0000000000..b004df5cf7
--- /dev/null
+++ b/blink/internal/fixer/rules/modifier_keywords_test.go
@@ -0,0 +1,24 @@
+package rules
+
+import "testing"
+
+func TestModifierKeywords(t *testing.T) {
+ f := ModifierKeywords{}
+
+ // reorders modifiers and adds missing visibility across members
+ got, changed := apply(t, f, " no change (idempotent)
+ if _, changed := apply(t, OrderedImports{}, want); changed {
+ t.Fatal("already-grouped imports should not change")
+ }
+}
+
+func TestBlankLineBetweenImportGroups(t *testing.T) {
+ got, changed := apply(t, BlankLineBetweenImportGroups{}, "= 0 && isComment(s.At(i-2)) && startsWithNewline(s.At(prevIdx).Value) {
+ sig := prevValueTokenIndex(s, i)
+ s.RemoveAt(i) // drop the ";"
+ s.RemoveAt(prevIdx) // drop the whitespace
+ s.InsertAt(sig+1, token.Token{Kind: token.Punct, Value: ";"})
+ changed = true
+ continue
+ }
+ // plain case: drop the multi-line whitespace so ";" follows the code
+ s.RemoveAt(prevIdx)
+ i--
+ changed = true
+ }
+ return changed
+}
+
+func startsWithNewline(v string) bool {
+ return strings.HasPrefix(v, "\n") || strings.HasPrefix(v, "\r")
+}
+
+// prevValueTokenIndex scans back from a ";" for the token the ";" belongs to,
+// stopping at a number, name, variable, string or ")" (PHP-CS-Fixer's
+// getPreviousSignificantTokenIndex).
+func prevValueTokenIndex(s *tokens.Stream, i int) int {
+ for j := i - 1; j > 0; j-- {
+ t := s.At(j)
+ if t.Kind == token.Number || t.Kind == token.Ident || t.Kind == token.Variable ||
+ t.Kind == token.String || (t.Kind == token.Punct && t.Value == ")") {
+ return j
+ }
+ }
+ return i
+}
diff --git a/blink/internal/fixer/rules/no_alternative_syntax.go b/blink/internal/fixer/rules/no_alternative_syntax.go
new file mode 100644
index 0000000000..84976f11a1
--- /dev/null
+++ b/blink/internal/fixer/rules/no_alternative_syntax.go
@@ -0,0 +1,167 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/NoAlternativeSyntaxFixer.php
+//
+// NoAlternativeSyntax replaces control-structure alternative syntax with braces
+// ("if (x): ... endif;" -> "if (x) { ... }"). Matches ECS's default
+// (fix_non_monolithic_code=true), so inline-HTML blocks are converted too.
+type NoAlternativeSyntax struct{}
+
+func (NoAlternativeSyntax) Name() string {
+ return `PhpCsFixer\Fixer\ControlStructure\NoAlternativeSyntaxFixer`
+}
+
+func (NoAlternativeSyntax) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/NoAlternativeSyntaxFixer.php"
+}
+
+var nasOpenControls = map[string]bool{
+ "if": true, "foreach": true, "while": true, "for": true, "switch": true, "declare": true,
+}
+
+var nasEndControls = map[string]bool{
+ "endif": true, "endforeach": true, "endwhile": true, "endfor": true,
+ "endswitch": true, "enddeclare": true,
+}
+
+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 {
+ changed := false
+ for index := s.Len() - 1; index >= 0; index-- {
+ tok := s.At(index)
+ if tok.Kind == token.Keyword && strings.EqualFold(tok.Value, "elseif") {
+ changed = nasFixElseif(s, index) || changed
+ continue
+ }
+ if tok.Kind == token.Keyword && strings.EqualFold(tok.Value, "else") {
+ changed = nasFixElse(s, index) || changed
+ continue
+ }
+ changed = nasFixOpenClose(s, index) || changed
+ }
+ return changed
+}
+
+func nasFixOpenClose(s *tokens.Stream, index int) bool {
+ tok := s.At(index)
+ if nasIsKeyword(tok, nasOpenControls) {
+ openIndex := nasNextParen(s, index)
+ if openIndex == -1 {
+ return false
+ }
+ closeIndex := s.MatchForward(openIndex)
+ if closeIndex == -1 {
+ return false
+ }
+ afterIndex := nextMeaningfulIndex(s, closeIndex)
+ if afterIndex == -1 || !nasIsPunct(s.At(afterIndex), ":") {
+ return false
+ }
+ var items []token.Token
+ if afterIndex-1 >= 0 && s.At(afterIndex-1).Kind != token.Whitespace {
+ items = append(items, token.Token{Kind: token.Whitespace, Value: " "})
+ }
+ items = append(items, token.Token{Kind: token.Punct, Value: "{"})
+ if afterIndex+1 < s.Len() && s.At(afterIndex+1).Kind != token.Whitespace {
+ items = append(items, token.Token{Kind: token.Whitespace, Value: " "})
+ }
+ nasReplace(s, afterIndex, items)
+ return true
+ }
+
+ if !nasIsKeyword(tok, nasEndControls) {
+ return false
+ }
+ nextIndex := nextMeaningfulIndex(s, index)
+ s.Set(index, token.Token{Kind: token.Punct, Value: "}"})
+ if nextIndex != -1 && nasIsPunct(s.At(nextIndex), ";") {
+ s.RemoveAt(nextIndex)
+ }
+ return true
+}
+
+func nasFixElse(s *tokens.Stream, index int) bool {
+ afterIndex := nextMeaningfulIndex(s, index)
+ if afterIndex == -1 || !nasIsPunct(s.At(afterIndex), ":") {
+ return false
+ }
+ nasAddBraces(s, token.Token{Kind: token.Keyword, Value: "else"}, index, afterIndex)
+ return true
+}
+
+func nasFixElseif(s *tokens.Stream, index int) bool {
+ parenEnd := nasFindParenthesisEnd(s, index)
+ afterIndex := nextMeaningfulIndex(s, parenEnd)
+ if afterIndex == -1 || !nasIsPunct(s.At(afterIndex), ":") {
+ return false
+ }
+ nasAddBraces(s, token.Token{Kind: token.Keyword, Value: "elseif"}, index, afterIndex)
+ return true
+}
+
+// nasAddBraces turns "else :" / "elseif (...) :" into "} else {" / "} elseif (...) {".
+func nasAddBraces(s *tokens.Stream, keyword token.Token, index, colonIndex int) {
+ trailingWs := index+1 < s.Len() && s.At(index+1).Kind != token.Whitespace
+ open := []token.Token{
+ {Kind: token.Punct, Value: "}"},
+ {Kind: token.Whitespace, Value: " "},
+ keyword,
+ }
+ if trailingWs {
+ open = append(open, token.Token{Kind: token.Whitespace, Value: " "})
+ }
+ nasReplace(s, index, open)
+
+ colonIndex += len(open) - 1
+ closing := []token.Token{{Kind: token.Punct, Value: "{"}}
+ if colonIndex+1 < s.Len() && s.At(colonIndex+1).Kind != token.Whitespace {
+ closing = append(closing, token.Token{Kind: token.Whitespace, Value: " "})
+ }
+ nasReplace(s, colonIndex, closing)
+}
+
+// nasReplace replaces the single token at pos with items, preserving order.
+func nasReplace(s *tokens.Stream, pos int, items []token.Token) {
+ s.RemoveAt(pos)
+ for i, t := range items {
+ s.InsertAt(pos+i, t)
+ }
+}
+
+// nasNextParen returns the index of the first "(" after index, or -1.
+func nasNextParen(s *tokens.Stream, index int) int {
+ for j := index + 1; j < s.Len(); j++ {
+ if nasIsPunct(s.At(j), "(") {
+ return j
+ }
+ }
+ return -1
+}
+
+func nasIsPunct(t token.Token, v string) bool {
+ return t.Kind == token.Punct && t.Value == v
+}
+
+// nasFindParenthesisEnd returns the ")" index of the control's condition, or the
+// control token index itself when it has no parenthesis.
+func nasFindParenthesisEnd(s *tokens.Stream, controlIndex int) int {
+ ni := nextMeaningfulIndex(s, controlIndex)
+ if ni == -1 || !nasIsPunct(s.At(ni), "(") {
+ return controlIndex
+ }
+ end := s.MatchForward(ni)
+ if end == -1 {
+ return controlIndex
+ }
+ return end
+}
diff --git a/blink/internal/fixer/rules/no_alternative_syntax_test.go b/blink/internal/fixer/rules/no_alternative_syntax_test.go
new file mode 100644
index 0000000000..4e3e5ca5aa
--- /dev/null
+++ b/blink/internal/fixer/rules/no_alternative_syntax_test.go
@@ -0,0 +1,44 @@
+package rules
+
+import "testing"
+
+func TestNoAlternativeSyntax(t *testing.T) {
+ cases := []struct {
+ src string
+ want string
+ changed bool
+ }{
+ // if / else / endif
+ {`hi", "hi", true},
+ }
+ for _, c := range cases {
+ got, changed := apply(t, NoAlternativeSyntax{}, c.src)
+ if got != c.want || changed != c.changed {
+ t.Fatalf("src=%q changed=%v got=%q want=%q", c.src, changed, got, c.want)
+ }
+ if again, changed2 := apply(t, NoAlternativeSyntax{}, got); changed2 || again != got {
+ t.Fatalf("not idempotent: %q -> %q changed=%v", got, again, changed2)
+ }
+ }
+}
diff --git a/blink/internal/fixer/rules/no_blank_line_between_imports.go b/blink/internal/fixer/rules/no_blank_line_between_imports.go
new file mode 100644
index 0000000000..445da079ad
--- /dev/null
+++ b/blink/internal/fixer/rules/no_blank_line_between_imports.go
@@ -0,0 +1,60 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// Symplify: https://github.com/ecsphp/ecs-src/blob/main/packages/coding-standard/src/Fixer/Spacing/NoBlankLineBetweenImportsFixer.php
+//
+// NoBlankLineBetweenImports removes blank lines between consecutive import "use"
+// statements. The blank line after the namespace (before the first import) is
+// left untouched.
+type NoBlankLineBetweenImports struct{}
+
+func (NoBlankLineBetweenImports) Name() string {
+ return `Symplify\CodingStandard\Fixer\Spacing\NoBlankLineBetweenImportsFixer`
+}
+
+func (NoBlankLineBetweenImports) SourceURL() string {
+ return "https://github.com/ecsphp/ecs-src/blob/main/packages/coding-standard/src/Fixer/Spacing/NoBlankLineBetweenImportsFixer.php"
+}
+
+func (NoBlankLineBetweenImports) Fix(s *tokens.Stream) bool {
+ // collect import "use" indexes (skip in-class trait use and closure use)
+ var uses []int
+ for i := 0; i < s.Len(); i++ {
+ if s.At(i).Kind != token.Keyword || strings.ToLower(s.At(i).Value) != "use" {
+ continue
+ }
+ if inClassLikeBody(s, i) {
+ continue
+ }
+ if j := skipWhitespace(s, i+1); j < s.Len() && s.At(j).Kind == token.Punct && s.At(j).Value == "(" {
+ continue
+ }
+ uses = append(uses, i)
+ }
+
+ changed := false
+ // start at the 2nd use, so the blank line after the namespace stays
+ for k := 1; k < len(uses); k++ {
+ useIndex := uses[k]
+ prev := sigPrev(s, useIndex)
+ if prev < 0 || s.At(prev).Kind != token.Punct || s.At(prev).Value != ";" {
+ continue
+ }
+ ws := useIndex - 1
+ if ws < 0 || s.At(ws).Kind != token.Whitespace {
+ continue
+ }
+ if strings.Count(s.At(ws).Value, "\n") < 2 {
+ continue
+ }
+ s.SetValue(ws, "\n")
+ changed = true
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/no_blank_line_between_imports_test.go b/blink/internal/fixer/rules/no_blank_line_between_imports_test.go
new file mode 100644
index 0000000000..fec7108bbc
--- /dev/null
+++ b/blink/internal/fixer/rules/no_blank_line_between_imports_test.go
@@ -0,0 +1,23 @@
+package rules
+
+import "testing"
+
+func TestNoBlankLineBetweenImports(t *testing.T) {
+ f := NoBlankLineBetweenImports{}
+
+ // blank lines between consecutive imports collapse; the namespace gap stays
+ got, changed := apply(t, f, "= 0; i-- {
+ t := s.At(i)
+ if kwIs(t, "default") {
+ if n := sigNext(s, i); n >= 0 && isPunctVal(s, n, "=>") {
+ continue // "default" of a match expression
+ }
+ } else if !kwIs(t, "case") || isEnumCase(s, i, hasEnum) {
+ continue
+ }
+
+ colon := nextPunctOfKind(s, i, ":", ";")
+ if colon < 0 {
+ continue
+ }
+ fixNoBreakCase(s, colon)
+ }
+ return s.Render() != before
+}
+
+func fixNoBreakCase(s *tokens.Stream, casePosition int) {
+ empty := true
+ fallThrough := true
+ commentPosition := -1
+
+ for i := casePosition + 1; i < s.Len(); i++ {
+ t := s.At(i)
+
+ if t.Kind == token.Keyword {
+ lv := strings.ToLower(t.Value)
+ if structureKinds[lv] || lv == "else" || lv == "do" || lv == "class" {
+ empty = false
+ i = noBreakStructureEnd(s, i)
+ continue
+ }
+ switch lv {
+ case "break", "continue", "return", "exit", "die", "goto":
+ fallThrough = false
+ continue
+ case "throw":
+ prev := sigPrev(s, i)
+ if prev == casePosition || (prev >= 0 && isThrowStatementStart(s, prev)) {
+ fallThrough = false
+ }
+ continue
+ case "endswitch":
+ if commentPosition >= 0 {
+ removeNoBreakComment(s, commentPosition)
+ }
+ return
+ case "case", "default":
+ handleNextCase(s, i, empty, fallThrough, commentPosition)
+ return
+ }
+ }
+
+ if isPunctVal(s, i, "}") {
+ if commentPosition >= 0 {
+ removeNoBreakComment(s, commentPosition)
+ }
+ return
+ }
+
+ if isNoBreakCommentToken(t) {
+ commentPosition = i
+ continue
+ }
+
+ if t.Kind != token.Comment && t.Kind != token.DocComment && t.Kind != token.Whitespace {
+ empty = false
+ }
+ }
+}
+
+// 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) {
+ if !empty && fallThrough {
+ if commentPosition >= 0 && getPrevNonWhitespace(s, casePos) != commentPosition {
+ removeNoBreakComment(s, commentPosition)
+ commentPosition = -1
+ }
+ if commentPosition < 0 {
+ insertNoBreakCommentAt(s, casePos)
+ } else {
+ ensureNewLineAt(s, commentPosition)
+ }
+ return
+ }
+ if commentPosition >= 0 {
+ removeNoBreakComment(s, commentPosition)
+ }
+}
+
+func isThrowStatementStart(s *tokens.Stream, prev int) bool {
+ if s.At(prev).Kind == token.OpenTag {
+ return true
+ }
+ if s.At(prev).Kind == token.Punct {
+ switch s.At(prev).Value {
+ case "{", ";", "}":
+ return true
+ }
+ }
+ return false
+}
+
+func isNoBreakCommentToken(t token.Token) bool {
+ if t.Kind != token.Comment && t.Kind != token.DocComment {
+ return false
+ }
+ return noBreakCommentRe.MatchString(t.Value)
+}
+
+// noBreakStructureEnd mirrors getStructureEnd: the index of the token closing
+// the control structure that starts at position.
+func noBreakStructureEnd(s *tokens.Stream, position int) int {
+ initial := strings.ToLower(s.At(position).Value)
+
+ if structureKinds[initial] {
+ if op := nextPunctOfKind(s, position, "("); op >= 0 {
+ position = s.MatchForward(op)
+ }
+ } else if initial == "class" {
+ op := sigNext(s, position)
+ if op >= 0 && isPunctVal(s, op, "(") {
+ position = s.MatchForward(op)
+ }
+ }
+
+ if initial == "function" {
+ position = nextPunctOfKind(s, position, "{")
+ } else {
+ position = sigNext(s, position)
+ }
+ if position < 0 {
+ return s.Len() - 1
+ }
+
+ if !isPunctVal(s, position, "{") {
+ return nextPunctOfKind(s, position, ";")
+ }
+
+ position = s.MatchForward(position)
+
+ if initial == "do" {
+ if op := nextPunctOfKind(s, position, "("); op >= 0 {
+ position = s.MatchForward(op)
+ }
+ return nextPunctOfKind(s, position, ";")
+ }
+ return position
+}
+
+func insertNoBreakCommentAt(s *tokens.Stream, casePosition int) {
+ newlinePosition := ensureNewLineAt(s, casePosition)
+ content := s.At(newlinePosition).Value
+ nbNewlines := strings.Count(content, "\n")
+
+ if s.At(newlinePosition).Kind == token.OpenTag && hasNewline(content) {
+ nbNewlines++
+ } else if newlinePosition-1 >= 0 && s.At(newlinePosition-1).Kind == token.OpenTag && hasNewline(s.At(newlinePosition-1).Value) {
+ nbNewlines++
+ if !hasNewline(content) {
+ s.SetValue(newlinePosition, "\n"+content)
+ content = s.At(newlinePosition).Value
+ }
+ }
+
+ if nbNewlines > 1 {
+ head, tail := splitTrailingNewline(content)
+ indent := detectIndent(s, newlinePosition-1)
+ s.SetValue(newlinePosition, head+"\n"+indent)
+ newlinePosition++
+ s.InsertAt(newlinePosition, token.Token{Kind: token.Whitespace, Value: tail})
+ }
+
+ s.InsertAt(newlinePosition, token.Token{Kind: token.Comment, Value: "// " + noBreakCommentText})
+ ensureNewLineAt(s, newlinePosition)
+}
+
+// ensureNewLineAt guarantees a newline before position and returns the index of
+// the newline token.
+func ensureNewLineAt(s *tokens.Stream, position int) int {
+ content := "\n" + detectIndent(s, position)
+ if position-1 < 0 {
+ return position
+ }
+ ws := s.At(position - 1)
+
+ if ws.Kind != token.Whitespace {
+ if ws.Kind == token.OpenTag {
+ content = stripNewlines(content)
+ if !hasNewline(ws.Value) {
+ s.SetValue(position-1, replaceTrailingSpace(ws.Value, "\n"))
+ }
+ }
+ if content != "" {
+ s.InsertAt(position, token.Token{Kind: token.Whitespace, Value: content})
+ return position
+ }
+ return position - 1
+ }
+
+ if position-2 >= 0 && s.At(position-2).Kind == token.OpenTag && hasNewline(s.At(position-2).Value) {
+ content = strings.TrimPrefix(content, "\n")
+ }
+ if !hasNewline(ws.Value) {
+ s.SetValue(position-1, content)
+ }
+ return position - 1
+}
+
+func removeNoBreakComment(s *tokens.Stream, commentPosition int) {
+ var whitespacePosition int
+ prevNW := getPrevNonWhitespace(s, commentPosition)
+ afterOpenTag := prevNW >= 0 && s.At(prevNW).Kind == token.OpenTag
+
+ if afterOpenTag {
+ whitespacePosition = commentPosition + 1
+ } else {
+ whitespacePosition = commentPosition - 1
+ }
+
+ if whitespacePosition >= 0 && whitespacePosition < s.Len() && s.At(whitespacePosition).Kind == token.Whitespace {
+ v := s.At(whitespacePosition).Value
+ if afterOpenTag {
+ v = stripLeadingNewlineIndent(v)
+ } else {
+ v = stripTrailingNewlineIndent(v)
+ }
+ if v == "" {
+ s.RemoveAt(whitespacePosition)
+ if whitespacePosition < commentPosition {
+ commentPosition--
+ }
+ } else {
+ s.SetValue(whitespacePosition, v)
+ }
+ }
+
+ clearAndMergeWhitespace(s, commentPosition)
+}
+
+// isEnumCase reports whether the "case" at caseIndex belongs to an enum.
+func isEnumCase(s *tokens.Stream, caseIndex int, hasEnum bool) bool {
+ if !hasEnum {
+ return false
+ }
+ prev := caseIndex
+ for {
+ prev = prevOfKinds(s, prev)
+ if prev < 0 {
+ return false
+ }
+ if isPunctVal(s, prev, "}") {
+ prev = s.MatchBackward(prev)
+ if prev < 0 {
+ return false
+ }
+ continue
+ }
+ return kwIs(s.At(prev), "enum")
+ }
+}
+
+// prevOfKinds finds the previous "}", "enum" or "switch" before idx.
+func prevOfKinds(s *tokens.Stream, idx int) int {
+ for j := idx - 1; j >= 0; j-- {
+ if isPunctVal(s, j, "}") {
+ return j
+ }
+ if s.At(j).Kind == token.Keyword {
+ switch strings.ToLower(s.At(j).Value) {
+ case "enum", "switch":
+ return j
+ }
+ }
+ }
+ return -1
+}
+
+func kwIs(t token.Token, name string) bool {
+ return t.Kind == token.Keyword && strings.EqualFold(t.Value, name)
+}
+
+func isPunctVal(s *tokens.Stream, i int, v string) bool {
+ return i >= 0 && i < s.Len() && s.At(i).Kind == token.Punct && s.At(i).Value == v
+}
+
+// nextPunctOfKind scans forward from idx for a punctuation token matching one of
+// vals and returns its index, or -1.
+func nextPunctOfKind(s *tokens.Stream, idx int, vals ...string) int {
+ for j := idx + 1; j < s.Len(); j++ {
+ if s.At(j).Kind != token.Punct {
+ continue
+ }
+ for _, v := range vals {
+ if s.At(j).Value == v {
+ return j
+ }
+ }
+ }
+ return -1
+}
+
+func getPrevNonWhitespace(s *tokens.Stream, i int) int {
+ for j := i - 1; j >= 0; j-- {
+ if s.At(j).Kind != token.Whitespace {
+ return j
+ }
+ }
+ return -1
+}
+
+func prevWhitespace(s *tokens.Stream, i int) int {
+ for j := i - 1; j >= 0; j-- {
+ if s.At(j).Kind == token.Whitespace {
+ return j
+ }
+ }
+ return -1
+}
+
+// detectIndent mirrors WhitespacesAnalyzer::detectIndent.
+func detectIndent(s *tokens.Stream, index int) string {
+ idx := index
+ for {
+ wi := prevWhitespace(s, idx)
+ if wi < 0 {
+ return ""
+ }
+ w := s.At(wi).Value
+ if strings.Contains(w, "\n") {
+ parts := strings.Split(w, "\n")
+ return parts[len(parts)-1]
+ }
+ if wi-1 >= 0 {
+ prev := s.At(wi - 1)
+ if (prev.Kind == token.OpenTag || prev.Kind == token.Comment) && strings.HasSuffix(prev.Value, "\n") {
+ parts := strings.Split(w, "\n")
+ return parts[len(parts)-1]
+ }
+ }
+ idx = wi
+ }
+}
+
+func splitTrailingNewline(content string) (head, tail string) {
+ nl := strings.LastIndexByte(content, '\n')
+ if nl < 0 {
+ return content, ""
+ }
+ return content[:nl], content[nl:]
+}
+
+func stripNewlines(v string) string {
+ return strings.NewReplacer("\r", "", "\n", "").Replace(v)
+}
+
+func replaceTrailingSpace(v, repl string) string {
+ trimmed := strings.TrimRight(v, " \t\r\n")
+ if trimmed == v {
+ return v
+ }
+ return trimmed + repl
+}
+
+func stripLeadingNewlineIndent(v string) string {
+ i := 0
+ if i < len(v) && (v[i] == '\n' || v[i] == '\r') {
+ for i < len(v) && (v[i] == '\n' || v[i] == '\r') {
+ i++
+ }
+ for i < len(v) && (v[i] == ' ' || v[i] == '\t') {
+ i++
+ }
+ return v[i:]
+ }
+ return v
+}
+
+func stripTrailingNewlineIndent(v string) string {
+ end := len(v)
+ j := end
+ for j > 0 && (v[j-1] == ' ' || v[j-1] == '\t') {
+ j--
+ }
+ if j > 0 && (v[j-1] == '\n' || v[j-1] == '\r') {
+ for j > 0 && (v[j-1] == '\n' || v[j-1] == '\r') {
+ j--
+ }
+ return v[:j]
+ }
+ return v
+}
+
+// clearAndMergeWhitespace removes the token at i and merges surrounding
+// whitespace, mirroring Tokens::clearTokenAndMergeSurroundingWhitespace on the
+// rendered bytes.
+func clearAndMergeWhitespace(s *tokens.Stream, i int) {
+ s.RemoveAt(i)
+}
diff --git a/blink/internal/fixer/rules/no_break_comment_test.go b/blink/internal/fixer/rules/no_break_comment_test.go
new file mode 100644
index 0000000000..0c15883188
--- /dev/null
+++ b/blink/internal/fixer/rules/no_break_comment_test.go
@@ -0,0 +1,39 @@
+package rules
+
+import "testing"
+
+func TestNoBreakComment(t *testing.T) {
+ f := NoBreakComment{}
+
+ // fall-through with a non-empty body gains a "// no break" comment
+ got, changed := apply(t, f, "= 1; i-- {
+ t := s.At(i)
+ if t.Kind != token.Punct || t.Value != ";" {
+ continue
+ }
+ prev := s.At(i - 1)
+ if prev.Kind == token.Whitespace && !strings.ContainsAny(prev.Value, "\n\r") {
+ s.RemoveAt(i - 1)
+ changed = true
+ }
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/no_superfluous_elseif.go b/blink/internal/fixer/rules/no_superfluous_elseif.go
new file mode 100644
index 0000000000..66bde601fb
--- /dev/null
+++ b/blink/internal/fixer/rules/no_superfluous_elseif.go
@@ -0,0 +1,264 @@
+package rules
+
+import (
+ "regexp"
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/NoSuperfluousElseifFixer.php
+//
+// NoSuperfluousElseif replaces `elseif` with `if` when every preceding branch of
+// the if-chain always exits (return/throw/break/continue/exit/goto), so the
+// elseif is not actually reachable as an else. It ports the flow analysis from
+// AbstractNoUselessElseFixer.
+type NoSuperfluousElseif struct{}
+
+func (NoSuperfluousElseif) Name() string {
+ return `PhpCsFixer\Fixer\ControlStructure\NoSuperfluousElseifFixer`
+}
+
+func (NoSuperfluousElseif) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/NoSuperfluousElseifFixer.php"
+}
+
+var nseTrailingIndentRe = regexp.MustCompile(`(?:\r\n|\r|\n)[^\r\n]*$`)
+
+func (NoSuperfluousElseif) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ if nseIsElseif(s, i) && nseIsSuperfluousElse(s, i) {
+ nseConvertElseifToIf(s, i)
+ changed = true
+ }
+ }
+ return changed
+}
+
+func nseIsElseif(s *tokens.Stream, i int) bool {
+ if kwIs(s.At(i), "elseif") {
+ return true
+ }
+ if kwIs(s.At(i), "else") {
+ n := sigNext(s, i)
+ return n >= 0 && kwIs(s.At(n), "if")
+ }
+ return false
+}
+
+func nseConvertElseifToIf(s *tokens.Stream, index int) {
+ if kwIs(s.At(index), "else") {
+ // clearTokenAndMergeSurroundingWhitespace: merge the whitespace on both
+ // sides of the removed "else" into one; index then lands on the "if".
+ before, after := index-1, index+1
+ if before >= 0 && s.At(before).Kind == token.Whitespace &&
+ after < s.Len() && s.At(after).Kind == token.Whitespace {
+ s.SetValue(before, s.At(before).Value+s.At(after).Value)
+ s.RemoveAt(after)
+ }
+ s.RemoveAt(index)
+ } else {
+ s.Set(index, token.Token{Kind: token.Keyword, Value: "if"})
+ }
+
+ whitespace := ""
+ for previous := index - 1; previous > 0; previous-- {
+ t := s.At(previous)
+ if t.Kind == token.Whitespace {
+ if m := nseTrailingIndentRe.FindString(t.Value); m != "" {
+ whitespace = m
+ break
+ }
+ }
+ }
+ if whitespace == "" {
+ return
+ }
+
+ if index-1 < 0 {
+ return
+ }
+ prev := s.At(index - 1)
+ if prev.Kind != token.Whitespace {
+ s.InsertAt(index, token.Token{Kind: token.Whitespace, Value: whitespace})
+ } else if !strings.ContainsAny(prev.Value, "\r\n") {
+ s.SetValue(index-1, whitespace)
+ }
+}
+
+func nseIsSuperfluousElse(s *tokens.Stream, index int) bool {
+ previousBlockStart := index
+ for {
+ var previousBlockEnd int
+ previousBlockStart, previousBlockEnd = nseGetPreviousBlock(s, previousBlockStart)
+ if previousBlockStart < 0 || previousBlockEnd < 0 {
+ return false
+ }
+
+ previous := previousBlockEnd
+ if isPunctVal(s, previous, "}") {
+ previous = sigPrev(s, previous)
+ }
+ if previous < 0 {
+ return false
+ }
+ // 'if' block doesn't end with ';' -> keep; empty '{ }' block -> keep
+ if !isPunctVal(s, previous, ";") || isPunctVal(s, sigPrev(s, previous), "{") {
+ return false
+ }
+
+ candidateIndex := nsePrevTokenOfKind(s, previous, nseTerminators)
+ if candidateIndex < 0 {
+ return false
+ }
+ cand := s.At(candidateIndex)
+ if isPunctVal(s, candidateIndex, ";") || cand.Kind == token.CloseTag || kwIs(cand, "if") {
+ return false
+ }
+
+ if kwIs(cand, "throw") {
+ pi := sigPrev(s, candidateIndex)
+ if !isPunctVal(s, pi, ";") && !isPunctVal(s, pi, "{") {
+ return false
+ }
+ }
+
+ if nseIsInConditional(s, candidateIndex, previousBlockStart) ||
+ nseIsInConditionWithoutBraces(s, candidateIndex, previousBlockStart) {
+ return false
+ }
+
+ if kwIs(s.At(previousBlockStart), "if") {
+ break
+ }
+ }
+ return true
+}
+
+func nseGetPreviousBlock(s *tokens.Stream, index int) (int, int) {
+ closeIdx := sigPrev(s, index)
+ previous := closeIdx
+ if closeIdx >= 0 && isPunctVal(s, closeIdx, "}") {
+ previous = s.MatchBackward(closeIdx)
+ }
+ if previous < 0 {
+ return -1, closeIdx
+ }
+ open := nsePrevTokenOfKind(s, previous, nseIfElseElseif)
+ if open < 0 {
+ return -1, closeIdx
+ }
+ if kwIs(s.At(open), "if") {
+ elseCandidate := sigPrev(s, open)
+ if elseCandidate >= 0 && kwIs(s.At(elseCandidate), "else") {
+ open = elseCandidate
+ }
+ }
+ return open, closeIdx
+}
+
+func nseIsInConditional(s *tokens.Stream, index, lowerLimit int) bool {
+ candidateIndex := nsePrevTokenOfKind(s, index, nseParenSemiColon)
+ if candidateIndex < 0 {
+ return false
+ }
+ if isPunctVal(s, candidateIndex, ":") {
+ return true
+ }
+ if !isPunctVal(s, candidateIndex, ")") {
+ return false
+ }
+ open := s.MatchBackward(candidateIndex)
+ if open < 0 {
+ return false
+ }
+ return sigPrev(s, open) > lowerLimit
+}
+
+func nseIsInConditionWithoutBraces(s *tokens.Stream, index, lowerLimit int) bool {
+ for index > lowerLimit {
+ if k := s.At(index).Kind; k == token.Comment || k == token.DocComment || k == token.Whitespace {
+ index = sigPrev(s, index)
+ if index < 0 {
+ return false
+ }
+ }
+ t := s.At(index)
+ if t.Kind == token.Keyword {
+ switch strings.ToLower(t.Value) {
+ case "if", "elseif", "else":
+ return true
+ }
+ }
+ if isPunctVal(s, index, ";") {
+ return false
+ }
+ if isPunctVal(s, index, "{") {
+ index = sigPrev(s, index)
+ if index >= 0 && kwIs(s.At(index), "do") {
+ index--
+ continue
+ }
+ if !isPunctVal(s, index, ")") {
+ return false
+ }
+ index = s.MatchBackward(index)
+ if index < 0 {
+ return false
+ }
+ index = sigPrev(s, index)
+ if index >= 0 && (kwIs(s.At(index), "if") || kwIs(s.At(index), "elseif")) {
+ return false
+ }
+ } else if isPunctVal(s, index, ")") {
+ index = s.MatchBackward(index)
+ if index < 0 {
+ return false
+ }
+ index = sigPrev(s, index)
+ } else {
+ index--
+ }
+ }
+ return false
+}
+
+// token-kind specs for nsePrevTokenOfKind
+type nseKind struct {
+ kw string // keyword name (lowercase), or ""
+ punct string // punct value, or ""
+ close bool // matches token.CloseTag
+}
+
+var nseTerminators = []nseKind{
+ {punct: ";"}, {kw: "break"}, {close: true}, {kw: "continue"},
+ {kw: "exit"}, {kw: "die"}, {kw: "goto"}, {kw: "if"}, {kw: "return"}, {kw: "throw"},
+}
+var nseIfElseElseif = []nseKind{{kw: "if"}, {kw: "else"}, {kw: "elseif"}}
+var nseParenSemiColon = []nseKind{{punct: ")"}, {punct: ";"}, {punct: ":"}}
+
+func nseMatchKind(t token.Token, k nseKind) bool {
+ if k.close {
+ return t.Kind == token.CloseTag
+ }
+ if k.kw != "" {
+ return t.Kind == token.Keyword && strings.EqualFold(t.Value, k.kw)
+ }
+ return t.Kind == token.Punct && t.Value == k.punct
+}
+
+// nsePrevTokenOfKind mirrors Tokens::getPrevTokenOfKind: scan back from before
+// idx for the first token matching any spec.
+func nsePrevTokenOfKind(s *tokens.Stream, idx int, kinds []nseKind) int {
+ for j := idx - 1; j >= 0; j-- {
+ t := s.At(j)
+ for _, k := range kinds {
+ if nseMatchKind(t, k) {
+ return j
+ }
+ }
+ }
+ return -1
+}
diff --git a/blink/internal/fixer/rules/no_superfluous_elseif_test.go b/blink/internal/fixer/rules/no_superfluous_elseif_test.go
new file mode 100644
index 0000000000..3b12395a89
--- /dev/null
+++ b/blink/internal/fixer/rules/no_superfluous_elseif_test.go
@@ -0,0 +1,33 @@
+package rules
+
+import "testing"
+
+func TestNoSuperfluousElseif(t *testing.T) {
+ f := NoSuperfluousElseif{}
+
+ // preceding branch returns -> elseif becomes if
+ got, changed := apply(t, f, " unchanged
+ if _, changed := apply(t, f, " normalized native type ("" if none)
+ ret string // normalized native return type ("" if none)
+ hasRet bool
+ varType string // normalized native type of a documented property
+ hasVar bool // the docblock documents a property
+}
+
+// signatureAfter finds the function declaration a docblock at index i documents
+// and extracts its parameter and return types.
+func signatureAfter(s *tokens.Stream, doc int) (funcSig, bool) {
+ // Walk forward across whitespace, attributes (#[...] Comment) and modifier
+ // keywords to the "function" keyword.
+ j := doc + 1
+ for j < s.Len() {
+ t := s.At(j)
+ switch t.Kind {
+ case token.Whitespace, token.Comment:
+ j++
+ continue
+ case token.Keyword:
+ lv := strings.ToLower(t.Value)
+ if lv == "function" {
+ return parseSignature(s, j)
+ }
+ if lv == "const" {
+ return funcSig{}, false
+ }
+ if lv == "var" || isFuncModifier(lv) || lv == "readonly" {
+ j++
+ continue
+ }
+ // a typed property whose type is a reserved word (e.g. "array")
+ return parseProperty(s, j)
+ case token.Ident, token.Punct, token.Variable:
+ // a property: "Foo $x", "?Foo $x", "\Foo\Bar $x", or untyped "$x"
+ return parseProperty(s, j)
+ default:
+ return funcSig{}, false
+ }
+ }
+ return funcSig{}, false
+}
+
+// parseProperty reads the native type of a property declaration starting at the
+// first type (or variable) token, for validating a documenting @var tag.
+func parseProperty(s *tokens.Stream, start int) (funcSig, bool) {
+ var typeToks []string
+ for k := start; k < s.Len(); k++ {
+ t := s.At(k)
+ switch t.Kind {
+ case token.Whitespace, token.Comment:
+ continue
+ case token.Variable:
+ return funcSig{hasVar: true, varType: normalizeType(strings.Join(typeToks, ""))}, true
+ case token.Ident, token.Keyword:
+ typeToks = append(typeToks, t.Value)
+ case token.Punct:
+ if t.Value == "?" || t.Value == "|" || t.Value == "&" || t.Value == `\` {
+ typeToks = append(typeToks, t.Value)
+ continue
+ }
+ return funcSig{}, false
+ default:
+ return funcSig{}, false
+ }
+ }
+ return funcSig{}, false
+}
+
+func isFuncModifier(v string) bool {
+ switch v {
+ case "public", "private", "protected", "static", "final", "abstract":
+ return true
+ }
+ return false
+}
+
+// parseSignature parses "(...)" params and an optional ": returnType" starting at
+// the "function" keyword index.
+func parseSignature(s *tokens.Stream, fn int) (funcSig, bool) {
+ open := nextSignificantIndex(s, fn)
+ // skip an optional function name and the "&" of a by-ref function
+ for open >= 0 && s.At(open).Kind != token.Punct {
+ open = nextSignificantIndex(s, open)
+ }
+ if open < 0 || s.At(open).Value == "&" {
+ open = nextSignificantIndex(s, open)
+ }
+ if open < 0 || s.At(open).Kind != token.Punct || s.At(open).Value != "(" {
+ return funcSig{}, false
+ }
+ close := s.MatchForward(open)
+ if close < 0 {
+ return funcSig{}, false
+ }
+ sig := funcSig{params: map[string]string{}}
+ parseParams(s, open, close, &sig)
+
+ // return type: ": type ..." up to "{" or ";"
+ c := nextSignificantIndex(s, close)
+ if c >= 0 && s.At(c).Kind == token.Punct && s.At(c).Value == ":" {
+ var parts []string
+ for k := c + 1; k < s.Len(); k++ {
+ t := s.At(k)
+ if t.Kind == token.Whitespace {
+ continue
+ }
+ if t.Kind == token.Punct && (t.Value == "{" || t.Value == ";") {
+ break
+ }
+ parts = append(parts, t.Value)
+ }
+ sig.ret = normalizeType(strings.Join(parts, ""))
+ sig.hasRet = true
+ }
+ return sig, true
+}
+
+// parseParams collects each parameter's variable name and native type between the
+// parameter-list parentheses.
+func parseParams(s *tokens.Stream, open, close int, sig *funcSig) {
+ var typeToks []string
+ var varName string
+ depth := 0
+ inDefault := false
+ flush := func() {
+ if varName != "" {
+ sig.params[varName] = normalizeType(strings.Join(typeToks, ""))
+ }
+ typeToks = nil
+ varName = ""
+ inDefault = false
+ }
+ for k := open + 1; k < close; k++ {
+ t := s.At(k)
+ if t.Kind == token.Whitespace || t.Kind == token.Comment || t.Kind == token.DocComment {
+ continue
+ }
+ if t.Kind == token.Punct {
+ switch t.Value {
+ case "(", "[", "{":
+ depth++
+ case ")", "]", "}":
+ depth--
+ case ",":
+ if depth == 0 {
+ flush()
+ continue
+ }
+ case "=":
+ if depth == 0 {
+ inDefault = true
+ continue
+ }
+ }
+ if !inDefault && depth == 0 && (t.Value == "?" || t.Value == "|" || t.Value == "&" || t.Value == `\`) {
+ typeToks = append(typeToks, t.Value)
+ }
+ continue
+ }
+ if inDefault {
+ continue
+ }
+ switch t.Kind {
+ case token.Variable:
+ if depth == 0 {
+ varName = strings.TrimPrefix(t.Value, "$")
+ }
+ case token.Ident, token.Keyword:
+ if depth == 0 && !isParamModifier(strings.ToLower(t.Value)) {
+ typeToks = append(typeToks, t.Value)
+ }
+ }
+ }
+ flush()
+}
+
+func isParamModifier(v string) bool {
+ switch v {
+ case "public", "private", "protected", "readonly":
+ return true
+ }
+ return false
+}
+
+// filterSuperfluous drops @param/@return lines made redundant by sig. Reports
+// whether anything was removed.
+func filterSuperfluous(d *docblock, sig funcSig) bool {
+ kept := make([]docLine, 0, len(d.inner))
+ removed := false
+ for idx := 0; idx < len(d.inner); idx++ {
+ l := d.inner[idx]
+ content := strings.TrimLeft(l.content, " ")
+ if drop, ok := superfluousTag(content, sig); ok && drop && !hasContinuation(d.inner, idx) {
+ removed = true
+ continue
+ }
+ kept = append(kept, l)
+ }
+ if removed {
+ d.inner = kept
+ }
+ return removed
+}
+
+// hasContinuation reports whether the tag line at idx is followed by a wrapped
+// description line (a non-blank, non-tag inner line), which means it carries a
+// description and must be kept.
+func hasContinuation(inner []docLine, idx int) bool {
+ if idx+1 >= len(inner) {
+ return false
+ }
+ next := strings.TrimSpace(inner[idx+1].content)
+ return next != "" && !strings.HasPrefix(next, "@")
+}
+
+// superfluousTag reports whether a @param/@return line is redundant given sig.
+func superfluousTag(content string, sig funcSig) (bool, bool) {
+ if m := nspParamRe.FindStringSubmatch(content); m != nil {
+ phpType, name, desc := m[1], m[2], strings.TrimSpace(m[3])
+ if desc != "" {
+ return false, true
+ }
+ varName := name[strings.IndexByte(name, '$')+1:]
+ native, ok := sig.params[varName]
+ if !ok {
+ return false, true
+ }
+ return typeIsSuperfluous(phpType, native), true
+ }
+ if m := nspReturnRe.FindStringSubmatch(content); m != nil {
+ phpType, desc := m[1], strings.TrimSpace(m[2])
+ if desc != "" {
+ return false, true
+ }
+ return typeIsSuperfluous(phpType, sig.ret), true
+ }
+ if sig.hasVar {
+ if m := nspVarRe.FindStringSubmatch(content); m != nil {
+ phpType, desc := m[1], strings.TrimSpace(m[2])
+ if desc != "" {
+ return false, true
+ }
+ return typeIsSuperfluous(phpType, sig.varType), true
+ }
+ }
+ return false, false
+}
+
+// typeIsSuperfluous reports whether a phpdoc type adds nothing over the native
+// type: it normalizes to exactly the native type and is not more specific (no
+// generics/shapes/callable signatures). A "mixed" tag is superfluous only when
+// the native type is also mixed; on an untyped param/return "@param mixed" adds
+// information and is kept, matching ECS (allow_mixed).
+func typeIsSuperfluous(phpType, native string) bool {
+ if strings.ContainsAny(phpType, "<{(") {
+ return false
+ }
+ if native == "" {
+ return false
+ }
+ return normalizeType(phpType) == native
+}
+
+// normalizeType canonicalizes a type for comparison: lowercase, each union member
+// reduced to its short name, "?T" expanded to the "null|t" set, members sorted
+// and de-duplicated.
+func normalizeType(t string) string {
+ t = strings.TrimSpace(t)
+ if t == "" {
+ return ""
+ }
+ nullable := strings.HasPrefix(t, "?")
+ t = strings.TrimPrefix(t, "?")
+ seps := func(r rune) bool { return r == '|' || r == '&' }
+ members := strings.FieldsFunc(t, seps)
+ set := map[string]struct{}{}
+ for _, m := range members {
+ m = strings.ToLower(strings.TrimSpace(m))
+ if i := strings.LastIndex(m, `\`); i >= 0 {
+ m = m[i+1:]
+ }
+ if m != "" {
+ set[m] = struct{}{}
+ }
+ }
+ if nullable {
+ set["null"] = struct{}{}
+ }
+ out := make([]string, 0, len(set))
+ for m := range set {
+ out = append(out, m)
+ }
+ sort.Strings(out)
+ return strings.Join(out, "|")
+}
diff --git a/blink/internal/fixer/rules/no_superfluous_phpdoc_tags_test.go b/blink/internal/fixer/rules/no_superfluous_phpdoc_tags_test.go
new file mode 100644
index 0000000000..9e5d916ba1
--- /dev/null
+++ b/blink/internal/fixer/rules/no_superfluous_phpdoc_tags_test.go
@@ -0,0 +1,93 @@
+package rules
+
+import (
+ "testing"
+
+ "blink/internal/fixer"
+)
+
+func TestNoSuperfluousPhpdocTags(t *testing.T) {
+ f := NoSuperfluousPhpdocTags{}
+
+ // @param equal to the native type, no description -> removed
+ assertFix(t, f,
+ " kept
+ assertFix(t, f,
+ " $b\n */\nfunction f(array $b) {}",
+ " $b\n */\nfunction f(array $b) {}", false)
+
+ // @param with a description -> kept
+ assertFix(t, f,
+ " kept (ECS allow_mixed: mixed adds info)
+ assertFix(t, f,
+ " removed (superfluous)
+ assertFix(t, f,
+ " kept (phpdoc adds info)
+ assertFix(t, f,
+ " removed
+ assertFix(t, f,
+ " kept
+ assertFix(t, f,
+ " kept
+ assertFix(t, f,
+ "|float\n */\nfunction f(): array|float {}",
+ "|float\n */\nfunction f(): array|float {}", false)
+
+ // FQ phpdoc type equals the imported short native type -> removed
+ assertFix(t, f,
+ " removed
+ assertFix(t, f,
+ " removed
+ assertFix(t, f,
+ " kept
+ assertFix(t, f,
+ " "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{}
+
+func (NoUnneededBraces) Name() string {
+ return `PhpCsFixer\Fixer\ControlStructure\NoUnneededCurlyBracesFixer`
+}
+
+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 {
+ changed := false
+ for i := s.Len() - 1; i > 0; i-- {
+ if !nubIsPunct(s.At(i), "{") {
+ continue
+ }
+ prev := nubPrevMeaningful(s, i)
+ if prev == -1 {
+ continue
+ }
+ if nubIsPunct(s.At(prev), `\`) {
+ // group import: "use A\{B};" -> "use A\B;" when single element, one line
+ if nubGroupImportOverComplete(s, i) {
+ closeIndex := s.MatchForward(i)
+ if closeIndex != -1 {
+ s.RemoveAt(closeIndex)
+ s.RemoveAt(i)
+ changed = true
+ }
+ }
+ continue
+ }
+ if nubRegularOverComplete(s, prev) {
+ closeIndex := s.MatchForward(i)
+ if closeIndex != -1 {
+ s.RemoveAt(closeIndex)
+ s.RemoveAt(i)
+ changed = true
+ }
+ }
+ }
+ return changed
+}
+
+// nubRegularOverComplete reports whether a "{" whose previous meaningful token is
+// at prev opens a superfluous block (prev is "{", "}", "= 0; j-- {
+ k := s.At(j).Kind
+ if k == token.Whitespace || k == token.Comment || k == token.DocComment {
+ continue
+ }
+ return j
+ }
+ return -1
+}
diff --git a/blink/internal/fixer/rules/no_unneeded_braces_test.go b/blink/internal/fixer/rules/no_unneeded_braces_test.go
new file mode 100644
index 0000000000..70759de6a5
--- /dev/null
+++ b/blink/internal/fixer/rules/no_unneeded_braces_test.go
@@ -0,0 +1,38 @@
+package rules
+
+import "testing"
+
+func TestNoUnneededBraces(t *testing.T) {
+ cases := []struct {
+ src string
+ want string
+ changed bool
+ }{
+ // standalone block after open tag
+ {" %q changed=%v", got, again, changed2)
+ }
+ }
+}
diff --git a/blink/internal/fixer/rules/no_unneeded_control_parentheses.go b/blink/internal/fixer/rules/no_unneeded_control_parentheses.go
new file mode 100644
index 0000000000..99fb67cbc7
--- /dev/null
+++ b/blink/internal/fixer/rules/no_unneeded_control_parentheses.go
@@ -0,0 +1,81 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/NoUnneededControlParenthesesFixer.php
+//
+// NoUnneededControlParentheses removes a redundant parenthesis pair wrapping the
+// whole operand of a control statement: "return ($x);" -> "return $x;". It fires
+// for return, echo, print, yield, break, continue, clone and case, only when the
+// "(" directly follows the keyword and its matching ")" sits right before the
+// statement terminator (";" or, for case, ":"). Nested pairs are peeled in one
+// pass. The conservative boundary check leaves anything else (multiple arguments,
+// a partial sub-expression, a mid-expression clone) untouched.
+type NoUnneededControlParentheses struct{}
+
+func (NoUnneededControlParentheses) Name() string {
+ return `PhpCsFixer\Fixer\ControlStructure\NoUnneededControlParenthesesFixer`
+}
+
+func (NoUnneededControlParentheses) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/NoUnneededControlParenthesesFixer.php"
+}
+
+func (NoUnneededControlParentheses) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ if t.Kind != token.Keyword {
+ continue
+ }
+ var term string
+ switch strings.ToLower(t.Value) {
+ case "return", "echo", "print", "yield", "break", "continue", "clone":
+ term = ";"
+ case "case":
+ term = ":"
+ default:
+ continue
+ }
+ // a keyword after "->"/"::" is a member name (Foo::case(...)), not a control
+ // statement
+ if memberPrev(s, i) {
+ continue
+ }
+ // peel redundant pairs: "return (($x));" -> "return $x;"
+ for {
+ j := nextSignificantIndex(s, i)
+ if j < 0 || s.At(j).Kind != token.Punct || s.At(j).Value != "(" {
+ break
+ }
+ k := s.MatchForward(j)
+ if k < 0 {
+ break
+ }
+ // the matching ")" must sit right before the terminator, and the pair
+ // must not be empty
+ nj := nextSignificantIndex(s, k)
+ if nj < 0 || s.At(nj).Kind != token.Punct || s.At(nj).Value != term {
+ break
+ }
+ if nextSignificantIndex(s, j) >= k {
+ break
+ }
+ s.RemoveAt(k)
+ if j == i+1 {
+ // "return($x)" -> keep a separator so the keyword stays a keyword
+ s.RemoveAt(j)
+ s.InsertAt(j, token.Token{Kind: token.Whitespace, Value: " "})
+ } else {
+ s.RemoveAt(j)
+ }
+ changed = true
+ }
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/no_unreachable_default_argument_value.go b/blink/internal/fixer/rules/no_unreachable_default_argument_value.go
new file mode 100644
index 0000000000..053064b6cd
--- /dev/null
+++ b/blink/internal/fixer/rules/no_unreachable_default_argument_value.go
@@ -0,0 +1,234 @@
+package rules
+
+import (
+ "slices"
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/FunctionNotation/NoUnreachableDefaultArgumentValueFixer.php
+//
+// NoUnreachableDefaultArgumentValue removes default values of arguments that
+// precede a required (non-default) argument, since such defaults are never used.
+// A "= null" default on a non-nullable typed argument is kept, matching
+// PHP-CS-Fixer.
+type NoUnreachableDefaultArgumentValue struct{}
+
+func (NoUnreachableDefaultArgumentValue) Name() string {
+ return `PhpCsFixer\Fixer\FunctionNotation\NoUnreachableDefaultArgumentValueFixer`
+}
+
+func (NoUnreachableDefaultArgumentValue) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/FunctionNotation/NoUnreachableDefaultArgumentValueFixer.php"
+}
+
+type nudArg struct {
+ start, end int // inclusive token range of the argument
+ nameIndex int
+ variadic bool
+ equalsIdx int // depth-0 "=" after the name, or -1
+ defaultEnd int // last meaningful token of the default value
+ nullDflt bool
+ hasType bool
+ nullable bool
+}
+
+func (NoUnreachableDefaultArgumentValue) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := s.Len() - 1; i >= 0; i-- {
+ t := s.At(i)
+ if t.Kind != token.Keyword {
+ continue
+ }
+ if !strings.EqualFold(t.Value, "function") && !strings.EqualFold(t.Value, "fn") {
+ continue
+ }
+ if nudFixFunction(s, i) {
+ changed = true
+ }
+ }
+ return changed
+}
+
+func nudFixFunction(s *tokens.Stream, fnIndex int) bool {
+ openParen := nudNextParen(s, fnIndex)
+ if openParen == -1 {
+ return false
+ }
+ closeParen := s.MatchForward(openParen)
+ if closeParen == -1 {
+ return false
+ }
+ args := nudParseArgs(s, openParen, closeParen)
+
+ changed := false
+ removeDefault := false
+ for _, a := range slices.Backward(args) {
+ if a.variadic {
+ continue
+ }
+ if a.equalsIdx == -1 {
+ removeDefault = true
+ continue
+ }
+ if !removeDefault {
+ continue
+ }
+ if a.nullDflt && a.hasType && !a.nullable {
+ continue
+ }
+ // remove "= " and the whitespace before "="
+ for j := a.defaultEnd; j >= a.equalsIdx; j-- {
+ s.RemoveAt(j)
+ }
+ if a.equalsIdx-1 >= 0 && s.At(a.equalsIdx-1).Kind == token.Whitespace {
+ prevNonWs := prevSignificantIndex(s, a.equalsIdx-1)
+ if prevNonWs != -1 && s.At(prevNonWs).Kind != token.Comment {
+ s.RemoveAt(a.equalsIdx - 1)
+ }
+ }
+ changed = true
+ }
+ return changed
+}
+
+// nudParseArgs splits the parameter list (openParen..closeParen) into arguments,
+// analysing each. It runs before any mutation, so all indices are valid.
+func nudParseArgs(s *tokens.Stream, openParen, closeParen int) []nudArg {
+ var args []nudArg
+ depth := 0
+ start := openParen + 1
+ flush := func(end int) {
+ if start > end {
+ return
+ }
+ // trim to a non-empty, meaningful range
+ if a, ok := nudAnalyzeArg(s, start, end); ok {
+ args = append(args, a)
+ }
+ }
+ for i := openParen + 1; i < closeParen; i++ {
+ t := s.At(i)
+ if t.Kind == token.Punct {
+ switch t.Value {
+ case "(", "[", "{":
+ depth++
+ case ")", "]", "}":
+ depth--
+ case ",":
+ if depth == 0 {
+ flush(i - 1)
+ start = i + 1
+ }
+ }
+ }
+ }
+ flush(closeParen - 1)
+ return args
+}
+
+func nudAnalyzeArg(s *tokens.Stream, start, end int) (nudArg, bool) {
+ a := nudArg{start: start, end: end, nameIndex: -1, equalsIdx: -1}
+ depth := 0
+ for i := start; i <= end; i++ {
+ t := s.At(i)
+ if t.Kind == token.Punct {
+ switch t.Value {
+ case "(", "[", "{":
+ depth++
+ continue
+ case ")", "]", "}":
+ depth--
+ continue
+ }
+ }
+ if depth != 0 {
+ continue
+ }
+ if t.Kind == token.Variable && a.nameIndex == -1 && a.equalsIdx == -1 {
+ a.nameIndex = i
+ continue
+ }
+ if t.Kind == token.Punct && t.Value == "=" && a.nameIndex != -1 && a.equalsIdx == -1 {
+ a.equalsIdx = i
+ continue
+ }
+ }
+ if a.nameIndex == -1 {
+ return a, false
+ }
+
+ // variadic: "..." right before the name
+ prev := nudPrevMeaningfulFrom(s, a.nameIndex, start)
+ if prev != -1 && s.At(prev).Kind == token.Punct && s.At(prev).Value == "..." {
+ a.variadic = true
+ }
+
+ // type: meaningful tokens before the name, excluding "&" and "..."
+ for i := start; i < a.nameIndex; i++ {
+ t := s.At(i)
+ if t.Kind == token.Whitespace || t.Kind == token.Comment || t.Kind == token.DocComment {
+ continue
+ }
+ if t.Kind == token.Punct && (t.Value == "&" || t.Value == "...") {
+ continue
+ }
+ a.hasType = true
+ if t.Kind == token.Punct && t.Value == "?" {
+ a.nullable = true
+ }
+ if t.Kind == token.Ident && strings.EqualFold(t.Value, "null") {
+ a.nullable = true
+ }
+ }
+
+ // default value range + whether it is exactly "null"
+ if a.equalsIdx != -1 {
+ a.defaultEnd = end
+ for a.defaultEnd > a.equalsIdx {
+ k := s.At(a.defaultEnd).Kind
+ if k == token.Whitespace || k == token.Comment || k == token.DocComment {
+ a.defaultEnd--
+ continue
+ }
+ break
+ }
+ var meaningful []int
+ for i := a.equalsIdx + 1; i <= end; i++ {
+ k := s.At(i).Kind
+ if k == token.Whitespace || k == token.Comment || k == token.DocComment {
+ continue
+ }
+ meaningful = append(meaningful, i)
+ }
+ if len(meaningful) == 1 {
+ v := s.At(meaningful[0])
+ if (v.Kind == token.Ident || v.Kind == token.Keyword) && strings.EqualFold(v.Value, "null") {
+ a.nullDflt = true
+ }
+ }
+ }
+ return a, true
+}
+
+func nudNextParen(s *tokens.Stream, index int) int {
+ for j := index + 1; j < s.Len(); j++ {
+ if s.At(j).Kind == token.Punct && s.At(j).Value == "(" {
+ return j
+ }
+ }
+ return -1
+}
+
+func nudPrevMeaningfulFrom(s *tokens.Stream, i, low int) int {
+ for j := i - 1; j >= low; j-- {
+ k := s.At(j).Kind
+ if k == token.Whitespace || k == token.Comment || k == token.DocComment {
+ continue
+ }
+ return j
+ }
+ return -1
+}
diff --git a/blink/internal/fixer/rules/no_unreachable_default_argument_value_test.go b/blink/internal/fixer/rules/no_unreachable_default_argument_value_test.go
new file mode 100644
index 0000000000..fc7057d283
--- /dev/null
+++ b/blink/internal/fixer/rules/no_unreachable_default_argument_value_test.go
@@ -0,0 +1,35 @@
+package rules
+
+import "testing"
+
+func TestNoUnreachableDefaultArgumentValue(t *testing.T) {
+ cases := []struct {
+ src string
+ want string
+ changed bool
+ }{
+ {` $a;`, ` $a;`, true},
+ {` %q changed=%v", got, again, changed2)
+ }
+ }
+}
diff --git a/blink/internal/fixer/rules/no_useless_nullsafe_operator.go b/blink/internal/fixer/rules/no_useless_nullsafe_operator.go
new file mode 100644
index 0000000000..b18ee06fae
--- /dev/null
+++ b/blink/internal/fixer/rules/no_useless_nullsafe_operator.go
@@ -0,0 +1,38 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/NoUselessNullsafeOperatorFixer.php
+//
+// NoUselessNullsafeOperator turns "$this?->" into "$this->": a nullsafe call on
+// $this can never be null, so the operator is useless.
+type NoUselessNullsafeOperator struct{}
+
+func (NoUselessNullsafeOperator) Name() string {
+ return `PhpCsFixer\Fixer\Operator\NoUselessNullsafeOperatorFixer`
+}
+
+func (NoUselessNullsafeOperator) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/NoUselessNullsafeOperatorFixer.php"
+}
+
+func (NoUselessNullsafeOperator) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := s.Len() - 1; i >= 0; i-- {
+ if s.At(i).Kind != token.Punct || s.At(i).Value != "?->" {
+ continue
+ }
+ p := prevSignificantIndex(s, i)
+ if p < 0 || s.At(p).Kind != token.Variable || !strings.EqualFold(s.At(p).Value, "$this") {
+ continue
+ }
+ s.SetValue(i, "->")
+ changed = true
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/no_useless_nullsafe_operator_test.go b/blink/internal/fixer/rules/no_useless_nullsafe_operator_test.go
new file mode 100644
index 0000000000..03b9f7d51c
--- /dev/null
+++ b/blink/internal/fixer/rules/no_useless_nullsafe_operator_test.go
@@ -0,0 +1,22 @@
+package rules
+
+import "testing"
+
+func TestNoUselessNullsafeOperator(t *testing.T) {
+ f := NoUselessNullsafeOperator{}
+
+ got, changed := apply(t, f, "parentMethod(); }\n}\n")
+ if want := "parentMethod(); }\n}\n"; !changed || got != want {
+ t.Fatalf("this: changed=%v got=%q", changed, got)
+ }
+
+ // nullsafe on a non-$this variable is left alone
+ if _, changed := apply(t, f, "b();\n"); changed {
+ t.Fatal("only $this?-> is rewritten")
+ }
+
+ // already "->" is a no-op
+ if _, changed := apply(t, f, "b();\n"); changed {
+ t.Fatal("plain -> must not change")
+ }
+}
diff --git a/blink/internal/fixer/rules/no_useless_return.go b/blink/internal/fixer/rules/no_useless_return.go
new file mode 100644
index 0000000000..53e323b8e7
--- /dev/null
+++ b/blink/internal/fixer/rules/no_useless_return.go
@@ -0,0 +1,58 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ReturnNotation/NoUselessReturnFixer.php
+//
+// NoUselessReturn removes a bare "return;" that is the last statement of a
+// function, method or closure body.
+type NoUselessReturn struct{}
+
+func (NoUselessReturn) Name() string {
+ return `PhpCsFixer\Fixer\ReturnNotation\NoUselessReturnFixer`
+}
+
+func (NoUselessReturn) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ReturnNotation/NoUselessReturnFixer.php"
+}
+
+func (NoUselessReturn) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ if s.At(i).Kind != token.Keyword || !strings.EqualFold(s.At(i).Value, "return") {
+ continue
+ }
+ if prev, ok := prevSignificant(s, i); ok && prev.Kind == token.Punct {
+ switch prev.Value {
+ case "->", "?->", "::":
+ continue
+ }
+ }
+ semi := nextSignificantIndex(s, i)
+ if semi < 0 || s.At(semi).Kind != token.Punct || s.At(semi).Value != ";" {
+ continue
+ }
+ after := nextSignificantIndex(s, semi)
+ if after < 0 || s.At(after).Kind != token.Punct || s.At(after).Value != "}" {
+ continue
+ }
+ open := s.MatchBackward(after)
+ if open < 0 {
+ continue
+ }
+ if kind, _ := classifyBrace(s, open); kind != braceFunctionDecl && kind != braceClosure {
+ continue
+ }
+ for k := semi; k >= i; k-- {
+ s.RemoveAt(k)
+ }
+ i--
+ changed = true
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/no_whitespace_in_blank_line.go b/blink/internal/fixer/rules/no_whitespace_in_blank_line.go
new file mode 100644
index 0000000000..8c0fdf9add
--- /dev/null
+++ b/blink/internal/fixer/rules/no_whitespace_in_blank_line.go
@@ -0,0 +1,50 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/NoWhitespaceInBlankLineFixer.php
+//
+// NoWhitespaceInBlankLine removes spaces and tabs from otherwise-empty lines.
+// It only clears interior blank lines inside a whitespace run; the last segment
+// (indentation of the next code line) and the first segment (trailing
+// whitespace of the previous line) are left to their own fixers.
+type NoWhitespaceInBlankLine struct{}
+
+func (NoWhitespaceInBlankLine) Name() string {
+ return `PhpCsFixer\Fixer\Whitespace\NoWhitespaceInBlankLineFixer`
+}
+
+func (NoWhitespaceInBlankLine) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/NoWhitespaceInBlankLineFixer.php"
+}
+
+func (NoWhitespaceInBlankLine) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ if t.Kind != token.Whitespace {
+ continue
+ }
+ segments := strings.Split(t.Value, "\n")
+ if len(segments) <= 2 {
+ continue // no interior blank line
+ }
+ touched := false
+ for j := 1; j < len(segments)-1; j++ {
+ if segments[j] != "" {
+ segments[j] = ""
+ touched = true
+ }
+ }
+ if touched {
+ s.SetValue(i, strings.Join(segments, "\n"))
+ changed = true
+ }
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/not_operator_with_successor_space.go b/blink/internal/fixer/rules/not_operator_with_successor_space.go
new file mode 100644
index 0000000000..d7a75dfc7a
--- /dev/null
+++ b/blink/internal/fixer/rules/not_operator_with_successor_space.go
@@ -0,0 +1,42 @@
+package rules
+
+import (
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/NotOperatorWithSuccessorSpaceFixer.php
+//
+// NotOperatorWithSuccessorSpace ensures exactly one space after the "!" operator
+// ("!$a" -> "! $a").
+type NotOperatorWithSuccessorSpace struct{}
+
+func (NotOperatorWithSuccessorSpace) Name() string {
+ return `PhpCsFixer\Fixer\Operator\NotOperatorWithSuccessorSpaceFixer`
+}
+
+func (NotOperatorWithSuccessorSpace) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/NotOperatorWithSuccessorSpaceFixer.php"
+}
+
+func (NotOperatorWithSuccessorSpace) 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 != "!" {
+ continue
+ }
+ if i+1 >= s.Len() {
+ continue
+ }
+ if s.At(i+1).Kind == token.Whitespace {
+ if s.At(i+1).Value != " " {
+ s.SetValue(i+1, " ")
+ changed = true
+ }
+ continue
+ }
+ s.InsertAt(i+1, token.Token{Kind: token.Whitespace, Value: " "})
+ changed = true
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/nullable_type_declaration.go b/blink/internal/fixer/rules/nullable_type_declaration.go
new file mode 100644
index 0000000000..f61f39f411
--- /dev/null
+++ b/blink/internal/fixer/rules/nullable_type_declaration.go
@@ -0,0 +1,113 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/LanguageConstruct/NullableTypeDeclarationFixer.php
+//
+// NullableTypeDeclaration standardises a nullable single type to the question
+// mark syntax (the fixer default): "int|null" and "null|int" become "?int". It
+// fires only for a two-member union whose sole other member is a single type
+// name, in a confirmed type position (parameter, return or typed property).
+// Unions of three or more members, intersections and DNF types are left alone.
+type NullableTypeDeclaration struct{}
+
+func (NullableTypeDeclaration) Name() string {
+ return `PhpCsFixer\Fixer\LanguageConstruct\NullableTypeDeclarationFixer`
+}
+
+func (NullableTypeDeclaration) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/LanguageConstruct/NullableTypeDeclarationFixer.php"
+}
+
+func (NullableTypeDeclaration) 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 != "|" {
+ continue
+ }
+ if !isTypeUnionOperator(s, i) {
+ continue
+ }
+ // significant token indices of the whole type run around this "|"
+ start := typeRunBoundaryPrev(s, i)
+ end := typeRunBoundaryNext(s, i)
+ if end < 0 {
+ continue
+ }
+ var parts []int
+ for j := start + 1; j < end; j++ {
+ if k := s.At(j).Kind; k == token.Whitespace || k == token.Comment || k == token.DocComment {
+ continue
+ }
+ parts = append(parts, j)
+ }
+ if len(parts) == 0 {
+ continue
+ }
+ // exactly one "|" and no "&"/"?" -> a plain two-member union
+ pipes := 0
+ bad := false
+ for _, p := range parts {
+ v := s.At(p).Value
+ if s.At(p).Kind == token.Punct {
+ switch v {
+ case "|":
+ pipes++
+ case "&", "?":
+ bad = true
+ }
+ }
+ }
+ if bad || pipes != 1 {
+ continue
+ }
+ // split members on the single "|"
+ var left, right []int
+ seen := false
+ for _, p := range parts {
+ if !seen && s.At(p).Kind == token.Punct && s.At(p).Value == "|" {
+ seen = true
+ continue
+ }
+ if seen {
+ right = append(right, p)
+ } else {
+ left = append(left, p)
+ }
+ }
+ // one side must be exactly the single token "null"; keep the other as T
+ var typ []int
+ switch {
+ case isNullMember(s, right):
+ typ = left
+ case isNullMember(s, left):
+ typ = right
+ default:
+ continue
+ }
+ if len(typ) == 0 {
+ continue
+ }
+ // rebuild the run as "?T", dropping the null member, the "|" and any
+ // internal whitespace; ReplaceRange spans the first..last run token
+ repl := []token.Token{{Kind: token.Punct, Value: "?"}}
+ for _, p := range typ {
+ repl = append(repl, s.At(p))
+ }
+ s.ReplaceRange(parts[0], parts[len(parts)-1], repl)
+ changed = true
+ i = parts[0] // continue past the rewritten run
+ }
+ return changed
+}
+
+// isNullMember reports whether idxs is a single token spelling "null".
+func isNullMember(s *tokens.Stream, idxs []int) bool {
+ return len(idxs) == 1 && strings.EqualFold(s.At(idxs[0]).Value, "null")
+}
diff --git a/blink/internal/fixer/rules/offset.go b/blink/internal/fixer/rules/offset.go
new file mode 100644
index 0000000000..8505ec1a0e
--- /dev/null
+++ b/blink/internal/fixer/rules/offset.go
@@ -0,0 +1,55 @@
+package rules
+
+import (
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// isOffsetOpen reports whether the "[" at open is an array offset access (as
+// opposed to an array literal), based on the preceding significant token.
+func isOffsetOpen(s *tokens.Stream, open int) bool {
+ prev, ok := prevSignificant(s, open)
+ if !ok {
+ return false
+ }
+ if prev.Kind == token.Variable || prev.Kind == token.Ident || prev.Kind == token.String {
+ return true
+ }
+ return prev.Value == ")" || prev.Value == "]"
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/NoSpacesAroundOffsetFixer.php
+//
+// NoSpacesAroundOffset removes single-line spaces just inside an array offset
+// ("$a[ 0 ]" -> "$a[0]"). Array literals are left alone.
+type NoSpacesAroundOffset struct{}
+
+func (NoSpacesAroundOffset) Name() string {
+ return `PhpCsFixer\Fixer\Whitespace\NoSpacesAroundOffsetFixer`
+}
+
+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 {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ if s.At(i).Kind != token.Punct || s.At(i).Value != "[" || !isOffsetOpen(s, i) {
+ continue
+ }
+ closeIdx := s.MatchForward(i)
+ 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 i+1 < s.Len() && s.At(i+1).Kind == token.Whitespace && !hasNewline(s.At(i+1).Value) {
+ s.RemoveAt(i + 1)
+ changed = true
+ }
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/operator_linebreak.go b/blink/internal/fixer/rules/operator_linebreak.go
new file mode 100644
index 0000000000..3dcf0ad100
--- /dev/null
+++ b/blink/internal/fixer/rules/operator_linebreak.go
@@ -0,0 +1,158 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/OperatorLinebreakFixer.php
+//
+// OperatorLinebreak moves a multiline operator to the beginning of the next line
+// (default position). It handles the full operator set, including ":", "?", "|"
+// and "&", by classifying those ambiguous tokens the way PHP-CS-Fixer's
+// tokenizer transformers + analyzers do: a ":" that is a type-colon, named
+// argument, switch case, goto label or alternative-syntax colon is left alone; a
+// "?" that is a nullable type marker is left alone; a "|"/"&" that is a union or
+// intersection type separator is left alone; and a "&" that is a return-ref or a
+// reference is left alone. Object operators "->"/"?->" are not operators here.
+type OperatorLinebreak struct{}
+
+// operatorLinebreakPunct are the always-unambiguous operator tokens.
+var operatorLinebreakPunct = map[string]bool{
+ "||": true, "&&": true, ".": true, "+": true, "-": true, "*": true,
+ "/": true, "%": true, "**": true, "==": true, "===": true, "!=": true,
+ "!==": true, "<>": true, "<": true, ">": true, "<=": true, ">=": true,
+ "<=>": true, "??": true, "=>": true, "=": true, ".=": true, "+=": true,
+ "-=": true, "*=": true, "/=": true, "%=": true, "**=": true, "&=": true,
+ "|=": true, "^=": true, "<<=": true, ">>=": true, "??=": true, "^": true,
+ "<<": true, ">>": true, "|": true, "&": true, ":": true, "?": true,
+}
+
+func (OperatorLinebreak) Name() string {
+ return `PhpCsFixer\Fixer\Operator\OperatorLinebreakFixer`
+}
+
+func (OperatorLinebreak) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/OperatorLinebreakFixer.php"
+}
+
+func isOperatorLinebreakToken(t token.Token) bool {
+ switch t.Kind {
+ case token.Keyword:
+ switch strings.ToLower(t.Value) {
+ case "and", "or", "xor":
+ return true
+ }
+ case token.Punct:
+ return operatorLinebreakPunct[t.Value]
+ }
+ return false
+}
+
+// operatorLinebreakExcluded reports whether the token at i, although in the
+// operator set, must be left alone because of its context (mirrors what the
+// PHP-CS-Fixer tokenizer transforms into a CT token, plus the fixer's own
+// goto-label / reference / alternative-syntax / switch guards).
+func operatorLinebreakExcluded(s *tokens.Stream, i int, switchColons map[int]bool) bool {
+ if s.At(i).Kind != token.Punct {
+ return false
+ }
+ switch s.At(i).Value {
+ case ":":
+ return isTypeColon(s, i) || isNamedArgumentColon(s, i) ||
+ belongsToGotoLabel(s, i) || belongsToAlternativeSyntax(s, i) || switchColons[i]
+ case "?":
+ return isNullableType(s, i)
+ case "|":
+ return isPartOfType(s, i)
+ case "&":
+ return isPartOfType(s, i) || isReturnRef(s, i) || isReferenceAmp(s, i)
+ }
+ return false
+}
+
+func (OperatorLinebreak) Fix(s *tokens.Stream) bool {
+ changed := false
+ switchColons := switchCaseColons(s)
+ for i := s.Len() - 1; i > 0; i-- {
+ if !isOperatorLinebreakToken(s.At(i)) {
+ continue
+ }
+ if operatorLinebreakExcluded(s, i, switchColons) {
+ continue
+ }
+
+ opIndices := []int{i}
+ // short-ternary "?:" is moved as a single unit
+ if s.At(i).Kind == token.Punct && s.At(i).Value == ":" {
+ if p := sigPrev(s, i); p >= 0 && s.At(p).Kind == token.Punct && s.At(p).Value == "?" {
+ opIndices = []int{p, i}
+ }
+ }
+
+ lo, hi := opIndices[0], opIndices[len(opIndices)-1]
+ prevM := sigPrev(s, lo)
+ nextM := sigNext(s, hi)
+ if prevM < 0 || nextM < 0 {
+ continue
+ }
+ // operator must be surrounded by multiline whitespace
+ if !operatorSpanMultiline(s, prevM+1, nextM-1) {
+ continue
+ }
+ // already at the beginning of a line if there is no newline after it
+ if !operatorSpanMultiline(s, hi, nextM-1) {
+ continue
+ }
+ moveOperatorToLineStart(s, opIndices, nextM)
+ changed = true
+ i = lo // resume before the moved group
+ }
+ return changed
+}
+
+// operatorSpanMultiline mirrors OperatorLinebreak::isMultiline - whether any
+// token in the inclusive range [from, to] contains a newline.
+func operatorSpanMultiline(s *tokens.Stream, from, to int) bool {
+ if from < 0 {
+ from = 0
+ }
+ for j := from; j <= to && j < s.Len(); j++ {
+ if strings.ContainsAny(s.At(j).Value, "\n\r") {
+ return true
+ }
+ }
+ return false
+}
+
+// moveOperatorToLineStart mirrors fixMoveToTheBeginning: it clears each operator
+// (and the whitespace immediately before it) and re-inserts the operators just
+// before the next meaningful token, keeping a single leading space when a space
+// preceded the group.
+func moveOperatorToLineStart(s *tokens.Stream, opIndices []int, nextM int) {
+ lo := opIndices[0]
+ hadSpaceBefore := lo > 0 && s.At(lo-1).Kind == token.Whitespace
+
+ // clone operator tokens and clear them plus the whitespace directly before each
+ clones := make([]token.Token, len(opIndices))
+ for k, idx := range opIndices {
+ clones[k] = token.Token{Kind: s.At(idx).Kind, Value: s.At(idx).Value}
+ }
+ for _, idx := range opIndices {
+ if idx > 0 && s.At(idx-1).Kind == token.Whitespace {
+ s.SetValue(idx-1, "")
+ }
+ s.SetValue(idx, "")
+ }
+
+ at := nextM
+ for _, c := range clones {
+ s.InsertAt(at, c)
+ at++
+ }
+ if hadSpaceBefore {
+ s.InsertAt(at, token.Token{Kind: token.Whitespace, Value: " "})
+ }
+}
diff --git a/blink/internal/fixer/rules/operator_linebreak_ct.go b/blink/internal/fixer/rules/operator_linebreak_ct.go
new file mode 100644
index 0000000000..ff7a0f0594
--- /dev/null
+++ b/blink/internal/fixer/rules/operator_linebreak_ct.go
@@ -0,0 +1,369 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// Context classifiers for OperatorLinebreak. Each mirrors a PHP-CS-Fixer
+// tokenizer transformer or analyzer so an ambiguous ":" / "?" / "|" / "&" is
+// recognised as a type / nullable / union / intersection / reference / label /
+// switch / alternative-syntax context and left untouched.
+
+func olKwLower(t token.Token) string {
+ if t.Kind != token.Keyword {
+ return ""
+ }
+ return strings.ToLower(t.Value)
+}
+
+// isTypeColon mirrors TypeColonTransformer: a ":" that introduces a return type
+// (or an enum backing type).
+func isTypeColon(s *tokens.Stream, index int) bool {
+ if !isPunctVal(s, index, ":") {
+ return false
+ }
+ endIndex := sigPrev(s, index)
+ if endIndex < 0 {
+ return false
+ }
+ if pe := sigPrev(s, endIndex); pe >= 0 && kwIs(s.At(pe), "enum") {
+ return true
+ }
+ if !isPunctVal(s, endIndex, ")") {
+ return false
+ }
+ startIndex := s.MatchBackward(endIndex)
+ if startIndex < 0 {
+ return false
+ }
+ prevIndex := sigPrev(s, startIndex)
+ if prevIndex < 0 {
+ return false
+ }
+ if s.At(prevIndex).Kind == token.Ident {
+ prevIndex = sigPrev(s, prevIndex)
+ if prevIndex < 0 {
+ return false
+ }
+ }
+ switch olKwLower(s.At(prevIndex)) {
+ case "function", "fn", "use":
+ return true
+ }
+ return isPunctVal(s, prevIndex, "&") && isReturnRef(s, prevIndex)
+}
+
+// isNamedArgumentColon mirrors NamedArgumentTransformer.
+func isNamedArgumentColon(s *tokens.Stream, index int) bool {
+ if !isPunctVal(s, index, ":") {
+ return false
+ }
+ stringIndex := sigPrev(s, index)
+ if stringIndex < 0 || s.At(stringIndex).Kind != token.Ident {
+ return false
+ }
+ preString := sigPrev(s, stringIndex)
+ if preString < 0 {
+ return false
+ }
+ return isPunctVal(s, preString, ",") || isPunctVal(s, preString, "(")
+}
+
+// isNullableType mirrors NullableTypeTransformer.
+func isNullableType(s *tokens.Stream, index int) bool {
+ if !isPunctVal(s, index, "?") {
+ return false
+ }
+ prevIndex := sigPrev(s, index)
+ if prevIndex < 0 {
+ return false
+ }
+ ok := isPunctVal(s, prevIndex, "(") || isPunctVal(s, prevIndex, ",") || isTypeColon(s, prevIndex)
+ if !ok {
+ switch olKwLower(s.At(prevIndex)) {
+ case "public", "protected", "private", "var", "static", "const", "abstract", "final", "readonly":
+ ok = true
+ }
+ }
+ if !ok {
+ return false
+ }
+ if olKwLower(s.At(prevIndex)) == "static" {
+ if pp := sigPrev(s, prevIndex); pp >= 0 && kwIs(s.At(pp), "instanceof") {
+ return false
+ }
+ }
+ return true
+}
+
+// isReturnRef mirrors ReturnRefTransformer: "&" right after function/fn.
+func isReturnRef(s *tokens.Stream, index int) bool {
+ if !isPunctVal(s, index, "&") {
+ return false
+ }
+ prev := sigPrev(s, index)
+ if prev < 0 {
+ return false
+ }
+ return kwIs(s.At(prev), "function") || kwIs(s.At(prev), "fn")
+}
+
+// isReferenceAmp mirrors ReferenceAnalyzer::isReference (the "&" is a reference,
+// not bitwise-and), excluding the return-ref case handled separately.
+func isReferenceAmp(s *tokens.Stream, index int) bool {
+ if !isPunctVal(s, index, "&") {
+ return false
+ }
+ idx := sigPrev(s, index)
+ if idx < 0 {
+ return false
+ }
+ t := s.At(idx)
+ if isPunctVal(s, idx, "=") || isPunctVal(s, idx, "=>") ||
+ kwIs(t, "as") || kwIs(t, "callable") || kwIs(t, "array") {
+ return true
+ }
+ if t.Kind == token.Ident {
+ idx = sigPrev(s, idx)
+ if idx < 0 {
+ return false
+ }
+ }
+ return isPunctVal(s, idx, "(") || isPunctVal(s, idx, ",") || isPunctVal(s, idx, `\`) ||
+ (isPunctVal(s, idx, "?") && isNullableType(s, idx))
+}
+
+// belongsToGotoLabel mirrors GotoLabelAnalyzer.
+func belongsToGotoLabel(s *tokens.Stream, index int) bool {
+ if !isPunctVal(s, index, ":") {
+ return false
+ }
+ prev := sigPrev(s, index)
+ if prev < 0 || s.At(prev).Kind != token.Ident {
+ return false
+ }
+ prev2 := sigPrev(s, prev)
+ if prev2 < 0 {
+ return false
+ }
+ return isPunctVal(s, prev2, ":") || isPunctVal(s, prev2, ";") ||
+ isPunctVal(s, prev2, "{") || isPunctVal(s, prev2, "}") || s.At(prev2).Kind == token.OpenTag
+}
+
+// belongsToAlternativeSyntax mirrors AlternativeSyntaxAnalyzer.
+func belongsToAlternativeSyntax(s *tokens.Stream, index int) bool {
+ if !isPunctVal(s, index, ":") {
+ return false
+ }
+ prev := sigPrev(s, index)
+ if prev < 0 {
+ return false
+ }
+ if kwIs(s.At(prev), "else") {
+ return true
+ }
+ if !isPunctVal(s, prev, ")") {
+ return false
+ }
+ open := s.MatchBackward(prev)
+ if open < 0 {
+ return false
+ }
+ before := sigPrev(s, open)
+ if before < 0 {
+ return false
+ }
+ switch olKwLower(s.At(before)) {
+ case "declare", "elseif", "for", "foreach", "if", "switch", "while":
+ return true
+ }
+ return false
+}
+
+// typeSkipToken reports whether j is one of AbstractTypeTransformer::TYPE_TOKENS.
+func typeSkipToken(s *tokens.Stream, j int) bool {
+ t := s.At(j)
+ switch t.Kind {
+ case token.Whitespace, token.Comment, token.DocComment, token.Ident:
+ return true
+ case token.Punct:
+ switch t.Value {
+ case "|", "&", "(", ")", `\`:
+ return true
+ }
+ case token.Keyword:
+ switch strings.ToLower(t.Value) {
+ case "callable", "static", "array":
+ return true
+ }
+ }
+ return false
+}
+
+// notOfKindSiblingType walks from index in direction dir, skipping TYPE_TOKENS.
+func notOfKindSiblingType(s *tokens.Stream, index, dir int) int {
+ for j := index + dir; j >= 0 && j < s.Len(); j += dir {
+ if !typeSkipToken(s, j) {
+ return j
+ }
+ }
+ return -1
+}
+
+func isTypeEndToken(s *tokens.Stream, idx int) bool {
+ if isPunctVal(s, idx, ")") || isPunctVal(s, idx, `\`) {
+ return true
+ }
+ t := s.At(idx)
+ if t.Kind == token.Ident {
+ return true
+ }
+ switch olKwLower(t) {
+ case "callable", "static", "array":
+ return true
+ }
+ return false
+}
+
+// isPartOfType mirrors AbstractTypeTransformer::isPartOfType for a "|"/"&".
+func isPartOfType(s *tokens.Stream, index int) bool {
+ if !isPunctVal(s, index, "|") && !isPunctVal(s, index, "&") {
+ return false
+ }
+ if typeColonIndex := notOfKindSiblingType(s, index, -1); typeColonIndex >= 0 {
+ tc := s.At(typeColonIndex)
+ if kwIs(tc, "catch") || kwIs(tc, "const") || isTypeColon(s, typeColonIndex) {
+ return true
+ }
+ }
+ afterTypeIndex := notOfKindSiblingType(s, index, 1)
+ if afterTypeIndex < 0 {
+ return false
+ }
+ if isPunctVal(s, afterTypeIndex, "...") {
+ return true
+ }
+ if s.At(afterTypeIndex).Kind != token.Variable {
+ return false
+ }
+ beforeVar := sigPrev(s, afterTypeIndex)
+ if beforeVar < 0 {
+ return false
+ }
+ if isPunctVal(s, beforeVar, "&") {
+ prevIndex := getPrevTokenOfKindType(s, index)
+ return prevIndex >= 0 && (kwIs(s.At(prevIndex), "fn") || kwIs(s.At(prevIndex), "function"))
+ }
+ return isTypeEndToken(s, beforeVar)
+}
+
+// getPrevTokenOfKindType scans back for "{", "}", ";", a close tag, or fn/function.
+func getPrevTokenOfKindType(s *tokens.Stream, index int) int {
+ for j := index - 1; j >= 0; j-- {
+ if isPunctVal(s, j, "{") || isPunctVal(s, j, "}") || isPunctVal(s, j, ";") ||
+ s.At(j).Kind == token.CloseTag || kwIs(s.At(j), "fn") || kwIs(s.At(j), "function") {
+ return j
+ }
+ }
+ return -1
+}
+
+// switchCaseColons returns the set of ":" indices that terminate a case/default
+// label inside a switch (SwitchAnalyzer::belongsToSwitch).
+func switchCaseColons(s *tokens.Stream) map[int]bool {
+ result := map[int]bool{}
+ for i := 0; i < s.Len(); i++ {
+ if !kwIs(s.At(i), "switch") {
+ continue
+ }
+ open := nextPunctOfKind(s, i, "(")
+ if open < 0 {
+ continue
+ }
+ closeIdx := s.MatchForward(open)
+ if closeIdx < 0 {
+ continue
+ }
+ bodyStart := sigNext(s, closeIdx)
+ if bodyStart < 0 {
+ continue
+ }
+ if isPunctVal(s, bodyStart, "{") {
+ bodyEnd := s.MatchForward(bodyStart)
+ if bodyEnd < 0 {
+ continue
+ }
+ scanCaseColons(s, bodyStart+1, bodyEnd, result)
+ } else if isPunctVal(s, bodyStart, ":") {
+ result[bodyStart] = true
+ if end := altSwitchEnd(s, bodyStart); end > bodyStart {
+ scanCaseColons(s, bodyStart+1, end, result)
+ }
+ }
+ }
+ return result
+}
+
+func scanCaseColons(s *tokens.Stream, from, to int, result map[int]bool) {
+ depth := 0
+ for j := from; j < to && j < s.Len(); j++ {
+ if isPunctVal(s, j, "{") {
+ depth++
+ continue
+ }
+ if isPunctVal(s, j, "}") {
+ depth--
+ continue
+ }
+ if depth == 0 && (kwIs(s.At(j), "case") || kwIs(s.At(j), "default")) {
+ if c := caseColonAfter(s, j, to); c >= 0 {
+ result[c] = true
+ }
+ }
+ }
+}
+
+// caseColonAfter finds the ":" (or ";") terminating a case/default label.
+func caseColonAfter(s *tokens.Stream, caseIdx, limit int) int {
+ depth := 0
+ for j := caseIdx + 1; j < limit && j < s.Len(); j++ {
+ if s.At(j).Kind != token.Punct {
+ continue
+ }
+ switch s.At(j).Value {
+ case "(", "[", "{":
+ depth++
+ case ")", "]", "}":
+ depth--
+ case ":", ";":
+ if depth == 0 {
+ if s.At(j).Value == ":" {
+ return j
+ }
+ return -1
+ }
+ }
+ }
+ return -1
+}
+
+// altSwitchEnd finds the endswitch terminating an alternative-syntax switch body.
+func altSwitchEnd(s *tokens.Stream, colonIndex int) int {
+ depth := 0
+ for j := colonIndex + 1; j < s.Len(); j++ {
+ if kwIs(s.At(j), "switch") {
+ depth++
+ continue
+ }
+ if kwIs(s.At(j), "endswitch") {
+ if depth == 0 {
+ return j
+ }
+ depth--
+ }
+ }
+ return -1
+}
diff --git a/blink/internal/fixer/rules/operator_linebreak_test.go b/blink/internal/fixer/rules/operator_linebreak_test.go
new file mode 100644
index 0000000000..b4099a56ad
--- /dev/null
+++ b/blink/internal/fixer/rules/operator_linebreak_test.go
@@ -0,0 +1,52 @@
+package rules
+
+import "testing"
+
+func TestOperatorLinebreak(t *testing.T) {
+ f := OperatorLinebreak{}
+
+ got, changed := apply(t, f, "" and "?->"
+// ("$a -> b" -> "$a->b").
+type ObjectOperatorWithoutWhitespace struct{}
+
+func (ObjectOperatorWithoutWhitespace) Name() string {
+ return `PhpCsFixer\Fixer\Operator\ObjectOperatorWithoutWhitespaceFixer`
+}
+
+func (ObjectOperatorWithoutWhitespace) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/ObjectOperatorWithoutWhitespaceFixer.php"
+}
+
+func (ObjectOperatorWithoutWhitespace) 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 != "->" && t.Value != "?->") {
+ continue
+ }
+ 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 > 0 && s.At(i-1).Kind == token.Whitespace && !hasNewline(s.At(i-1).Value) {
+ s.RemoveAt(i - 1)
+ i--
+ changed = true
+ }
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/ordered_class_elements.go b/blink/internal/fixer/rules/ordered_class_elements.go
new file mode 100644
index 0000000000..f20108d798
--- /dev/null
+++ b/blink/internal/fixer/rules/ordered_class_elements.go
@@ -0,0 +1,193 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+const (
+ groupTraitUse = iota
+ groupConst
+ groupProperty
+ groupMethod
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/OrderedClassElementsFixer.php
+//
+// OrderedClassElements sorts direct class members into groups - trait use,
+// constants, properties, then methods - keeping the original order within each
+// group. It is deliberately conservative: any comment, doc comment or attribute
+// in the body makes it skip the whole class so leading trivia is never detached.
+type OrderedClassElements struct{}
+
+func (OrderedClassElements) Name() string {
+ return `PhpCsFixer\Fixer\ClassNotation\OrderedClassElementsFixer`
+}
+
+func (OrderedClassElements) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/OrderedClassElementsFixer.php"
+}
+
+func (OrderedClassElements) Fix(s *tokens.Stream) bool {
+ changed := false
+ i := 0
+ for i < s.Len() {
+ t := s.At(i)
+ if t.Kind == token.Punct && t.Value == "{" {
+ if kind, _ := classifyBrace(s, i); kind == braceClassLike {
+ if reorderClassBody(s, i) {
+ changed = true
+ i = 0 // members moved; restart - each class sorts once so this ends
+ continue
+ }
+ }
+ }
+ i++
+ }
+ return changed
+}
+
+func reorderClassBody(s *tokens.Stream, open int) bool {
+ closeIdx := s.MatchForward(open)
+ if closeIdx < 0 {
+ return false
+ }
+ // Any comment/attribute in the body means trivia we cannot safely move.
+ for k := open + 1; k < closeIdx; k++ {
+ switch s.At(k).Kind {
+ case token.Comment, token.DocComment:
+ return false
+ }
+ }
+ starts := classMemberStarts(s, open)
+ if len(starts) < 2 {
+ return false
+ }
+ ends := make([]int, len(starts))
+ groups := make([]int, len(starts))
+ for idx, m := range starts {
+ end := memberSpanEnd(s, m)
+ if end < 0 || end >= closeIdx {
+ return false
+ }
+ g := classifyMemberGroup(s, m, end)
+ if g < 0 {
+ return false
+ }
+ ends[idx] = end
+ groups[idx] = g
+ }
+ // separators between members must be whitespace only
+ for idx := 0; idx+1 < len(starts); idx++ {
+ for k := ends[idx] + 1; k < starts[idx+1]; k++ {
+ if s.At(k).Kind != token.Whitespace {
+ return false
+ }
+ }
+ }
+ order := make([]int, 0, len(starts))
+ for g := groupTraitUse; g <= groupMethod; g++ {
+ for idx, gg := range groups {
+ if gg == g {
+ order = append(order, idx)
+ }
+ }
+ }
+ if isIdentityOrder(order) {
+ return false
+ }
+ // reordered member spans joined by the original positional separators
+ var repl []token.Token
+ for pos, memberIdx := range order {
+ for k := starts[memberIdx]; k <= ends[memberIdx]; k++ {
+ repl = append(repl, s.At(k))
+ }
+ if pos+1 < len(order) {
+ for k := ends[pos] + 1; k < starts[pos+1]; k++ {
+ repl = append(repl, s.At(k))
+ }
+ }
+ }
+ s.ReplaceRange(starts[0], ends[len(ends)-1], repl)
+ return true
+}
+
+// memberSpanEnd returns the last token index of the member starting at m: the
+// terminating ";" for properties/consts/trait-use, or the matching "}" of a
+// method body. Nested (), [] and {} groups are skipped. Returns -1 on trouble.
+func memberSpanEnd(s *tokens.Stream, m int) int {
+ for k := m; k < s.Len(); k++ {
+ t := s.At(k)
+ if t.Kind != token.Punct {
+ continue
+ }
+ switch t.Value {
+ case "(", "[":
+ mm := s.MatchForward(k)
+ if mm < 0 {
+ return -1
+ }
+ k = mm
+ case "{":
+ return s.MatchForward(k)
+ case ";":
+ return k
+ case "}":
+ return -1
+ }
+ }
+ return -1
+}
+
+// classifyMemberGroup returns the ordering group of the member in [m, end], or
+// -1 when it is anything unexpected (enum case, unknown token) so the caller
+// can skip the whole class.
+func classifyMemberGroup(s *tokens.Stream, m, end int) int {
+ k := m
+ for k <= end && s.At(k).Kind == token.Keyword && memberModifiers[strings.ToLower(s.At(k).Value)] {
+ k = skipWhitespace(s, k+1)
+ }
+ if k > end {
+ return -1
+ }
+ if s.At(k).Kind == token.Keyword {
+ switch strings.ToLower(s.At(k).Value) {
+ case "use":
+ return groupTraitUse
+ case "const":
+ return groupConst
+ case "function":
+ return groupMethod
+ default:
+ return -1
+ }
+ }
+ // property: a variable at the top level before the terminating ";"
+ for x := k; x <= end; x++ {
+ t := s.At(x)
+ if t.Kind == token.Punct {
+ switch t.Value {
+ case "(", "[", "{":
+ if mm := s.MatchForward(x); mm > 0 {
+ x = mm
+ continue
+ }
+ }
+ }
+ if t.Kind == token.Variable {
+ return groupProperty
+ }
+ }
+ return -1
+}
+
+func isIdentityOrder(order []int) bool {
+ for i, v := range order {
+ if i != v {
+ return false
+ }
+ }
+ return true
+}
diff --git a/blink/internal/fixer/rules/ordered_interfaces.go b/blink/internal/fixer/rules/ordered_interfaces.go
new file mode 100644
index 0000000000..d9db644701
--- /dev/null
+++ b/blink/internal/fixer/rules/ordered_interfaces.go
@@ -0,0 +1,134 @@
+package rules
+
+import (
+ "sort"
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/OrderedInterfacesFixer.php
+//
+// OrderedInterfaces sorts the interface list of an "implements" or
+// "interface extends" clause. Default config: alpha order, ascending,
+// case-insensitive.
+type OrderedInterfaces struct{}
+
+func (OrderedInterfaces) Name() string {
+ return `PhpCsFixer\Fixer\ClassNotation\OrderedInterfacesFixer`
+}
+
+func (OrderedInterfaces) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/OrderedInterfacesFixer.php"
+}
+
+func (OrderedInterfaces) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ if kwIs(t, "implements") {
+ // ok
+ } else if kwIs(t, "extends") {
+ name := sigPrev(s, i)
+ if name < 0 {
+ continue
+ }
+ iface := sigPrev(s, name)
+ if iface < 0 || !kwIs(s.At(iface), "interface") {
+ continue
+ }
+ } else {
+ continue
+ }
+
+ start := i + 1
+ brace := nextPunctOfKind(s, start, "{")
+ if brace < 0 {
+ continue
+ }
+ end := sigPrev(s, brace)
+ if end < start {
+ continue
+ }
+
+ groups := interfaceGroups(s, start, end)
+ if len(groups) <= 1 {
+ continue
+ }
+
+ type keyed struct {
+ toks []token.Token
+ key string
+ orig int
+ }
+ items := make([]keyed, len(groups))
+ for gi, g := range groups {
+ items[gi] = keyed{toks: g, key: interfaceSortKey(g), orig: gi}
+ }
+ sort.SliceStable(items, func(a, b int) bool {
+ return strings.ToLower(items[a].key) < strings.ToLower(items[b].key)
+ })
+
+ reordered := false
+ for gi := range items {
+ if items[gi].orig != gi {
+ reordered = true
+ break
+ }
+ }
+ if !reordered {
+ continue
+ }
+
+ var repl []token.Token
+ for gi, it := range items {
+ if gi > 0 {
+ repl = append(repl, token.Token{Kind: token.Punct, Value: ","})
+ }
+ repl = append(repl, it.toks...)
+ }
+ s.ReplaceRange(start, end, repl)
+ changed = true
+ i = start + len(repl)
+ }
+ return changed
+}
+
+// interfaceGroups splits the inclusive range [start, end] on top-level commas;
+// each group keeps its own leading whitespace, mirroring getInterfaces.
+func interfaceGroups(s *tokens.Stream, start, end int) [][]token.Token {
+ var groups [][]token.Token
+ cur := []token.Token{}
+ for i := start; i <= end && i < s.Len(); i++ {
+ if isPunctVal(s, i, ",") {
+ groups = append(groups, cur)
+ cur = []token.Token{}
+ continue
+ }
+ cur = append(cur, s.At(i))
+ }
+ groups = append(groups, cur)
+ return groups
+}
+
+// interfaceSortKey mirrors the fixer's normalized name: from the first
+// meaningful token, concatenate token content (with "\" replaced by a space)
+// until whitespace or a comment ends the name.
+func interfaceSortKey(group []token.Token) string {
+ var b strings.Builder
+ started := false
+ for _, t := range group {
+ if !started {
+ if t.Kind == token.Whitespace || t.Kind == token.Comment || t.Kind == token.DocComment {
+ continue
+ }
+ started = true
+ }
+ if t.Kind == token.Whitespace || t.Kind == token.Comment || t.Kind == token.DocComment {
+ break
+ }
+ b.WriteString(strings.ReplaceAll(t.Value, `\`, " "))
+ }
+ return b.String()
+}
diff --git a/blink/internal/fixer/rules/ordered_traits.go b/blink/internal/fixer/rules/ordered_traits.go
new file mode 100644
index 0000000000..79877a9f93
--- /dev/null
+++ b/blink/internal/fixer/rules/ordered_traits.go
@@ -0,0 +1,273 @@
+package rules
+
+import (
+ "slices"
+ "sort"
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/OrderedTraitsFixer.php
+//
+// OrderedTraits sorts trait `use` statements alphabetically (case-insensitive by
+// default), and sorts multiple traits within one `use A, B;` statement. Only the
+// name portions move; surrounding whitespace and punctuation stay in place.
+type OrderedTraits struct{}
+
+func (OrderedTraits) Name() string {
+ return `PhpCsFixer\Fixer\ClassNotation\OrderedTraitsFixer`
+}
+
+func (OrderedTraits) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/OrderedTraitsFixer.php"
+}
+
+func (OrderedTraits) Fix(s *tokens.Stream) bool {
+ // gather all trait-use statements as (start, end) ranges
+ type stmt struct {
+ start, end int
+ }
+ var groups [][]stmt
+ var cur []stmt
+
+ i := 0
+ for i < s.Len() {
+ t := s.At(i)
+ if t.Kind == token.Whitespace || t.Kind == token.Comment || t.Kind == token.DocComment {
+ i++
+ continue
+ }
+ if !kwIs(t, "use") || !isTraitUse(s, i) {
+ if len(cur) > 0 {
+ groups = append(groups, cur)
+ cur = nil
+ }
+ i++
+ continue
+ }
+ // statement end: next ";" or "{...}" block
+ end := nextPunctOfKind(s, i, ";", "{")
+ if end < 0 {
+ break
+ }
+ if isPunctVal(s, end, "{") {
+ end = s.MatchForward(end)
+ if end < 0 {
+ break
+ }
+ }
+ cur = append(cur, stmt{start: i, end: end})
+ i = end + 1
+ }
+ if len(cur) > 0 {
+ groups = append(groups, cur)
+ }
+
+ changed := false
+ // process groups in reverse so earlier indices stay valid across ReplaceRange
+ for _, g := range slices.Backward(groups) {
+ // build each statement's token slice, with its multiple traits sorted
+ stmtToks := make([][]token.Token, len(g))
+ names := make([]string, len(g))
+ for k, st := range g {
+ toks := make([]token.Token, 0, st.end-st.start+1)
+ for j := st.start; j <= st.end; j++ {
+ toks = append(toks, s.At(j))
+ }
+ toks = sortTraitsInStatement(toks)
+ stmtToks[k] = toks
+ names[k] = traitName(toks)
+ }
+ // sort statement slices by trait name (case-insensitive, stable)
+ order := make([]int, len(g))
+ for k := range order {
+ order[k] = k
+ }
+ sort.SliceStable(order, func(a, b int) bool {
+ return strings.ToLower(names[order[a]]) < strings.ToLower(names[order[b]])
+ })
+ // assign sorted slices to original positions; detect change
+ reordered := false
+ for k := range order {
+ if order[k] != k {
+ reordered = true
+ break
+ }
+ }
+ if !reordered {
+ // even if statement order is unchanged, within-statement sort may have changed a slice
+ within := false
+ for k, st := range g {
+ if !sameTokens(stmtToks[k], s, st.start, st.end) {
+ within = true
+ break
+ }
+ }
+ if !within {
+ continue
+ }
+ }
+ // apply position -> sorted slice, in reverse index order
+ for k, gk := range slices.Backward(g) {
+ s.ReplaceRange(gk.start, gk.end, stmtToks[order[k]])
+ }
+ changed = true
+ }
+ return changed
+}
+
+func sameTokens(toks []token.Token, s *tokens.Stream, start, end int) bool {
+ if len(toks) != end-start+1 {
+ return false
+ }
+ for k, t := range toks {
+ o := s.At(start + k)
+ if o.Kind != t.Kind || o.Value != t.Value {
+ return false
+ }
+ }
+ return true
+}
+
+// isTraitUse reports whether the "use" keyword at i is a trait use (inside a
+// class/trait/enum body), not a namespace import or a closure "use".
+func isTraitUse(s *tokens.Stream, i int) bool {
+ n := sigNext(s, i)
+ if n < 0 {
+ return false
+ }
+ // closure use is "use (" ; imports/traits start with a name or "\"
+ if s.At(n).Kind != token.Ident && !isPunctVal(s, n, `\`) {
+ return false
+ }
+ // enclosing "{" must belong to a class/trait/enum
+ 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 braceIsClassy(s, j)
+ }
+ depth--
+ }
+ }
+ return false
+}
+
+// braceIsClassy reports whether the "{" at index opens a class/trait/enum body,
+// by walking back over the class header to its keyword.
+func braceIsClassy(s *tokens.Stream, brace int) bool {
+ for j := sigPrev(s, brace); j >= 0; j = sigPrev(s, j) {
+ t := s.At(j)
+ if t.Kind == token.Keyword {
+ switch strings.ToLower(t.Value) {
+ case "class", "trait", "enum":
+ return true
+ case "extends", "implements", "abstract", "final", "readonly":
+ continue
+ }
+ return false
+ }
+ switch t.Kind {
+ case token.Ident:
+ continue
+ case token.Punct:
+ if t.Value == "," || t.Value == `\` {
+ continue
+ }
+ return false
+ default:
+ return false
+ }
+ }
+ return false
+}
+
+// traitName returns the first trait's fully-qualified name with the leading "\"
+// trimmed, matching the fixer's toTraitName sort key.
+func traitName(toks []token.Token) string {
+ var b strings.Builder
+ for _, t := range toks {
+ if t.Kind == token.Punct && (t.Value == ";" || t.Value == "{" || t.Value == ",") {
+ break
+ }
+ if t.Kind == token.Ident || (t.Kind == token.Punct && t.Value == `\`) {
+ b.WriteString(t.Value)
+ }
+ }
+ return strings.TrimLeft(b.String(), `\`)
+}
+
+// sortTraitsInStatement sorts multiple traits within a single "use A, B;"
+// statement, swapping only the name token runs and keeping everything else.
+func sortTraitsInStatement(toks []token.Token) []token.Token {
+ // find name runs (T_STRING / "\") separated by "," ; stop at ";" or "{"
+ type run struct{ start, end int }
+ var runs []run
+ rs := -1
+ for idx, t := range toks {
+ isName := t.Kind == token.Ident || (t.Kind == token.Punct && t.Value == `\`)
+ if isName {
+ if rs < 0 {
+ rs = idx
+ }
+ continue
+ }
+ if t.Kind == token.Punct && (t.Value == "," || t.Value == ";" || t.Value == "{") {
+ if rs >= 0 {
+ runs = append(runs, run{rs, idx - 1})
+ rs = -1
+ }
+ if t.Value == "{" {
+ break
+ }
+ }
+ }
+ if len(runs) <= 1 {
+ return toks
+ }
+ keys := make([]string, len(runs))
+ for k, r := range runs {
+ var b strings.Builder
+ for j := r.start; j <= r.end; j++ {
+ b.WriteString(toks[j].Value)
+ }
+ keys[k] = strings.TrimLeft(b.String(), `\`)
+ }
+ order := make([]int, len(runs))
+ for k := range order {
+ order[k] = k
+ }
+ sort.SliceStable(order, func(a, b int) bool {
+ return strings.ToLower(keys[order[a]]) < strings.ToLower(keys[order[b]])
+ })
+ same := true
+ for k := range order {
+ if order[k] != k {
+ same = false
+ break
+ }
+ }
+ if same {
+ return toks
+ }
+ // rebuild sequentially: at each run's slot, place the sorted run's tokens
+ // (runs may differ in length); everything between runs is kept verbatim
+ var res []token.Token
+ prev := 0
+ for k, r := range runs {
+ res = append(res, toks[prev:r.start]...)
+ src := runs[order[k]]
+ res = append(res, toks[src.start:src.end+1]...)
+ prev = r.end + 1
+ }
+ res = append(res, toks[prev:]...)
+ return res
+}
diff --git a/blink/internal/fixer/rules/ordered_types.go b/blink/internal/fixer/rules/ordered_types.go
new file mode 100644
index 0000000000..c4c31b6099
--- /dev/null
+++ b/blink/internal/fixer/rules/ordered_types.go
@@ -0,0 +1,160 @@
+package rules
+
+import (
+ "sort"
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/OrderedTypesFixer.php
+//
+// OrderedTypes sorts the members of a union or intersection type declaration
+// using the fixer defaults: case-insensitive alphabetical order with "null"
+// always first ("B|A" -> "A|B", "string|int|null" -> "null|int|string"). It acts
+// only in confirmed type positions (parameter, return, typed property) on a flat
+// single-operator run; nullable "?" prefixes, DNF "()" types, mixed "|"/"&" runs
+// and types spanning multiple lines are left untouched.
+type OrderedTypes struct{}
+
+func (OrderedTypes) Name() string {
+ return `PhpCsFixer\Fixer\ClassNotation\OrderedTypesFixer`
+}
+
+func (OrderedTypes) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/OrderedTypesFixer.php"
+}
+
+// typeMember is one member of a union/intersection, its tokens and its sort key.
+type typeMember struct {
+ toks []token.Token
+ key string
+}
+
+func (OrderedTypes) 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 != "|" && t.Value != "&") {
+ continue
+ }
+ if !isTypeUnionOperator(s, i) {
+ continue
+ }
+ start := typeRunBoundaryPrev(s, i)
+ end := typeRunBoundaryNext(s, i)
+ if end < 0 {
+ continue
+ }
+ // the run's first..last significant token (excludes surrounding whitespace)
+ runStart := start + 1
+ for runStart < end && s.At(runStart).Kind == token.Whitespace {
+ runStart++
+ }
+ runEnd := end - 1
+ for runEnd > runStart && s.At(runEnd).Kind == token.Whitespace {
+ runEnd--
+ }
+ // process each run once, at its leftmost operator
+ first := -1
+ for j := runStart; j <= runEnd; j++ {
+ if v := s.At(j); v.Kind == token.Punct && (v.Value == "|" || v.Value == "&") {
+ first = j
+ break
+ }
+ }
+ if first != i {
+ continue
+ }
+
+ op := t.Value
+ members, ok := collectTypeMembers(s, runStart, runEnd+1, op)
+ if !ok || len(members) < 2 {
+ continue
+ }
+
+ sorted := make([]typeMember, len(members))
+ copy(sorted, members)
+ sort.SliceStable(sorted, func(a, b int) bool {
+ an, bn := sorted[a].key == "null", sorted[b].key == "null"
+ if an != bn {
+ return an // null first
+ }
+ return sorted[a].key < sorted[b].key
+ })
+ if sameMemberOrder(members, sorted) {
+ continue // already ordered; nothing to rewrite
+ }
+
+ repl := make([]token.Token, 0, len(sorted)*2)
+ for idx, m := range sorted {
+ if idx > 0 {
+ repl = append(repl, token.Token{Kind: token.Punct, Value: op})
+ }
+ repl = append(repl, m.toks...)
+ }
+ s.ReplaceRange(runStart, runEnd, repl)
+ changed = true
+ i = runStart + len(repl) - 1
+ }
+ return changed
+}
+
+// collectTypeMembers splits the significant tokens in [from, to) into members
+// separated by the single operator op. It fails (ok=false) when the run mixes
+// operators, contains a "?"/"("/")" or spans multiple lines - cases the fixer
+// must not reorder.
+func collectTypeMembers(s *tokens.Stream, from, to int, op string) (members []typeMember, ok bool) {
+ var cur typeMember
+ flush := func() {
+ cur.key = strings.ToLower(cur.key)
+ members = append(members, cur)
+ cur = typeMember{}
+ }
+ for j := from; j < to; j++ {
+ tk := s.At(j)
+ switch tk.Kind {
+ case token.Whitespace:
+ if hasNewline(tk.Value) {
+ return nil, false // multi-line type, leave alone
+ }
+ continue
+ case token.Comment, token.DocComment:
+ return nil, false
+ case token.Punct:
+ switch tk.Value {
+ case op:
+ if len(cur.toks) == 0 {
+ return nil, false
+ }
+ flush()
+ continue
+ case `\`:
+ cur.toks = append(cur.toks, tk)
+ cur.key += tk.Value
+ continue
+ default:
+ return nil, false // "?", "(", ")", "&"/"|" mismatch
+ }
+ default:
+ cur.toks = append(cur.toks, tk)
+ cur.key += tk.Value
+ }
+ }
+ if len(cur.toks) == 0 {
+ return nil, false
+ }
+ flush()
+ return members, true
+}
+
+// sameMemberOrder reports whether a and b list members in the same order.
+func sameMemberOrder(a, b []typeMember) bool {
+ for i := range a {
+ if a[i].key != b[i].key {
+ return false
+ }
+ }
+ return true
+}
diff --git a/blink/internal/fixer/rules/param_return_and_var_tag_malforms.go b/blink/internal/fixer/rules/param_return_and_var_tag_malforms.go
new file mode 100644
index 0000000000..3bf9cc5be4
--- /dev/null
+++ b/blink/internal/fixer/rules/param_return_and_var_tag_malforms.go
@@ -0,0 +1,23 @@
+package rules
+
+import "blink/internal/tokens"
+
+// Symplify: https://github.com/symplify/coding-standard/blob/main/src/Fixer/Commenting/ParamReturnAndVarTagMalformsFixer.php
+//
+// ParamReturnAndVarTagMalforms is deprecated upstream and a no-op: its
+// isCandidate returns false and fix does nothing (it was split into dedicated
+// single-task rules). It is kept as a registered no-op so the default set
+// matches ECS.
+type ParamReturnAndVarTagMalforms struct{}
+
+func (ParamReturnAndVarTagMalforms) Name() string {
+ return `Symplify\CodingStandard\Fixer\Commenting\ParamReturnAndVarTagMalformsFixer`
+}
+
+func (ParamReturnAndVarTagMalforms) SourceURL() string {
+ return "https://github.com/symplify/coding-standard/blob/main/src/Fixer/Commenting/ParamReturnAndVarTagMalformsFixer.php"
+}
+
+func (ParamReturnAndVarTagMalforms) Fix(*tokens.Stream) bool {
+ return false
+}
diff --git a/blink/internal/fixer/rules/parity_5bugs_test.go b/blink/internal/fixer/rules/parity_5bugs_test.go
new file mode 100644
index 0000000000..9dd8c06461
--- /dev/null
+++ b/blink/internal/fixer/rules/parity_5bugs_test.go
@@ -0,0 +1,48 @@
+package rules
+
+import "testing"
+
+func TestArrowFnSpacing(t *testing.T) {
+ got, changed := apply(t, FunctionDeclaration{}, " $x;")
+ if want := " $x;"; !changed || got != want {
+ t.Fatalf("changed=%v got=%q want=%q", changed, got, want)
+ }
+ if _, changed := apply(t, FunctionDeclaration{}, " $x;"); changed {
+ t.Fatal("already-spaced fn must not change")
+ }
+}
+
+func TestPhpdocTypesGenericsAndConst(t *testing.T) {
+ // a pseudo-type keyword inside generics is lowercased
+ got, changed := apply(t, PhpdocTypes{}, "\n */\nclass A {}")
+ if want := "\n */\nclass A {}"; !changed || got != want {
+ t.Fatalf("generics: changed=%v got=%q", changed, got)
+ }
+ // a class-constant reference (Ref::STATIC) is left untouched
+ if _, changed := apply(t, PhpdocTypes{}, "
+// "new Foo()". Anonymous classes and dynamic "new $var" are left alone.
+type NewWithParentheses struct{}
+
+func (NewWithParentheses) Name() string {
+ return `PhpCsFixer\Fixer\Operator\NewWithParenthesesFixer`
+}
+
+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 {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ if t.Kind != token.Keyword || strings.ToLower(t.Value) != "new" {
+ continue
+ }
+ j := skipWhitespace(s, i+1)
+ if j >= s.Len() {
+ continue
+ }
+ // a static class name (Ident, \-qualified, self/parent/static) or a
+ // dynamic class reference (new $var, new $this->prop, new $a::$b)
+ nt := s.At(j)
+ 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(...)"
+ }
+ s.InsertAt(j+1, token.Token{Kind: token.Punct, Value: "("})
+ s.InsertAt(j+2, token.Token{Kind: token.Punct, Value: ")"})
+ changed = true
+ continue
+ }
+ if nt.Kind == token.Variable {
+ k := consumeNewVarRef(s, j)
+ if nextSignificantValue(s, k-1) == "(" {
+ continue // already has parentheses
+ }
+ s.InsertAt(k, token.Token{Kind: token.Punct, Value: "("})
+ s.InsertAt(k+1, token.Token{Kind: token.Punct, Value: ")"})
+ changed = true
+ continue
+ }
+ isName := nt.Kind == token.Ident ||
+ (nt.Kind == token.Punct && nt.Value == `\`) ||
+ (nt.Kind == token.Keyword && isStaticRef(nt.Value))
+ if !isName {
+ continue // new (expr), ...
+ }
+ // consume the class reference (Ident / \ / self-parent-static)
+ k := j
+ for k < s.Len() {
+ c := s.At(k)
+ if c.Kind == token.Ident || (c.Kind == token.Punct && c.Value == `\`) ||
+ (c.Kind == token.Keyword && isStaticRef(c.Value)) {
+ k++
+ continue
+ }
+ break
+ }
+ if nextSignificantValue(s, k-1) == "(" {
+ continue // already has parentheses
+ }
+ s.InsertAt(k, token.Token{Kind: token.Punct, Value: "("})
+ s.InsertAt(k+1, token.Token{Kind: token.Punct, Value: ")"})
+ changed = true
+ }
+ return changed
+}
+
+// consumeNewVarRef returns the index just past a dynamic class reference that
+// starts with a variable at `start`: "$var", "$this->prop", "$a::$b", "$a[0]".
+// It stops before a "(" so an existing constructor/method call is left intact.
+func consumeNewVarRef(s *tokens.Stream, start int) int {
+ k := start + 1 // past the initial variable
+ for k < s.Len() {
+ c := s.At(k)
+ if c.Kind == token.Punct && (c.Value == "->" || c.Value == "?->" || c.Value == "::") {
+ m := skipWhitespace(s, k+1)
+ if m < s.Len() && (s.At(m).Kind == token.Ident || s.At(m).Kind == token.Variable) {
+ k = m + 1
+ continue
+ }
+ break
+ }
+ if c.Kind == token.Punct && c.Value == "[" {
+ cl := s.MatchForward(k)
+ if cl < 0 {
+ break
+ }
+ k = cl + 1
+ continue
+ }
+ break
+ }
+ return k
+}
+
+func isStaticRef(v string) bool {
+ switch strings.ToLower(v) {
+ case "self", "parent", "static":
+ return true
+ }
+ return false
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Basic/SingleLineEmptyBodyFixer.php
+//
+// SingleLineEmptyBody collapses an empty class or function body to "{}" on the
+// declaration line ("function f()\n{\n}" -> "function f() {}").
+type SingleLineEmptyBody struct{}
+
+func (SingleLineEmptyBody) Name() string {
+ return `PhpCsFixer\Fixer\Basic\SingleLineEmptyBodyFixer`
+}
+
+func (SingleLineEmptyBody) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Basic/SingleLineEmptyBodyFixer.php"
+}
+
+func (SingleLineEmptyBody) 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 != "{" {
+ continue
+ }
+ switch kind, _ := classifyBrace(s, i); kind {
+ case braceClassLike, braceFunctionDecl:
+ default:
+ continue
+ }
+ closeIdx := s.MatchForward(i)
+ if closeIdx < 0 {
+ continue
+ }
+ empty := true
+ for k := i + 1; k < closeIdx; k++ {
+ if s.At(k).Kind != token.Whitespace {
+ empty = false
+ break
+ }
+ }
+ if !empty || closeIdx == i+1 {
+ continue
+ }
+ // remove the whitespace between { and }
+ for k := closeIdx - 1; k > i; k-- {
+ s.RemoveAt(k)
+ }
+ // pull the brace onto the declaration line
+ if i > 0 && s.At(i-1).Kind == token.Whitespace && hasNewline(s.At(i-1).Value) {
+ s.SetValue(i-1, " ")
+ }
+ changed = true
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/percs_test.go b/blink/internal/fixer/rules/percs_test.go
new file mode 100644
index 0000000000..f4642ed03f
--- /dev/null
+++ b/blink/internal/fixer/rules/percs_test.go
@@ -0,0 +1,45 @@
+package rules
+
+import "testing"
+
+func TestNewWithParentheses(t *testing.T) {
+ got, changed := apply(t, NewWithParentheses{}, " true)
+ if got, changed := apply(t, NewWithParentheses{}, "job;")
+ if want := "job();"; !changed || got != want {
+ t.Fatalf("changed=%v got=%q want=%q", changed, got, want)
+ }
+ // an existing call on a dynamic reference is left intact
+ if _, changed := apply(t, NewWithParentheses{}, "make();"); changed {
+ t.Fatal("new $this->make() already has parentheses")
+ }
+}
+
+func TestSingleLineEmptyBody(t *testing.T) {
+ got, changed := apply(t, SingleLineEmptyBody{}, "" from a file that is only PHP (no inline
+// HTML), leaving a single trailing newline.
+type NoClosingTag struct{}
+
+func (NoClosingTag) Name() string {
+ return `PhpCsFixer\Fixer\PhpTag\NoClosingTagFixer`
+}
+
+func (NoClosingTag) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/PhpTag/NoClosingTagFixer.php"
+}
+
+func (NoClosingTag) Fix(s *tokens.Stream) bool {
+ last := -1
+ for i := range s.Len() {
+ t := s.At(i)
+ // real inline HTML means a templating file; whitespace-only inline HTML
+ // (e.g. the newline the lexer emits after "?>") does not count
+ if t.Kind == token.InlineHTML && strings.TrimSpace(t.Value) != "" {
+ return false
+ }
+ if t.Kind == token.CloseTag {
+ last = i
+ }
+ }
+ if last < 0 {
+ return false
+ }
+ // only whitespace (or whitespace-only inline HTML) may follow the final "?>"
+ for j := last + 1; j < s.Len(); j++ {
+ k := s.At(j).Kind
+ if k == token.Whitespace {
+ continue
+ }
+ if k == token.InlineHTML && strings.TrimSpace(s.At(j).Value) == "" {
+ continue
+ }
+ return false
+ }
+ for k := s.Len() - 1; k >= last; k-- {
+ s.RemoveAt(k)
+ }
+ // normalize EOF to exactly one trailing newline (idempotent with eof rule)
+ if s.Len() > 0 {
+ li := s.Len() - 1
+ if s.At(li).Kind == token.Whitespace {
+ s.SetValue(li, "\n")
+ } else {
+ s.InsertAt(s.Len(), token.Token{Kind: token.Whitespace, Value: "\n"})
+ }
+ }
+ return true
+}
diff --git a/blink/internal/fixer/rules/phpdoc.go b/blink/internal/fixer/rules/phpdoc.go
new file mode 100644
index 0000000000..4cfde92c80
--- /dev/null
+++ b/blink/internal/fixer/rules/phpdoc.go
@@ -0,0 +1,156 @@
+package rules
+
+import (
+ "regexp"
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// applyToDocblocks runs fn over each doc comment, replacing it when fn reports a
+// change.
+func applyToDocblocks(s *tokens.Stream, fn func(*docblock) bool) bool {
+ changed := false
+ for i := range s.Len() {
+ t := s.At(i)
+ if t.Kind != token.DocComment {
+ continue
+ }
+ d, ok := parseDoc(t.Value)
+ if !ok {
+ continue
+ }
+ if fn(&d) {
+ s.SetValue(i, d.render())
+ changed = true
+ }
+ }
+ return changed
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocTrimFixer.php
+//
+// PhpdocTrim removes blank lines at the start and end of a docblock.
+type PhpdocTrim struct{}
+
+func (PhpdocTrim) Name() string {
+ return `PhpCsFixer\Fixer\Phpdoc\PhpdocTrimFixer`
+}
+
+func (PhpdocTrim) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocTrimFixer.php"
+}
+
+func (PhpdocTrim) Fix(s *tokens.Stream) bool {
+ return applyToDocblocks(s, func(d *docblock) bool {
+ before := len(d.inner)
+ for len(d.inner) > 0 && strings.TrimSpace(d.inner[0].content) == "" {
+ d.inner = d.inner[1:]
+ }
+ for len(d.inner) > 0 && strings.TrimSpace(d.inner[len(d.inner)-1].content) == "" {
+ d.inner = d.inner[:len(d.inner)-1]
+ }
+ return len(d.inner) != before
+ })
+}
+
+// only when void/null is the whole return type - not part of a union like
+// "null|string", which is a real nullable type
+var emptyReturnRe = regexp.MustCompile(`(?i)^@return\s+(void|null)($|\s)`)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocNoEmptyReturnFixer.php
+//
+// PhpdocNoEmptyReturn removes an "@return void" or "@return null" tag.
+type PhpdocNoEmptyReturn struct{}
+
+func (PhpdocNoEmptyReturn) Name() string {
+ return `PhpCsFixer\Fixer\Phpdoc\PhpdocNoEmptyReturnFixer`
+}
+
+func (PhpdocNoEmptyReturn) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocNoEmptyReturnFixer.php"
+}
+
+func (PhpdocNoEmptyReturn) Fix(s *tokens.Stream) bool {
+ return applyToDocblocks(s, func(d *docblock) bool {
+ kept := d.inner[:0:0]
+ removed := false
+ for _, l := range d.inner {
+ if emptyReturnRe.MatchString(strings.TrimSpace(l.content)) {
+ removed = true
+ continue
+ }
+ kept = append(kept, l)
+ }
+ if removed {
+ d.inner = kept
+ }
+ return removed
+ })
+}
+
+var phpdocScalarMap = map[string]string{
+ "boolean": "bool", "integer": "int", "double": "float",
+ "real": "float", "str": "string", "callback": "callable",
+}
+
+var phpdocTypeTagRe = regexp.MustCompile(`(?i)^(@(?:param|return|var|throws|property|property-read|property-write|method)\s+)(\S+)(.*)$`)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocScalarFixer.php
+//
+// PhpdocScalar normalizes scalar type aliases in phpdoc tags (integer -> int,
+// boolean -> bool, double/real -> float, str -> string, callback -> callable).
+type PhpdocScalar struct{}
+
+func (PhpdocScalar) Name() string {
+ return `PhpCsFixer\Fixer\Phpdoc\PhpdocScalarFixer`
+}
+
+func (PhpdocScalar) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocScalarFixer.php"
+}
+
+func (PhpdocScalar) Fix(s *tokens.Stream) bool {
+ return applyToDocblocks(s, func(d *docblock) bool {
+ changed := false
+ for i, l := range d.inner {
+ m := phpdocTypeTagRe.FindStringSubmatch(strings.TrimLeft(l.content, " "))
+ if m == nil {
+ continue
+ }
+ newType := normalizeScalarType(m[2])
+ if newType == m[2] {
+ continue
+ }
+ lead := l.content[:len(l.content)-len(strings.TrimLeft(l.content, " "))]
+ d.inner[i].content = lead + m[1] + newType + m[3]
+ changed = true
+ }
+ return changed
+ })
+}
+
+// normalizeScalarType replaces scalar aliases in a phpdoc type, handling unions,
+// nullables and array suffixes ("integer[]|null" -> "int[]|null"). The lookup is
+// case-sensitive so a class named like an alias (e.g. Laravel's "Str") is safe.
+func normalizeScalarType(typ string) string {
+ nullable := strings.HasPrefix(typ, "?")
+ body := strings.TrimPrefix(typ, "?")
+ parts := strings.Split(body, "|")
+ for i, p := range parts {
+ base, suffix := p, ""
+ for strings.HasSuffix(base, "[]") {
+ base = base[:len(base)-2]
+ suffix = "[]" + suffix
+ }
+ if repl, ok := phpdocScalarMap[base]; ok {
+ parts[i] = repl + suffix
+ }
+ }
+ out := strings.Join(parts, "|")
+ if nullable {
+ out = "?" + out
+ }
+ return out
+}
diff --git a/blink/internal/fixer/rules/phpdoc_align.go b/blink/internal/fixer/rules/phpdoc_align.go
new file mode 100644
index 0000000000..7812b4dade
--- /dev/null
+++ b/blink/internal/fixer/rules/phpdoc_align.go
@@ -0,0 +1,180 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocAlignFixer.php
+//
+// PhpdocAlign here follows the ECS laravel config: align="left", spacing
+// {param:2,_default:1}, default tags. Left alignment collapses the spacing
+// between tag/hint/var/desc to the configured spacing and re-indents wrapped
+// description lines under the description column.
+type PhpdocAlign struct{}
+
+// default tags split by kind
+var (
+ phpAlignNameTags = []string{"param", "property", "property-read", "property-write", "phpstan-param", "phpstan-property", "phpstan-property-read", "phpstan-property-write", "phpstan-assert", "phpstan-assert-if-true", "phpstan-assert-if-false", "psalm-param", "psalm-param-out", "psalm-property", "psalm-property-read", "psalm-property-write", "psalm-assert", "psalm-assert-if-true", "psalm-assert-if-false"}
+ phpAlignMethodTags = []string{"method", "phpstan-method", "psalm-method"}
+ // DEFAULT_TAGS = method,param,property,return,throws,type,var
+ phpAlignNoNameTags = []string{"return", "throws", "type", "var"}
+)
+
+func (PhpdocAlign) Name() string {
+ return `PhpCsFixer\Fixer\Phpdoc\PhpdocAlignFixer`
+}
+
+func (PhpdocAlign) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocAlignFixer.php"
+}
+
+func phpAlignSpacingFor(tag string) int {
+ if tag == "param" {
+ return 2
+ }
+ return 1
+}
+
+type alignMatch struct {
+ indent string
+ tag string // "" for a comment-only continuation line
+ hint string
+ varName string
+ static string
+ desc string
+ isTag bool // matched the tag regex
+}
+
+func (PhpdocAlign) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ if s.At(i).Kind != token.DocComment {
+ continue
+ }
+ v := s.At(i).Value
+ rebuilt := phpAlignDocBlock(v)
+ if rebuilt != v {
+ s.SetValue(i, rebuilt)
+ changed = true
+ }
+ }
+ return changed
+}
+
+func phpAlignDocBlock(content string) string {
+ lines := splitDocLines(content)
+ l := len(lines)
+ for i := 0; i < l; i++ {
+ m := phpAlignGetMatches(lines[i], false)
+ if m == nil {
+ continue
+ }
+ current := i
+ items := []alignMatch{*m}
+ for {
+ i++
+ if i >= l {
+ // break both loops
+ phpAlignRewrite(lines, current, items)
+ return strings.Join(lines, "")
+ }
+ m2 := phpAlignGetMatches(lines[i], true)
+ if m2 == nil {
+ break
+ }
+ items = append(items, *m2)
+ }
+ phpAlignRewrite(lines, current, items)
+ i-- // reconsider the line that broke the group (it may start a new group)
+ }
+ return strings.Join(lines, "")
+}
+
+// phpAlignRewrite reformats the grouped item lines (left align).
+func phpAlignRewrite(lines []string, current int, items []alignMatch) {
+ // group-wide flag: any tag line carries a `static` keyword
+ hasStatic := false
+ for j := range items {
+ if items[j].isTag && items[j].static != "" {
+ hasStatic = true
+ break
+ }
+ }
+ var itemOpening *alignMatch
+ for j := range items {
+ item := items[j]
+ var line string
+ if !item.isTag {
+ if len(item.desc) > 0 && item.desc[0] == '@' {
+ line = item.indent + " * " + item.desc
+ lines[current+j] = line + "\n"
+ continue
+ }
+ leftIndent := phpAlignLeftDescIndent(items, j)
+ hintPad := ""
+ if itemOpening != nil && itemOpening.hint != "" {
+ hintPad = " "
+ }
+ line = item.indent + " * " + hintPad + strings.Repeat(" ", leftIndent) + item.desc
+ lines[current+j] = line + "\n"
+ continue
+ }
+ itemOpening = &items[j]
+ spacing := phpAlignSpacingFor(item.tag)
+ line = item.indent + " * @" + item.tag
+ // static: only emitted when the group has any static keyword
+ if hasStatic {
+ if item.static != "" {
+ line += strings.Repeat(" ", spacing) + item.static
+ }
+ // non-static line adds no padding under left align
+ }
+ // hint
+ if item.hint != "" {
+ line += strings.Repeat(" ", spacing) + item.hint
+ }
+ // var
+ if item.varName != "" {
+ line += strings.Repeat(" ", spacing) + item.varName
+ if item.desc != "" {
+ line += strings.Repeat(" ", spacing) + item.desc
+ }
+ } else if item.desc != "" {
+ line += strings.Repeat(" ", spacing) + item.desc
+ }
+ lines[current+j] = line + "\n"
+ }
+}
+
+// phpAlignLeftDescIndent mirrors getLeftAlignedDescriptionIndent for align=left.
+func phpAlignLeftDescIndent(items []alignMatch, index int) int {
+ var item *alignMatch
+ for ; index >= 0; index-- {
+ item = &items[index]
+ if item.isTag {
+ break
+ }
+ }
+ if item == nil || !item.isTag {
+ return 0
+ }
+ spacing := phpAlignSpacingFor(item.tag)
+ sent := func(sfrag string, hasVal bool) int {
+ if !hasVal {
+ return 0
+ }
+ n := len(sfrag)
+ if n == 0 {
+ return 0
+ }
+ return n + spacing
+ }
+ // static, tag, hint, var
+ return sent(item.static, item.static != "") +
+ sent(item.tag, true) +
+ sent(item.hint, item.hint != "") +
+ sent(item.varName, item.varName != "")
+}
diff --git a/blink/internal/fixer/rules/phpdoc_align_match.go b/blink/internal/fixer/rules/phpdoc_align_match.go
new file mode 100644
index 0000000000..537a2b134d
--- /dev/null
+++ b/blink/internal/fixer/rules/phpdoc_align_match.go
@@ -0,0 +1,335 @@
+package rules
+
+import (
+ "slices"
+ "strings"
+)
+
+func phpAlignIsHSpace(c byte) bool { return c == ' ' || c == '\t' }
+
+// phpAlignGetMatches parses a docblock line, mirroring PhpdocAlignFixer::getMatches
+// for the laravel config (default tags). Returns nil when the line is not a tag
+// line (or, when matchCommentOnly, not a description continuation line).
+func phpAlignGetMatches(rawLine string, matchCommentOnly bool) *alignMatch {
+ line := strings.TrimRight(rawLine, "\r\n")
+ // ^(?P(?:\ {2}|\t)*)\ ?\*
+ i := 0
+ indentEnd := 0
+ for i < len(line) {
+ if strings.HasPrefix(line[i:], " ") {
+ i += 2
+ indentEnd = i
+ } else if line[i] == '\t' {
+ i++
+ indentEnd = i
+ } else {
+ break
+ }
+ }
+ indent := line[:indentEnd]
+ if i < len(line) && line[i] == ' ' {
+ i++
+ }
+ if i >= len(line) || line[i] != '*' {
+ return nil
+ }
+ i++ // past '*'
+ starPos := i
+
+ // try the tag regex: \h*@tag...
+ j := i
+ for j < len(line) && phpAlignIsHSpace(line[j]) {
+ j++
+ }
+ if j < len(line) && line[j] == '@' {
+ if m := phpAlignParseTag(indent, line, j+1); m != nil {
+ return m
+ }
+ }
+
+ if !matchCommentOnly {
+ return nil
+ }
+ // regexCommentLine: \*(?!\h?+@)(?:\s+(?P\V+))(?= len(line) {
+ return nil
+ }
+ desc := line[d:]
+ if desc == "" {
+ return nil
+ }
+ // (?= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-'
+}
+
+func phpAlignInList(tag string, list []string) bool {
+ return slices.Contains(list, tag)
+}
+
+// name tag: @tag \s+ (hint)? \s* (var=(?:&|\.{3})?\$\S+) (?:\s+ desc)? \h*$
+func phpAlignParseNameTag(indent, tag, line string, pos int) *alignMatch {
+ // require \s+
+ p := pos
+ for p < len(line) && phpAlignIsWS(line[p]) {
+ p++
+ }
+ if p == pos {
+ return nil // needed \s+
+ }
+ // find the var: a '$' at bracket depth 0 whose token is separated from the
+ // hint by whitespace. A callable/Closure carries `$param` inside parentheses
+ // (depth > 0) and a union member like `Foo|$this` attaches with no space, so
+ // both stay in the hint; the documented variable follows a space.
+ dollar := -1
+ depth := 0
+ for k := p; k < len(line); k++ {
+ switch line[k] {
+ case '<', '[', '(', '{':
+ depth++
+ case '>', ']', ')', '}':
+ if depth > 0 {
+ depth--
+ }
+ case '$':
+ if depth == 0 {
+ ps := k
+ if k-1 >= p && line[k-1] == '&' {
+ ps = k - 1
+ } else if k-3 >= p && line[k-3:k] == "..." {
+ ps = k - 3
+ }
+ if ps == p || phpAlignIsWS(line[ps-1]) {
+ dollar = k
+ }
+ }
+ }
+ if dollar >= 0 {
+ break
+ }
+ }
+ if dollar < 0 {
+ return nil
+ }
+ varStart := dollar
+ if varStart-1 >= p && line[varStart-1] == '&' {
+ varStart--
+ } else if varStart-3 >= p && line[varStart-3:varStart] == "..." {
+ varStart -= 3
+ }
+ hint := strings.TrimRight(line[p:varStart], " \t")
+ hint = strings.TrimSpace(hint)
+ // var = \S+ from varStart
+ ve := varStart
+ for ve < len(line) && !phpAlignIsWS(line[ve]) {
+ ve++
+ }
+ varName := line[varStart:ve]
+ // desc: (?:\s+ (\V*))? then \h*$
+ desc := ""
+ q := ve
+ for q < len(line) && phpAlignIsWS(line[q]) {
+ q++
+ }
+ if q > ve && q < len(line) {
+ desc = line[q:]
+ }
+ return &alignMatch{indent: indent, tag: tag, isTag: true, hint: hint, varName: varName, desc: desc}
+}
+
+// no-name tag: @tag \s+ (hint=REGEX_TYPES)? (?:\s+ desc)? \h*$
+func phpAlignParseNoNameTag(indent, tag, line string, pos int) *alignMatch {
+ p := pos
+ for p < len(line) && phpAlignIsWS(line[p]) {
+ p++
+ }
+ if p == pos {
+ return nil
+ }
+ hintEnd, balanced := phpAlignScanType(line, p)
+ if !balanced {
+ // REGEX_TYPES cannot match an unbalanced type, so the hint is empty and
+ // desc needs its own \s+; that only exists when 2+ leading spaces let the
+ // hint's \s+ give one back. A single space leaves no split -> no match.
+ if p-pos < 2 {
+ return nil
+ }
+ return &alignMatch{indent: indent, tag: tag, isTag: true, hint: "", varName: "", desc: line[p:]}
+ }
+ hint := strings.TrimSpace(line[p:hintEnd])
+ desc := ""
+ q := hintEnd
+ for q < len(line) && phpAlignIsWS(line[q]) {
+ q++
+ }
+ if q < len(line) {
+ desc = line[q:]
+ }
+ return &alignMatch{indent: indent, tag: tag, isTag: true, hint: hint, varName: "", desc: desc}
+}
+
+// method tag: @tag (\s+static)? \s+ (hint)? \s+ (signature=.+\)) — best effort
+func phpAlignParseMethodTag(indent, tag, line string, pos int) *alignMatch {
+ // Fall back: treat as no-name if it doesn't clearly have a signature "(...)"
+ if strings.IndexByte(line[pos:], '(') < 0 {
+ return phpAlignParseNoNameTag(indent, tag, line, pos)
+ }
+ p := pos
+ for p < len(line) && phpAlignIsWS(line[p]) {
+ p++
+ }
+ if p == pos {
+ return nil
+ }
+ static := ""
+ if strings.HasPrefix(line[p:], "static") && (p+6 >= len(line) || phpAlignIsWS(line[p+6])) {
+ static = "static"
+ p += 6
+ for p < len(line) && phpAlignIsWS(line[p]) {
+ p++
+ }
+ }
+ // hint then \s+ signature (ends with ')')
+ sigParen := strings.LastIndexByte(line, ')')
+ if sigParen < 0 {
+ return nil
+ }
+ if p > sigParen {
+ return nil
+ }
+ // signature start: after hint + \s+. Find the last top-level ws before the signature name.
+ hintEnd, _ := phpAlignScanType(line, p)
+ var hint, signature string
+ if hintEnd > sigParen {
+ // no return type: the scanned run is the name+signature itself
+ signature = strings.TrimRight(line[p:sigParen+1], " \t")
+ } else {
+ hint = strings.TrimSpace(line[p:hintEnd])
+ q := hintEnd
+ for q < len(line) && phpAlignIsWS(line[q]) {
+ q++
+ }
+ if q > sigParen {
+ return nil
+ }
+ signature = strings.TrimRight(line[q:sigParen+1], " \t")
+ }
+ if !strings.HasSuffix(signature, ")") {
+ return nil
+ }
+ // desc: (?:\s+ \V*) after the signature's closing paren
+ desc := ""
+ r := sigParen + 1
+ for r < len(line) && phpAlignIsWS(line[r]) {
+ r++
+ }
+ if r < len(line) {
+ desc = line[r:]
+ }
+ // hint/static swap: if hint empty and static set, static becomes hint
+ if hint == "" && static != "" {
+ hint = static
+ static = ""
+ }
+ return &alignMatch{indent: indent, tag: tag, isTag: true, hint: hint, varName: signature, static: static, desc: desc}
+}
+
+func phpAlignIsWS(c byte) bool {
+ return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\v' || c == '\f'
+}
+
+// phpAlignScanType consumes a PHPDoc type expression starting at pos, returning
+// the index just past it. Approximates TypeExpression::REGEX_TYPES: it consumes
+// type characters and bracketed groups, treating a top-level space as the end
+// unless the next non-space is a union/intersection continuation (| or &).
+// phpAlignScanType returns the index just past the type and whether it ended
+// balanced. An unbalanced run (depth > 0 at end of line) means REGEX_TYPES would
+// not match at all.
+func phpAlignScanType(line string, pos int) (int, bool) {
+ depth := 0
+ i := pos
+ last := pos // last index that is definitely part of the type
+ for i < len(line) {
+ c := line[i]
+ switch c {
+ case '<', '[', '(', '{':
+ depth++
+ i++
+ last = i
+ case '>', ']', ')', '}':
+ if depth > 0 {
+ depth--
+ }
+ i++
+ last = i
+ case ' ', '\t':
+ if depth > 0 {
+ i++
+ continue
+ }
+ // top-level space: continuation only if next non-space is | or &
+ k := i
+ for k < len(line) && (line[k] == ' ' || line[k] == '\t') {
+ k++
+ }
+ if k < len(line) && (line[k] == '|' || line[k] == '&') {
+ i = k
+ continue
+ }
+ return last, depth == 0
+ case '\n', '\r':
+ return last, depth == 0
+ default:
+ i++
+ last = i
+ }
+ }
+ return last, depth == 0
+}
diff --git a/blink/internal/fixer/rules/phpdoc_align_test.go b/blink/internal/fixer/rules/phpdoc_align_test.go
new file mode 100644
index 0000000000..c5b01c6b3f
--- /dev/null
+++ b/blink/internal/fixer/rules/phpdoc_align_test.go
@@ -0,0 +1,34 @@
+package rules
+
+import "testing"
+
+func TestPhpdocAlign(t *testing.T) {
+ f := PhpdocAlign{}
+
+ // left align: param uses spacing 2, return uses spacing 1
+ got, changed := apply(t, f, ", ...).
+var phpdocGenericTagRe = regexp.MustCompile(`(?i)^(@(?:implements|extends|use|template-extends|template-implements)\s+)(\S+)(.*)$`)
+
+func (PhpdocTypes) Fix(s *tokens.Stream) bool {
+ return applyToDocblocks(s, func(d *docblock) bool {
+ changed := false
+ for i, l := range d.inner {
+ trimmed := strings.TrimLeft(l.content, " ")
+ m := phpdocTypeTagRe.FindStringSubmatch(trimmed)
+ if m == nil {
+ m = phpdocGenericTagRe.FindStringSubmatch(trimmed)
+ }
+ if m == nil {
+ continue
+ }
+ newType := normalizePhpdocTypeCase(m[2])
+ if newType == m[2] {
+ continue
+ }
+ lead := l.content[:len(l.content)-len(trimmed)]
+ d.inner[i].content = lead + m[1] + newType + m[3]
+ changed = true
+ }
+ return changed
+ })
+}
+
+var phpdocTypeWordRe = regexp.MustCompile(`[$A-Za-z_\\][A-Za-z0-9_\\]*`)
+
+// normalizePhpdocTypeCase lowercases keyword bases anywhere in a phpdoc type -
+// unions, array suffixes, nullables and generics (Foo -> Foo).
+// A word that is namespaced ("\Foo") or a class-constant/member reference
+// ("Ref::STATIC") is left as-is.
+func normalizePhpdocTypeCase(typ string) string {
+ var b strings.Builder
+ prev := 0
+ for _, loc := range phpdocTypeWordRe.FindAllStringIndex(typ, -1) {
+ st, en := loc[0], loc[1]
+ b.WriteString(typ[prev:st])
+ w := typ[st:en]
+ qualified := strings.Contains(w, `\`) || (st > 0 && (typ[st-1] == ':' || typ[st-1] == '\\'))
+ if !qualified {
+ if low := strings.ToLower(w); low != w && phpdocTypeKeywords[low] {
+ w = low
+ }
+ }
+ b.WriteString(w)
+ prev = en
+ }
+ b.WriteString(typ[prev:])
+ return b.String()
+}
+
+var aliasTagRe = regexp.MustCompile(`^@(type|link)\b`)
+
+var aliasTagMap = map[string]string{"type": "var", "link": "see"}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocNoAliasTagFixer.php
+//
+// PhpdocNoAliasTag rewrites alias tags: @type -> @var, @link -> @see.
+type PhpdocNoAliasTag struct{}
+
+func (PhpdocNoAliasTag) Name() string {
+ return `PhpCsFixer\Fixer\Phpdoc\PhpdocNoAliasTagFixer`
+}
+
+func (PhpdocNoAliasTag) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocNoAliasTagFixer.php"
+}
+
+func (PhpdocNoAliasTag) Fix(s *tokens.Stream) bool {
+ return applyToDocblocks(s, func(d *docblock) bool {
+ changed := false
+ for i, l := range d.inner {
+ trimmed := strings.TrimLeft(l.content, " ")
+ m := aliasTagRe.FindStringSubmatch(trimmed)
+ if m == nil {
+ continue
+ }
+ lead := l.content[:len(l.content)-len(trimmed)]
+ d.inner[i].content = lead + "@" + aliasTagMap[m[1]] + trimmed[len(m[0]):]
+ changed = true
+ }
+ return changed
+ })
+}
+
+var noPackageRe = regexp.MustCompile(`^@(?:package|subpackage)\b`)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocNoPackageFixer.php
+//
+// PhpdocNoPackage removes @package and @subpackage tag lines.
+type PhpdocNoPackage struct{}
+
+func (PhpdocNoPackage) Name() string {
+ return `PhpCsFixer\Fixer\Phpdoc\PhpdocNoPackageFixer`
+}
+
+func (PhpdocNoPackage) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocNoPackageFixer.php"
+}
+
+func (PhpdocNoPackage) Fix(s *tokens.Stream) bool {
+ return removeDocLines(s, noPackageRe)
+}
+
+var noAccessRe = regexp.MustCompile(`^@access\b`)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocNoAccessFixer.php
+//
+// PhpdocNoAccess removes @access tag lines.
+type PhpdocNoAccess struct{}
+
+func (PhpdocNoAccess) Name() string {
+ return `PhpCsFixer\Fixer\Phpdoc\PhpdocNoAccessFixer`
+}
+
+func (PhpdocNoAccess) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocNoAccessFixer.php"
+}
+
+func (PhpdocNoAccess) Fix(s *tokens.Stream) bool {
+ return removeDocLines(s, noAccessRe)
+}
+
+// removeDocLines drops every inner line whose tag matches re (checked at the
+// start of the content, after any leading spaces).
+func removeDocLines(s *tokens.Stream, re *regexp.Regexp) bool {
+ return applyToDocblocks(s, func(d *docblock) bool {
+ kept := d.inner[:0:0]
+ removed := false
+ for _, l := range d.inner {
+ if re.MatchString(strings.TrimLeft(l.content, " ")) {
+ removed = true
+ continue
+ }
+ kept = append(kept, l)
+ }
+ if removed {
+ d.inner = kept
+ }
+ return removed
+ })
+}
+
+var singleLineVarRe = regexp.MustCompile(`^(@(?:var|type|param))\s+([^\s$]+)\s*(\$\S+)?\s*(.*)$`)
+
+var phpdocWsRe = regexp.MustCompile(`\s+`)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocSingleLineVarSpacingFixer.php
+//
+// PhpdocSingleLineVarSpacing normalizes spacing in single-line @var/@type/@param docblocks.
+type PhpdocSingleLineVarSpacing struct{}
+
+func (PhpdocSingleLineVarSpacing) Name() string {
+ return `PhpCsFixer\Fixer\Phpdoc\PhpdocSingleLineVarSpacingFixer`
+}
+
+func (PhpdocSingleLineVarSpacing) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocSingleLineVarSpacingFixer.php"
+}
+
+func (PhpdocSingleLineVarSpacing) Fix(s *tokens.Stream) bool {
+ return applyToDocblocks(s, func(d *docblock) bool {
+ if !d.single || len(d.inner) == 0 {
+ return false
+ }
+ content := d.inner[0].content
+ m := singleLineVarRe.FindStringSubmatch(content)
+ if m == nil {
+ return false
+ }
+ out := []string{m[1], m[2]}
+ if m[3] != "" {
+ out = append(out, m[3])
+ }
+ normalized := strings.Join(out, " ")
+ if rest := strings.TrimSpace(phpdocWsRe.ReplaceAllString(m[4], " ")); rest != "" {
+ normalized += " " + rest
+ }
+ if normalized == content {
+ return false
+ }
+ d.inner[0].content = normalized
+ return true
+ })
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocTrimConsecutiveBlankLineSeparationFixer.php
+//
+// PhpdocTrimConsecutiveBlankLineSeparation collapses runs of blank inner lines into one.
+type PhpdocTrimConsecutiveBlankLineSeparation struct{}
+
+func (PhpdocTrimConsecutiveBlankLineSeparation) Name() string {
+ return `PhpCsFixer\Fixer\Phpdoc\PhpdocTrimConsecutiveBlankLineSeparationFixer`
+}
+
+func (PhpdocTrimConsecutiveBlankLineSeparation) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/PhpdocTrimConsecutiveBlankLineSeparationFixer.php"
+}
+
+func (PhpdocTrimConsecutiveBlankLineSeparation) Fix(s *tokens.Stream) bool {
+ return applyToDocblocks(s, func(d *docblock) bool {
+ kept := d.inner[:0:0]
+ prevBlank := false
+ changed := false
+ for _, l := range d.inner {
+ blank := strings.TrimSpace(l.content) == ""
+ if blank && prevBlank {
+ changed = true
+ continue
+ }
+ kept = append(kept, l)
+ prevBlank = blank
+ }
+ if changed {
+ d.inner = kept
+ }
+ return changed
+ })
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/NoBlankLinesAfterPhpdocFixer.php
+//
+// NoBlankLinesAfterPhpdoc removes blank lines between a docblock and the code it documents.
+type NoBlankLinesAfterPhpdoc struct{}
+
+func (NoBlankLinesAfterPhpdoc) Name() string {
+ return `PhpCsFixer\Fixer\Phpdoc\NoBlankLinesAfterPhpdocFixer`
+}
+
+func (NoBlankLinesAfterPhpdoc) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Phpdoc/NoBlankLinesAfterPhpdocFixer.php"
+}
+
+func (NoBlankLinesAfterPhpdoc) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ if s.At(i).Kind != token.DocComment {
+ continue
+ }
+ j := i + 1
+ if j >= s.Len() || s.At(j).Kind != token.Whitespace {
+ continue
+ }
+ v := s.At(j).Value
+ if strings.Count(v, "\n") < 2 {
+ continue
+ }
+ // a file-level docblock before "declare" keeps its blank line - ECS only
+ // trims the blank after a docblock attached to a structural element
+ if k := nextSignificantIndex(s, i); k >= 0 &&
+ s.At(k).Kind == token.Keyword && strings.EqualFold(s.At(k).Value, "declare") {
+ continue
+ }
+ // Keep the last newline plus the following statement's indentation.
+ nv := v[strings.LastIndexByte(v, '\n'):]
+ if nv != v {
+ s.SetValue(j, nv)
+ changed = true
+ }
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/phpdoc_batch_test.go b/blink/internal/fixer/rules/phpdoc_batch_test.go
new file mode 100644
index 0000000000..6189272752
--- /dev/null
+++ b/blink/internal/fixer/rules/phpdoc_batch_test.go
@@ -0,0 +1,185 @@
+package rules
+
+import (
+ "testing"
+
+ "blink/internal/fixer"
+)
+
+// idempotent asserts that a second Fix pass over the fixer's own output is a no-op.
+func idempotent(t *testing.T, r fixerRule, once string) {
+ t.Helper()
+ if _, changed := apply(t, r, once); changed {
+ t.Fatalf("not idempotent, second pass changed: %q", once)
+ }
+}
+
+func TestNoEmptyPhpdoc(t *testing.T) {
+ got, changed := apply(t, NoEmptyPhpdoc{}, " 0 && s.At(i-1).Kind == token.Whitespace {
+ s.RemoveAt(i - 1)
+ i--
+ }
+ i--
+ continue
+ }
+ d.inner = kept
+ s.SetValue(i, d.render())
+ }
+ return changed
+}
+
+func allBlankLines(lines []docLine) bool {
+ for _, l := range lines {
+ if strings.TrimSpace(l.content) != "" {
+ return false
+ }
+ }
+ return true
+}
+
+// isInheritdoc reports whether a docblock line is nothing but an inheritdoc tag.
+func isInheritdoc(content string) bool {
+ c := strings.ToLower(strings.TrimSpace(content))
+ return c == "{@inheritdoc}" || c == "@inheritdoc"
+}
diff --git a/blink/internal/fixer/rules/phpdoc_no_useless_inheritdoc_test.go b/blink/internal/fixer/rules/phpdoc_no_useless_inheritdoc_test.go
new file mode 100644
index 0000000000..2033751b2f
--- /dev/null
+++ b/blink/internal/fixer/rules/phpdoc_no_useless_inheritdoc_test.go
@@ -0,0 +1,30 @@
+package rules
+
+import "testing"
+
+func TestPhpdocNoUselessInheritdoc(t *testing.T) {
+ f := PhpdocNoUselessInheritdoc{}
+
+ // a sole {@inheritDoc}: the whole block and its indentation are removed
+ got, changed := apply(t, f, "= len(anns) {
+ break
+ }
+ first, next := anns[idx], anns[idx+1]
+ if separationShouldBeTogether(first.name, next.name) {
+ ensureAnnotationsTogether(lines, first, next)
+ } else {
+ ensureAnnotationsSeparate(lines, first, next)
+ }
+ }
+}
+
+// ensureAnnotationsTogether removes any lines between two annotations.
+func ensureAnnotationsTogether(lines []string, first, second docAnnotation) {
+ for pos := first.end + 1; pos < second.start; pos++ {
+ lines[pos] = ""
+ }
+}
+
+// ensureAnnotationsSeparate leaves exactly one blank line between two annotations.
+func ensureAnnotationsSeparate(lines []string, first, second docAnnotation) {
+ pos := first.end
+ final := second.start - 1
+ if pos == final {
+ lines[pos] = lineAddBlank(lines[pos])
+ return
+ }
+ for p := pos + 1; p < final; p++ {
+ lines[p] = ""
+ }
+}
+
+// separationShouldBeTogether reports whether two adjacent tags must stay
+// together (true only for the DocBlock's "true" case; null and false both mean
+// separate under the default skip_unlisted_annotations=false).
+func separationShouldBeTogether(first, second string) bool {
+ if first == "" || second == "" {
+ return false
+ }
+ if first == second {
+ return true
+ }
+ for _, group := range phpdocSeparationGroups {
+ firstIn := tagInGroup(first, group)
+ secondIn := tagInGroup(second, group)
+ if firstIn {
+ return secondIn
+ }
+ if secondIn {
+ return false
+ }
+ }
+ return false
+}
+
+func tagInGroup(tag string, group []string) bool {
+ for _, g := range group {
+ if !strings.Contains(g, "*") {
+ if g == tag {
+ return true
+ }
+ continue
+ }
+ if wildcardTagMatch(g, tag) {
+ return true
+ }
+ }
+ return false
+}
+
+// wildcardTagMatch matches a group entry containing "*" (any characters) against
+// a tag, anchored at both ends.
+func wildcardTagMatch(pattern, tag string) bool {
+ parts := strings.Split(pattern, "*")
+ pos := 0
+ for i, part := range parts {
+ if part == "" {
+ continue
+ }
+ if i == 0 {
+ if !strings.HasPrefix(tag[pos:], part) {
+ return false
+ }
+ pos += len(part)
+ continue
+ }
+ idx := strings.Index(tag[pos:], part)
+ if idx < 0 {
+ return false
+ }
+ pos += idx + len(part)
+ }
+ if parts[len(parts)-1] != "" {
+ return strings.HasSuffix(tag, parts[len(parts)-1])
+ }
+ return true
+}
diff --git a/blink/internal/fixer/rules/phpdoc_separation_test.go b/blink/internal/fixer/rules/phpdoc_separation_test.go
new file mode 100644
index 0000000000..bb47e2dc0a
--- /dev/null
+++ b/blink/internal/fixer/rules/phpdoc_separation_test.go
@@ -0,0 +1,33 @@
+package rules
+
+import "testing"
+
+func TestPhpdocSeparation(t *testing.T) {
+ f := PhpdocSeparation{}
+
+ // different unlisted tags get a blank line; same-name tags stay together
+ got, changed := apply(t, f, " $v) {\n}\n")
+ want := " $v) {\n}\n"
+ if !changed || got != want {
+ t.Fatalf("convert: changed=%v got=%q", changed, got)
+ }
+
+ // a docblock naming the loop variable is kept
+ if _, changed := apply(t, f, " $v) {\n}\n"); changed {
+ t.Fatal("docblock naming the loop var must stay a docblock")
+ }
+
+ // a docblock before a class is kept
+ if _, changed := apply(t, f, "= 0 && s.At(name).Kind == token.Ident && endsWithTestName(s.At(name).Value, false) {
+ return true
+ }
+ // any name in extends/implements clause
+ for j := index + 1; j < s.Len(); j++ {
+ if isPunctVal(s, j, "{") {
+ break
+ }
+ if s.At(j).Kind == token.Ident && endsWithTestName(s.At(j).Value, true) {
+ return true
+ }
+ }
+ return false
+}
+
+// endsWithTestName mirrors /(?:Test|TestCase)$/ (name) and, with iface,
+// /(?:Test|TestCase)(?:Interface)?$/ (extended/implemented names).
+func endsWithTestName(name string, iface bool) bool {
+ if strings.HasSuffix(name, "Test") || strings.HasSuffix(name, "TestCase") {
+ return true
+ }
+ if iface && (strings.HasSuffix(name, "TestInterface") || strings.HasSuffix(name, "TestCaseInterface")) {
+ return true
+ }
+ return false
+}
+
+// puIsMethod reports whether index is a real (non-lambda) method: a `function`
+// keyword directly followed by a name.
+func puIsMethod(s *tokens.Stream, index int) bool {
+ if !kwIs(s.At(index), "function") {
+ return false
+ }
+ n := sigNext(s, index)
+ if n < 0 {
+ return false
+ }
+ // a name means it's a method; "(" or "&" means a closure
+ return s.At(n).Kind == token.Ident
+}
+
+var puModifierKw = map[string]bool{
+ "public": true, "protected": true, "private": true,
+ "final": true, "abstract": true, "readonly": true, "static": true,
+}
+
+// puDocBlockIndex mirrors DocBlockAnnotationTrait::getDocBlockIndex: walk back
+// over whitespace, modifiers, comments and folded attributes to the token that
+// may be the method's doc comment.
+func puDocBlockIndex(s *tokens.Stream, index int) int {
+ idx := index
+ for {
+ p := getPrevNonWhitespace(s, idx)
+ if p < 0 {
+ return idx
+ }
+ t := s.At(p)
+ if t.Kind == token.Comment { // includes folded #[...] attributes
+ idx = p
+ continue
+ }
+ if t.Kind == token.Keyword && puModifierKw[strings.ToLower(t.Value)] {
+ idx = p
+ continue
+ }
+ return p
+ }
+}
+
+// camelCaseToUnderscore mirrors Utils::camelCaseToUnderscore for ASCII names
+// (PHPUnit method names): insert "_" at case boundaries, then lowercase.
+func camelCaseToUnderscore(str string) string {
+ b := []byte(str)
+ isUpper := func(c byte) bool { return c >= 'A' && c <= 'Z' }
+ var out []byte
+ for i := range b {
+ c := b[i]
+ if i > 0 && b[i-1] != '_' {
+ var next byte
+ hasNext := i+1 < len(b)
+ if hasNext {
+ next = b[i+1]
+ }
+ condA := isUpper(c) && hasNext && !isUpper(next)
+ condB := !isUpper(b[i-1]) && isUpper(c)
+ if condA || condB {
+ out = append(out, '_')
+ }
+ }
+ out = append(out, c)
+ }
+ return strings.ToLower(string(out))
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/PhpUnit/PhpUnitMethodCasingFixer.php
+//
+// PhpUnitMethodCasing enforces snake_case (the ECS laravel config) for PHPUnit
+// test method names.
+type PhpUnitMethodCasing struct{}
+
+func (PhpUnitMethodCasing) Name() string {
+ return `PhpCsFixer\Fixer\PhpUnit\PhpUnitMethodCasingFixer`
+}
+
+func (PhpUnitMethodCasing) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/PhpUnit/PhpUnitMethodCasingFixer.php"
+}
+
+func (PhpUnitMethodCasing) Fix(s *tokens.Stream) bool {
+ changed := false
+ for _, cls := range phpUnitTestClasses(s) {
+ if methodCasingClass(s, cls[0], cls[1]) {
+ changed = true
+ }
+ }
+ return changed
+}
+
+func methodCasingClass(s *tokens.Stream, start, end int) bool {
+ changed := false
+ existing := map[string]bool{}
+ for i := end - 1; i > start; i-- {
+ if !puIsMethod(s, i) {
+ continue
+ }
+ if n := sigNext(s, i); n >= 0 {
+ existing[strings.ToLower(s.At(n).Value)] = true
+ }
+ }
+ for i := end - 1; i > start; i-- {
+ if !isTestMethod(s, i) {
+ continue
+ }
+ nameIdx := sigNext(s, i)
+ if nameIdx < 0 {
+ continue
+ }
+ name := s.At(nameIdx).Value
+ nameLower := strings.ToLower(name)
+ newName := camelCaseToUnderscore(name)
+ newLower := strings.ToLower(newName)
+ if existing[newLower] && nameLower != newLower {
+ continue
+ }
+ existing[newLower] = true
+ if newName != name {
+ s.SetValue(nameIdx, newName)
+ changed = true
+ }
+ docIdx := puDocBlockIndex(s, i)
+ if docIdx >= 0 && s.At(docIdx).Kind == token.DocComment {
+ if updateDependsDoc(s, docIdx) {
+ changed = true
+ }
+ }
+ }
+ return changed
+}
+
+func isTestMethod(s *tokens.Stream, index int) bool {
+ if !puIsMethod(s, index) {
+ return false
+ }
+ nameIdx := sigNext(s, index)
+ if nameIdx < 0 {
+ return false
+ }
+ if strings.HasPrefix(s.At(nameIdx).Value, "test") {
+ return true
+ }
+ if isTestAttributePresent(s, index) {
+ return true
+ }
+ docIdx := puDocBlockIndex(s, index)
+ return docIdx >= 0 && s.At(docIdx).Kind == token.DocComment && strings.Contains(s.At(docIdx).Value, "@test")
+}
+
+// isTestAttributePresent reports whether the method carries a #[Test] attribute,
+// which the lexer folds into a single comment token starting with "#[".
+func isTestAttributePresent(s *tokens.Stream, index int) bool {
+ p := getPrevNonWhitespace(s, index)
+ for p >= 0 {
+ t := s.At(p)
+ if t.Kind == token.Comment {
+ if strings.HasPrefix(t.Value, "#[") && phpUnitTestAttrRe(t.Value) {
+ return true
+ }
+ p = getPrevNonWhitespace(s, p)
+ continue
+ }
+ if t.Kind == token.Keyword && puModifierKw[strings.ToLower(t.Value)] {
+ p = getPrevNonWhitespace(s, p)
+ continue
+ }
+ break
+ }
+ return false
+}
+
+// phpUnitTestAttrRe reports whether a folded attribute comment names the PHPUnit
+// Test attribute (short "Test" or fully-qualified).
+func phpUnitTestAttrRe(v string) bool {
+ inner := strings.TrimSuffix(strings.TrimPrefix(v, "#["), "]")
+ for part := range strings.SplitSeq(inner, ",") {
+ part = strings.TrimSpace(part)
+ if i := strings.IndexByte(part, '('); i >= 0 {
+ part = part[:i]
+ }
+ part = strings.TrimSpace(part)
+ short := part
+ if i := strings.LastIndex(part, `\`); i >= 0 {
+ short = part[i+1:]
+ }
+ if short == "Test" {
+ return true
+ }
+ }
+ return false
+}
+
+// updateDependsDoc rewrites @depends method references to the new casing.
+func updateDependsDoc(s *tokens.Stream, docIdx int) bool {
+ content := s.At(docIdx).Value
+ if !strings.Contains(content, "@depends") {
+ return false
+ }
+ changed := false
+ lines := strings.Split(content, "\n")
+ for i, line := range lines {
+ idx := strings.Index(line, "@depends")
+ if idx < 0 {
+ continue
+ }
+ rest := line[idx+len("@depends"):]
+ // skip whitespace
+ j := 0
+ for j < len(rest) && (rest[j] == ' ' || rest[j] == '\t') {
+ j++
+ }
+ ws := rest[:j]
+ // capture the reference up to whitespace/end
+ k := j
+ for k < len(rest) && rest[k] != ' ' && rest[k] != '\t' && rest[k] != '\r' && rest[k] != '\n' {
+ k++
+ }
+ ref := rest[j:k]
+ if ref == "" {
+ continue
+ }
+ newRef := camelCaseToUnderscore(ref)
+ if newRef != ref {
+ lines[i] = line[:idx+len("@depends")] + ws + newRef + rest[k:]
+ changed = true
+ }
+ }
+ if changed {
+ s.SetValue(docIdx, strings.Join(lines, "\n"))
+ }
+ return changed
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/PhpUnit/PhpUnitSetUpTearDownVisibilityFixer.php
+//
+// PhpUnitSetUpTearDownVisibility makes setUp()/tearDown() protected.
+type PhpUnitSetUpTearDownVisibility struct{}
+
+func (PhpUnitSetUpTearDownVisibility) Name() string {
+ return `PhpCsFixer\Fixer\PhpUnit\PhpUnitSetUpTearDownVisibilityFixer`
+}
+
+func (PhpUnitSetUpTearDownVisibility) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/PhpUnit/PhpUnitSetUpTearDownVisibilityFixer.php"
+}
+
+func (PhpUnitSetUpTearDownVisibility) Fix(s *tokens.Stream) bool {
+ changed := false
+ for _, cls := range phpUnitTestClasses(s) {
+ if setUpTearDownClass(s, cls[0], cls[1]) {
+ changed = true
+ }
+ }
+ return changed
+}
+
+func setUpTearDownClass(s *tokens.Stream, start, end int) bool {
+ changed := false
+ counter := 0
+ for i := start + 1; i < end && i < s.Len(); i++ {
+ if counter == 2 {
+ break
+ }
+ if isPunctVal(s, i, "{") {
+ if e := s.MatchForward(i); e > i {
+ i = e
+ }
+ continue
+ }
+ if !kwIs(s.At(i), "function") {
+ continue
+ }
+ nameIdx := sigNext(s, i)
+ if nameIdx < 0 {
+ continue
+ }
+ fn := strings.ToLower(s.At(nameIdx).Value)
+ if fn != "setup" && fn != "teardown" {
+ continue
+ }
+ counter++
+ // find the visibility modifier of this method, if any
+ visIdx := methodVisibilityIndex(s, i)
+ if visIdx >= 0 {
+ if strings.EqualFold(s.At(visIdx).Value, "public") {
+ s.Set(visIdx, token.Token{Kind: token.Keyword, Value: "protected"})
+ changed = true
+ }
+ continue
+ }
+ // no visibility: insert "protected " before the function keyword
+ s.InsertAt(i, token.Token{Kind: token.Whitespace, Value: " "})
+ s.InsertAt(i, token.Token{Kind: token.Keyword, Value: "protected"})
+ changed = true
+ end += 2
+ }
+ return changed
+}
+
+// methodVisibilityIndex returns the index of the method's visibility keyword
+// (public/protected/private) in its modifier run, or -1 if none.
+func methodVisibilityIndex(s *tokens.Stream, fnIdx int) int {
+ p := getPrevNonWhitespace(s, fnIdx)
+ for p >= 0 {
+ t := s.At(p)
+ if t.Kind == token.Keyword {
+ switch strings.ToLower(t.Value) {
+ case "public", "protected", "private":
+ return p
+ case "final", "abstract", "readonly", "static":
+ p = getPrevNonWhitespace(s, p)
+ continue
+ }
+ }
+ if t.Kind == token.Comment {
+ p = getPrevNonWhitespace(s, p)
+ continue
+ }
+ break
+ }
+ return -1
+}
diff --git a/blink/internal/fixer/rules/priorities.go b/blink/internal/fixer/rules/priorities.go
new file mode 100644
index 0000000000..6eea5e01c9
--- /dev/null
+++ b/blink/internal/fixer/rules/priorities.go
@@ -0,0 +1,194 @@
+package rules
+
+// fixerPriority maps a fixer's short name to its PHP-CS-Fixer getPriority()
+// value. Fixers run in descending priority, mirroring ECS's execution order.
+var fixerPriority = map[string]int{
+ "DoubleAsteriskInlineVarFixer": -37,
+ "FixTagTypoFixer": -37,
+ "TypeToVarTagFixer": -37,
+ "MergeDocBlockStartFixer": -37,
+ "AddMissingVarNameFixer": -37,
+ "SingleLineInlineVarDocBlockFixer": -37,
+ "RemoveSuperfluousReturnNameFixer": -37,
+ "RemoveSuperfluousVarNameFixer": -37,
+ "FixParamNameTypoFixer": -37,
+ "RemoveParamNameReferenceFixer": -37,
+ "SwitchedTypeAndNameFixer": -37,
+ "RemoveDeadParamFixer": -37,
+ "RemoveDeadVarThisFixer": -37,
+ "AddMissingParamNameFixer": 0,
+ "AlignMultilineCommentFixer": 27,
+ "ArrayIndentationFixer": 29,
+ "ArrayListItemNewlineFixer": 40,
+ "StandaloneLineInMultilineArrayFixer": 5,
+ "ArraySyntaxFixer": 37,
+ "AssignNullCoalescingToCoalesceEqualFixer": -1,
+ "AttributeBlockNoSpacesFixer": 0,
+ "BinaryOperatorSpacesFixer": -32,
+ "BlankLineAfterNamespaceFixer": -20,
+ "BlankLineAfterOpeningTagFixer": 1,
+ "DoctrineAnnotationArrayAssignmentFixer": 1,
+ "DoctrineAnnotationSpacesFixer": 0,
+ // getPriority() is 0 upstream; raised to run before blank_line_after_opening_tag
+ // so blink reaches the same output in a single pass (PHP-CS-Fixer loops to fixpoint)
+ "LinebreakAfterOpeningTagFixer": 2,
+ "IncrementStyleFixer": 15,
+ "NullableTypeDeclarationFixer": 2,
+ "OrderedTypesFixer": 0,
+ "MethodChainingIndentationFixer": 0,
+ "BlankLineAfterStrictTypesFixer": 0,
+ "BlankLineBetweenImportGroupsFixer": -40,
+ "BlankLinesBeforeNamespaceFixer": -31,
+ "BracesPositionFixer": -2,
+ "CastSpacesFixer": -10,
+ "ClassAttributesSeparationFixer": 55,
+ "ClassDefinitionFixer": 36,
+ "ClassReferenceNameCasingFixer": 0,
+ "CleanNamespaceFixer": 10,
+ "CompactNullableTypeDeclarationFixer": 0,
+ "ConcatSpaceFixer": 0,
+ "ConstantCaseFixer": 0,
+ "ControlStructureBracesFixer": 1,
+ "ControlStructureContinuationPositionFixer": 0,
+ "DeclareEqualNormalizeFixer": 0,
+ "DeclareParenthesesFixer": 0,
+ "ElseifFixer": 40,
+ "NoSuperfluousElseifFixer": 39,
+ "EmptyLoopBodyFixer": 39,
+ "EmptyLoopConditionFixer": 1,
+ "EncodingFixer": 100,
+ "ExplicitIndirectVariableFixer": 0,
+ "ExplicitStringVariableFixer": 6,
+ "FullOpeningTagFixer": 98,
+ "FullyQualifiedStrictTypesFixer": 7,
+ "FunctionDeclarationFixer": 31,
+ "FunctionToConstantFixer": 2,
+ "HeredocToNowdocFixer": 0,
+ "IncludeFixer": 0,
+ "IndentationTypeFixer": 50,
+ "IntegerLiteralCaseFixer": 0,
+ "IsNullFixer": 1,
+ "LineEndingFixer": 0,
+ "ListSyntaxFixer": 2,
+ "LongToShorthandOperatorFixer": 17,
+ "LambdaNotUsedImportFixer": 31,
+ "LowercaseCastFixer": 0,
+ "LowercaseKeywordsFixer": 0,
+ "LowercaseStaticReferenceFixer": 0,
+ "MagicConstantCasingFixer": 0,
+ "MagicMethodCasingFixer": 0,
+ "MethodArgumentSpaceFixer": 30,
+ "MethodChainingNewlineFixer": 39,
+ "MultilineCommentOpeningClosingFixer": 0,
+ "NativeFunctionCasingFixer": 0,
+ "NativeFunctionTypeDeclarationCasingFixer": 0,
+ "NativeTypeDeclarationCasingFixer": 0,
+ "NewWithParenthesesFixer": 38,
+ "NoBinaryStringFixer": 40,
+ "NoBlankLinesAfterClassOpeningFixer": 0,
+ "NoBlankLinesAfterPhpdocFixer": -20,
+ "NoClosingTagFixer": 0,
+ "NoEmptyCommentFixer": 2,
+ "NoEmptyPhpdocFixer": 3,
+ "NoEmptyStatementFixer": 40,
+ "NoExtraBlankLinesFixer": -20,
+ "NoLeadingImportSlashFixer": -20,
+ "NoAliasFunctionsFixer": 40,
+ "NoLeadingNamespaceWhitespaceFixer": 0,
+ "NoAlternativeSyntaxFixer": 42,
+ "NoMixedEchoPrintFixer": -10,
+ "NoUnneededCurlyBracesFixer": 40,
+ "NoMultilineWhitespaceAroundDoubleArrowFixer": 31,
+ "NoMultipleStatementsPerLineFixer": -1,
+ "MultilineWhitespaceBeforeSemicolonsFixer": 0,
+ "SingleLineEmptyBodyFixer": -19,
+ "NoNullPropertyInitializationFixer": 0,
+ "NoShortBoolCastFixer": -9,
+ "NoSinglelineWhitespaceBeforeSemicolonsFixer": 0,
+ "NoSpaceAroundDoubleColonFixer": 1,
+ "NoSpacesAfterFunctionNameFixer": 3,
+ "NoSpacesAroundOffsetFixer": 0,
+ "NoSuperfluousPhpdocTagsFixer": 6,
+ "NoTrailingCommaInSinglelineFixer": 0,
+ "NoTrailingWhitespaceFixer": 0,
+ "NoTrailingWhitespaceInCommentFixer": 0,
+ "NoUnneededControlParenthesesFixer": 30,
+ "NoUnneededImportAliasFixer": 1,
+ "NoUnsetCastFixer": 0,
+ "NoUnusedImportsFixer": -10,
+ "NoUselessConcatOperatorFixer": 5,
+ "NoUselessReturnFixer": -18,
+ "NoWhitespaceBeforeCommaInArrayFixer": 0,
+ "NoWhitespaceInBlankLineFixer": -99,
+ "NoWhitespaceInEmptyArrayFixer": 0,
+ "NormalizeIndexBraceFixer": 0,
+ "NotOperatorWithSuccessorSpaceFixer": -10,
+ "NullableTypeDeclarationForDefaultNullValueFixer": 3,
+ "ObjectOperatorWithoutWhitespaceFixer": 0,
+ "OrderedClassElementsFixer": 65,
+ "OrderedImportsFixer": -30,
+ "PhpdocIndentFixer": 20,
+ "PhpdocInlineTagNormalizerFixer": 0,
+ "PhpdocLineSpanFixer": 5,
+ "PhpdocNoAccessFixer": 0,
+ "PhpdocNoAliasTagFixer": 0,
+ "PhpdocNoDuplicateTypesFixer": 0,
+ "PhpdocNoEmptyReturnFixer": 4,
+ "PhpdocNoPackageFixer": 0,
+ "PhpdocNoUselessInheritdocFixer": 6,
+ "PhpdocOrderByValueFixer": -10,
+ "PhpdocReturnSelfReferenceFixer": 10,
+ "PhpdocScalarFixer": 0,
+ "PhpdocSingleLineVarSpacingFixer": -10,
+ "PhpdocTagCasingFixer": 0,
+ "PhpdocTrimConsecutiveBlankLineSeparationFixer": -41,
+ "PhpdocTrimFixer": -5,
+ "PhpdocTypesFixer": 0,
+ "PhpdocTypesOrderFixer": 0,
+ "PhpdocVarAnnotationCorrectOrderFixer": 0,
+ "PhpdocVarWithoutNameFixer": 0,
+ "ProtectedToPrivateFixer": 66,
+ "RemoveUselessDefaultCommentFixer": 40,
+ "ReturnTypeDeclarationFixer": -17,
+ "SelfAccessorFixer": -11,
+ "SelfStaticAccessorFixer": -10,
+ "ShortScalarCastFixer": 0,
+ "SimplifiedNullReturnFixer": 16,
+ "SingleBlankLineAtEofFixer": -100,
+ "SingleClassElementPerStatementFixer": 56,
+ "SingleImportPerStatementFixer": 1,
+ "SingleLineAfterImportsFixer": -11,
+ "NoBlankLineBetweenImportsFixer": -50,
+ "SpaceAfterCommaHereNowDocFixer": 0,
+ "ArrayOpenerAndCloserNewlineFixer": 34,
+ "SingleLineCommentSpacingFixer": 1,
+ "SingleLineCommentStyleFixer": -31,
+ "SingleQuoteFixer": 10,
+ "SingleSpaceAroundConstructFixer": 36,
+ "SingleTraitInsertPerStatementFixer": 36,
+ "SpaceAfterSemicolonFixer": -1,
+ "SpacesInsideParenthesesFixer": 3,
+ "StandaloneLinePromotedPropertyFixer": 40,
+ "StandardizeIncrementFixer": 16,
+ "StandardizeNotEqualsFixer": 0,
+ "StatementIndentationFixer": -3,
+ "SwitchCaseSemicolonToColonFixer": 0,
+ "SwitchCaseSpaceFixer": 0,
+ "SwitchContinueToBreakFixer": 0,
+ "TernaryOperatorSpacesFixer": 1,
+ "TernaryToNullCoalescingFixer": 0,
+ "TrailingCommaInMultilineFixer": 0,
+ "TrimArraySpacesFixer": 0,
+ "TypeDeclarationSpacesFixer": 0,
+ "TypesSpacesFixer": -1,
+ "UnaryOperatorSpacesFixer": 0,
+ "VisibilityRequiredFixer": 0,
+ "WhitespaceAfterCommaInArrayFixer": 0,
+ "PhpdocAlignFixer": -42,
+ "YodaStyleFixer": 0,
+ "NoAliasLanguageConstructCallFixer": 0,
+ "BlankLineBeforeStatementFixer": -21,
+ "PhpdocSummaryFixer": 0,
+ "PhpdocTagTypeFixer": 0,
+ "PhpdocOrderFixer": -2,
+}
diff --git a/blink/internal/fixer/rules/protected_to_private.go b/blink/internal/fixer/rules/protected_to_private.go
new file mode 100644
index 0000000000..71f8ade651
--- /dev/null
+++ b/blink/internal/fixer/rules/protected_to_private.go
@@ -0,0 +1,113 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/ProtectedToPrivateFixer.php
+//
+// ProtectedToPrivate converts "protected" members to "private" in a final class
+// (a final class cannot be extended, so protected is equivalent to private).
+type ProtectedToPrivate struct{}
+
+func (ProtectedToPrivate) Name() string {
+ return `PhpCsFixer\Fixer\ClassNotation\ProtectedToPrivateFixer`
+}
+
+func (ProtectedToPrivate) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/ProtectedToPrivateFixer.php"
+}
+
+func (ProtectedToPrivate) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ if s.At(i).Kind != token.Keyword || !strings.EqualFold(s.At(i).Value, "class") {
+ continue
+ }
+ prev, ok := prevSignificant(s, i)
+ if !ok || prev.Kind != token.Keyword || !strings.EqualFold(prev.Value, "final") {
+ continue
+ }
+ nameIdx := nextSignificantIndex(s, i)
+ if nameIdx < 0 || s.At(nameIdx).Kind != token.Ident {
+ continue
+ }
+ open := findBodyBrace(s, nameIdx)
+ if open < 0 {
+ continue
+ }
+ // a class extending a parent may inherit/override protected members;
+ // ECS leaves those alone
+ if classExtends(s, nameIdx, open) {
+ continue
+ }
+ closeIdx := s.MatchForward(open)
+ if closeIdx < 0 {
+ continue
+ }
+ depth := 0
+ for k := open; k < closeIdx; k++ {
+ if s.At(k).Kind == token.Punct {
+ switch s.At(k).Value {
+ case "{":
+ depth++
+ case "}":
+ depth--
+ }
+ continue
+ }
+ // only this class's own members (depth 1), not a nested class body,
+ // and not a constant named "protected" (lexed as a keyword)
+ if depth == 1 && s.At(k).Kind == token.Keyword && strings.EqualFold(s.At(k).Value, "protected") && !isConstNamePosition(s, k) {
+ s.SetValue(k, "private")
+ changed = true
+ }
+ }
+ }
+ return changed
+}
+
+func classExtends(s *tokens.Stream, nameIdx, open int) bool {
+ for k := nameIdx + 1; k < open; k++ {
+ if s.At(k).Kind == token.Keyword && strings.EqualFold(s.At(k).Value, "extends") {
+ return true
+ }
+ }
+ return false
+}
+
+// isConstNamePosition reports whether the token at k is the name (or type) of a
+// "const" declaration, e.g. the "PROTECTED" in "const int PROTECTED", which the
+// lexer classifies as a keyword but is not a visibility modifier.
+func isConstNamePosition(s *tokens.Stream, k int) bool {
+ j := prevSignificantIndex(s, k)
+ for j >= 0 {
+ t := s.At(j)
+ if t.Kind == token.Keyword && strings.EqualFold(t.Value, "const") {
+ return true
+ }
+ if t.Kind == token.Ident ||
+ (t.Kind == token.Keyword && !strings.EqualFold(t.Value, "const")) ||
+ (t.Kind == token.Punct && (t.Value == "?" || t.Value == `\`)) {
+ // a type token before the name; keep walking back
+ if t.Kind == token.Keyword && isModifierKeyword(t.Value) {
+ return false
+ }
+ j = prevSignificantIndex(s, j)
+ continue
+ }
+ return false
+ }
+ return false
+}
+
+func isModifierKeyword(v string) bool {
+ switch strings.ToLower(v) {
+ case "public", "private", "protected", "static", "final", "abstract", "readonly", "var":
+ return true
+ }
+ return false
+}
diff --git a/blink/internal/fixer/rules/rector_regression_test.go b/blink/internal/fixer/rules/rector_regression_test.go
new file mode 100644
index 0000000000..fd6ce7b284
--- /dev/null
+++ b/blink/internal/fixer/rules/rector_regression_test.go
@@ -0,0 +1,34 @@
+package rules
+
+import "testing"
+
+// SELF/PARENT/STATIC as constant names (member access or declaration) must not
+// be lowercased by lowercase_static_reference.
+func TestLowercaseStaticReferenceKeepsConstants(t *testing.T) {
+ for _, src := range []string{
+ " fixerPriorityOf(all[b])
+ })
+ return all
+}
+
+// fixerPriorityOf returns a fixer's PHP-CS-Fixer priority (0 when unknown).
+func fixerPriorityOf(f fixer.Fixer) int {
+ name := f.Name()
+ short := name
+ if i := strings.LastIndexByte(name, '\\'); i >= 0 {
+ short = name[i+1:]
+ }
+ return fixerPriority[short]
+}
+
+// ByName returns the fixer whose Name matches, if any.
+func ByName(name string) (fixer.Fixer, bool) {
+ for _, f := range All() {
+ if f.Name() == name {
+ return f, true
+ }
+ }
+ return nil, false
+}
diff --git a/blink/internal/fixer/rules/regression_test.go b/blink/internal/fixer/rules/regression_test.go
new file mode 100644
index 0000000000..3aded384d6
--- /dev/null
+++ b/blink/internal/fixer/rules/regression_test.go
@@ -0,0 +1,47 @@
+package rules
+
+import "testing"
+
+// keyword-spelled class-constant names must not be lowercased (sweep finding)
+func TestLowercaseKeywordsKeepsConstantName(t *testing.T) {
+ src := "= 0 && s.At(n).Kind == token.Ident {
+ return s.At(n).Value
+ }
+ }
+ }
+ return ""
+}
+
+func clearUselessDocContent(docContent, className string) string {
+ lines := strings.Split(docContent, "\n")
+ cleaned := make([]string, 0, len(lines))
+ for _, line := range lines {
+ if className != "" && strings.Trim(line, "* ") == className {
+ continue
+ }
+ for _, re := range uselessCommentRegexes {
+ line = re.ReplaceAllString(line, "")
+ }
+ cleaned = append(cleaned, line)
+ }
+ // drop empty lines
+ kept := cleaned[:0]
+ for _, l := range cleaned {
+ if l != "" {
+ kept = append(kept, l)
+ }
+ }
+ if len(kept) == 2 && kept[0] == "/**" && strings.TrimSpace(kept[1]) == "*/" {
+ return ""
+ }
+ commentText := strings.Join(kept, "\n")
+ return doctrineGeneratedRegex.ReplaceAllString(commentText, "")
+}
diff --git a/blink/internal/fixer/rules/return_ternary_test.go b/blink/internal/fixer/rules/return_ternary_test.go
new file mode 100644
index 0000000000..76b3302509
--- /dev/null
+++ b/blink/internal/fixer/rules/return_ternary_test.go
@@ -0,0 +1,110 @@
+package rules
+
+import "testing"
+
+func TestReturnTypeDeclaration(t *testing.T) {
+ // space before the colon is removed, one space kept after
+ got, changed := apply(t, ReturnTypeDeclaration{}, " $x;")
+ if want := " $x;"; !changed || got != want {
+ t.Fatalf("arrow: changed=%v got=%q want=%q", changed, got, want)
+ }
+
+ // no-op on already-correct code
+ if _, changed := apply(t, ReturnTypeDeclaration{}, "b;",
+ } {
+ if got, changed := apply(t, TernaryOperatorSpaces{}, src); changed || got != src {
+ t.Fatalf("must not touch %q: changed=%v got=%q", src, changed, got)
+ }
+ }
+
+ // multiline ternary alignment is preserved
+ if _, changed := apply(t, TernaryOperatorSpaces{}, " "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{}
+
+func (ReturnTypeDeclaration) Name() string {
+ return `PhpCsFixer\Fixer\FunctionNotation\ReturnTypeDeclarationFixer`
+}
+
+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 {
+ changed := false
+ i := 0
+ for i < s.Len() {
+ if s.At(i).Kind != token.Punct || s.At(i).Value != ":" {
+ i++
+ continue
+ }
+ if !isReturnTypeColon(s, i) {
+ i++
+ continue
+ }
+
+ // after side: exactly one space, unless a newline follows (leave multiline)
+ if i+1 < s.Len() {
+ next := s.At(i + 1)
+ if next.Kind == token.Whitespace {
+ if !hasNewline(next.Value) && next.Value != " " {
+ s.SetValue(i+1, " ")
+ changed = true
+ }
+ } else {
+ s.InsertAt(i+1, token.Token{Kind: token.Whitespace, Value: " "})
+ changed = true
+ }
+ }
+
+ // 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++
+ }
+ return changed
+}
+
+// isReturnTypeColon reports whether the ":" at index i closes a function
+// signature: its previous significant token is a ")" that matches a "(" opened
+// by a function/fn declaration.
+func isReturnTypeColon(s *tokens.Stream, i int) bool {
+ c := prevSignificantIndex(s, i)
+ if c < 0 || s.At(c).Kind != token.Punct || s.At(c).Value != ")" {
+ return false
+ }
+ open := s.MatchBackward(c)
+ if open < 0 {
+ return false
+ }
+ p := prevSignificantIndex(s, open)
+ if p < 0 {
+ return false
+ }
+ pt := s.At(p)
+ if pt.Kind == token.Keyword {
+ v := strings.ToLower(pt.Value)
+ return v == "function" || v == "fn"
+ }
+ // named function: an identifier name preceded (optionally via "&") by "function"
+ if pt.Kind == token.Ident {
+ q := prevSignificantIndex(s, p)
+ if q >= 0 && s.At(q).Kind == token.Punct && s.At(q).Value == "&" {
+ q = prevSignificantIndex(s, q)
+ }
+ return q >= 0 && s.At(q).Kind == token.Keyword && strings.ToLower(s.At(q).Value) == "function"
+ }
+ return false
+}
+
+// prevSignificantIndex returns the index of the first non-whitespace token before
+// i, or -1 if there is none.
+func prevSignificantIndex(s *tokens.Stream, i int) int {
+ for j := i - 1; j >= 0; j-- {
+ if s.Kind(j) != token.Whitespace {
+ return j
+ }
+ }
+ return -1
+}
diff --git a/blink/internal/fixer/rules/rules_test.go b/blink/internal/fixer/rules/rules_test.go
new file mode 100644
index 0000000000..e48af016b6
--- /dev/null
+++ b/blink/internal/fixer/rules/rules_test.go
@@ -0,0 +1,372 @@
+package rules
+
+import (
+ "testing"
+
+ "blink/internal/lexer"
+ "blink/internal/tokens"
+)
+
+type fixerRule interface {
+ Fix(*tokens.Stream) bool
+}
+
+func apply(t *testing.T, r fixerRule, src string) (string, bool) {
+ t.Helper()
+ s := tokens.New(lexer.Lex(src))
+ changed := r.Fix(s)
+ return s.Render(), changed
+}
+
+func TestNoSinglelineWhitespaceBeforeSemicolons(t *testing.T) {
+ got, changed := apply(t, NoSinglelineWhitespaceBeforeSemicolons{}, "1, 'y' => 2];")
+ if want := " 1, 'y' => 2];"; !changed || got != want {
+ t.Fatalf("changed=%v got=%q want=%q", changed, got, want)
+ }
+ if _, changed := apply(t, BinaryOperatorSpaces{}, " 1];"); changed {
+ t.Fatal("already-normalized arrow should not change")
+ }
+ // arrow spanning a newline is left alone (alignment)
+ if _, changed := apply(t, BinaryOperatorSpaces{}, "\n 1,\n];"); changed {
+ t.Fatal("multi-line arrow should be kept")
+ }
+ // assignment "=" is normalized too
+ got, changed = apply(t, BinaryOperatorSpaces{}, "TRUE;"); changed {
+ t.Fatal("member named TRUE should not be touched")
+ }
+}
+
+func TestLowercaseStaticReference(t *testing.T) {
+ got, changed := apply(t, LowercaseStaticReference{}, "bar ();")
+ if want := "bar();"; !changed || got != want {
+ t.Fatalf("changed=%v got=%q want=%q", changed, got, want)
+ }
+ if _, changed := apply(t, NoSpacesAfterFunctionName{}, "1,'b' => 2];\n",
+ "\n",
+ "\n",
+ " $b) {\n};\necho __line__;\n",
+ ""); changed {
+ t.Fatal("XML declaration must not be converted")
+ }
+}
+
+func TestNoClosingTag(t *testing.T) {
+ got, changed := apply(t, NoClosingTag{}, "\n")
+ if want := "\n\n\n"); changed {
+ t.Fatal("file with inline HTML must keep closing tags")
+ }
+}
+
+func TestBlankLineAfterNamespace(t *testing.T) {
+ got, changed := apply(t, BlankLineAfterNamespace{}, " "new self()",
+// "Foo::CONST" -> "self::CONST", type declarations "Foo $x" -> "self $x".
+type SelfAccessor struct{}
+
+func (SelfAccessor) Name() string {
+ return `PhpCsFixer\Fixer\ClassNotation\SelfAccessorFixer`
+}
+
+func (SelfAccessor) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/SelfAccessorFixer.php"
+}
+
+func (SelfAccessor) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ if s.At(i).Kind != token.Keyword || !isClassLikeKeyword(strings.ToLower(s.At(i).Value)) {
+ continue
+ }
+ // skip "new class" (anonymous) and "::class"
+ if prev, ok := prevSignificant(s, i); ok {
+ if prev.Kind == token.Keyword && strings.EqualFold(prev.Value, "new") {
+ continue
+ }
+ if prev.Kind == token.Punct && (prev.Value == "->" || prev.Value == "?->" || prev.Value == "::") {
+ continue
+ }
+ }
+ nameIdx := nextSignificantIndex(s, i)
+ if nameIdx < 0 || s.At(nameIdx).Kind != token.Ident {
+ continue
+ }
+ className := s.At(nameIdx).Value
+ open := findBodyBrace(s, nameIdx)
+ if open < 0 {
+ continue
+ }
+ closeIdx := s.MatchForward(open)
+ if closeIdx < 0 {
+ continue
+ }
+ for k := open + 1; k < closeIdx; k++ {
+ if s.At(k).Kind != token.Ident || s.At(k).Value != className {
+ continue
+ }
+ if selfAccessorRef(s, k) {
+ s.SetValue(k, "self")
+ changed = true
+ }
+ }
+ }
+ return changed
+}
+
+func isClassLikeKeyword(lw string) bool {
+ switch lw {
+ case "class", "interface", "trait", "enum":
+ return true
+ }
+ return false
+}
+
+// findBodyBrace returns the index of the "{" opening a class body declared at
+// nameIdx, scanning past any "extends"/"implements" clause. Returns -1 if none.
+func findBodyBrace(s *tokens.Stream, nameIdx int) int {
+ for k := nameIdx + 1; k < s.Len(); k++ {
+ if s.At(k).Kind == token.Punct {
+ switch s.At(k).Value {
+ case "{":
+ return k
+ case ";":
+ return -1
+ }
+ }
+ }
+ return -1
+}
+
+// selfAccessorRef reports whether the class-name token at k is a reference to the
+// class (a "new"/"instanceof" operand, a "::" access, or a type declaration)
+// rather than a name in another position.
+func selfAccessorRef(s *tokens.Stream, k int) bool {
+ prev, hasPrev := prevSignificant(s, k)
+ nextIdx := nextSignificantIndex(s, k)
+
+ if hasPrev {
+ if prev.Kind == token.Punct {
+ switch prev.Value {
+ case "->", "?->", "::", `\`:
+ return false
+ }
+ }
+ if prev.Kind == token.Keyword {
+ switch strings.ToLower(prev.Value) {
+ case "function", "const", "extends", "implements", "use", "namespace", "as":
+ return false
+ case "new", "instanceof":
+ return true
+ }
+ }
+ }
+ if nextIdx >= 0 && s.At(nextIdx).Kind == token.Punct && s.At(nextIdx).Value == `\` {
+ return false // heads a namespaced name
+ }
+ if nextIdx >= 0 && s.At(nextIdx).Kind == token.Punct && s.At(nextIdx).Value == "::" {
+ return true
+ }
+ // a type declaration only counts inside a parameter list or a return type;
+ // property types are left untouched by ECS
+ if enclosingFuncParamOpen(s, k) >= 0 {
+ return true
+ }
+ return selfAccessorInReturnType(s, k)
+}
+
+// selfAccessorInReturnType reports whether the type token at k sits in a return
+// type, walking back over "?", "|", "&", "\" to a return-type colon.
+func selfAccessorInReturnType(s *tokens.Stream, k int) bool {
+ j := k
+ for {
+ p := prevSignificantIndex(s, j)
+ if p < 0 {
+ return false
+ }
+ if s.At(p).Kind == token.Punct && s.At(p).Value == ":" {
+ return isReturnTypeColon(s, p)
+ }
+ if s.At(p).Kind == token.Ident ||
+ (s.At(p).Kind == token.Punct && (s.At(p).Value == "?" || s.At(p).Value == "|" || s.At(p).Value == "&" || s.At(p).Value == `\`)) {
+ j = p
+ continue
+ }
+ return false
+ }
+}
diff --git a/blink/internal/fixer/rules/self_static_accessor.go b/blink/internal/fixer/rules/self_static_accessor.go
new file mode 100644
index 0000000000..93f5d2ccbd
--- /dev/null
+++ b/blink/internal/fixer/rules/self_static_accessor.go
@@ -0,0 +1,118 @@
+package rules
+
+import (
+ "slices"
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/SelfStaticAccessorFixer.php
+//
+// SelfStaticAccessor replaces "static" with "self" inside a final class or an
+// enum (both cannot be extended, so late static binding is redundant): a static
+// accessor "static::foo()" becomes "self::foo()" and "new static" becomes
+// "new self". A "static" used as a type ("): static") or a closure/modifier
+// ("static function") is left alone.
+type SelfStaticAccessor struct{}
+
+func (SelfStaticAccessor) Name() string {
+ return `PhpCsFixer\Fixer\ClassNotation\SelfStaticAccessorFixer`
+}
+
+func (SelfStaticAccessor) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/SelfStaticAccessorFixer.php"
+}
+
+type selfStaticFrame struct {
+ classLike bool
+ final bool
+}
+
+func (SelfStaticAccessor) Fix(s *tokens.Stream) bool {
+ changed := false
+ var stack []selfStaticFrame
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ if t.Kind == token.Punct {
+ switch t.Value {
+ case "{":
+ if kind, kw := classifyBrace(s, i); kind == braceClassLike {
+ stack = append(stack, selfStaticFrame{classLike: true, final: isFinalClassLike(s, kw)})
+ } else {
+ stack = append(stack, selfStaticFrame{})
+ }
+ case "}":
+ if len(stack) > 0 {
+ stack = stack[:len(stack)-1]
+ }
+ }
+ continue
+ }
+ if t.Kind == token.Keyword && strings.EqualFold(t.Value, "static") &&
+ enclosingFinalClassLike(stack) && isStaticAccessor(s, i) {
+ s.SetValue(i, "self")
+ changed = true
+ }
+ }
+ return changed
+}
+
+// isFinalClassLike reports whether the class-like whose keyword sits at kw cannot
+// be extended: an enum, or a class carrying the "final" modifier. An anonymous
+// class ("new class") is treated conservatively as non-final.
+func isFinalClassLike(s *tokens.Stream, kw int) bool {
+ if kw < 0 {
+ return false
+ }
+ if strings.EqualFold(s.At(kw).Value, "enum") {
+ return true
+ }
+ for j := kw - 1; j >= 0; j-- {
+ t := s.At(j)
+ switch t.Kind {
+ case token.Whitespace, token.Comment, token.DocComment:
+ continue
+ }
+ if t.Kind == token.Keyword {
+ switch strings.ToLower(t.Value) {
+ case "final":
+ return true
+ case "new":
+ return false // anonymous class
+ case "abstract", "readonly":
+ continue
+ }
+ return false
+ }
+ if t.Kind == token.Punct && (t.Value == ";" || t.Value == "{" || t.Value == "}") {
+ return false // statement boundary - no modifier seen
+ }
+ return false
+ }
+ return false
+}
+
+// enclosingFinalClassLike reports whether the nearest class-like frame on the
+// stack is final.
+func enclosingFinalClassLike(stack []selfStaticFrame) bool {
+ for _, f := range slices.Backward(stack) {
+ if f.classLike {
+ return f.final
+ }
+ }
+ return false
+}
+
+// isStaticAccessor reports whether the "static" at i is a class reference:
+// "static::" (next significant is "::") or "new static" (previous is "new").
+func isStaticAccessor(s *tokens.Stream, i int) bool {
+ if nextSignificantValue(s, i) == "::" {
+ return true
+ }
+ if prev, ok := prevSignificant(s, i); ok && prev.Kind == token.Keyword && strings.EqualFold(prev.Value, "new") {
+ return true
+ }
+ return false
+}
diff --git a/blink/internal/fixer/rules/self_static_accessor_test.go b/blink/internal/fixer/rules/self_static_accessor_test.go
new file mode 100644
index 0000000000..7e7df76f03
--- /dev/null
+++ b/blink/internal/fixer/rules/self_static_accessor_test.go
@@ -0,0 +1,43 @@
+package rules
+
+import "testing"
+
+func TestSelfStaticAccessor(t *testing.T) {
+ // final class: static accessor and "new static" become self
+ src := "return / ::return member access
+ }
+ // expect exactly "return null ;"
+ j := nextSignificantIndex(s, i)
+ if j < 0 || !isNullLiteral(s.At(j)) {
+ continue
+ }
+ k := nextSignificantIndex(s, j)
+ if k < 0 || s.At(k).Kind != token.Punct || s.At(k).Value != ";" {
+ continue
+ }
+ if enclosingReturnTypeNullable(s, i) {
+ continue
+ }
+ // drop everything between "return" and ";" -> "return;"
+ for x := k - 1; x > i; x-- {
+ s.RemoveAt(x)
+ }
+ changed = true
+ }
+ return changed
+}
+
+// enclosingReturnTypeNullable reports whether the innermost function enclosing
+// position `at` declares a nullable return type.
+func enclosingReturnTypeNullable(s *tokens.Stream, at int) bool {
+ depth := 0
+ for j := at - 1; j >= 0; j-- {
+ t := s.At(j)
+ if t.Kind != token.Punct {
+ continue
+ }
+ switch t.Value {
+ case "}":
+ depth++
+ case "{":
+ if depth > 0 {
+ depth--
+ continue
+ }
+ kind, kw := classifyBrace(s, j)
+ switch kind {
+ case braceFunctionDecl, braceClosure:
+ return funcReturnTypeNullable(s, kw, j)
+ case braceClassLike:
+ return false // a return cannot sit directly in a class body
+ default:
+ // inner control/free block; the function is further out
+ }
+ }
+ }
+ return false
+}
+
+// funcReturnTypeNullable inspects the signature of the function whose keyword is
+// at kw and whose body opens at brace, reporting a nullable return type.
+func funcReturnTypeNullable(s *tokens.Stream, kw, brace int) bool {
+ paramsOpen := -1
+ for x := kw + 1; x < brace; x++ {
+ if s.At(x).Kind == token.Punct && s.At(x).Value == "(" {
+ paramsOpen = x
+ break
+ }
+ }
+ if paramsOpen < 0 {
+ return false
+ }
+ paramsClose := s.MatchForward(paramsOpen)
+ if paramsClose < 0 || paramsClose >= brace {
+ return false
+ }
+ c := nextSignificantIndex(s, paramsClose)
+ // a closure may carry a "use (...)" clause between the params and the return type
+ if c >= 0 && s.At(c).Kind == token.Keyword && strings.EqualFold(s.At(c).Value, "use") {
+ uo := nextSignificantIndex(s, c)
+ if uo < 0 || s.At(uo).Kind != token.Punct || s.At(uo).Value != "(" {
+ return false
+ }
+ uc := s.MatchForward(uo)
+ if uc < 0 || uc >= brace {
+ return false
+ }
+ c = nextSignificantIndex(s, uc)
+ }
+ if c < 0 || s.At(c).Kind != token.Punct || s.At(c).Value != ":" {
+ return false // no return type declared
+ }
+ var b strings.Builder
+ for x := c + 1; x < brace; x++ {
+ tk := s.At(x)
+ switch tk.Kind {
+ case token.Whitespace, token.Comment, token.DocComment:
+ continue
+ }
+ b.WriteString(tk.Value)
+ }
+ typ := strings.ToLower(b.String())
+ if typ == "" {
+ return false
+ }
+ if strings.HasPrefix(typ, "?") {
+ return true
+ }
+ for _, part := range strings.FieldsFunc(typ, func(r rune) bool { return r == '|' || r == '&' }) {
+ part = strings.Trim(part, "()")
+ if part == "null" || part == "mixed" {
+ return true
+ }
+ }
+ return false
+}
diff --git a/blink/internal/fixer/rules/single_blank_line_at_eof.go b/blink/internal/fixer/rules/single_blank_line_at_eof.go
new file mode 100644
index 0000000000..a48c0d5782
--- /dev/null
+++ b/blink/internal/fixer/rules/single_blank_line_at_eof.go
@@ -0,0 +1,44 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/SingleBlankLineAtEofFixer.php
+//
+// SingleBlankLineAtEndOfFile ensures the file ends with exactly one newline.
+type SingleBlankLineAtEndOfFile struct{}
+
+func (SingleBlankLineAtEndOfFile) Name() string {
+ return `PhpCsFixer\Fixer\Whitespace\SingleBlankLineAtEofFixer`
+}
+
+func (SingleBlankLineAtEndOfFile) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/SingleBlankLineAtEofFixer.php"
+}
+
+func (SingleBlankLineAtEndOfFile) Fix(s *tokens.Stream) bool {
+ if s.Len() == 0 {
+ return false
+ }
+ last := s.Len() - 1
+ t := s.At(last)
+ if t.Kind == token.Whitespace {
+ // the final whitespace token is entirely trailing; collapse to one \n
+ if t.Value != "\n" {
+ s.SetValue(last, "\n")
+ return true
+ }
+ return false
+ }
+ // file does not end in whitespace: append a single newline, unless the last
+ // token already carries a trailing newline (e.g. a comment)
+ if strings.HasSuffix(t.Value, "\n") {
+ return false
+ }
+ s.InsertAt(s.Len(), token.Token{Kind: token.Whitespace, Value: "\n"})
+ return true
+}
diff --git a/blink/internal/fixer/rules/single_blank_line_before_namespace.go b/blink/internal/fixer/rules/single_blank_line_before_namespace.go
new file mode 100644
index 0000000000..e36617b8c9
--- /dev/null
+++ b/blink/internal/fixer/rules/single_blank_line_before_namespace.go
@@ -0,0 +1,42 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/NamespaceNotation/SingleBlankLineBeforeNamespaceFixer.php
+//
+// SingleBlankLineBeforeNamespace ensures exactly one blank line before a
+// namespace declaration on its own line (the older sibling of
+// BlankLinesBeforeNamespace, kept for parity with configs that reference it).
+type SingleBlankLineBeforeNamespace struct{}
+
+func (SingleBlankLineBeforeNamespace) Name() string {
+ return `PhpCsFixer\Fixer\NamespaceNotation\SingleBlankLineBeforeNamespaceFixer`
+}
+
+func (SingleBlankLineBeforeNamespace) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/NamespaceNotation/SingleBlankLineBeforeNamespaceFixer.php"
+}
+
+func (SingleBlankLineBeforeNamespace) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := range s.Len() {
+ t := s.At(i)
+ if t.Kind != token.Keyword || strings.ToLower(t.Value) != "namespace" || memberPrev(s, i) {
+ continue
+ }
+ if i == 0 {
+ continue
+ }
+ prev := s.At(i - 1)
+ if prev.Kind == token.Whitespace && hasNewline(prev.Value) && prev.Value != "\n\n" {
+ s.SetValue(i-1, "\n\n")
+ changed = true
+ }
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/source_test.go b/blink/internal/fixer/rules/source_test.go
new file mode 100644
index 0000000000..14865de839
--- /dev/null
+++ b/blink/internal/fixer/rules/source_test.go
@@ -0,0 +1,30 @@
+package rules
+
+import (
+ "regexp"
+ "testing"
+
+ "blink/internal/fixer"
+)
+
+var sourceURLPattern = regexp.MustCompile(
+ `^https://github\.com/(PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src|symplify/coding-standard/blob/main/src|ecsphp/ecs-src/blob/main/packages/coding-standard/src)/Fixer/[A-Za-z]+/[A-Za-z]+Fixer\.php$`,
+)
+
+// TestEveryFixerHasSourceURL enforces the Fixer contract offline: every rule
+// must expose a well-formed PHP-CS-Fixer source link consistent with its name.
+func TestEveryFixerHasSourceURL(t *testing.T) {
+ for _, f := range All() {
+ url := f.SourceURL()
+ if url == "" {
+ t.Errorf("%s: empty SourceURL", f.Name())
+ continue
+ }
+ if !sourceURLPattern.MatchString(url) {
+ t.Errorf("%s: SourceURL %q does not match the PHP-CS-Fixer source pattern", f.Name(), url)
+ }
+ if want := fixer.SourceURLFor(f.Name()); url != want {
+ t.Errorf("%s: SourceURL %q is inconsistent with the name, want %q", f.Name(), url, want)
+ }
+ }
+}
diff --git a/blink/internal/fixer/rules/space_after_comma_here_now_doc.go b/blink/internal/fixer/rules/space_after_comma_here_now_doc.go
new file mode 100644
index 0000000000..944fd49184
--- /dev/null
+++ b/blink/internal/fixer/rules/space_after_comma_here_now_doc.go
@@ -0,0 +1,42 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// Symplify: https://github.com/ecsphp/ecs-src/blob/main/packages/coding-standard/src/Fixer/Spacing/SpaceAfterCommaHereNowDocFixer.php
+//
+// SpaceAfterCommaHereNowDoc puts a newline between a heredoc/nowdoc closing
+// marker and a following "," or "]" (a heredoc argument in a call/array). The
+// blink lexer keeps a whole heredoc/nowdoc as a single string token, so the
+// closing marker is the end of that token.
+type SpaceAfterCommaHereNowDoc struct{}
+
+func (SpaceAfterCommaHereNowDoc) Name() string {
+ return `Symplify\CodingStandard\Fixer\Spacing\SpaceAfterCommaHereNowDocFixer`
+}
+
+func (SpaceAfterCommaHereNowDoc) SourceURL() string {
+ return "https://github.com/ecsphp/ecs-src/blob/main/packages/coding-standard/src/Fixer/Spacing/SpaceAfterCommaHereNowDocFixer.php"
+}
+
+func (SpaceAfterCommaHereNowDoc) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := s.Len() - 1; i >= 0; i-- {
+ if s.At(i).Kind != token.String || !strings.HasPrefix(s.At(i).Value, "<<<") {
+ continue
+ }
+ n := i + 1
+ if n >= s.Len() {
+ continue
+ }
+ if s.At(n).Kind == token.Punct && (s.At(n).Value == "," || s.At(n).Value == "]") {
+ s.InsertAt(n, token.Token{Kind: token.Whitespace, Value: "\n"})
+ changed = true
+ }
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/space_after_comma_here_now_doc_test.go b/blink/internal/fixer/rules/space_after_comma_here_now_doc_test.go
new file mode 100644
index 0000000000..c61ac10a57
--- /dev/null
+++ b/blink/internal/fixer/rules/space_after_comma_here_now_doc_test.go
@@ -0,0 +1,22 @@
+package rules
+
+import "testing"
+
+func TestSpaceAfterCommaHereNowDoc(t *testing.T) {
+ f := SpaceAfterCommaHereNowDoc{}
+
+ got, changed := apply(t, f, "= s.Len() {
+ continue
+ }
+ next := s.At(i + 1)
+ if next.Kind == token.Whitespace || next.Kind == token.CloseTag {
+ continue
+ }
+ if next.Kind == token.Punct && (next.Value == ")" || next.Value == ";") {
+ continue
+ }
+ s.InsertAt(i+1, token.Token{Kind: token.Whitespace, Value: " "})
+ changed = true
+ i++ // skip inserted whitespace
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/space_around.go b/blink/internal/fixer/rules/space_around.go
new file mode 100644
index 0000000000..ce333d8977
--- /dev/null
+++ b/blink/internal/fixer/rules/space_around.go
@@ -0,0 +1,152 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+func hasNewline(s string) bool { return strings.ContainsAny(s, "\n\r") }
+
+// nextSignificantValue returns the value of the first non-whitespace token after
+// index i, or "" if none.
+func nextSignificantValue(s *tokens.Stream, i int) string {
+ for j := i + 1; j < s.Len(); j++ {
+ if s.At(j).Kind != token.Whitespace {
+ return s.At(j).Value
+ }
+ }
+ return ""
+}
+
+// prevSignificant returns the first non-whitespace token before index i.
+func prevSignificant(s *tokens.Stream, i int) (token.Token, bool) {
+ for j := i - 1; j >= 0; j-- {
+ if s.At(j).Kind != token.Whitespace {
+ return s.At(j), true
+ }
+ }
+ return token.Token{}, false
+}
+
+// memberPrev reports whether the token before index i is an object/static
+// access operator, meaning the token at i is a property or method name.
+func memberPrev(s *tokens.Stream, i int) bool {
+ if prev, ok := prevSignificant(s, i); ok {
+ return prev.Value == "->" || prev.Value == "?->" || prev.Value == "::"
+ }
+ return false
+}
+
+// isOperand reports whether a token can be the operand of ++/-- (a variable,
+// name, or a closing ) or ]).
+func isOperand(t token.Token) bool {
+ if t.Kind == token.Variable || t.Kind == token.Ident {
+ return true
+ }
+ return t.Kind == token.Punct && (t.Value == ")" || t.Value == "]")
+}
+
+// castAt detects a cast at an opening "(" and returns the type token index and
+// the closing ")" index. It reuses CastSpaces' guard so grouping, calls and
+// index accesses are not mistaken for casts.
+func castAt(s *tokens.Stream, open int) (typeIdx, closeIdx int, ok bool) {
+ if s.At(open).Kind != token.Punct || s.At(open).Value != "(" {
+ return 0, 0, false
+ }
+ j := open + 1
+ if j < s.Len() && s.At(j).Kind == token.Whitespace {
+ j++
+ }
+ if j >= s.Len() || !isCastType(s.At(j)) {
+ return 0, 0, false
+ }
+ typeIdx = j
+ j++
+ if j < s.Len() && s.At(j).Kind == token.Whitespace {
+ j++
+ }
+ if j >= s.Len() || s.At(j).Kind != token.Punct || s.At(j).Value != ")" {
+ return 0, 0, false
+ }
+ if prev, ok := prevSignificant(s, open); ok {
+ if prev.Kind == token.Ident || prev.Kind == token.Variable ||
+ prev.Value == ")" || prev.Value == "]" {
+ return 0, 0, false
+ }
+ }
+ return typeIdx, j, true
+}
+
+// insideDeclareArgs reports whether index i sits inside a declare( ... ) header,
+// so binary-operator spacing leaves declare_equal_normalize to handle its "=".
+func insideDeclareArgs(s *tokens.Stream, i int) bool {
+ depth := 0
+ for j := i - 1; j >= 0; j-- {
+ t := s.At(j)
+ if t.Kind != token.Punct {
+ continue
+ }
+ switch t.Value {
+ case ")":
+ depth++
+ case "(":
+ if depth == 0 {
+ if prev, ok := prevSignificant(s, j); ok {
+ return prev.Kind == token.Keyword && strings.EqualFold(prev.Value, "declare")
+ }
+ return false
+ }
+ depth--
+ case ";":
+ if depth == 0 {
+ return false
+ }
+ }
+ }
+ return false
+}
+
+// normalizeSpaceAround forces exactly one space on both sides of every token the
+// target predicate selects. Whitespace spanning a newline is left alone so
+// intentional alignment survives.
+func normalizeSpaceAround(s *tokens.Stream, target func(*tokens.Stream, int) bool) bool {
+ changed := false
+ i := 0
+ for i < s.Len() {
+ if !target(s, i) {
+ i++
+ continue
+ }
+
+ if i > 0 {
+ prev := s.At(i - 1)
+ if prev.Kind == token.Whitespace {
+ if !hasNewline(prev.Value) && prev.Value != " " {
+ s.SetValue(i-1, " ")
+ changed = true
+ }
+ } else {
+ s.InsertAt(i, token.Token{Kind: token.Whitespace, Value: " "})
+ i++ // operator shifted right by the inserted space
+ changed = true
+ }
+ }
+
+ if i+1 < s.Len() {
+ next := s.At(i + 1)
+ if next.Kind == token.Whitespace {
+ if !hasNewline(next.Value) && next.Value != " " {
+ s.SetValue(i+1, " ")
+ changed = true
+ }
+ } else {
+ s.InsertAt(i+1, token.Token{Kind: token.Whitespace, Value: " "})
+ changed = true
+ }
+ }
+ i++
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/standalone_line_in_multiline_array.go b/blink/internal/fixer/rules/standalone_line_in_multiline_array.go
new file mode 100644
index 0000000000..415849266e
--- /dev/null
+++ b/blink/internal/fixer/rules/standalone_line_in_multiline_array.go
@@ -0,0 +1,97 @@
+package rules
+
+import (
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// Symplify: https://github.com/symplify/coding-standard/blob/main/src/Fixer/ArrayNotation/StandaloneLineInMultilineArrayFixer.php
+//
+// StandaloneLineInMultilineArray puts each item of an associative array literal
+// on its own line. Unlike ArrayListItemNewline it also reflows arrays that are
+// already spread across lines, and it does not add a trailing comma. Matching
+// the upstream fix(), the first array that must be skipped (a non-associative
+// array, or a lone non-array item not introduced by "=>") stops the whole pass.
+type StandaloneLineInMultilineArray struct{}
+
+func (StandaloneLineInMultilineArray) Name() string {
+ return `Symplify\CodingStandard\Fixer\ArrayNotation\StandaloneLineInMultilineArrayFixer`
+}
+
+func (StandaloneLineInMultilineArray) SourceURL() string {
+ return "https://github.com/symplify/coding-standard/blob/main/src/Fixer/ArrayNotation/StandaloneLineInMultilineArrayFixer.php"
+}
+
+func (StandaloneLineInMultilineArray) Fix(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
+ }
+ if !isArrayLiteralOpen(s, open) {
+ continue
+ }
+ closeIdx := s.MatchForward(open)
+ if closeIdx < 0 || sigNext(s, open) == closeIdx {
+ continue // empty []
+ }
+ if standaloneArrayShouldSkip(s, open, closeIdx) {
+ return changed // upstream fix() returns on the first skipped array
+ }
+ if reflowParen(s, open, closeIdx) {
+ changed = true
+ }
+ }
+ return changed
+}
+
+// standaloneArrayShouldSkip mirrors shouldSkipNestedArrayValue.
+func standaloneArrayShouldSkip(s *tokens.Stream, open, closeIdx int) bool {
+ if !arrayHasTopLevelArrow(s, open, closeIdx) {
+ return true // not an associative array
+ }
+ count, firstIsArray := arrayItemInfo(s, open, closeIdx)
+ if count == 1 && !firstIsArray {
+ prev := sigPrev(s, open)
+ if prev < 0 {
+ return false
+ }
+ return s.At(prev).Value != "=>"
+ }
+ return false
+}
+
+// arrayItemInfo returns the top-level item count and whether the first item's
+// value is itself an array.
+func arrayItemInfo(s *tokens.Stream, open, closeIdx int) (count int, firstIsArray bool) {
+ count = 1
+ depth := 0
+ firstArrowChecked := false
+ for j := open + 1; j < closeIdx; j++ {
+ t := s.At(j)
+ if t.Kind == token.Punct {
+ switch t.Value {
+ case "(", "[", "{":
+ depth++
+ continue
+ case ")", "]", "}":
+ depth--
+ continue
+ case ",":
+ if depth == 0 && sigNext(s, j) != closeIdx {
+ count++
+ }
+ continue
+ case "=>":
+ if depth == 0 && !firstArrowChecked {
+ firstArrowChecked = true
+ if v := sigNext(s, j); v >= 0 && s.At(v).Kind == token.Punct && s.At(v).Value == "[" {
+ firstIsArray = true
+ }
+ }
+ continue
+ }
+ }
+ }
+ return count, firstIsArray
+}
diff --git a/blink/internal/fixer/rules/standalone_line_in_multiline_array_test.go b/blink/internal/fixer/rules/standalone_line_in_multiline_array_test.go
new file mode 100644
index 0000000000..13f4cf7e17
--- /dev/null
+++ b/blink/internal/fixer/rules/standalone_line_in_multiline_array_test.go
@@ -0,0 +1,29 @@
+package rules
+
+import "testing"
+
+func TestStandaloneLineInMultilineArray(t *testing.T) {
+ f := StandaloneLineInMultilineArray{}
+
+ // a single-line associative array is broken to one item per line
+ got, changed := apply(t, f, " \"x\", 2 => \"y\"];\n")
+ want := " \"x\",\n 2 => \"y\"\n];\n"
+ if !changed || got != want {
+ t.Fatalf("single-line: changed=%v got=%q", changed, got)
+ }
+
+ // a plain (non-associative) list is left alone
+ if _, changed := apply(t, f, " is left alone
+ if _, changed := apply(t, f, " \"x\",\n];\n"); changed {
+ t.Fatal("single associative item must be left alone")
+ }
+
+ // the first skipped array stops the whole pass (upstream fix() returns)
+ if _, changed := apply(t, f, " 1, \"j\" => 2]];\n"); changed {
+ t.Fatal("a leading non-associative array aborts the pass")
+ }
+}
diff --git a/blink/internal/fixer/rules/standalone_line_plain_constructor_param.go b/blink/internal/fixer/rules/standalone_line_plain_constructor_param.go
new file mode 100644
index 0000000000..52852b1e79
--- /dev/null
+++ b/blink/internal/fixer/rules/standalone_line_plain_constructor_param.go
@@ -0,0 +1,78 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// Symplify: https://github.com/ecsphp/ecs-src/blob/main/packages/coding-standard/src/Fixer/Spacing/StandaloneLinePlainConstructorParamFixer.php
+//
+// StandaloneLinePlainConstructorParam puts each parameter of a constructor
+// without promoted properties on its own line, but only when it has 4 or more
+// parameters. Promoted constructors are left to StandaloneLinePromotedProperty.
+type StandaloneLinePlainConstructorParam struct{}
+
+// Short parameter lists read fine on a single line.
+const plainConstructorMinParamCount = 4
+
+func (StandaloneLinePlainConstructorParam) Name() string {
+ return `Symplify\CodingStandard\Fixer\Spacing\StandaloneLinePlainConstructorParamFixer`
+}
+
+func (StandaloneLinePlainConstructorParam) SourceURL() string {
+ return "https://github.com/ecsphp/ecs-src/blob/main/packages/coding-standard/src/Fixer/Spacing/StandaloneLinePlainConstructorParamFixer.php"
+}
+
+func (StandaloneLinePlainConstructorParam) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ if s.At(i).Kind != token.Ident || !strings.EqualFold(s.At(i).Value, "__construct") {
+ continue
+ }
+ if p := sigPrev(s, i); p < 0 || s.At(p).Kind != token.Keyword || strings.ToLower(s.At(p).Value) != "function" {
+ continue
+ }
+ open := sigNext(s, i)
+ if open < 0 || s.At(open).Kind != token.Punct || s.At(open).Value != "(" {
+ continue
+ }
+ closeIdx := s.MatchForward(open)
+ if closeIdx < 0 || sigNext(s, open) == closeIdx {
+ continue // no parameters
+ }
+ if countParams(s, open, closeIdx) < plainConstructorMinParamCount {
+ continue
+ }
+ if hasPromotedParam(s, open, closeIdx) {
+ continue // promoted constructors are handled by StandaloneLinePromotedProperty
+ }
+ if reflowParen(s, open, closeIdx) {
+ changed = true
+ }
+ }
+ return changed
+}
+
+// countParams counts top-level parameters (variables at nesting depth 0) in the paren.
+func countParams(s *tokens.Stream, open, closeIdx int) int {
+ count := 0
+ depth := 0
+ for j := open + 1; j < closeIdx; j++ {
+ t := s.At(j)
+ if t.Kind == token.Punct {
+ switch t.Value {
+ case "(", "[", "{":
+ depth++
+ case ")", "]", "}":
+ depth--
+ }
+ continue
+ }
+ if depth == 0 && t.Kind == token.Variable {
+ count++
+ }
+ }
+ return count
+}
diff --git a/blink/internal/fixer/rules/standalone_line_plain_constructor_param_test.go b/blink/internal/fixer/rules/standalone_line_plain_constructor_param_test.go
new file mode 100644
index 0000000000..138d41e823
--- /dev/null
+++ b/blink/internal/fixer/rules/standalone_line_plain_constructor_param_test.go
@@ -0,0 +1,29 @@
+package rules
+
+import "testing"
+
+func TestStandaloneLinePlainConstructorParam(t *testing.T) {
+ f := StandaloneLinePlainConstructorParam{}
+
+ // 4+ plain params: each on its own line
+ got, changed := apply(t, f, "= 0; j-- {
+ t := s.At(j)
+ switch t.Kind {
+ case token.Whitespace:
+ continue
+ case token.Comment:
+ if requiredAttributeRe.MatchString(t.Value) {
+ isRequired = true
+ }
+ continue
+ case token.DocComment:
+ if requiredAnnotationRe.MatchString(t.Value) {
+ isRequired = true
+ }
+ continue
+ case token.Keyword:
+ switch strings.ToLower(t.Value) {
+ case "public":
+ isPublic = true
+ continue
+ case "protected", "private", "static", "final", "abstract", "readonly":
+ continue
+ }
+ return isPublic && isRequired
+ default:
+ return isPublic && isRequired
+ }
+ }
+ return isPublic && isRequired
+}
diff --git a/blink/internal/fixer/rules/standalone_line_required_param_test.go b/blink/internal/fixer/rules/standalone_line_required_param_test.go
new file mode 100644
index 0000000000..f62b4b65bd
--- /dev/null
+++ b/blink/internal/fixer/rules/standalone_line_required_param_test.go
@@ -0,0 +1,35 @@
+package rules
+
+import "testing"
+
+func TestStandaloneLineRequiredParam(t *testing.T) {
+ f := StandaloneLineRequiredParam{}
+
+ // #[Required] public method: each parameter on its own line
+ got, changed := apply(t, f, " 0 {
+ c := attr[start-1]
+ if c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') {
+ start--
+ continue
+ }
+ break
+ }
+ return attr[start:end]
+}
+
+// matchParen returns the index of the ")" matching the "(" at open, or -1.
+func matchParen(attr string, open int) int {
+ depth := 0
+ for j := open; j < len(attr); j++ {
+ switch attr[j] {
+ case '(', '[':
+ depth++
+ case ')', ']':
+ depth--
+ if depth == 0 {
+ return j
+ }
+ }
+ }
+ return -1
+}
+
+// splitTopLevelArgs splits the argument list on top-level commas, trimming each
+// segment. A trailing empty segment (from a trailing comma) is dropped.
+func splitTopLevelArgs(inner string) []string {
+ var args []string
+ depth := 0
+ start := 0
+ inString := byte(0)
+ for j := 0; j < len(inner); j++ {
+ c := inner[j]
+ if inString != 0 {
+ switch c {
+ case '\\':
+ j++
+ case inString:
+ inString = 0
+ }
+ continue
+ }
+ switch c {
+ case '\'', '"':
+ inString = c
+ case '(', '[', '{':
+ depth++
+ case ')', ']', '}':
+ depth--
+ case ',':
+ if depth == 0 {
+ args = append(args, strings.TrimSpace(inner[start:j]))
+ start = j + 1
+ }
+ }
+ }
+ if last := strings.TrimSpace(inner[start:]); last != "" {
+ args = append(args, last)
+ }
+ return args
+}
diff --git a/blink/internal/fixer/rules/standalone_line_symfony_attribute_param_test.go b/blink/internal/fixer/rules/standalone_line_symfony_attribute_param_test.go
new file mode 100644
index 0000000000..3902c9face
--- /dev/null
+++ b/blink/internal/fixer/rules/standalone_line_symfony_attribute_param_test.go
@@ -0,0 +1,29 @@
+package rules
+
+import "testing"
+
+func TestStandaloneLineSymfonyAttributeParam(t *testing.T) {
+ f := StandaloneLineSymfonyAttributeParam{}
+
+ // AsCommand always breaks, even with a single argument set
+ got, changed := apply(t, f, " 0 {
+ brace--
+ }
+ case "(", "[":
+ paren++
+ case ")", "]":
+ if paren > 0 {
+ paren--
+ }
+ }
+ }
+ if t.Kind != token.Whitespace || !hasNewline(t.Value) || paren != 0 {
+ continue
+ }
+ prev, ok := prevSignificant(s, i)
+ if !ok || (prev.Value != ";" && prev.Value != "{" && prev.Value != "}") {
+ continue // continuation line - leave its alignment alone
+ }
+ if nextSignificantValue(s, i) == "" {
+ continue
+ }
+
+ level := brace
+ if nextSignificantValue(s, i) == "}" {
+ level-- // a closing brace dedents to the outer level
+ }
+ if level < 0 {
+ level = 0
+ }
+ 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
+ }
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/statements.go b/blink/internal/fixer/rules/statements.go
new file mode 100644
index 0000000000..e5d88e4c84
--- /dev/null
+++ b/blink/internal/fixer/rules/statements.go
@@ -0,0 +1,80 @@
+package rules
+
+import (
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Basic/NoMultipleStatementsPerLineFixer.php
+//
+// NoMultipleStatementsPerLine puts each statement on its own line: "$a=1; $b=2;"
+// becomes two lines. Semicolons inside "(...)"/"[...]" (e.g. a for header) and a
+// trailing ";" before "}" or a newline are left alone.
+type NoMultipleStatementsPerLine struct{}
+
+func (NoMultipleStatementsPerLine) Name() string {
+ return `PhpCsFixer\Fixer\Basic\NoMultipleStatementsPerLineFixer`
+}
+
+func (NoMultipleStatementsPerLine) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Basic/NoMultipleStatementsPerLineFixer.php"
+}
+
+func (NoMultipleStatementsPerLine) Fix(s *tokens.Stream) bool {
+ changed := false
+ depth := 0
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ if t.Kind != token.Punct {
+ continue
+ }
+ switch t.Value {
+ case "(", "[":
+ depth++
+ continue
+ case ")", "]":
+ depth--
+ continue
+ case ";":
+ default:
+ continue
+ }
+ if depth != 0 || i+1 >= s.Len() {
+ continue
+ }
+
+ // what follows the ";" on the same line?
+ nextIdx := i + 1
+ inlineWS := false
+ if s.At(nextIdx).Kind == token.Whitespace {
+ if hasNewline(s.At(nextIdx).Value) {
+ continue // already end of line
+ }
+ inlineWS = true
+ nextIdx++
+ }
+ if nextIdx >= s.Len() {
+ continue
+ }
+ nt := s.At(nextIdx)
+ if nt.Kind == token.Punct && (nt.Value == "}" || nt.Value == ";") {
+ continue
+ }
+ if nt.Kind == token.CloseTag {
+ continue
+ }
+ // a trailing comment after ";" is not a second statement
+ if nt.Kind == token.Comment || nt.Kind == token.DocComment {
+ continue
+ }
+
+ indent := lineIndent(s, i)
+ if inlineWS {
+ s.SetValue(i+1, "\n"+indent)
+ } else {
+ s.InsertAt(i+1, token.Token{Kind: token.Whitespace, Value: "\n" + indent})
+ }
+ changed = true
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/string_notation.go b/blink/internal/fixer/rules/string_notation.go
new file mode 100644
index 0000000000..41460f2093
--- /dev/null
+++ b/blink/internal/fixer/rules/string_notation.go
@@ -0,0 +1,42 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/StringNotation/SingleQuoteFixer.php
+//
+// SingleQuote converts a double-quoted string to single quotes when it is safe:
+// no variables ($), no escape sequences (\), and no single quote in the content.
+type SingleQuote struct{}
+
+func (SingleQuote) Name() string {
+ return `PhpCsFixer\Fixer\StringNotation\SingleQuoteFixer`
+}
+
+func (SingleQuote) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/StringNotation/SingleQuoteFixer.php"
+}
+
+func (SingleQuote) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := range s.Len() {
+ t := s.At(i)
+ if t.Kind != token.String || len(t.Value) < 2 {
+ continue
+ }
+ if t.Value[0] != '"' || t.Value[len(t.Value)-1] != '"' {
+ continue // not a plain double-quoted string (e.g. heredoc)
+ }
+ content := t.Value[1 : len(t.Value)-1]
+ if strings.ContainsAny(content, "$\\'") {
+ continue // interpolation, escape, or a quote that would need escaping
+ }
+ s.SetValue(i, "'"+content+"'")
+ changed = true
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/structural.go b/blink/internal/fixer/rules/structural.go
new file mode 100644
index 0000000000..4213af70cc
--- /dev/null
+++ b/blink/internal/fixer/rules/structural.go
@@ -0,0 +1,212 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/NamespaceNotation/BlankLinesBeforeNamespaceFixer.php
+//
+// BlankLinesBeforeNamespace ensures exactly one blank line before a namespace
+// declaration that sits on its own line.
+type BlankLinesBeforeNamespace struct{}
+
+func (BlankLinesBeforeNamespace) Name() string {
+ return `PhpCsFixer\Fixer\NamespaceNotation\BlankLinesBeforeNamespaceFixer`
+}
+
+func (BlankLinesBeforeNamespace) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/NamespaceNotation/BlankLinesBeforeNamespaceFixer.php"
+}
+
+func (BlankLinesBeforeNamespace) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := range s.Len() {
+ t := s.At(i)
+ if t.Kind != token.Keyword || strings.ToLower(t.Value) != "namespace" || memberPrev(s, i) {
+ continue
+ }
+ if i == 0 {
+ continue
+ }
+ prev := s.At(i - 1)
+ if prev.Kind == token.Whitespace && hasNewline(prev.Value) && prev.Value != "\n\n" {
+ s.SetValue(i-1, "\n\n")
+ changed = true
+ }
+ }
+ return changed
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/NamespaceNotation/BlankLineAfterNamespaceFixer.php
+//
+// BlankLineAfterNamespace ensures one blank line after a "namespace X;"
+// declaration. Bracketed namespaces ("namespace X { }") are left alone.
+type BlankLineAfterNamespace struct{}
+
+func (BlankLineAfterNamespace) Name() string {
+ return `PhpCsFixer\Fixer\NamespaceNotation\BlankLineAfterNamespaceFixer`
+}
+
+func (BlankLineAfterNamespace) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/NamespaceNotation/BlankLineAfterNamespaceFixer.php"
+}
+
+func (BlankLineAfterNamespace) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := range s.Len() {
+ t := s.At(i)
+ if t.Kind != token.Keyword || strings.ToLower(t.Value) != "namespace" || memberPrev(s, i) {
+ continue
+ }
+ semi := -1
+ for j := i + 1; j < s.Len(); j++ {
+ if s.At(j).Kind != token.Punct {
+ continue
+ }
+ if s.At(j).Value == "{" {
+ break // bracketed namespace
+ }
+ if s.At(j).Value == ";" {
+ semi = j
+ break
+ }
+ }
+ if semi < 0 || semi+1 >= s.Len() {
+ continue
+ }
+ if nextSignificantValue(s, semi) == "" {
+ continue // namespace is the last statement; nothing to separate
+ }
+ if s.At(semi+1).Kind == token.Whitespace {
+ if hasNewline(s.At(semi+1).Value) && s.At(semi+1).Value != "\n\n" {
+ s.SetValue(semi+1, "\n\n")
+ changed = true
+ }
+ } else {
+ s.InsertAt(semi+1, token.Token{Kind: token.Whitespace, Value: "\n\n"})
+ changed = true
+ }
+ }
+ return changed
+}
+
+var classLikeKeywords = map[string]bool{
+ "class": true, "interface": true, "trait": true, "enum": true,
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/NoBlankLinesAfterClassOpeningFixer.php
+//
+// NoBlankLinesAfterClassOpening removes blank lines right after a class,
+// interface, trait or enum opening brace.
+type NoBlankLinesAfterClassOpening struct{}
+
+func (NoBlankLinesAfterClassOpening) Name() string {
+ return `PhpCsFixer\Fixer\ClassNotation\NoBlankLinesAfterClassOpeningFixer`
+}
+
+func (NoBlankLinesAfterClassOpening) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/NoBlankLinesAfterClassOpeningFixer.php"
+}
+
+func (NoBlankLinesAfterClassOpening) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := range s.Len() {
+ t := s.At(i)
+ if t.Kind != token.Keyword || !classLikeKeywords[strings.ToLower(t.Value)] || memberPrev(s, i) {
+ continue
+ }
+ brace := -1
+ for j := i + 1; j < s.Len(); j++ {
+ if s.At(j).Kind != token.Punct {
+ continue
+ }
+ if s.At(j).Value == "{" {
+ brace = j
+ break
+ }
+ if s.At(j).Value == ";" {
+ break // e.g. no body
+ }
+ }
+ if brace < 0 || brace+1 >= s.Len() {
+ continue
+ }
+ ws := s.At(brace + 1)
+ if ws.Kind != token.Whitespace || strings.Count(ws.Value, "\n") <= 1 {
+ continue
+ }
+ idx := strings.LastIndexByte(ws.Value, '\n')
+ want := "\n" + ws.Value[idx+1:]
+ if ws.Value != want {
+ s.SetValue(brace+1, want)
+ changed = true
+ }
+ }
+ return changed
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/IndentationTypeFixer.php
+//
+// IndentationType converts leading indentation tabs to four spaces.
+type IndentationType struct{}
+
+func (IndentationType) Name() string {
+ return `PhpCsFixer\Fixer\Whitespace\IndentationTypeFixer`
+}
+
+func (IndentationType) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Whitespace/IndentationTypeFixer.php"
+}
+
+func (IndentationType) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := range s.Len() {
+ t := s.At(i)
+ if !strings.Contains(t.Value, "\t") {
+ continue
+ }
+ switch t.Kind {
+ case token.Whitespace:
+ // segments after a newline are line indentation; the first segment is
+ // trailing whitespace of the previous line and is left alone
+ parts := strings.Split(t.Value, "\n")
+ touched := false
+ for k := 1; k < len(parts); k++ {
+ if strings.Contains(parts[k], "\t") {
+ parts[k] = strings.ReplaceAll(parts[k], "\t", " ")
+ touched = true
+ }
+ }
+ if touched {
+ s.SetValue(i, strings.Join(parts, "\n"))
+ changed = true
+ }
+ case token.Comment, token.DocComment:
+ if v := reindentCommentTabs(t.Value); v != t.Value {
+ s.SetValue(i, v)
+ changed = true
+ }
+ }
+ }
+ return changed
+}
+
+// reindentCommentTabs converts leading-indentation tabs to four spaces on each
+// continuation line of a multi-line comment or docblock, matching ECS.
+func reindentCommentTabs(v string) string {
+ lines := strings.Split(v, "\n")
+ for k := 1; k < len(lines); k++ {
+ line := lines[k]
+ j := 0
+ for j < len(line) && (line[j] == ' ' || line[j] == '\t') {
+ j++
+ }
+ if strings.Contains(line[:j], "\t") {
+ lines[k] = strings.ReplaceAll(line[:j], "\t", " ") + line[j:]
+ }
+ }
+ return strings.Join(lines, "\n")
+}
diff --git a/blink/internal/fixer/rules/switch_case.go b/blink/internal/fixer/rules/switch_case.go
new file mode 100644
index 0000000000..a25a5b8989
--- /dev/null
+++ b/blink/internal/fixer/rules/switch_case.go
@@ -0,0 +1,101 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+var caseKeywords = map[string]bool{"case": true, "default": true}
+
+// caseTerminator finds the ":" (or ";") that ends the case/default label at i,
+// skipping ternary "?:" pairs and "::". It stops at a block brace for safety.
+func caseTerminator(s *tokens.Stream, i int) (idx int, isSemicolon, ok bool) {
+ ternary := 0
+ for k := i + 1; k < s.Len(); k++ {
+ t := s.At(k)
+ if t.Kind != token.Punct {
+ continue
+ }
+ switch t.Value {
+ case "?":
+ ternary++
+ case ":":
+ if ternary > 0 {
+ ternary--
+ } else {
+ return k, false, true
+ }
+ case ";":
+ if ternary == 0 {
+ return k, true, true
+ }
+ case "{", "}":
+ return 0, false, false
+ }
+ }
+ return 0, false, false
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/SwitchCaseSemicolonToColonFixer.php
+//
+// SwitchCaseSemicolonToColon turns a "case 1;" terminator into "case 1:". Enum
+// cases are left alone.
+type SwitchCaseSemicolonToColon struct{}
+
+func (SwitchCaseSemicolonToColon) Name() string {
+ return `PhpCsFixer\Fixer\ControlStructure\SwitchCaseSemicolonToColonFixer`
+}
+
+func (SwitchCaseSemicolonToColon) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/SwitchCaseSemicolonToColonFixer.php"
+}
+
+func (SwitchCaseSemicolonToColon) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := range s.Len() {
+ t := s.At(i)
+ if t.Kind != token.Keyword || !caseKeywords[strings.ToLower(t.Value)] || inClassLikeBody(s, i) {
+ continue
+ }
+ if idx, isSemicolon, ok := caseTerminator(s, i); ok && isSemicolon {
+ s.SetValue(idx, ":")
+ changed = true
+ }
+ }
+ return changed
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/SwitchCaseSpaceFixer.php
+//
+// SwitchCaseSpace removes whitespace between a case/default label and its colon
+// ("case 1 :" -> "case 1:").
+type SwitchCaseSpace struct{}
+
+func (SwitchCaseSpace) Name() string {
+ return `PhpCsFixer\Fixer\ControlStructure\SwitchCaseSpaceFixer`
+}
+
+func (SwitchCaseSpace) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/SwitchCaseSpaceFixer.php"
+}
+
+func (SwitchCaseSpace) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ if t.Kind != token.Keyword || !caseKeywords[strings.ToLower(t.Value)] || inClassLikeBody(s, i) {
+ continue
+ }
+ idx, _, ok := caseTerminator(s, i)
+ if !ok {
+ continue
+ }
+ if idx > 0 && s.At(idx-1).Kind == token.Whitespace && !hasNewline(s.At(idx-1).Value) {
+ s.RemoveAt(idx - 1)
+ changed = true
+ }
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/symplify_docblock.go b/blink/internal/fixer/rules/symplify_docblock.go
new file mode 100644
index 0000000000..8980782c48
--- /dev/null
+++ b/blink/internal/fixer/rules/symplify_docblock.go
@@ -0,0 +1,215 @@
+package rules
+
+import (
+ "regexp"
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// Shared base for Symplify's single-task @param/@return/@var doc-block fixers
+// (AbstractDocBlockFixer): iterate every comment/doc-comment carrying a
+// param/return/var tag, transform it, and set the result (or drop an emptied
+// block). Priority -37 upstream.
+
+var (
+ docBlockTypeAnnotationRe = regexp.MustCompile(`@(psalm-|phpstan-)?(param|return|var)`)
+ docBlockEmptyRe = regexp.MustCompile(`/\*\*|\*/|\*|\s`)
+)
+
+// docBlockFixerCandidate mirrors AbstractDocBlockFixer::isCandidate.
+func docBlockFixerCandidate(s *tokens.Stream) bool {
+ hasComment := false
+ for i := 0; i < s.Len(); i++ {
+ if k := s.At(i).Kind; k == token.Comment || k == token.DocComment {
+ hasComment = true
+ break
+ }
+ }
+ if !hasComment {
+ return false
+ }
+ for i := 0; i < s.Len(); i++ {
+ if s.At(i).Kind == token.Keyword && strings.EqualFold(s.At(i).Value, "callable") {
+ if i+3 < s.Len() && s.At(i+3).Kind == token.Punct && s.At(i+3).Value == ")" {
+ return false
+ }
+ }
+ }
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ if t.Kind == token.Variable || (t.Kind == token.Keyword && strings.EqualFold(t.Value, "function")) {
+ return true
+ }
+ }
+ return false
+}
+
+func docBlockIsEmpty(content string) bool {
+ return docBlockEmptyRe.ReplaceAllString(content, "") == ""
+}
+
+// docBlockFixerApply runs transform over each param/return/var comment, mirroring
+// AbstractDocBlockFixer::fix (set as doc comment, or drop an emptied block).
+func docBlockFixerApply(s *tokens.Stream, transform func(content string, s *tokens.Stream, i int) string) bool {
+ if !docBlockFixerCandidate(s) {
+ return false
+ }
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ if t.Kind != token.Comment && t.Kind != token.DocComment {
+ continue
+ }
+ if !docBlockTypeAnnotationRe.MatchString(t.Value) {
+ continue
+ }
+ newContent := transform(t.Value, s, i)
+ if newContent == t.Value {
+ continue
+ }
+ if docBlockIsEmpty(newContent) {
+ followWS := i+1 < s.Len() && s.At(i+1).Kind == token.Whitespace
+ s.RemoveAt(i)
+ if followWS {
+ s.RemoveAt(i)
+ }
+ i--
+ changed = true
+ continue
+ }
+ s.Set(i, token.Token{Kind: token.DocComment, Value: newContent})
+ changed = true
+ }
+ return changed
+}
+
+// --- DoubleAsteriskInlineVar: "/* @var" -> "/** @var" on a single-asterisk comment ---
+
+type DoubleAsteriskInlineVar struct{}
+
+var doubleAsteriskStartRe = regexp.MustCompile(`^/\*(\n?\s+@(?:psalm-|phpstan-)?var)`)
+
+func (DoubleAsteriskInlineVar) Name() string {
+ return `Symplify\CodingStandard\Fixer\Commenting\DoubleAsteriskInlineVarFixer`
+}
+
+func (DoubleAsteriskInlineVar) SourceURL() string {
+ return "https://github.com/symplify/coding-standard/blob/main/src/Fixer/Commenting/DoubleAsteriskInlineVarFixer.php"
+}
+
+func (DoubleAsteriskInlineVar) Fix(s *tokens.Stream) bool {
+ return docBlockFixerApply(s, func(content string, s *tokens.Stream, i int) string {
+ if s.At(i).Kind != token.Comment {
+ return content
+ }
+ return doubleAsteriskStartRe.ReplaceAllString(content, "/**$1")
+ })
+}
+
+// --- FixTagTypo: "@returns/@params/@vars" -> singular ---
+
+type FixTagTypo struct{}
+
+var fixTagTypoRe = regexp.MustCompile(`@((?:psalm-|phpstan-)?(?:param|return|var))s\b`)
+
+func (FixTagTypo) Name() string {
+ return `Symplify\CodingStandard\Fixer\Commenting\FixTagTypoFixer`
+}
+
+func (FixTagTypo) SourceURL() string {
+ return "https://github.com/ecsphp/ecs-src/blob/main/packages/coding-standard/src/Fixer/Commenting/FixTagTypoFixer.php"
+}
+
+func (FixTagTypo) Fix(s *tokens.Stream) bool {
+ return docBlockFixerApply(s, func(content string, _ *tokens.Stream, _ int) string {
+ return fixTagTypoRe.ReplaceAllString(content, "@$1")
+ })
+}
+
+// --- TypeToVarTag: "@type" -> "@var" (+ single-asterisk to double) ---
+
+type TypeToVarTag struct{}
+
+var (
+ typeTagRe = regexp.MustCompile(`@type\b`)
+ typeToVarSingleAsteriskRe = regexp.MustCompile(`^/\*(\n?\s+@var)`)
+)
+
+func (TypeToVarTag) Name() string {
+ return `Symplify\CodingStandard\Fixer\Commenting\TypeToVarTagFixer`
+}
+
+func (TypeToVarTag) SourceURL() string {
+ return "https://github.com/ecsphp/ecs-src/blob/main/packages/coding-standard/src/Fixer/Commenting/TypeToVarTagFixer.php"
+}
+
+func (TypeToVarTag) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ if t.Kind != token.Comment && t.Kind != token.DocComment {
+ continue
+ }
+ if !typeTagRe.MatchString(t.Value) {
+ continue
+ }
+ nc := typeTagRe.ReplaceAllString(t.Value, "@var")
+ nc = typeToVarSingleAsteriskRe.ReplaceAllString(nc, "/**$1")
+ s.Set(i, token.Token{Kind: token.DocComment, Value: nc})
+ changed = true
+ }
+ return changed
+}
+
+// --- MergeDocBlockStart: single-asterisk "/*" doc block -> "/**", drop empty "*"-only lines ---
+
+type MergeDocBlockStart struct{}
+
+var (
+ mergeDocEmptyAsteriskLineRe = regexp.MustCompile(`^\s*\*\s*$`)
+ mergeDocPhpdocTagLineRe = regexp.MustCompile(`\n\s*\*\s*@`)
+ mergeDocOpenerRe = regexp.MustCompile(`^/\*`)
+)
+
+func (MergeDocBlockStart) Name() string {
+ return `Symplify\CodingStandard\Fixer\Commenting\MergeDocBlockStartFixer`
+}
+
+func (MergeDocBlockStart) SourceURL() string {
+ return "https://github.com/ecsphp/ecs-src/blob/main/packages/coding-standard/src/Fixer/Commenting/MergeDocBlockStartFixer.php"
+}
+
+func (MergeDocBlockStart) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ if t.Kind != token.Comment {
+ continue
+ }
+ if !strings.HasPrefix(t.Value, "/*") || strings.HasPrefix(t.Value, "/**") {
+ continue
+ }
+ if !mergeDocPhpdocTagLineRe.MatchString(t.Value) {
+ continue
+ }
+ lines := strings.Split(t.Value, "\n")
+ kept := lines[:0:0]
+ removed := false
+ for k, line := range lines {
+ if k != 0 && mergeDocEmptyAsteriskLineRe.MatchString(line) {
+ removed = true
+ continue
+ }
+ kept = append(kept, line)
+ }
+ if !removed {
+ continue
+ }
+ kept[0] = mergeDocOpenerRe.ReplaceAllString(kept[0], "/**")
+ s.Set(i, token.Token{Kind: token.DocComment, Value: strings.Join(kept, "\n")})
+ changed = true
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/symplify_docblock2.go b/blink/internal/fixer/rules/symplify_docblock2.go
new file mode 100644
index 0000000000..c90b5d73ca
--- /dev/null
+++ b/blink/internal/fixer/rules/symplify_docblock2.go
@@ -0,0 +1,176 @@
+package rules
+
+import (
+ "regexp"
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// --- AddMissingVarName: "/** @var Type */" with no name -> add the next variable's name ---
+
+type AddMissingVarName struct{}
+
+var varWithoutNameRe = regexp.MustCompile(`^(/\*\* @(?:psalm-|phpstan-)?var )([\\\w|\[\]&-]+)(\s+\*/)$`)
+
+func (AddMissingVarName) Name() string {
+ return `Symplify\CodingStandard\Fixer\Commenting\AddMissingVarNameFixer`
+}
+
+func (AddMissingVarName) SourceURL() string {
+ return "https://github.com/symplify/coding-standard/blob/main/src/Fixer/Commenting/AddMissingVarNameFixer.php"
+}
+
+func (AddMissingVarName) Fix(s *tokens.Stream) bool {
+ return docBlockFixerApply(s, func(content string, s *tokens.Stream, i int) string {
+ m := varWithoutNameRe.FindStringSubmatch(content)
+ if m == nil {
+ return content
+ }
+ varTok := addMissingNextVariable(s, i)
+ if varTok == "" {
+ return content
+ }
+ return m[1] + m[2] + " " + varTok + m[3]
+ })
+}
+
+func addMissingNextVariable(s *tokens.Stream, i int) string {
+ n := sigNext(s, i)
+ if n < 0 || s.At(n).Kind != token.Variable {
+ return ""
+ }
+ if a := sigNext(s, n); a >= 0 && s.At(a).Kind == token.Punct && (s.At(a).Value == "->" || s.At(a).Value == "?->") {
+ return ""
+ }
+ return s.At(n).Value
+}
+
+// --- SingleLineInlineVarDocBlock: collapse a multiline inline @var above a variable ---
+
+type SingleLineInlineVarDocBlock struct{}
+
+var (
+ singleLineAsteriskStartRe = regexp.MustCompile(`^/\*\s+\*(\s+@(?:psalm-|phpstan-)?var)`)
+ singleLineSpaceRe = regexp.MustCompile(`\s+`)
+ singleLineAsteriskLeftRe = regexp.MustCompile(`(\*\*)(\s+\*)`)
+)
+
+func (SingleLineInlineVarDocBlock) Name() string {
+ return `Symplify\CodingStandard\Fixer\Commenting\SingleLineInlineVarDocBlockFixer`
+}
+
+func (SingleLineInlineVarDocBlock) SourceURL() string {
+ return "https://github.com/symplify/coding-standard/blob/main/src/Fixer/Commenting/SingleLineInlineVarDocBlockFixer.php"
+}
+
+func (SingleLineInlineVarDocBlock) Fix(s *tokens.Stream) bool {
+ return docBlockFixerApply(s, func(content string, s *tokens.Stream, i int) string {
+ if !singleLineIsVariableComment(s, i) {
+ return content
+ }
+ if strings.Count(content, "\n") > 2 {
+ return content
+ }
+ content = singleLineAsteriskStartRe.ReplaceAllString(content, "/**$1")
+ content = singleLineSpaceRe.ReplaceAllString(content, " ")
+ return singleLineAsteriskLeftRe.ReplaceAllString(content, "$1")
+ })
+}
+
+func singleLineIsVariableComment(s *tokens.Stream, i int) bool {
+ n := sigNext(s, i)
+ if n < 0 {
+ return false
+ }
+ nn := sigNext(s, n+2)
+ if nn < 0 {
+ return false
+ }
+ if s.At(nn).Kind == token.Keyword {
+ switch strings.ToLower(s.At(nn).Value) {
+ case "static", "function":
+ return false
+ }
+ }
+ return s.At(n).Kind == token.Variable
+}
+
+// --- RemoveSuperfluousReturnName: "@return Type $var" -> "@return Type" ---
+
+type RemoveSuperfluousReturnName struct{}
+
+var (
+ returnVarNameRe = regexp.MustCompile(`(@(?:psalm-|phpstan-)?return)(\s+[|\\\w]+)?(\s+)(\$[\w]+)`)
+ docVariableNameRe = regexp.MustCompile(`\$\w+`)
+)
+
+func (RemoveSuperfluousReturnName) Name() string {
+ return `Symplify\CodingStandard\Fixer\Commenting\RemoveSuperfluousReturnNameFixer`
+}
+
+func (RemoveSuperfluousReturnName) SourceURL() string {
+ return "https://github.com/symplify/coding-standard/blob/main/src/Fixer/Commenting/RemoveSuperfluousReturnNameFixer.php"
+}
+
+func (RemoveSuperfluousReturnName) Fix(s *tokens.Stream) bool {
+ return docBlockFixerApply(s, func(content string, _ *tokens.Stream, _ int) string {
+ lines := splitDocLines(content)
+ for idx, line := range lines {
+ m := returnVarNameRe.FindStringSubmatch(line)
+ if m == nil {
+ continue
+ }
+ if m[4] == "$this" {
+ continue
+ }
+ if len(docVariableNameRe.FindAllString(line, -1)) >= 2 {
+ continue
+ }
+ lines[idx] = returnVarNameRe.ReplaceAllString(line, "$1$2")
+ }
+ return strings.Join(lines, "")
+ })
+}
+
+// --- RemoveSuperfluousVarName: property "@var Type $prop" -> "@var Type" ($this -> self) ---
+
+type RemoveSuperfluousVarName struct{}
+
+var varVariableNameRe = regexp.MustCompile(`(@(?:psalm-|phpstan-)?var)(\s+[|\\\w]+)?(\s+)(\$[\w]+)`)
+
+func (RemoveSuperfluousVarName) Name() string {
+ return `Symplify\CodingStandard\Fixer\Commenting\RemoveSuperfluousVarNameFixer`
+}
+
+func (RemoveSuperfluousVarName) SourceURL() string {
+ return "https://github.com/symplify/coding-standard/blob/main/src/Fixer/Commenting/RemoveSuperfluousVarNameFixer.php"
+}
+
+func (RemoveSuperfluousVarName) Fix(s *tokens.Stream) bool {
+ return docBlockFixerApply(s, func(content string, s *tokens.Stream, i int) string {
+ n := sigNext(s, i)
+ if n < 0 || s.At(n).Kind != token.Keyword {
+ return content
+ }
+ switch strings.ToLower(s.At(n).Value) {
+ case "public", "protected", "private", "static":
+ default:
+ return content
+ }
+ lines := splitDocLines(content)
+ for idx, line := range lines {
+ m := varVariableNameRe.FindStringSubmatch(line)
+ if m == nil {
+ continue
+ }
+ if strings.HasSuffix(m[4], "$this") && m[4] == "$this" {
+ lines[idx] = varVariableNameRe.ReplaceAllString(line, "$1 self")
+ } else {
+ lines[idx] = varVariableNameRe.ReplaceAllString(line, "$1$2")
+ }
+ }
+ return strings.Join(lines, "")
+ })
+}
diff --git a/blink/internal/fixer/rules/symplify_docblock3.go b/blink/internal/fixer/rules/symplify_docblock3.go
new file mode 100644
index 0000000000..b6e24a9782
--- /dev/null
+++ b/blink/internal/fixer/rules/symplify_docblock3.go
@@ -0,0 +1,175 @@
+package rules
+
+import (
+ "regexp"
+ "sort"
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// --- FixParamNameTypo: correct a @param variable name to match the real argument ---
+
+type FixParamNameTypo struct{}
+
+var fpntParamNameRe = regexp.MustCompile(`@param(\s+)(callable)?(.*?)(\$\w+)`)
+
+// fpntParamAnnRe gates a line to a real doc-block @param annotation, mirroring
+// PhpCsFixer DocBlock::getAnnotationsOfType (a tag line has `* ` before `@`).
+// This stops mid-text "@param" inside // comments from counting as a param.
+var fpntParamAnnRe = regexp.MustCompile(`\*\s*@param`)
+
+func (FixParamNameTypo) Name() string {
+ return `Symplify\CodingStandard\Fixer\Commenting\FixParamNameTypoFixer`
+}
+
+func (FixParamNameTypo) SourceURL() string {
+ return "https://github.com/symplify/coding-standard/blob/main/src/Fixer/Commenting/FixParamNameTypoFixer.php"
+}
+
+func (FixParamNameTypo) Fix(s *tokens.Stream) bool {
+ return docBlockFixerApply(s, func(content string, s *tokens.Stream, i int) string {
+ argumentNames := fpntResolveArgNames(s, i)
+ if len(argumentNames) == 0 {
+ return content
+ }
+ paramNames := fpntParamNames(content) // map index->name
+ missArg := map[int]string{}
+ for k, arg := range argumentNames {
+ found := -1
+ for _, pk := range sortedKeys(paramNames) {
+ if paramNames[pk] == arg {
+ found = pk
+ break
+ }
+ }
+ if found >= 0 {
+ delete(paramNames, found)
+ } else {
+ missArg[k] = arg
+ }
+ }
+ if len(missArg) == 0 || len(paramNames) == 0 {
+ return content
+ }
+ return fpntFixTypos(argumentNames, missArg, paramNames, content)
+ })
+}
+
+// fpntResolveArgNames mirrors DocblockRelatedParamNamesResolver: the parameter
+// variable names of the next function/fn after the doc block.
+func fpntResolveArgNames(s *tokens.Stream, docPos int) []string {
+ fn := -1
+ for j := docPos + 1; j < s.Len(); j++ {
+ if s.At(j).Kind == token.Keyword {
+ lv := strings.ToLower(s.At(j).Value)
+ if lv == "function" || lv == "fn" {
+ fn = j
+ break
+ }
+ }
+ }
+ if fn < 0 {
+ return nil
+ }
+ open := nextPunctOfKind(s, fn, "(")
+ if open < 0 {
+ return nil
+ }
+ closeIdx := s.MatchForward(open)
+ if closeIdx < 0 {
+ return nil
+ }
+ var names []string
+ depth := 0
+ for i := open + 1; i < closeIdx; i++ {
+ t := s.At(i)
+ if t.Kind == token.Punct {
+ switch t.Value {
+ case "(", "[", "{":
+ depth++
+ case ")", "]", "}":
+ depth--
+ }
+ continue
+ }
+ if depth == 0 && t.Kind == token.Variable {
+ names = append(names, t.Value)
+ }
+ }
+ return names
+}
+
+func fpntParamNames(content string) map[int]string {
+ out := map[int]string{}
+ idx := 0
+ for line := range strings.SplitSeq(content, "\n") {
+ if !fpntParamAnnRe.MatchString(line) {
+ continue
+ }
+ m := fpntParamNameRe.FindStringSubmatch(line)
+ if m == nil {
+ continue
+ }
+ if m[2] == "callable" {
+ continue
+ }
+ out[idx] = m[4]
+ idx++
+ }
+ return out
+}
+
+func fpntFixTypos(argumentNames []string, missArg, paramNames map[int]string, content string) string {
+ replaced := map[string]bool{}
+ for _, a := range argumentNames {
+ replaced[a] = false
+ }
+ for _, k := range sortedKeys(missArg) {
+ argName := missArg[k]
+ typoName, ok := paramNames[k]
+ if !ok {
+ continue
+ }
+ re := regexp.MustCompile(`@param(.*?)(` + regexp.QuoteMeta(typoName) + `\b)`)
+ content = replaceFirstFunc(content, re, func(groups []string) string {
+ paramName := groups[2]
+ if done, exists := replaced[paramName]; exists && !done {
+ replaced[paramName] = true
+ return groups[0]
+ }
+ return "@param" + groups[1] + argName
+ })
+ }
+ return content
+}
+
+// replaceFirstFunc replaces every match (like preg_replace_callback) using the
+// submatch groups.
+func replaceFirstFunc(content string, re *regexp.Regexp, fn func(groups []string) string) string {
+ var b strings.Builder
+ last := 0
+ for _, loc := range re.FindAllStringSubmatchIndex(content, -1) {
+ groups := make([]string, len(loc)/2)
+ for g := 0; g < len(loc)/2; g++ {
+ if loc[2*g] >= 0 {
+ groups[g] = content[loc[2*g]:loc[2*g+1]]
+ }
+ }
+ b.WriteString(content[last:loc[0]])
+ b.WriteString(fn(groups))
+ last = loc[1]
+ }
+ b.WriteString(content[last:])
+ return b.String()
+}
+
+func sortedKeys(m map[int]string) []int {
+ ks := make([]int, 0, len(m))
+ for k := range m {
+ ks = append(ks, k)
+ }
+ sort.Ints(ks)
+ return ks
+}
diff --git a/blink/internal/fixer/rules/symplify_docblock_fixers.go b/blink/internal/fixer/rules/symplify_docblock_fixers.go
new file mode 100644
index 0000000000..de1498bc96
--- /dev/null
+++ b/blink/internal/fixer/rules/symplify_docblock_fixers.go
@@ -0,0 +1,543 @@
+package rules
+
+import (
+ "regexp"
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// Shared machinery for Symplify's single-task @param/@return/@var doc block
+// fixers (AbstractDocBlockFixer): iterate doc/comment tokens carrying a
+// param/return/var annotation, transform the content, and drop the whole block
+// when it becomes empty.
+
+var docTypeAnnotationRe = regexp.MustCompile(`@(psalm-|phpstan-)?(param|return|var)`)
+
+// applyDocBlockFixer runs process over each doc/comment token that holds a
+// @param/@return/@var annotation, mirroring AbstractDocBlockFixer::fix.
+func applyDocBlockFixer(s *tokens.Stream, process func(string) string) bool {
+ if !dbfCandidateW4(s) {
+ return false
+ }
+ changed := false
+ for i := s.Len() - 1; i >= 0; i-- {
+ t := s.At(i)
+ if t.Kind != token.DocComment && t.Kind != token.Comment {
+ continue
+ }
+ if !docTypeAnnotationRe.MatchString(t.Value) {
+ continue
+ }
+ nc := process(t.Value)
+ if nc == t.Value {
+ continue
+ }
+ if isEmptyDocBlock(nc) {
+ s.RemoveAt(i)
+ if i < s.Len() && s.At(i).Kind == token.Whitespace {
+ s.RemoveAt(i)
+ }
+ } else {
+ s.Set(i, token.Token{Kind: token.DocComment, Value: nc})
+ }
+ changed = true
+ }
+ return changed
+}
+
+// dbfCandidateW4 mirrors AbstractDocBlockFixer::isCandidate.
+func dbfCandidateW4(s *tokens.Stream) bool {
+ hasComment, hasFuncOrVar := false, false
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ switch t.Kind {
+ case token.DocComment, token.Comment:
+ hasComment = true
+ case token.Variable:
+ hasFuncOrVar = true
+ case token.Keyword:
+ if strings.EqualFold(t.Value, "function") {
+ hasFuncOrVar = true
+ }
+ // a "callable(...)" typehint disables the fixer
+ if strings.EqualFold(t.Value, "callable") && i+3 < s.Len() && s.At(i+3).Value == ")" {
+ return false
+ }
+ }
+ }
+ return hasComment && hasFuncOrVar
+}
+
+// isEmptyDocBlock reports whether only "/**", "*/", "*" and whitespace remain.
+func isEmptyDocBlock(content string) bool {
+ r := strings.NewReplacer("/**", "", "*/", "", "*", "")
+ stripped := r.Replace(content)
+ return strings.TrimSpace(stripped) == ""
+}
+
+// --- RemoveDeadParam: drop a @param line that carries only a name, no type ---
+
+var deadParamLineRe = regexp.MustCompile(`(?m)@(?:psalm-|phpstan-)?param\s+\$\w+\s*$`)
+
+type RemoveDeadParam struct{}
+
+func (RemoveDeadParam) Name() string {
+ return `Symplify\CodingStandard\Fixer\Commenting\RemoveDeadParamFixer`
+}
+
+func (RemoveDeadParam) SourceURL() string {
+ return "https://github.com/ecsphp/ecs-src/blob/main/packages/coding-standard/src/Fixer/Commenting/RemoveDeadParamFixer.php"
+}
+
+func (RemoveDeadParam) Fix(s *tokens.Stream) bool {
+ return applyDocBlockFixer(s, func(content string) string {
+ return removeDocLinesMatching(content, deadParamLineRe)
+ })
+}
+
+// --- RemoveDeadVarThis: drop an inline @var line typing $this ---
+
+var deadVarThisLineRe = regexp.MustCompile(`@(?:psalm-|phpstan-)?var\b[^\n]*\$this\b`)
+
+type RemoveDeadVarThis struct{}
+
+func (RemoveDeadVarThis) Name() string {
+ return `Symplify\CodingStandard\Fixer\Commenting\RemoveDeadVarThisFixer`
+}
+
+func (RemoveDeadVarThis) SourceURL() string {
+ return "https://github.com/ecsphp/ecs-src/blob/main/packages/coding-standard/src/Fixer/Commenting/RemoveDeadVarThisFixer.php"
+}
+
+func (RemoveDeadVarThis) Fix(s *tokens.Stream) bool {
+ return applyDocBlockFixer(s, func(content string) string {
+ return removeDocLinesMatching(content, deadVarThisLineRe)
+ })
+}
+
+// --- RemoveParamNameReference: drop "&" from a @param variable name ---
+
+var paramNameRefRe = regexp.MustCompile(`(@param.*?)&(\$\w+)`)
+
+type RemoveParamNameReference struct{}
+
+func (RemoveParamNameReference) Name() string {
+ return `Symplify\CodingStandard\Fixer\Commenting\RemoveParamNameReferenceFixer`
+}
+
+func (RemoveParamNameReference) SourceURL() string {
+ return "https://github.com/ecsphp/ecs-src/blob/main/packages/coding-standard/src/Fixer/Commenting/RemoveParamNameReferenceFixer.php"
+}
+
+func (RemoveParamNameReference) Fix(s *tokens.Stream) bool {
+ return applyDocBlockFixer(s, func(content string) string {
+ return paramNameRefRe.ReplaceAllString(content, "$1$2")
+ })
+}
+
+// --- SwitchedTypeAndName: reorder "@param $name Type" -> "@param Type $name" ---
+
+var switchedTypeNameRe = regexp.MustCompile(`(?m)@((?:psalm-|phpstan-)?(?:param|var))(\s+)(\$\w+)(\s+)((?:[|\\\w\[\]]|<[^<>]*>)+)(\s+\S.*)?$`)
+var typeSyntaxRe = regexp.MustCompile(`[\\\[\]<>|]`)
+
+var switchedKnownPrimitiveTypes = map[string]bool{
+ "string": true, "int": true, "integer": true, "float": true, "bool": true,
+ "boolean": true, "array": true, "object": true, "callable": true, "iterable": true,
+ "mixed": true, "void": true, "null": true, "false": true, "true": true, "self": true,
+ "static": true, "parent": true, "resource": true, "scalar": true, "never": true,
+ "number": true, "double": true,
+}
+
+type SwitchedTypeAndName struct{}
+
+func (SwitchedTypeAndName) Name() string {
+ return `Symplify\CodingStandard\Fixer\Commenting\SwitchedTypeAndNameFixer`
+}
+
+func (SwitchedTypeAndName) SourceURL() string {
+ return "https://github.com/ecsphp/ecs-src/blob/main/packages/coding-standard/src/Fixer/Commenting/SwitchedTypeAndNameFixer.php"
+}
+
+func (SwitchedTypeAndName) Fix(s *tokens.Stream) bool {
+ return applyDocBlockFixer(s, func(content string) string {
+ lines := splitDocLines(content)
+ changed := false
+ for i, l := range lines {
+ m := switchedTypeNameRe.FindStringSubmatch(l)
+ if m == nil || m[3] == "" || m[5] == "" || !switchedIsKnownType(m[5]) {
+ continue
+ }
+ lines[i] = switchedTypeNameRe.ReplaceAllString(l, "@${1}${2}${5}${4}${3}${6}")
+ changed = true
+ }
+ if !changed {
+ return content
+ }
+ return strings.Join(lines, "")
+ })
+}
+
+func switchedIsKnownType(t string) bool {
+ if typeSyntaxRe.MatchString(t) {
+ return true
+ }
+ return switchedKnownPrimitiveTypes[strings.ToLower(t)]
+}
+
+// --- RemovePHPStormAnnotation: drop a "Created by PhpStorm" doc block ---
+
+var createdByPhpStormRe = regexp.MustCompile(`(?is)/\*\*\s+\*\s+Created by PHPStorm(.*?)\*/`)
+
+type RemovePHPStormAnnotation struct{}
+
+func (RemovePHPStormAnnotation) Name() string {
+ return `Symplify\CodingStandard\Fixer\Annotation\RemovePHPStormAnnotationFixer`
+}
+
+func (RemovePHPStormAnnotation) SourceURL() string {
+ return "https://github.com/ecsphp/ecs-src/blob/main/packages/coding-standard/src/Fixer/Annotation/RemovePHPStormAnnotationFixer.php"
+}
+
+func (RemovePHPStormAnnotation) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := s.Len() - 1; i >= 0; i-- {
+ t := s.At(i)
+ if t.Kind != token.DocComment && t.Kind != token.Comment {
+ continue
+ }
+ if createdByPhpStormRe.ReplaceAllString(t.Value, "") == "" {
+ s.RemoveAt(i)
+ changed = true
+ }
+ }
+ return changed
+}
+
+// --- shared name resolvers (mirror Symplify Naming resolvers) ---
+
+// methodNameAfter returns the name of the first named function after commentIdx.
+func methodNameAfter(s *tokens.Stream, commentIdx int) string {
+ for i := commentIdx + 1; i < s.Len(); i++ {
+ if s.At(i).Kind == token.Keyword && strings.EqualFold(s.At(i).Value, "function") {
+ n := sigNext(s, i)
+ if n >= 0 && s.At(n).Kind == token.Ident {
+ return s.At(n).Value
+ }
+ }
+ }
+ return ""
+}
+
+// propertyNameAfter returns the first variable after commentIdx.
+func propertyNameAfter(s *tokens.Stream, commentIdx int) string {
+ for i := commentIdx + 1; i < s.Len(); i++ {
+ if s.At(i).Kind == token.Variable {
+ return s.At(i).Value
+ }
+ }
+ return ""
+}
+
+var camelBoundaryRe = regexp.MustCompile(`([a-z])([A-Z])`)
+var wordRe = regexp.MustCompile(`[a-zA-Z]+`)
+
+// resolveWords splits camelCase, lowercases and extracts word runs.
+func resolveWords(value string) []string {
+ spaced := camelBoundaryRe.ReplaceAllString(value, "$1 $2")
+ return wordRe.FindAllString(strings.ToLower(spaced), -1)
+}
+
+// --- RemovePropertyVariableNameDescription: drop trailing "$name" from @var ---
+
+var varTagRe = regexp.MustCompile(`@(?:psalm-|phpstan-)?var`)
+
+type RemovePropertyVariableNameDescription struct{}
+
+func (RemovePropertyVariableNameDescription) Name() string {
+ return `Symplify\CodingStandard\Fixer\Annotation\RemovePropertyVariableNameDescriptionFixer`
+}
+
+func (RemovePropertyVariableNameDescription) SourceURL() string {
+ return "https://github.com/ecsphp/ecs-src/blob/main/packages/coding-standard/src/Fixer/Annotation/RemovePropertyVariableNameDescriptionFixer.php"
+}
+
+func (RemovePropertyVariableNameDescription) Fix(s *tokens.Stream) bool {
+ if !streamHasVariable(s) || !streamHasComment(s) {
+ return false
+ }
+ changed := false
+ for i := s.Len() - 1; i >= 0; i-- {
+ t := s.At(i)
+ if t.Kind != token.DocComment && t.Kind != token.Comment {
+ continue
+ }
+ propertyName := propertyNameAfter(s, i)
+ if propertyName == "" {
+ continue
+ }
+ if len(varTagRe.FindAllString(t.Value, -1)) != 1 {
+ continue
+ }
+ lines := strings.Split(t.Value, "\n")
+ lineChanged := false
+ suffix := " " + propertyName
+ for k, line := range lines {
+ if !strings.HasSuffix(line, suffix) || !varTagRe.MatchString(line) {
+ continue
+ }
+ lines[k] = strings.TrimRight(line[:len(line)-len(suffix)], " \t\n\v\f\r")
+ lineChanged = true
+ }
+ if lineChanged {
+ s.Set(i, token.Token{Kind: token.DocComment, Value: strings.Join(lines, "\n")})
+ changed = true
+ }
+ }
+ return changed
+}
+
+// --- RemoveMethodNameDuplicateDescription ---
+
+var articlesRe = regexp.MustCompile(`(?i)\b(?:a|an|the)\b`)
+var thirdPersonSRe = regexp.MustCompile(`^([\s*]*\w+?)s\b`)
+var spaceNlRe = regexp.MustCompile(`[\s\n]+`)
+
+type RemoveMethodNameDuplicateDescription struct{}
+
+func (RemoveMethodNameDuplicateDescription) Name() string {
+ return `Symplify\CodingStandard\Fixer\Annotation\RemoveMethodNameDuplicateDescriptionFixer`
+}
+
+func (RemoveMethodNameDuplicateDescription) SourceURL() string {
+ return "https://github.com/ecsphp/ecs-src/blob/main/packages/coding-standard/src/Fixer/Annotation/RemoveMethodNameDuplicateDescriptionFixer.php"
+}
+
+func (RemoveMethodNameDuplicateDescription) Fix(s *tokens.Stream) bool {
+ if !streamHasFunction(s) || !streamHasComment(s) {
+ return false
+ }
+ changed := false
+ for i := s.Len() - 1; i >= 0; i-- {
+ t := s.At(i)
+ if t.Kind != token.DocComment && t.Kind != token.Comment {
+ continue
+ }
+ methodName := methodNameAfter(s, i)
+ if methodName == "" {
+ continue
+ }
+ lines := strings.Split(t.Value, "\n")
+ var kept []string
+ lineChanged := false
+ for _, line := range lines {
+ l := articlesRe.ReplaceAllString(line, "")
+ l = thirdPersonSRe.ReplaceAllString(l, "$1")
+ spaceless := spaceNlRe.ReplaceAllString(l, "")
+ spaceless = strings.TrimRight(spaceless, ".!")
+ if isDuplicateDescription(spaceless, methodName) {
+ lineChanged = true
+ continue
+ }
+ kept = append(kept, line)
+ }
+ if lineChanged {
+ s.Set(i, token.Token{Kind: token.DocComment, Value: strings.Join(kept, "\n")})
+ changed = true
+ }
+ }
+ return changed
+}
+
+func isDuplicateDescription(spaceless, methodName string) bool {
+ desc := strings.ToLower(strings.TrimLeft(spaceless, "*"))
+ mn := strings.ToLower(methodName)
+ if desc == mn {
+ return true
+ }
+ dn := getterSetterNoun(desc)
+ return dn != "" && dn == getterSetterNoun(mn)
+}
+
+func getterSetterNoun(v string) string {
+ for _, p := range []string{"get", "set"} {
+ if strings.HasPrefix(v, p) && len(v) > len(p) {
+ return v[len(p):]
+ }
+ }
+ return ""
+}
+
+// --- RemoveEventSubscriberDescription ---
+
+type RemoveEventSubscriberDescription struct{}
+
+func (RemoveEventSubscriberDescription) Name() string {
+ return `Symplify\CodingStandard\Fixer\Annotation\RemoveEventSubscriberDescriptionFixer`
+}
+
+func (RemoveEventSubscriberDescription) SourceURL() string {
+ return "https://github.com/ecsphp/ecs-src/blob/main/packages/coding-standard/src/Fixer/Annotation/RemoveEventSubscriberDescriptionFixer.php"
+}
+
+func (RemoveEventSubscriberDescription) Fix(s *tokens.Stream) bool {
+ if !streamHasFunction(s) || !streamHasComment(s) {
+ return false
+ }
+ changed := false
+ for i := s.Len() - 1; i >= 0; i-- {
+ t := s.At(i)
+ if t.Kind != token.DocComment && t.Kind != token.Comment {
+ continue
+ }
+ fnIdx := nextFunctionIndex(s, i)
+ if fnIdx < 0 || !isPublicBetween(s, i, fnIdx) {
+ continue
+ }
+ methodName := methodNameAfter(s, i)
+ if methodName == "" {
+ continue
+ }
+ lines := strings.Split(t.Value, "\n")
+ var kept []string
+ lineChanged := false
+ for _, line := range lines {
+ if isEventDescriptionLine(line, methodName) {
+ lineChanged = true
+ continue
+ }
+ kept = append(kept, line)
+ }
+ if !lineChanged {
+ continue
+ }
+ if isEmptyDocblockLines(kept) {
+ s.RemoveAt(i)
+ } else {
+ s.Set(i, token.Token{Kind: token.DocComment, Value: strings.Join(kept, "\n")})
+ }
+ changed = true
+ }
+ return changed
+}
+
+func nextFunctionIndex(s *tokens.Stream, commentIdx int) int {
+ for i := commentIdx + 1; i < s.Len(); i++ {
+ if s.At(i).Kind == token.Keyword && strings.EqualFold(s.At(i).Value, "function") {
+ return i
+ }
+ }
+ return -1
+}
+
+func isPublicBetween(s *tokens.Stream, commentIdx, fnIdx int) bool {
+ for i := commentIdx + 1; i < fnIdx; i++ {
+ if s.At(i).Kind == token.Keyword {
+ lv := strings.ToLower(s.At(i).Value)
+ if lv == "private" || lv == "protected" {
+ return false
+ }
+ }
+ }
+ return true
+}
+
+func isEventDescriptionLine(line, methodName string) bool {
+ words := resolveWords(line)
+ hasEvent := false
+ var descWords []string
+ for _, w := range words {
+ if w == "event" {
+ hasEvent = true
+ continue
+ }
+ descWords = append(descWords, w)
+ }
+ if !hasEvent || len(descWords) == 0 {
+ return false
+ }
+ var methodWords []string
+ for _, w := range resolveWords(methodName) {
+ if w != "on" {
+ methodWords = append(methodWords, w)
+ }
+ }
+ sortStrings(descWords)
+ sortStrings(methodWords)
+ return equalStrings(descWords, methodWords)
+}
+
+func isEmptyDocblockLines(lines []string) bool {
+ joined := strings.Join(lines, "")
+ for _, r := range joined {
+ if r != '/' && r != '*' && r != ' ' && r != '\t' && r != '\n' && r != '\r' && r != '\v' && r != '\f' {
+ return false
+ }
+ }
+ return true
+}
+
+func sortStrings(a []string) {
+ for i := 1; i < len(a); i++ {
+ for j := i; j > 0 && a[j] < a[j-1]; j-- {
+ a[j], a[j-1] = a[j-1], a[j]
+ }
+ }
+}
+
+func equalStrings(a, b []string) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for i := range a {
+ if a[i] != b[i] {
+ return false
+ }
+ }
+ return true
+}
+
+func streamHasFunction(s *tokens.Stream) bool {
+ for i := 0; i < s.Len(); i++ {
+ if s.At(i).Kind == token.Keyword && strings.EqualFold(s.At(i).Value, "function") {
+ return true
+ }
+ }
+ return false
+}
+
+func streamHasVariable(s *tokens.Stream) bool {
+ for i := 0; i < s.Len(); i++ {
+ if s.At(i).Kind == token.Variable {
+ return true
+ }
+ }
+ return false
+}
+
+func streamHasComment(s *tokens.Stream) bool {
+ for i := 0; i < s.Len(); i++ {
+ if k := s.At(i).Kind; k == token.DocComment || k == token.Comment {
+ return true
+ }
+ }
+ return false
+}
+
+// removeDocLinesMatching removes each doc line whose content matches re,
+// mirroring DocBlock getLines()->remove().
+func removeDocLinesMatching(content string, re *regexp.Regexp) string {
+ lines := splitDocLines(content)
+ changed := false
+ for i, l := range lines {
+ if re.MatchString(l) {
+ lines[i] = ""
+ changed = true
+ }
+ }
+ if !changed {
+ return content
+ }
+ return strings.Join(lines, "")
+}
diff --git a/blink/internal/fixer/rules/symplify_docblock_test.go b/blink/internal/fixer/rules/symplify_docblock_test.go
new file mode 100644
index 0000000000..3ca776d62d
--- /dev/null
+++ b/blink/internal/fixer/rules/symplify_docblock_test.go
@@ -0,0 +1,112 @@
+package rules
+
+import (
+ "testing"
+
+ "blink/internal/fixer"
+)
+
+// TestDoubleAsteriskInlineVar promotes a single-asterisk inline /* @var */ to a docblock.
+func TestDoubleAsteriskInlineVar(t *testing.T) {
+ got, changed := apply(t, DoubleAsteriskInlineVar{}, " "$a ? $b : $c"). The elvis "?:" keeps the "?" and
+// ":" adjacent (one space before "?", one after ":"). Nullable types ("?int")
+// and null-coalescing ("??", "?->") are left untouched.
+type TernaryOperatorSpaces struct{}
+
+func (TernaryOperatorSpaces) Name() string {
+ return `PhpCsFixer\Fixer\Operator\TernaryOperatorSpacesFixer`
+}
+
+func (TernaryOperatorSpaces) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/TernaryOperatorSpacesFixer.php"
+}
+
+// sides records whether the before and after side of an operator token needs a
+// single space.
+type sides struct{ before, after bool }
+
+func (TernaryOperatorSpaces) Fix(s *tokens.Stream) bool {
+ mark := map[int]sides{}
+ for i := 0; i < s.Len(); i++ {
+ if s.At(i).Kind != token.Punct || s.At(i).Value != "?" {
+ continue
+ }
+ // a "?" in type position (return type ": ?int", param "(?int $x)",
+ // union "int|?string") is a nullable marker, never a ternary
+ if prev, ok := prevSignificant(s, i); ok {
+ switch prev.Value {
+ case ":", "(", ",", "|", "&":
+ continue
+ }
+ }
+ m := matchTernaryColon(s, i)
+ if m < 0 {
+ continue // nullable type or coalescing "?": not a ternary
+ }
+ if ternaryIsElvis(s, i, m) {
+ mark[i] = sides{before: true}
+ mark[m] = sides{after: true}
+ } else {
+ mark[i] = sides{before: true, after: true}
+ mark[m] = sides{before: true, after: true}
+ }
+ }
+ if len(mark) == 0 {
+ return false
+ }
+
+ changed := false
+ // Right-to-left so insertions at higher indices never shift a token that a
+ // lower original index still refers to.
+ for i := s.Len() - 1; i >= 0; i-- {
+ sp, ok := mark[i]
+ if !ok {
+ continue
+ }
+ if sp.after && i+1 < s.Len() {
+ next := s.At(i + 1)
+ if next.Kind == token.Whitespace {
+ if !hasNewline(next.Value) && next.Value != " " {
+ s.SetValue(i+1, " ")
+ changed = true
+ }
+ } else {
+ s.InsertAt(i+1, token.Token{Kind: token.Whitespace, Value: " "})
+ changed = true
+ }
+ }
+ if sp.before && i > 0 {
+ prev := s.At(i - 1)
+ if prev.Kind == token.Whitespace {
+ if !hasNewline(prev.Value) && prev.Value != " " {
+ s.SetValue(i-1, " ")
+ changed = true
+ }
+ } else {
+ s.InsertAt(i, token.Token{Kind: token.Whitespace, Value: " "})
+ changed = true
+ }
+ }
+ }
+ return changed
+}
+
+// matchTernaryColon returns the index of the ":" matching the ternary "?" at i,
+// or -1 when there is none (making the "?" a nullable-type marker). Nested
+// ternaries and bracketed groups are skipped; the search stops at the end of the
+// statement.
+func matchTernaryColon(s *tokens.Stream, i int) int {
+ depth := 0 // nested ternary depth
+ bracket := 0 // (), [], {} nesting
+ for j := i + 1; j < s.Len(); j++ {
+ t := s.At(j)
+ if t.Kind != token.Punct {
+ continue
+ }
+ switch t.Value {
+ case "{":
+ if bracket == 0 {
+ return -1 // a ternary cannot span a block; "?" was a type marker
+ }
+ bracket++
+ case "(", "[":
+ bracket++
+ case ")", "]", "}":
+ if bracket == 0 {
+ return -1 // closes an enclosing group before any ternary ":"
+ }
+ bracket--
+ case ";":
+ if bracket == 0 {
+ return -1 // statement ended, no ternary
+ }
+ case "?":
+ if bracket == 0 {
+ depth++
+ }
+ case ":":
+ if bracket == 0 {
+ if depth == 0 {
+ return j
+ }
+ depth--
+ }
+ }
+ }
+ return -1
+}
+
+// ternaryIsElvis reports whether the "?" at i and its colon at m form an elvis
+// operator, i.e. there is no operand between them.
+func ternaryIsElvis(s *tokens.Stream, i, m int) bool {
+ for k := i + 1; k < m; k++ {
+ if s.At(k).Kind != token.Whitespace {
+ return false
+ }
+ }
+ return true
+}
diff --git a/blink/internal/fixer/rules/ternary_to_null_coalescing.go b/blink/internal/fixer/rules/ternary_to_null_coalescing.go
new file mode 100644
index 0000000000..cdf8e637d7
--- /dev/null
+++ b/blink/internal/fixer/rules/ternary_to_null_coalescing.go
@@ -0,0 +1,177 @@
+package rules
+
+import (
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/TernaryToNullCoalescingFixer.php
+//
+// TernaryToNullCoalescing rewrites "isset($a) ? $a : $b" as "$a ?? $b" when the
+// single isset argument is exactly the ternary's true branch. Multi-argument
+// isset, a mismatched true branch, or a span containing comments are left alone.
+type TernaryToNullCoalescing struct{}
+
+func (TernaryToNullCoalescing) Name() string {
+ return `PhpCsFixer\Fixer\Operator\TernaryToNullCoalescingFixer`
+}
+
+func (TernaryToNullCoalescing) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/TernaryToNullCoalescingFixer.php"
+}
+
+func (TernaryToNullCoalescing) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ if t.Kind != token.Keyword || strings.ToLower(t.Value) != "isset" {
+ continue
+ }
+ b := sigNext(s, i)
+ if b < 0 || s.At(b).Kind != token.Punct || s.At(b).Value != "(" {
+ continue
+ }
+ c := s.MatchForward(b)
+ if c < 0 {
+ continue
+ }
+ d := sigNext(s, c)
+ if d < 0 || s.At(d).Kind != token.Punct || s.At(d).Value != "?" {
+ continue // not a ternary (also skips "??" and "?->", which are one token)
+ }
+ e := ternaryColon(s, d+1)
+ if e < 0 || sigNext(s, e) < 0 {
+ continue // no matching ":" or no default branch
+ }
+ if !spanClean(s, i, e) {
+ continue // a comment inside the span would be dropped
+ }
+ if hasTopLevelComma(s, b+1, c-1) {
+ continue // isset($a, $b) - not convertible
+ }
+ issetToks := sigSlice(s, b+1, c-1)
+ trueToks := sigSlice(s, d+1, e-1)
+ if len(issetToks) == 0 || !tokensEqualAt(s, issetToks, trueToks) {
+ continue
+ }
+ // replace "isset ( expr ) ? expr :" with "expr ?? "
+ repl := trimmedCopy(s, b+1, c-1)
+ repl = append(repl,
+ token.Token{Kind: token.Whitespace, Value: " "},
+ token.Token{Kind: token.Punct, Value: "??"},
+ token.Token{Kind: token.Whitespace, Value: " "},
+ )
+ end := e
+ if e+1 < s.Len() && s.At(e+1).Kind == token.Whitespace {
+ end = e + 1 // swallow the space after ":" so spacing stays single
+ }
+ s.ReplaceRange(i, end, repl)
+ changed = true
+ }
+ return changed
+}
+
+// ternaryColon returns the index of the ":" that closes the ternary begun by a
+// "?" before `from`, at bracket depth 0 and accounting for nested "?:".
+func ternaryColon(s *tokens.Stream, from int) int {
+ depth := 0
+ tern := 0
+ for j := from; j < s.Len(); j++ {
+ t := s.At(j)
+ if t.Kind != token.Punct {
+ continue
+ }
+ switch t.Value {
+ case "(", "[", "{":
+ depth++
+ case ")", "]", "}":
+ if depth == 0 {
+ return -1
+ }
+ depth--
+ case "?":
+ if depth == 0 {
+ tern++
+ }
+ case ":":
+ if depth == 0 {
+ if tern == 0 {
+ return j
+ }
+ tern--
+ }
+ case ";":
+ if depth == 0 {
+ return -1
+ }
+ }
+ }
+ return -1
+}
+
+// hasTopLevelComma reports whether [lo, hi] holds a comma at bracket depth 0.
+func hasTopLevelComma(s *tokens.Stream, lo, hi int) bool {
+ depth := 0
+ for j := lo; j <= hi && 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 {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// sigSlice returns the indices of the significant (non-trivia) tokens in [lo, hi].
+func sigSlice(s *tokens.Stream, lo, hi int) []int {
+ var out []int
+ for j := lo; j <= hi && j < s.Len(); j++ {
+ switch s.At(j).Kind {
+ case token.Whitespace, token.Comment, token.DocComment:
+ continue
+ }
+ out = append(out, j)
+ }
+ return out
+}
+
+// tokensEqualAt reports whether two significant-token index slices carry the
+// same kinds and values.
+func tokensEqualAt(s *tokens.Stream, a, b []int) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for k := range a {
+ ta, tb := s.At(a[k]), s.At(b[k])
+ if ta.Kind != tb.Kind || ta.Value != tb.Value {
+ return false
+ }
+ }
+ return true
+}
+
+// trimmedCopy deep-copies the tokens in [lo, hi], dropping edge whitespace.
+func trimmedCopy(s *tokens.Stream, lo, hi int) []token.Token {
+ for lo <= hi && s.At(lo).Kind == token.Whitespace {
+ lo++
+ }
+ for hi >= lo && s.At(hi).Kind == token.Whitespace {
+ hi--
+ }
+ out := make([]token.Token, 0, hi-lo+1)
+ for j := lo; j <= hi; j++ {
+ out = append(out, s.At(j))
+ }
+ return out
+}
diff --git a/blink/internal/fixer/rules/ternary_to_null_coalescing_test.go b/blink/internal/fixer/rules/ternary_to_null_coalescing_test.go
new file mode 100644
index 0000000000..77db49d388
--- /dev/null
+++ b/blink/internal/fixer/rules/ternary_to_null_coalescing_test.go
@@ -0,0 +1,25 @@
+package rules
+
+import "testing"
+
+func TestTernaryToNullCoalescing(t *testing.T) {
+ f := TernaryToNullCoalescing{}
+
+ cases := []struct {
+ src, want string
+ changed bool
+ }{
+ {`p) ? $o->p : 5;`, `p ?? 5;`, true},
+ {`sizeof($a); Foo\join($x); Bar::join($y);`); changed {
+ t.Fatal("method/namespaced/static must not change")
+ }
+ if _, changed := apply(t, NoAliasFunctions{}, `= 2 && (v[0] == '\'' || v[0] == '"') && v[len(v)-1] == v[0] {
+ return v[0]
+ }
+ return 0
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/AttributeNotation/AttributeBlockNoSpacesFixer.php
+//
+// AttributeBlockNoSpaces trims spaces just inside the outer brackets of a PHP 8
+// attribute: "#[ Foo ]" -> "#[Foo]". Inner content is left intact.
+type AttributeBlockNoSpaces struct{}
+
+func (AttributeBlockNoSpaces) Name() string {
+ return `PhpCsFixer\Fixer\AttributeNotation\AttributeBlockNoSpacesFixer`
+}
+
+func (AttributeBlockNoSpaces) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/AttributeNotation/AttributeBlockNoSpacesFixer.php"
+}
+
+func (AttributeBlockNoSpaces) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ if t.Kind != token.Comment || !strings.HasPrefix(t.Value, "#[") || !strings.HasSuffix(t.Value, "]") {
+ continue
+ }
+ inner := t.Value[2 : len(t.Value)-1]
+ trimmed := strings.TrimRight(strings.TrimLeft(inner, " \t"), " \t")
+ if trimmed == inner {
+ continue
+ }
+ s.SetValue(i, "#["+trimmed+"]")
+ changed = true
+ }
+ return changed
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/NoSpaceAroundDoubleColonFixer.php
+//
+// NoSpaceAroundDoubleColon removes single-line whitespace around a "::" operator
+// ("Foo :: bar" -> "Foo::bar"). Whitespace spanning a newline is kept.
+type NoSpaceAroundDoubleColon struct{}
+
+func (NoSpaceAroundDoubleColon) Name() string {
+ return `PhpCsFixer\Fixer\Operator\NoSpaceAroundDoubleColonFixer`
+}
+
+func (NoSpaceAroundDoubleColon) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/NoSpaceAroundDoubleColonFixer.php"
+}
+
+func (NoSpaceAroundDoubleColon) 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 != "::" {
+ continue
+ }
+ 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 >= 0 && s.At(i-1).Kind == token.Whitespace && !hasNewline(s.At(i-1).Value) {
+ s.RemoveAt(i - 1)
+ i--
+ changed = true
+ }
+ }
+ return changed
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ArrayNotation/TrimArraySpacesFixer.php
+//
+// TrimArraySpaces removes single-line whitespace just inside an array-literal
+// "[" ... "]" ("[ 1, 2 ]" -> "[1, 2]"). Offset access is left alone. Long
+// "array( ... )" syntax is not handled (short "[]" literals only).
+type TrimArraySpaces struct{}
+
+func (TrimArraySpaces) Name() string {
+ return `PhpCsFixer\Fixer\ArrayNotation\TrimArraySpacesFixer`
+}
+
+func (TrimArraySpaces) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ArrayNotation/TrimArraySpacesFixer.php"
+}
+
+func (TrimArraySpaces) 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) {
+ continue
+ }
+ closeIdx := s.MatchForward(i)
+ 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 i+1 < s.Len() && s.At(i+1).Kind == token.Whitespace && !hasNewline(s.At(i+1).Value) {
+ s.RemoveAt(i + 1)
+ changed = true
+ }
+ }
+ return changed
+}
+
+// nativeTypeNames are the built-in type names lowercased in type declarations.
+// Every entry is a reserved word, so none can name a user class or constant.
+var nativeTypeNames = map[string]bool{
+ "int": true, "string": true, "bool": true, "float": true, "void": true,
+ "array": true, "iterable": true, "object": true, "mixed": true, "null": true,
+ "false": true, "true": true, "never": true, "callable": true, "self": true,
+ "parent": true, "static": true,
+}
+
+// typeContextPrev reports whether the significant token before i marks a type
+// position (a signature/property/return-type boundary).
+func typeContextPrev(s *tokens.Stream, i int) bool {
+ p := prevSignificantIndex(s, i)
+ if p < 0 {
+ return false
+ }
+ pt := s.At(p)
+ if pt.Kind == token.Punct {
+ switch pt.Value {
+ case "(", ",", "|", "?", "&", ":":
+ return true
+ }
+ return false
+ }
+ if pt.Kind == token.Keyword {
+ switch strings.ToLower(pt.Value) {
+ case "public", "private", "protected", "static", "readonly", "var":
+ return true
+ }
+ }
+ return false
+}
+
+// typeContextNext reports whether the significant token after i is consistent
+// with a type declaration (a variable, a union/intersection, or a body/end).
+func typeContextNext(s *tokens.Stream, i int) bool {
+ n := nextSignificantIndex(s, i)
+ if n < 0 {
+ return false
+ }
+ nt := s.At(n)
+ if nt.Kind == token.Variable {
+ return true
+ }
+ if nt.Kind == token.Punct {
+ switch nt.Value {
+ case "|", "&", "{", ";":
+ return true
+ }
+ }
+ return false
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Casing/NativeTypeDeclarationCasingFixer.php
+//
+// NativeTypeDeclarationCasing lowercases native type names used in type
+// declarations (parameters, properties, return types). It only ever touches
+// reserved words, which cannot name a class, and only in a type position.
+type NativeTypeDeclarationCasing struct{}
+
+func (NativeTypeDeclarationCasing) Name() string {
+ return `PhpCsFixer\Fixer\Casing\NativeTypeDeclarationCasingFixer`
+}
+
+func (NativeTypeDeclarationCasing) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Casing/NativeTypeDeclarationCasingFixer.php"
+}
+
+func (NativeTypeDeclarationCasing) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ if t.Kind != token.Ident && t.Kind != token.Keyword {
+ continue
+ }
+ lower := strings.ToLower(t.Value)
+ if !nativeTypeNames[lower] || t.Value == lower {
+ continue
+ }
+ if !typeContextPrev(s, i) || !typeContextNext(s, i) {
+ continue
+ }
+ s.SetValue(i, lower)
+ changed = true
+ }
+ return changed
+}
+
+// isLabelByte reports whether c is valid in a heredoc/nowdoc label.
+func isLabelByte(c byte) bool {
+ return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')
+}
+
+// heredocToNowdoc converts a heredoc token value to a nowdoc by quoting the
+// label, when the label is unquoted and the body has no interpolation ("$" or
+// "{") and no backslash escapes. It returns the new value and whether it changed.
+func heredocToNowdoc(v string) (string, bool) {
+ if !strings.HasPrefix(v, "<<<") {
+ return v, false
+ }
+ i := 3
+ for i < len(v) && (v[i] == ' ' || v[i] == '\t') {
+ i++
+ }
+ if i < len(v) && (v[i] == '\'' || v[i] == '"') {
+ return v, false // already quoted (nowdoc or explicit heredoc)
+ }
+ labelStart := i
+ for i < len(v) && isLabelByte(v[i]) {
+ i++
+ }
+ if i == labelStart {
+ return v, false
+ }
+ labelEnd := i
+ _, after, ok := strings.Cut(v, "\n")
+ if !ok {
+ return v, false
+ }
+ body := after
+ if strings.ContainsAny(body, "$\\") || strings.Contains(body, "{") {
+ return v, false
+ }
+ return v[:labelStart] + "'" + v[labelStart:labelEnd] + "'" + v[labelEnd:], true
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/StringNotation/HeredocToNowdocFixer.php
+//
+// HeredocToNowdoc converts a heredoc with no interpolation or escapes into a
+// nowdoc by quoting its label ("<< "<<<'EOT'").
+type HeredocToNowdoc struct{}
+
+func (HeredocToNowdoc) Name() string {
+ return `PhpCsFixer\Fixer\StringNotation\HeredocToNowdocFixer`
+}
+
+func (HeredocToNowdoc) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/StringNotation/HeredocToNowdocFixer.php"
+}
+
+func (HeredocToNowdoc) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ if t.Kind != token.String || !strings.HasPrefix(t.Value, "<<<") {
+ continue
+ }
+ if out, ok := heredocToNowdoc(t.Value); ok {
+ s.SetValue(i, out)
+ changed = true
+ }
+ }
+ return changed
+}
+
+// rangeHasNewline reports whether any whitespace token strictly between lo and
+// hi spans a newline.
+func rangeHasNewline(s *tokens.Stream, lo, hi int) bool {
+ for j := lo + 1; j < hi; j++ {
+ if s.At(j).Kind == token.Whitespace && hasNewline(s.At(j).Value) {
+ return true
+ }
+ }
+ return false
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/Operator/NoUselessConcatOperatorFixer.php
+//
+// NoUselessConcatOperator merges two adjacent string literals joined by "." into
+// one when they share a quote style and neither interpolates ("'a' . 'b'" ->
+// "'ab'"). Merges across a newline are left alone.
+type NoUselessConcatOperator struct{}
+
+func (NoUselessConcatOperator) Name() string {
+ return `PhpCsFixer\Fixer\Operator\NoUselessConcatOperatorFixer`
+}
+
+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 {
+ changed := false
+ i := 0
+ for i < s.Len() {
+ t := s.At(i)
+ if t.Kind != token.String {
+ i++
+ continue
+ }
+ q := stringQuote(t.Value)
+ if q == 0 || (q == '"' && strings.Contains(t.Value, "$")) {
+ i++
+ continue
+ }
+ j := nextSignificantIndex(s, i)
+ if j < 0 || s.At(j).Kind != token.Punct || s.At(j).Value != "." {
+ i++
+ continue
+ }
+ k := nextSignificantIndex(s, j)
+ if k < 0 || s.At(k).Kind != token.String {
+ i++
+ continue
+ }
+ next := s.At(k).Value
+ if stringQuote(next) != q || (q == '"' && strings.Contains(next, "$")) {
+ i++
+ continue
+ }
+ if rangeHasNewline(s, i, k) {
+ i++
+ continue
+ }
+ s.SetValue(i, t.Value[:len(t.Value)-1]+next[1:])
+ for idx := k; idx > i; idx-- {
+ s.RemoveAt(idx)
+ }
+ changed = true
+ // stay at i so chained literals ("'a'.'b'.'c'") collapse fully
+ }
+ return changed
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/StringNotation/NoBinaryStringFixer.php
+//
+// NoBinaryString drops the binary-string prefix "b"/"B" (b"x" -> "x"). The
+// lexer emits the prefix as a separate identifier directly before the string.
+type NoBinaryString struct{}
+
+func (NoBinaryString) Name() string {
+ return `PhpCsFixer\Fixer\StringNotation\NoBinaryStringFixer`
+}
+
+func (NoBinaryString) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/StringNotation/NoBinaryStringFixer.php"
+}
+
+func (NoBinaryString) Fix(s *tokens.Stream) bool {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ if t.Kind != token.Ident || (t.Value != "b" && t.Value != "B") {
+ continue
+ }
+ if i+1 >= s.Len() || s.At(i+1).Kind != token.String {
+ continue
+ }
+ if prev, ok := prevSignificant(s, i); ok {
+ switch prev.Value {
+ case "->", "?->", "::", `\`:
+ continue
+ }
+ }
+ s.RemoveAt(i)
+ i--
+ changed = true
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/token_batch_test.go b/blink/internal/fixer/rules/token_batch_test.go
new file mode 100644
index 0000000000..0779682054
--- /dev/null
+++ b/blink/internal/fixer/rules/token_batch_test.go
@@ -0,0 +1,137 @@
+package rules
+
+import "testing"
+
+func TestAttributeBlockNoSpaces(t *testing.T) {
+ got, changed := apply(t, AttributeBlockNoSpaces{}, " open && (s.At(p).Kind == token.Whitespace ||
+ s.At(p).Kind == token.Comment || s.At(p).Kind == token.DocComment) {
+ p--
+ }
+ if p <= open {
+ continue // empty
+ }
+ if s.At(p).Value == "," || s.At(p).Value == "..." {
+ continue // already trailing, or a spread (no trailing comma after it)
+ }
+ // multi-line only: a newline sits between the last element and the close
+ multiline := false
+ for k := p + 1; k < i; k++ {
+ if s.At(k).Kind == token.Whitespace && hasNewline(s.At(k).Value) {
+ multiline = true
+ break
+ }
+ }
+ if !multiline {
+ continue
+ }
+ s.InsertAt(p+1, token.Token{Kind: token.Punct, Value: ","})
+ changed = true
+ }
+ return changed
+}
+
+// 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{}
+
+func (NoTrailingCommaInSingleline) Name() string {
+ return `PhpCsFixer\Fixer\Basic\NoTrailingCommaInSinglelineFixer`
+}
+
+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 {
+ changed := false
+ for i := 0; i < s.Len(); i++ {
+ if s.At(i).Kind != token.Punct || s.At(i).Value != "," {
+ continue
+ }
+ j := i + 1
+ for j < s.Len() && s.At(j).Kind == token.Whitespace {
+ if hasNewline(s.At(j).Value) {
+ j = -1
+ break
+ }
+ j++
+ }
+ if j < 0 || j >= s.Len() {
+ continue
+ }
+ if v := s.At(j).Value; v == ")" || v == "]" || v == "}" {
+ s.RemoveAt(i)
+ i--
+ changed = true
+ }
+ }
+ return changed
+}
diff --git a/blink/internal/fixer/rules/visibility_required.go b/blink/internal/fixer/rules/visibility_required.go
new file mode 100644
index 0000000000..05cd1f8908
--- /dev/null
+++ b/blink/internal/fixer/rules/visibility_required.go
@@ -0,0 +1,87 @@
+package rules
+
+import (
+ "slices"
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+var memberModifiers = map[string]bool{
+ "public": true, "private": true, "protected": true, "static": true,
+ "abstract": true, "final": true, "readonly": true, "var": true,
+}
+
+var visibilityModifiers = map[string]bool{
+ "public": true, "private": true, "protected": true,
+}
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/VisibilityRequiredFixer.php
+//
+// VisibilityRequired adds an explicit "public" to class methods, properties and
+// constants that declare no visibility. "var $x" becomes "public $x". Trait use
+// and enum cases are left alone.
+type VisibilityRequired struct{}
+
+func (VisibilityRequired) Name() string {
+ return `PhpCsFixer\Fixer\ClassNotation\VisibilityRequiredFixer`
+}
+
+func (VisibilityRequired) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ClassNotation/VisibilityRequiredFixer.php"
+}
+
+func (VisibilityRequired) 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 != "{" {
+ continue
+ }
+ if kind, _ := classifyBrace(s, i); kind != braceClassLike {
+ continue
+ }
+ // process back to front so insertions do not shift earlier starts
+ for _, m := range slices.Backward(classMemberStarts(s, i)) {
+ if addVisibility(s, m) {
+ changed = true
+ }
+ }
+ }
+ return changed
+}
+
+func addVisibility(s *tokens.Stream, m int) bool {
+ hasVisibility := false
+ varIdx := -1
+ for k := m; k < s.Len(); {
+ t := s.At(k)
+ if t.Kind != token.Keyword {
+ break // a type, "?", or "$var": this is a property declaration
+ }
+ lw := strings.ToLower(t.Value)
+ if lw == "use" || lw == "case" {
+ return false // trait use / enum case - not a visibility target
+ }
+ if !memberModifiers[lw] {
+ break // "function" or "const": the member declaration begins here
+ }
+ if visibilityModifiers[lw] {
+ hasVisibility = true
+ }
+ if lw == "var" {
+ varIdx = k
+ }
+ k = skipWhitespace(s, k+1)
+ }
+ if hasVisibility {
+ return false
+ }
+ if varIdx >= 0 {
+ s.SetValue(varIdx, "public")
+ return true
+ }
+ s.InsertAt(m, token.Token{Kind: token.Keyword, Value: "public"})
+ s.InsertAt(m+1, token.Token{Kind: token.Whitespace, Value: " "})
+ return true
+}
diff --git a/blink/internal/fixer/rules/yoda_style.go b/blink/internal/fixer/rules/yoda_style.go
new file mode 100644
index 0000000000..8a3bb48513
--- /dev/null
+++ b/blink/internal/fixer/rules/yoda_style.go
@@ -0,0 +1,256 @@
+package rules
+
+import (
+ "slices"
+ "strings"
+
+ "blink/internal/token"
+ "blink/internal/tokens"
+)
+
+// PHP-CS-Fixer: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/YodaStyleFixer.php
+//
+// YodaStyle here follows ECS's common-set configuration (equal=false,
+// identical=false, less_and_greater=false): non-yoda. When a constant is on the
+// left of an == / === / != / !== comparison and a variable expression on the
+// right, they are swapped so the variable comes first ("null === $x" -> "$x ===
+// null"). "<"/">" comparisons are left untouched.
+type YodaStyle struct{}
+
+func (YodaStyle) Name() string {
+ return `PhpCsFixer\Fixer\ControlStructure\YodaStyleFixer`
+}
+
+func (YodaStyle) SourceURL() string {
+ return "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/src/Fixer/ControlStructure/YodaStyleFixer.php"
+}
+
+func yodaOp(v string) bool {
+ switch v {
+ case "==", "===", "!=", "!==", "<", ">", "<=", ">=":
+ return true
+ }
+ return false
+}
+
+// yodaMirror flips a relational operator when its operands are swapped, so
+// "5 > $z" becomes "$z < 5". Equality operators are symmetric and unchanged.
+func yodaMirror(v string) string {
+ switch v {
+ case "<":
+ return ">"
+ case ">":
+ return "<"
+ case "<=":
+ return ">="
+ case ">=":
+ return "<="
+ }
+ return v
+}
+
+func isYodaLiteral(t token.Token) bool {
+ switch t.Kind {
+ case token.Number, token.String:
+ return true
+ case token.Ident:
+ return true
+ case token.Keyword:
+ switch strings.ToLower(t.Value) {
+ case "true", "false", "null":
+ return true
+ }
+ }
+ return false
+}
+
+func (YodaStyle) Fix(s *tokens.Stream) bool {
+ type swap struct{ ls, le, rs, re int }
+ var swaps []swap
+ for i := 0; i < s.Len(); i++ {
+ t := s.At(i)
+ 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)) {
+ continue
+ }
+ swaps = append(swaps, swap{ls, le, rs, re})
+ }
+ if len(swaps) == 0 {
+ return false
+ }
+ for _, sw := range slices.Backward(swaps) {
+ left := append([]token.Token(nil), s.Tokens()[sw.ls:sw.le+1]...)
+ mid := append([]token.Token(nil), s.Tokens()[sw.le+1:sw.rs]...)
+ right := append([]token.Token(nil), s.Tokens()[sw.rs:sw.re+1]...)
+ for k := range mid {
+ if mid[k].Kind == token.Punct {
+ mid[k].Value = yodaMirror(mid[k].Value)
+ }
+ }
+ repl := append(append(append([]token.Token(nil), right...), mid...), left...)
+ s.ReplaceRange(sw.ls, sw.re, repl)
+ }
+ return true
+}
+
+// leftLiteralOperand returns the span of a constant left operand ending just
+// before the comparison at op: a plain literal, signed number, empty array,
+// bare constant, or "Name::class".
+func leftLiteralOperand(s *tokens.Stream, op int) (int, int, bool) {
+ le := prevSignificantIndex(s, op)
+ if le < 0 {
+ return 0, 0, false
+ }
+ t := s.At(le)
+ // "]" of an empty array "[]"
+ if t.Kind == token.Punct && t.Value == "]" {
+ o := s.MatchBackward(le)
+ if o >= 0 && nextSignificantIndex(s, o) == le {
+ return o, le, true
+ }
+ return 0, 0, false
+ }
+ // "class" of "Name::class"
+ if t.Kind == token.Ident && strings.EqualFold(t.Value, "class") {
+ p := prevSignificantIndex(s, le)
+ if p >= 0 && s.At(p).Value == "::" {
+ n := prevSignificantIndex(s, p)
+ if n >= 0 && s.At(n).Kind == token.Ident {
+ return n, le, true
+ }
+ }
+ return 0, 0, false
+ }
+ if isYodaLiteral(t) {
+ // signed number "-1"
+ if t.Kind == token.Number {
+ p := prevSignificantIndex(s, le)
+ if p >= 0 && s.At(p).Kind == token.Punct && (s.At(p).Value == "-" || s.At(p).Value == "+") {
+ // only treat as sign when what precedes the sign is a boundary
+ if isLeftBoundary(s, prevSignificantIndex(s, p)) {
+ return p, le, true
+ }
+ }
+ }
+ return le, le, true
+ }
+ return 0, 0, false
+}
+
+// rightPrimaryEnd walks right from rs over a primary expression (name/variable,
+// member and static access, matched call/index groups) and returns its last
+// token index, or -1.
+func rightPrimaryEnd(s *tokens.Stream, rs int) int {
+ if !isPrimaryStart(s.At(rs)) {
+ return -1
+ }
+ end := rs
+ for {
+ n := nextSignificantIndex(s, end)
+ if n < 0 {
+ return end
+ }
+ nt := s.At(n)
+ if nt.Kind == token.Punct {
+ switch nt.Value {
+ case "->", "?->", "::", `\`:
+ m := nextSignificantIndex(s, n)
+ if m < 0 {
+ return end
+ }
+ end = m
+ continue
+ case "(", "[":
+ c := s.MatchForward(n)
+ if c < 0 {
+ return end
+ }
+ end = c
+ continue
+ }
+ }
+ return end
+ }
+}
+
+// isPrimaryStart reports whether t can begin a primary (variable) expression.
+func isPrimaryStart(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 == `\`
+ }
+ return false
+}
+
+func isPrimaryKeyword(v string) bool {
+ switch strings.ToLower(v) {
+ case "static", "self", "parent":
+ return true
+ }
+ return false
+}
+
+func isLeftBoundary(s *tokens.Stream, p int) bool {
+ if p < 0 {
+ return true
+ }
+ t := s.At(p)
+ if t.Kind == token.Punct {
+ switch t.Value {
+ case "(", "[", "{", ",", ";", "&&", "||", "?", "??", ":", "=", "!", "=>", ".":
+ return true
+ }
+ return false
+ }
+ if t.Kind == token.Keyword {
+ switch strings.ToLower(t.Value) {
+ case "return", "and", "or", "xor", "echo", "print", "case", "if", "elseif", "while":
+ return true
+ }
+ }
+ return false
+}
+
+func isRightBoundary(s *tokens.Stream, j int) bool {
+ if j < 0 {
+ return true
+ }
+ t := s.At(j)
+ if t.Kind == token.Punct {
+ switch t.Value {
+ case ")", "]", "}", ";", ",", ":", "&&", "||", "?", "??", ".", "=>":
+ return true
+ }
+ return false
+ }
+ if t.Kind == token.Keyword {
+ switch strings.ToLower(t.Value) {
+ case "and", "or", "xor":
+ return true
+ }
+ }
+ return false
+}
diff --git a/blink/internal/fixer/rules/yoda_style_test.go b/blink/internal/fixer/rules/yoda_style_test.go
new file mode 100644
index 0000000000..4b408e26d9
--- /dev/null
+++ b/blink/internal/fixer/rules/yoda_style_test.go
@@ -0,0 +1,47 @@
+package rules
+
+import (
+ "testing"
+
+ "blink/internal/fixer"
+)
+
+func TestYodaStyle(t *testing.T) {
+ f := YodaStyle{}
+
+ // constant on the left moves to the right (non-yoda, ECS common config)
+ assertFix(t, f, "pending) {}", "pending !== null) {}", true)
+ assertFix(t, f, "resolved($a)) {}", "resolved($a) === true) {}", true)
+
+ // constant name, ::class, signed number, empty array on the left
+ assertFix(t, f, " <= >= are de-yoda'd with the operator flipped
+ assertFix(t, f, " $x) {}", "= $y;", " boundary) is de-yoda'd too
+ assertFix(t, f, " 1,\n};", " 1,\n};", true)
+
+ // already non-yoda - no-op
+ assertFix(t, f, "html ?? '') === '';", "html ?? '') === '';", false)
+}
+
+func TestYodaStyleSourceURL(t *testing.T) {
+ f := YodaStyle{}
+ if got, want := f.SourceURL(), fixer.SourceURLFor(f.Name()); got != want {
+ t.Fatalf("SourceURL %q, want %q", got, want)
+ }
+}
diff --git a/blink/internal/lexer/lexer.go b/blink/internal/lexer/lexer.go
new file mode 100644
index 0000000000..c294b26545
--- /dev/null
+++ b/blink/internal/lexer/lexer.go
@@ -0,0 +1,411 @@
+// Package lexer is a small, self-contained PHP tokenizer producing a flat,
+// lossless token stream. It mirrors the shape of PHP's token_get_all rather
+// than building an AST, so fixers can walk and mutate tokens by index.
+//
+// It classifies keywords vs names, emits multi-char operators (=> -> :: === ...)
+// as single Punct tokens, and captures heredoc/nowdoc as one opaque String
+// token. It is a pragmatic subset, not a full PHP grammar: string interpolation
+// is not split and casts are not tokenized. Enough for coding-standard fixers.
+package lexer
+
+import (
+ "slices"
+ "strings"
+
+ "blink/internal/token"
+)
+
+// Lex converts source into a flat token slice. Concatenating the Value of each
+// returned token reproduces src exactly.
+func Lex(src string) []token.Token {
+ // most tokens are a few bytes; presize to avoid repeated slice regrowth
+ l := &lexer{src: src, toks: make([]token.Token, 0, len(src)/3+8)}
+ l.run()
+ return l.toks
+}
+
+type lexer struct {
+ src string
+ pos int
+ inPHP bool
+ toks []token.Token
+}
+
+func (l *lexer) emit(k token.Kind, start int) {
+ l.toks = append(l.toks, token.Token{Kind: k, Value: l.src[start:l.pos], Pos: start})
+}
+
+func (l *lexer) run() {
+ for l.pos < len(l.src) {
+ if l.inPHP {
+ l.lexPHP()
+ } else {
+ l.lexHTML()
+ }
+ }
+}
+
+func (l *lexer) lexHTML() {
+ start := l.pos
+ for {
+ idx := strings.Index(l.src[l.pos:], "")
+ if idx < 0 {
+ l.pos = len(l.src)
+ break
+ }
+ l.pos += idx
+ if l.isOpenTagHere() {
+ break
+ }
+ l.pos += 2 // a "" that is not a PHP tag (e.g. " start {
+ l.toks = append(l.toks, token.Token{Kind: token.InlineHTML, Value: l.src[start:l.pos], Pos: start})
+ }
+ if l.pos < len(l.src) {
+ l.lexOpenTag()
+ l.inPHP = true
+ }
+}
+
+// isOpenTagHere reports whether l.pos starts a real PHP open tag. "= 3 && strings.EqualFold(rest[:3], "xml") {
+ return false
+ }
+ return true
+}
+
+func (l *lexer) lexOpenTag() {
+ start := l.pos
+ switch {
+ case l.hasPrefix(""):
+ l.pos += 2
+ l.emit(token.CloseTag, start)
+ l.inPHP = false
+ case l.hasPrefix("<<<"):
+ if end := l.scanHeredoc(); end > l.pos {
+ l.pos = end
+ l.emit(token.String, start)
+ } else {
+ if op := l.matchOperator(); op > 0 {
+ l.pos += op
+ } else {
+ l.pos++
+ }
+ l.emit(token.Punct, start)
+ }
+ case isSpace(c):
+ for l.pos < len(l.src) && isSpace(l.src[l.pos]) {
+ l.pos++
+ }
+ l.emit(token.Whitespace, start)
+ case c == '$':
+ l.pos++
+ for l.pos < len(l.src) && isIdent(l.src[l.pos]) {
+ l.pos++
+ }
+ l.emit(token.Variable, start)
+ case l.hasPrefix("#["):
+ l.lexAttribute(start)
+ case l.hasPrefix("//") || c == '#':
+ l.lexLineComment(start)
+ case l.hasPrefix("/*"):
+ l.lexBlockComment(start)
+ case c == '\'':
+ l.lexString(start, '\'')
+ case c == '"':
+ l.lexString(start, '"')
+ case isDigit(c):
+ for l.pos < len(l.src) && isNumber(l.src[l.pos]) {
+ l.pos++
+ }
+ l.emit(token.Number, start)
+ case isIdentStart(c):
+ for l.pos < len(l.src) && isIdent(l.src[l.pos]) {
+ l.pos++
+ }
+ l.emit(l.identKind(l.src[start:l.pos]), start)
+ default:
+ if op := l.matchOperator(); op > 0 {
+ l.pos += op
+ } else {
+ l.pos++
+ }
+ l.emit(token.Punct, start)
+ }
+}
+
+// identKind classifies an identifier run as Keyword or Ident. A name that
+// directly follows an object/static access operator (-> ?-> ::) is always a
+// property or method name, never a keyword.
+func (l *lexer) identKind(word string) token.Kind {
+ prev := l.lastSignificant()
+ if prev == "->" || prev == "?->" || prev == "::" || prev == `\` {
+ return token.Ident // property/method name or namespace segment
+ }
+ // a name right after "function" or "const" (e.g. a method named "match") is
+ // an identifier, not the keyword it happens to spell
+ if lp := strings.ToLower(prev); lp == "function" || lp == "const" {
+ return token.Ident
+ }
+ // a namespace segment like "Enum\Action" is an identifier, not a keyword
+ if l.pos < len(l.src) && l.src[l.pos] == '\\' {
+ return token.Ident
+ }
+ if keywords[strings.ToLower(word)] {
+ return token.Keyword
+ }
+ return token.Ident
+}
+
+// lastSignificant returns the value of the last non-trivia token emitted so far.
+func (l *lexer) lastSignificant() string {
+ for _, tk := range slices.Backward(l.toks) {
+ switch tk.Kind {
+ case token.Whitespace, token.Comment, token.DocComment:
+ continue
+ default:
+ return tk.Value
+ }
+ }
+ return ""
+}
+
+// matchOperator returns the byte length of the longest known multi-char operator
+// at the current position, or 0 when none matches (single-char punctuation).
+func (l *lexer) matchOperator() int {
+ if l.pos >= len(l.src) {
+ return 0
+ }
+ // only the operators starting with the current byte, longest first
+ for _, op := range operatorsByByte[l.src[l.pos]] {
+ if l.hasPrefix(op) {
+ return len(op)
+ }
+ }
+ return 0
+}
+
+func (l *lexer) lexLineComment(start int) {
+ for l.pos < len(l.src) && l.src[l.pos] != '\n' {
+ // a ?> ends a line comment in PHP; stop before it
+ if l.hasPrefix("?>") {
+ break
+ }
+ l.pos++
+ }
+ l.emit(token.Comment, start)
+}
+
+func (l *lexer) lexBlockComment(start int) {
+ l.pos += 2 // consume /*
+ for l.pos < len(l.src) && !l.hasPrefix("*/") {
+ l.pos++
+ }
+ if l.hasPrefix("*/") {
+ l.pos += 2
+ }
+ val := l.src[start:l.pos]
+ kind := token.Comment
+ // /** ... */ is a doc comment, but /**/ is not
+ if strings.HasPrefix(val, "/**") && val != "/**/" {
+ kind = token.DocComment
+ }
+ l.toks = append(l.toks, token.Token{Kind: kind, Value: val, Pos: start})
+}
+
+func (l *lexer) lexString(start int, quote byte) {
+ l.pos++ // opening quote
+ for l.pos < len(l.src) {
+ c := l.src[l.pos]
+ if c == '\\' && l.pos+1 < len(l.src) {
+ l.pos += 2
+ continue
+ }
+ if c == quote {
+ l.pos++
+ break
+ }
+ l.pos++
+ }
+ l.emit(token.String, start)
+}
+
+func (l *lexer) hasPrefix(s string) bool {
+ return strings.HasPrefix(l.src[l.pos:], s)
+}
+
+// lexAttribute consumes a "#[ ... ]" attribute as one opaque token, matching
+// brackets across lines and skipping string and heredoc/nowdoc content inside
+// (so a multiline nowdoc in an attribute never leaks out as code).
+func (l *lexer) lexAttribute(start int) {
+ l.pos += 2 // consume "#["
+ depth := 1
+ for l.pos < len(l.src) && depth > 0 {
+ switch c := l.src[l.pos]; {
+ case c == '[':
+ depth++
+ l.pos++
+ case c == ']':
+ depth--
+ l.pos++
+ case c == '\'' || c == '"':
+ l.skipString(c)
+ case l.hasPrefix("<<<"):
+ if end := l.scanHeredoc(); end > l.pos {
+ l.pos = end
+ } else {
+ l.pos++
+ }
+ default:
+ l.pos++
+ }
+ }
+ l.emit(token.Comment, start)
+}
+
+// skipString advances past a single- or double-quoted string, honoring escapes.
+func (l *lexer) skipString(quote byte) {
+ l.pos++ // opening quote
+ for l.pos < len(l.src) {
+ c := l.src[l.pos]
+ if c == '\\' && l.pos+1 < len(l.src) {
+ l.pos += 2
+ continue
+ }
+ l.pos++
+ if c == quote {
+ return
+ }
+ }
+}
+
+// scanHeredoc returns the end offset of a heredoc/nowdoc starting at "<<<", or
+// -1 when the "<<<" is not a valid heredoc opener. The whole construct (through
+// the closing label) is treated as one opaque token so fixers never touch it.
+func (l *lexer) scanHeredoc() int {
+ src := l.src
+ p := l.pos + 3
+ for p < len(src) && (src[p] == ' ' || src[p] == '\t') {
+ p++
+ }
+ var quote byte
+ if p < len(src) && (src[p] == '\'' || src[p] == '"') {
+ quote = src[p]
+ p++
+ }
+ labelStart := p
+ for p < len(src) && isIdent(src[p]) {
+ p++
+ }
+ if p == labelStart {
+ return -1 // no label -> not a heredoc
+ }
+ label := src[labelStart:p]
+ if quote != 0 {
+ if p >= len(src) || src[p] != quote {
+ return -1
+ }
+ p++
+ }
+ // opener must end the line
+ for p < len(src) && src[p] != '\n' {
+ p++
+ }
+ if p >= len(src) {
+ return -1
+ }
+ // scan body lines for the closing label (PHP 7.3+ allows it indented)
+ for p < len(src) {
+ p++ // step past the newline to the start of the next line
+ lineStart := p
+ for lineStart < len(src) && (src[lineStart] == ' ' || src[lineStart] == '\t') {
+ lineStart++
+ }
+ if strings.HasPrefix(src[lineStart:], label) {
+ after := lineStart + len(label)
+ if after >= len(src) || !isIdent(src[after]) {
+ return after // closing marker found
+ }
+ }
+ for p < len(src) && src[p] != '\n' {
+ p++
+ }
+ if p >= len(src) {
+ return len(src) // unterminated; consume the rest
+ }
+ }
+ return len(src)
+}
+
+func isSpace(c byte) bool { return c == ' ' || c == '\t' || c == '\n' || c == '\r' }
+func isDigit(c byte) bool { return c >= '0' && c <= '9' }
+func isNumber(c byte) bool {
+ return isDigit(c) || c == '.' || c == '_' || c == 'x' || c == 'X' || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
+}
+func isIdentStart(c byte) bool {
+ return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c >= 0x80
+}
+func isIdent(c byte) bool { return isIdentStart(c) || isDigit(c) }
+
+// operators lists multi-char operators, longest first, so matchOperator emits
+// each as a single Punct token (=> -> :: == === etc.) instead of splitting it.
+var operators = []string{
+ "<=>", "===", "!==", "**=", "...", "<<=", ">>=", "??=", "?->",
+ "->", "=>", "==", "!=", "<>", "<=", ">=", "&&", "||", "++", "--",
+ "+=", "-=", "*=", "/=", ".=", "%=", "&=", "|=", "^=", "::", "??",
+ "**", "<<", ">>",
+}
+
+// operatorsByByte groups operators by their first byte, preserving the
+// longest-first order of `operators`, so matchOperator only tests the few
+// candidates for the current byte instead of all of them.
+var operatorsByByte = func() [256][]string {
+ var m [256][]string
+ for _, op := range operators {
+ m[op[0]] = append(m[op[0]], op)
+ }
+ return m
+}()
+
+// keywords are PHP reserved words (case-insensitive). Type names (int, string,
+// ...) and constants (true, false, null) are T_STRING in PHP and stay Ident.
+var keywords = map[string]bool{
+ "abstract": true, "and": true, "array": true, "as": true, "break": true,
+ "callable": true, "case": true, "catch": true, "class": true, "clone": true,
+ "const": true, "continue": true, "declare": true, "default": true, "do": true,
+ "echo": true, "else": true, "elseif": true, "empty": true, "enddeclare": true,
+ "endfor": true, "endforeach": true, "endif": true, "endswitch": true,
+ "endwhile": true, "enum": true, "extends": true, "final": true, "finally": true,
+ "fn": true, "for": true, "foreach": true, "function": true, "global": true,
+ "goto": true, "if": true, "implements": true, "include": true,
+ "include_once": true, "instanceof": true, "insteadof": true, "interface": true,
+ "isset": true, "list": true, "match": true, "namespace": true, "new": true,
+ "or": true, "print": true, "private": true, "protected": true, "public": true,
+ "readonly": true, "require": true, "require_once": true, "return": true,
+ "static": true, "switch": true, "throw": true, "trait": true, "try": true,
+ "unset": true, "use": true, "var": true, "while": true, "xor": true,
+ "yield": true,
+}
diff --git a/blink/internal/lexer/lexer_test.go b/blink/internal/lexer/lexer_test.go
new file mode 100644
index 0000000000..736010585a
--- /dev/null
+++ b/blink/internal/lexer/lexer_test.go
@@ -0,0 +1,180 @@
+package lexer
+
+import (
+ "strings"
+ "testing"
+
+ "blink/internal/token"
+)
+
+// lossless is the core invariant: rendering all token values equals the source.
+func TestLexLossless(t *testing.T) {
+ cases := []string{
+ "",
+ "after
",
+ " more",
+ }
+ for _, src := range cases {
+ var got strings.Builder
+ for _, tk := range Lex(src) {
+ got.WriteString(tk.Value)
+ }
+ if got.String() != src {
+ t.Errorf("not lossless\n src: %q\n got: %q", src, got.String())
+ }
+ }
+}
+
+func TestLexClassifies(t *testing.T) {
+ toks := Lex(" 3 && tk.Value[:3] == "<<<" {
+ heredoc = tk.Value
+ }
+ }
+ if heredoc == "" {
+ t.Errorf("heredoc not captured as a single String token in %q", src)
+ }
+ // lossless
+ var got strings.Builder
+ for _, tk := range Lex(src) {
+ got.WriteString(tk.Value)
+ }
+ if got.String() != src {
+ t.Errorf("not lossless\n src: %q\n got: %q", src, got.String())
+ }
+ }
+}
+
+func TestLexMultilineAttributeWithNowdoc(t *testing.T) {
+ src := " 2 && tk.Value[:2] == "#[" {
+ attr = tk.Value
+ }
+ }
+ if attr == "" {
+ t.Fatal("multiline attribute not captured as one token")
+ }
+ if !strings.Contains(attr, "prose e.g. with. dots") {
+ t.Errorf("nowdoc body not inside the attribute token: %q", attr)
+ }
+ // lossless
+ var got strings.Builder
+ for _, tk := range Lex(src) {
+ got.WriteString(tk.Value)
+ }
+ if got.String() != src {
+ t.Errorf("not lossless:\n src: %q\n got: %q", src, got.String())
+ }
+}
+
+func TestLexKeywordsVsIdent(t *testing.T) {
+ toks := Lex("list;")
+ for _, tk := range toks {
+ if tk.Value == "list" && tk.Kind != token.Ident {
+ t.Errorf("property `list` after -> should be Ident, got %s", tk.Kind)
+ }
+ }
+}
+
+func TestLexMultiCharOperators(t *testing.T) {
+ cases := map[string]string{
+ "$b;": "=>",
+ "b;": "->",
+ " $b;": "<=>",
+ "= p.width:
+ bar = strings.Repeat("=", p.width)
+ case filled == 0:
+ bar = ">" + strings.Repeat(" ", p.width-1)
+ default:
+ bar = strings.Repeat("=", filled-1) + ">" + strings.Repeat(" ", p.width-filled)
+ }
+ _, _ = fmt.Fprintf(p.w, "\r %d/%d [%s] %3d%%", p.cur, p.total, bar, pct)
+}
diff --git a/blink/internal/reporter/reporter.go b/blink/internal/reporter/reporter.go
new file mode 100644
index 0000000000..35d7753625
--- /dev/null
+++ b/blink/internal/reporter/reporter.go
@@ -0,0 +1,196 @@
+// Package reporter reproduces ECS's console output: numbered file-diff blocks
+// with "Applied checkers" listings, wrapped diffs, and Symfony-style
+// success/warning status blocks.
+package reporter
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+
+ "blink/internal/runner"
+)
+
+type formatter struct {
+ w io.Writer
+ width int
+ color bool
+}
+
+// Report writes results to w and returns the process exit code (0 when clean or
+// after a successful fix, 1 when a check finds fixable issues).
+func Report(w io.Writer, results []runner.FileResult, isFixer bool) int {
+ f := newFormatter(w)
+
+ // --fix applies changes and prints a summary; the per-file diff listing is a
+ // check (dry-run) concern, matching ECS.
+ if !isFixer {
+ f.reportFileDiffs(results)
+ }
+ f.newLine(1)
+
+ if len(results) == 0 {
+ f.success("No errors found. Great job - your code is shiny in style!")
+ return 0
+ }
+
+ f.newLine(1)
+
+ n := len(results)
+ if isFixer {
+ f.success(fmt.Sprintf(
+ "%d error%s successfully fixed and no other errors found!",
+ n, plural(n),
+ ))
+ return 0
+ }
+
+ verb := "errors are"
+ if n == 1 {
+ verb = "error is"
+ }
+ f.warning(fmt.Sprintf(
+ "%d %s fixable! Just add \"--fix\" to console command and rerun to apply.",
+ n, verb,
+ ))
+ return 1
+}
+
+// Footer prints an ECS-style run summary: files scanned, time and memory.
+func Footer(w io.Writer, files int, d time.Duration, memBytes uint64) {
+ _, _ = fmt.Fprintf(w, "\n // %d files · %s · %d MB\n", files, d.Truncate(time.Millisecond), memBytes/(1024*1024))
+}
+
+func newFormatter(w io.Writer) formatter {
+ width := 80
+ color := false
+ if file, ok := w.(*os.File); ok {
+ if ws, ok2 := winsize(file); ok2 {
+ color = true
+ if ws > 0 {
+ width = ws
+ }
+ }
+ }
+ if c := os.Getenv("COLUMNS"); c != "" {
+ if n, err := strconv.Atoi(c); err == nil && n > 0 {
+ width = n
+ }
+ }
+ return formatter{w: w, width: width, color: color}
+}
+
+func (f formatter) reportFileDiffs(results []runner.FileResult) {
+ if len(results) == 0 {
+ return
+ }
+ f.newLine(1)
+
+ for i, r := range results {
+ f.newLine(2)
+ f.writeln(f.bold(fmt.Sprintf("%d) %s", i+1, r.Path)))
+ f.newLine(1)
+ f.writeln(f.formatDiff(r.Diff))
+ f.newLine(1)
+ f.writeln(f.underscore("Applied checkers:"))
+ f.newLine(1)
+ f.listing(r.AppliedRules)
+ }
+}
+
+// formatDiff wraps a unified diff in the begin/end frame and colors +, - and @
+// lines, matching ECS's ColorConsoleDiffFormatter.
+func (f formatter) formatDiff(diff string) string {
+ var colored []string
+ for line := range strings.SplitSeq(strings.TrimRight(diff, "\n"), "\n") {
+ switch {
+ case line == "--- Original", line == "+++ New":
+ continue
+ case strings.HasPrefix(line, "+"):
+ line = f.fg(line, 32) // green
+ case strings.HasPrefix(line, "-"):
+ line = f.fg(line, 31) // red
+ case strings.HasPrefix(line, "@"):
+ line = f.fg(line, 36) // cyan
+ case line == " ":
+ line = ""
+ }
+ colored = append(colored, line)
+ }
+
+ begin := f.comment(" ---------- begin diff ----------")
+ end := f.comment(" ----------- end diff -----------")
+ return begin + "\n" + strings.Join(colored, "\n") + "\n" + end + "\n"
+}
+
+func (f formatter) listing(items []string) {
+ for _, it := range items {
+ f.writeln(" * " + it)
+ }
+ f.newLine(1)
+}
+
+// success / warning render Symfony-style padded, colored blocks.
+func (f formatter) success(msg string) { f.block("[OK]", msg, 30, 42) }
+func (f formatter) warning(msg string) { f.block("[WARNING]", msg, 30, 43) }
+
+func (f formatter) block(tag, msg string, fg, bg int) {
+ f.newLine(1)
+ pad := func(s string) string {
+ if len([]rune(s)) < f.width {
+ s += strings.Repeat(" ", f.width-len([]rune(s)))
+ }
+ return f.bg(s, fg, bg)
+ }
+ f.writeln(pad(""))
+ f.writeln(pad(" " + tag + " " + msg))
+ f.writeln(pad(""))
+ f.newLine(1)
+}
+
+func (f formatter) newLine(n int) {
+ for range n {
+ _, _ = io.WriteString(f.w, "\n")
+ }
+}
+
+func (f formatter) writeln(s string) { _, _ = fmt.Fprintln(f.w, s) }
+
+// ANSI helpers, no-ops when color is disabled.
+func (f formatter) fg(s string, code int) string {
+ if !f.color {
+ return s
+ }
+ return fmt.Sprintf("\x1b[%dm%s\x1b[39m", code, s)
+}
+
+func (f formatter) bg(s string, fg, bg int) string {
+ if !f.color {
+ return s
+ }
+ return fmt.Sprintf("\x1b[%d;%dm%s\x1b[39;49m", fg, bg, s)
+}
+
+func (f formatter) comment(s string) string { return f.fg(s, 33) } // yellow
+func (f formatter) bold(s string) string {
+ if !f.color {
+ return s
+ }
+ return "\x1b[1m" + s + "\x1b[22m"
+}
+func (f formatter) underscore(s string) string {
+ if !f.color {
+ return s
+ }
+ return "\x1b[4m" + s + "\x1b[24m"
+}
+
+func plural(n int) string {
+ if n == 1 {
+ return ""
+ }
+ return "s"
+}
diff --git a/blink/internal/reporter/reporter_test.go b/blink/internal/reporter/reporter_test.go
new file mode 100644
index 0000000000..085370469c
--- /dev/null
+++ b/blink/internal/reporter/reporter_test.go
@@ -0,0 +1,57 @@
+package reporter
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+
+ "blink/internal/runner"
+)
+
+func TestReportClean(t *testing.T) {
+ var buf bytes.Buffer
+ code := Report(&buf, nil, false)
+ if code != 0 {
+ t.Fatalf("clean exit code = %d, want 0", code)
+ }
+ if !strings.Contains(buf.String(), "No errors found. Great job - your code is shiny in style!") {
+ t.Fatalf("missing success message:\n%s", buf.String())
+ }
+}
+
+func TestReportCheckFixable(t *testing.T) {
+ var buf bytes.Buffer
+ results := []runner.FileResult{{
+ Path: "src/Foo.php",
+ AppliedRules: []string{`PhpCsFixer\Fixer\Semicolon\SpaceAfterSemicolonFixer`},
+ Diff: "--- Original\n+++ New\n@@ @@\n-$a=1;$b=2;\n+$a=1; $b=2;\n",
+ }}
+ code := Report(&buf, results, false)
+ out := buf.String()
+ if code != 1 {
+ t.Fatalf("check exit code = %d, want 1", code)
+ }
+ for _, want := range []string{
+ "1) src/Foo.php",
+ "begin diff",
+ "Applied checkers:",
+ `PhpCsFixer\Fixer\Semicolon\SpaceAfterSemicolonFixer`,
+ "1 error is fixable!",
+ } {
+ if !strings.Contains(out, want) {
+ t.Fatalf("output missing %q:\n%s", want, out)
+ }
+ }
+}
+
+func TestReportFixed(t *testing.T) {
+ var buf bytes.Buffer
+ results := []runner.FileResult{{Path: "a.php", AppliedRules: []string{"X"}}, {Path: "b.php", AppliedRules: []string{"Y"}}}
+ code := Report(&buf, results, true)
+ if code != 0 {
+ t.Fatalf("fix exit code = %d, want 0", code)
+ }
+ if !strings.Contains(buf.String(), "2 errors successfully fixed and no other errors found!") {
+ t.Fatalf("missing fixed message:\n%s", buf.String())
+ }
+}
diff --git a/blink/internal/reporter/winsize_unix.go b/blink/internal/reporter/winsize_unix.go
new file mode 100644
index 0000000000..22b83e3549
--- /dev/null
+++ b/blink/internal/reporter/winsize_unix.go
@@ -0,0 +1,25 @@
+//go:build !windows
+
+package reporter
+
+import (
+ "os"
+ "syscall"
+ "unsafe"
+)
+
+const tiocgwinsz = 0x5413 // linux TIOCGWINSZ
+
+func winsize(file *os.File) (int, bool) {
+ ws := struct{ Row, Col, X, Y uint16 }{}
+ _, _, errno := syscall.Syscall(
+ syscall.SYS_IOCTL,
+ file.Fd(),
+ uintptr(tiocgwinsz),
+ uintptr(unsafe.Pointer(&ws)),
+ )
+ if errno != 0 {
+ return 0, false
+ }
+ return int(ws.Col), true
+}
diff --git a/blink/internal/reporter/winsize_windows.go b/blink/internal/reporter/winsize_windows.go
new file mode 100644
index 0000000000..7f42f5eac7
--- /dev/null
+++ b/blink/internal/reporter/winsize_windows.go
@@ -0,0 +1,29 @@
+//go:build windows
+
+package reporter
+
+import (
+ "os"
+ "syscall"
+ "unsafe"
+)
+
+var procGetConsoleScreenBufferInfo = syscall.NewLazyDLL("kernel32.dll").NewProc("GetConsoleScreenBufferInfo")
+
+// consoleScreenBufferInfo mirrors the Win32 CONSOLE_SCREEN_BUFFER_INFO struct.
+type consoleScreenBufferInfo struct {
+ SizeX, SizeY int16
+ CursorX, CursorY int16
+ Attributes uint16
+ WindowLeft, WindowTop, WindowRight, WindowBottom int16
+ MaxWindowSizeX, MaxWindowSizeY int16
+}
+
+func winsize(file *os.File) (int, bool) {
+ var info consoleScreenBufferInfo
+ ok, _, _ := procGetConsoleScreenBufferInfo.Call(file.Fd(), uintptr(unsafe.Pointer(&info)))
+ if ok == 0 {
+ return 0, false
+ }
+ return int(info.WindowRight-info.WindowLeft) + 1, true
+}
diff --git a/blink/internal/runner/runner.go b/blink/internal/runner/runner.go
new file mode 100644
index 0000000000..939a68e16b
--- /dev/null
+++ b/blink/internal/runner/runner.go
@@ -0,0 +1,129 @@
+// Package runner ties finder, lexer, token stream and fixers together, echoing
+// ECS's application layer. Files are processed by a pool of parallel workers.
+package runner
+
+import (
+ "os"
+ "sort"
+ "sync"
+
+ "blink/internal/config"
+ "blink/internal/diff"
+ "blink/internal/finder"
+ "blink/internal/lexer"
+ "blink/internal/tokens"
+)
+
+// FileResult records the change made to one file.
+type FileResult struct {
+ Path string
+ AppliedRules []string // rules that changed this file, in application order
+ Diff string // unified diff (Original vs fixed)
+ After string // fixed content
+}
+
+func (r FileResult) Changed() bool { return len(r.AppliedRules) > 0 }
+
+// Run scans the config's paths and applies rules across Jobs workers. When
+// write is true, changed files are written back to disk. Results are returned
+// sorted by path so output is deterministic regardless of worker scheduling.
+// Progress receives per-file progress updates during a run.
+type Progress interface {
+ Start(total int)
+ Advance()
+}
+
+func Run(cfg *config.Config, write bool, prog Progress) ([]FileResult, error) {
+ files, err := finder.Find(cfg.Paths, cfg.Skip)
+ if err != nil {
+ return nil, err
+ }
+ if prog != nil {
+ prog.Start(len(files))
+ }
+
+ jobs := max(cfg.Jobs, 1)
+
+ paths := make(chan string)
+ results := make(chan FileResult)
+ var firstErr error
+ var errOnce sync.Once
+
+ var wg sync.WaitGroup
+ for range jobs {
+ wg.Go(func() {
+ for path := range paths {
+ res, err := fixFile(cfg, path, write)
+ if prog != nil {
+ prog.Advance()
+ }
+ if err != nil {
+ errOnce.Do(func() { firstErr = err })
+ continue
+ }
+ if res.Changed() {
+ results <- res
+ }
+ }
+ })
+ }
+
+ go func() {
+ for _, p := range files {
+ paths <- p
+ }
+ close(paths)
+ wg.Wait()
+ close(results)
+ }()
+
+ var collected []FileResult
+ for r := range results {
+ collected = append(collected, r)
+ }
+ if firstErr != nil {
+ return nil, firstErr
+ }
+
+ sort.Slice(collected, func(i, j int) bool { return collected[i].Path < collected[j].Path })
+ return collected, nil
+}
+
+func fixFile(cfg *config.Config, path string, write bool) (FileResult, error) {
+ src, err := os.ReadFile(path)
+ if err != nil {
+ return FileResult{}, err
+ }
+ original := string(src)
+
+ stream := tokens.New(lexer.Lex(original))
+ res := FileResult{Path: path}
+
+ for _, rule := range cfg.Rules {
+ if rule.Fix(stream) {
+ res.AppliedRules = append(res.AppliedRules, rule.Name())
+ }
+ }
+
+ res.After = stream.Render()
+ if !res.Changed() {
+ return res, nil
+ }
+ // The unified diff is only rendered in check (dry-run) mode; --fix reports a
+ // count and rewrites the file, so computing the diff there is wasted work.
+ if !write {
+ res.Diff = diff.Unified(original, res.After)
+ }
+
+ if write {
+ info, statErr := os.Stat(path)
+ mode := os.FileMode(0o644)
+ if statErr == nil {
+ mode = info.Mode()
+ }
+ if err := os.WriteFile(path, []byte(res.After), mode); err != nil {
+ return FileResult{}, err
+ }
+ }
+ return res, nil
+}
diff --git a/blink/internal/runner/runner_test.go b/blink/internal/runner/runner_test.go
new file mode 100644
index 0000000000..81bef6b679
--- /dev/null
+++ b/blink/internal/runner/runner_test.go
@@ -0,0 +1,51 @@
+package runner
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "blink/internal/config"
+)
+
+func TestRunFixesFile(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "sample.php")
+ dirty := " "FooFixer").
+func shortName(name string) string {
+ if i := strings.LastIndexByte(name, '\\'); i >= 0 {
+ return name[i+1:]
+ }
+ return name
+}
+
+// Get returns the fixers of a named set.
+func Get(name string) ([]fixer.Fixer, bool) {
+ build, ok := byName[name]
+ if !ok {
+ return nil, false
+ }
+ return build(), true
+}
+
+// SpacesLevel returns the first n rules of the spaces set, for gradual adoption
+// (withSpacesLevel in ECS). n is clamped to the set size.
+func SpacesLevel(n int) []fixer.Fixer {
+ all := Spaces()
+ if n < 0 {
+ n = 0
+ }
+ if n > len(all) {
+ n = len(all)
+ }
+ return all[:n]
+}
+
+// Names lists the available set names, sorted.
+func Names() []string {
+ names := make([]string, 0, len(byName))
+ for name := range byName {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ return names
+}
+
+// defaultMembers is psr12Members ∪ commonMembers.
+var defaultMembers = func() map[string]bool {
+ m := map[string]bool{}
+ for k := range psr12Members {
+ m[k] = true
+ }
+ for k := range commonMembers {
+ m[k] = true
+ }
+ return m
+}()
+
+// psr12Members is the @PSR-12 set (from ECS config/set/psr12.php), by short name.
+var psr12Members = map[string]bool{
+ "BinaryOperatorSpacesFixer": true,
+ "BlankLineAfterNamespaceFixer": true,
+ "BlankLineAfterOpeningTagFixer": true,
+ "BracesPositionFixer": true,
+ "ClassDefinitionFixer": true,
+ "ConcatSpaceFixer": true,
+ "ConstantCaseFixer": true,
+ "ControlStructureBracesFixer": true,
+ "ControlStructureContinuationPositionFixer": true,
+ "DeclareEqualNormalizeFixer": true,
+ "DeclareParenthesesFixer": true,
+ "ElseifFixer": true,
+ "EncodingFixer": true,
+ "FullOpeningTagFixer": true,
+ "FunctionDeclarationFixer": true,
+ "IndentationTypeFixer": true,
+ "LineEndingFixer": true,
+ "LowercaseCastFixer": true,
+ "LowercaseKeywordsFixer": true,
+ "MethodArgumentSpaceFixer": true,
+ "NewWithParenthesesFixer": true,
+ "NoBlankLinesAfterClassOpeningFixer": true,
+ "NoBreakCommentFixer": true,
+ "NoClosingTagFixer": true,
+ "NoExtraBlankLinesFixer": true,
+ "NoLeadingImportSlashFixer": true,
+ "NoMultipleStatementsPerLineFixer": true,
+ "NoSinglelineWhitespaceBeforeSemicolonsFixer": true,
+ "NoSpacesAfterFunctionNameFixer": true,
+ "NoTrailingWhitespaceFixer": true,
+ "NoTrailingWhitespaceInCommentFixer": true,
+ "NoWhitespaceBeforeCommaInArrayFixer": true,
+ "OrderedImportsFixer": true,
+ "ReturnTypeDeclarationFixer": true,
+ "ShortScalarCastFixer": true,
+ "SingleBlankLineAtEofFixer": true,
+ "SingleClassElementPerStatementFixer": true,
+ "SingleImportPerStatementFixer": true,
+ "SingleLineAfterImportsFixer": true,
+ "SingleSpaceAroundConstructFixer": true,
+ "SpacesInsideParenthesesFixer": true,
+ "StatementIndentationFixer": true,
+ "SwitchCaseSemicolonToColonFixer": true,
+ "SwitchCaseSpaceFixer": true,
+ "TernaryOperatorSpacesFixer": true,
+ "UnaryOperatorSpacesFixer": true,
+ "VisibilityRequiredFixer": true,
+ "WhitespaceAfterCommaInArrayFixer": true,
+}
+
+// commonMembers is ECS's common set (array, casing, cleanup, comments,
+// control-structures, docblock, namespaces, spaces, clean-code), by short name.
+var commonMembers = map[string]bool{
+ "AddMissingParamNameFixer": true,
+ "AddMissingVarNameFixer": true,
+ "AlignMultilineCommentFixer": true,
+ "ArrayIndentationFixer": true,
+ "ArrayListItemNewlineFixer": true,
+ "ArrayOpenerAndCloserNewlineFixer": true,
+ "ArraySyntaxFixer": true,
+ "AssignNullCoalescingToCoalesceEqualFixer": true,
+ "BinaryOperatorSpacesFixer": true,
+ "BlankLineAfterOpeningTagFixer": true,
+ "BlankLineAfterStrictTypesFixer": true,
+ "CastSpacesFixer": true,
+ "ClassAttributesSeparationFixer": true,
+ "ClassDefinitionFixer": true,
+ "ClassReferenceNameCasingFixer": true,
+ "ConcatSpaceFixer": true,
+ "DoubleAsteriskInlineVarFixer": true,
+ "ExplicitIndirectVariableFixer": true,
+ "ExplicitStringVariableFixer": true,
+ "FixParamNameTypoFixer": true,
+ "FixTagTypoFixer": true,
+ "FunctionToConstantFixer": true,
+ "GeneralPhpdocAnnotationRemoveFixer": true,
+ "IncludeFixer": true,
+ "IntegerLiteralCaseFixer": true,
+ "IsNullFixer": true,
+ "LambdaNotUsedImportFixer": true,
+ "ListSyntaxFixer": true,
+ "LongToShorthandOperatorFixer": true,
+ "MagicConstantCasingFixer": true,
+ "MagicMethodCasingFixer": true,
+ "MergeDocBlockStartFixer": true,
+ "MethodArgumentSpaceFixer": true,
+ "MethodChainingIndentationFixer": true,
+ "MethodChainingNewlineFixer": true,
+ "MultilineCommentOpeningClosingFixer": true,
+ "NativeFunctionCasingFixer": true,
+ "NativeTypeDeclarationCasingFixer": true,
+ "NewWithParenthesesFixer": true,
+ "NoAlternativeSyntaxFixer": true,
+ "NoBlankLineBetweenImportsFixer": true,
+ "NoBlankLinesAfterClassOpeningFixer": true,
+ "NoBlankLinesAfterPhpdocFixer": true,
+ "NoEmptyCommentFixer": true,
+ "NoEmptyPhpdocFixer": true,
+ "NoEmptyStatementFixer": true,
+ "NoExtraBlankLinesFixer": true,
+ "NoLeadingNamespaceWhitespaceFixer": true,
+ "NoMultilineWhitespaceAroundDoubleArrowFixer": true,
+ "NoNullPropertyInitializationFixer": true,
+ "NoShortBoolCastFixer": true,
+ "NoSinglelineWhitespaceBeforeSemicolonsFixer": true,
+ "NoSpacesAroundOffsetFixer": true,
+ "NoSuperfluousElseifFixer": true,
+ "NoSuperfluousPhpdocTagsFixer": true,
+ "NoTrailingCommaInSinglelineFixer": true,
+ "NoTrailingWhitespaceInCommentFixer": true,
+ "NoUnneededControlParenthesesFixer": true,
+ "NoUnneededCurlyBracesFixer": true,
+ "NoUnneededImportAliasFixer": true,
+ "NoUnusedImportsFixer": true,
+ "NoUselessConcatOperatorFixer": true,
+ "NoUselessNullsafeOperatorFixer": true,
+ "NoUselessReturnFixer": true,
+ "NoWhitespaceBeforeCommaInArrayFixer": true,
+ "NoWhitespaceInBlankLineFixer": true,
+ "NoWhitespaceInEmptyArrayFixer": true,
+ "NotOperatorWithSuccessorSpaceFixer": true,
+ "NullableTypeDeclarationForDefaultNullValueFixer": true,
+ "ObjectOperatorWithoutWhitespaceFixer": true,
+ "OrderedImportsFixer": true,
+ "ParamReturnAndVarTagMalformsFixer": true,
+ "PhpdocIndentFixer": true,
+ "PhpdocInlineTagNormalizerFixer": true,
+ "PhpdocLineSpanFixer": true,
+ "PhpdocNoAccessFixer": true,
+ "PhpdocNoAliasTagFixer": true,
+ "PhpdocNoDuplicateTypesFixer": true,
+ "PhpdocNoEmptyReturnFixer": true,
+ "PhpdocNoPackageFixer": true,
+ "PhpdocOrderByValueFixer": true,
+ "PhpdocReturnSelfReferenceFixer": true,
+ "PhpdocScalarFixer": true,
+ "PhpdocSingleLineVarSpacingFixer": true,
+ "PhpdocTagCasingFixer": true,
+ "PhpdocTrimConsecutiveBlankLineSeparationFixer": true,
+ "PhpdocTrimFixer": true,
+ "PhpdocTypesFixer": true,
+ "PhpdocVarWithoutNameFixer": true,
+ "ProtectedToPrivateFixer": true,
+ "RemoveDeadParamFixer": true,
+ "RemoveDeadVarThisFixer": true,
+ "RemoveEventSubscriberDescriptionFixer": true,
+ "RemoveMethodNameDuplicateDescriptionFixer": true,
+ "RemovePHPStormAnnotationFixer": true,
+ "RemoveParamNameReferenceFixer": true,
+ "RemovePropertyVariableNameDescriptionFixer": true,
+ "RemoveSuperfluousReturnNameFixer": true,
+ "RemoveSuperfluousVarNameFixer": true,
+ "RemoveUselessDefaultCommentFixer": true,
+ "ReturnTypeDeclarationFixer": true,
+ "SelfAccessorFixer": true,
+ "SingleBlankLineBeforeNamespaceFixer": true,
+ "SingleClassElementPerStatementFixer": true,
+ "SingleLineCommentSpacingFixer": true,
+ "SingleLineInlineVarDocBlockFixer": true,
+ "SingleQuoteFixer": true,
+ "SingleTraitInsertPerStatementFixer": true,
+ "SpaceAfterCommaHereNowDocFixer": true,
+ "SpaceAfterSemicolonFixer": true,
+ "StandaloneLinePromotedPropertyFixer": true,
+ "StandaloneLineRequiredParamFixer": true,
+ "StandardizeIncrementFixer": true,
+ "StandardizeNotEqualsFixer": true,
+ "SwitchContinueToBreakFixer": true,
+ "SwitchedTypeAndNameFixer": true,
+ "TernaryOperatorSpacesFixer": true,
+ "TernaryToNullCoalescingFixer": true,
+ "TrailingCommaInMultilineFixer": true,
+ "TrimArraySpacesFixer": true,
+ "TypeDeclarationSpacesFixer": true,
+ "TypeToVarTagFixer": true,
+ "TypesSpacesFixer": true,
+ "WhitespaceAfterCommaInArrayFixer": true,
+ "YodaStyleFixer": true,
+}
diff --git a/blink/internal/set/set_test.go b/blink/internal/set/set_test.go
new file mode 100644
index 0000000000..afa14063e0
--- /dev/null
+++ b/blink/internal/set/set_test.go
@@ -0,0 +1,25 @@
+package set
+
+import "testing"
+
+func TestGet(t *testing.T) {
+ if _, ok := Get("spaces"); !ok {
+ t.Fatal("spaces set should exist")
+ }
+ if _, ok := Get("does-not-exist"); ok {
+ t.Fatal("unknown set should not resolve")
+ }
+}
+
+func TestSpacesLevelClamps(t *testing.T) {
+ full := len(Spaces())
+ if got := len(SpacesLevel(-5)); got != 0 {
+ t.Fatalf("negative level should give 0, got %d", got)
+ }
+ if got := len(SpacesLevel(1000)); got != full {
+ t.Fatalf("overflow level should clamp to %d, got %d", full, got)
+ }
+ if got := len(SpacesLevel(2)); got != 2 {
+ t.Fatalf("level 2 should give 2, got %d", got)
+ }
+}
diff --git a/blink/internal/token/token.go b/blink/internal/token/token.go
new file mode 100644
index 0000000000..045518678c
--- /dev/null
+++ b/blink/internal/token/token.go
@@ -0,0 +1,53 @@
+package token
+
+// Kind is the lexical category of a token.
+type Kind int
+
+const (
+ Unknown Kind = iota
+ OpenTag //
+ InlineHTML // text outside PHP tags
+ Whitespace // spaces, tabs, newlines
+ Comment // // # /* */
+ DocComment // /** */
+ Variable // $foo
+ Ident // names (T_STRING): function/class names, constants, types
+ Keyword // reserved words: function, class, return, namespace, ...
+ Number
+ String // '...' "..."
+ Punct // operators, braces, ; , etc.
+ EOF
+)
+
+var kindNames = map[Kind]string{
+ Unknown: "Unknown",
+ OpenTag: "OpenTag",
+ CloseTag: "CloseTag",
+ InlineHTML: "InlineHTML",
+ Whitespace: "Whitespace",
+ Comment: "Comment",
+ DocComment: "DocComment",
+ Variable: "Variable",
+ Ident: "Ident",
+ Keyword: "Keyword",
+ Number: "Number",
+ String: "String",
+ Punct: "Punct",
+ EOF: "EOF",
+}
+
+func (k Kind) String() string {
+ if n, ok := kindNames[k]; ok {
+ return n
+ }
+ return "Kind(?)"
+}
+
+// Token is a single lexical unit. The stream is lossless: concatenating every
+// Value in order reproduces the original source byte for byte.
+type Token struct {
+ Kind Kind
+ Value string
+ Pos int // byte offset in source
+}
diff --git a/blink/internal/tokens/stream.go b/blink/internal/tokens/stream.go
new file mode 100644
index 0000000000..ab18ee8dbe
--- /dev/null
+++ b/blink/internal/tokens/stream.go
@@ -0,0 +1,138 @@
+// Package tokens holds a flat, mutable, index-addressable token stream. This is
+// the ECS/PHP-CS-Fixer working model: fixers read tokens by index and insert,
+// replace or remove them in place, then Render rebuilds the source.
+package tokens
+
+import (
+ "strings"
+
+ "blink/internal/token"
+)
+
+type Stream struct {
+ toks []token.Token
+}
+
+func New(toks []token.Token) *Stream {
+ return &Stream{toks: toks}
+}
+
+func (s *Stream) Len() int { return len(s.toks) }
+
+func (s *Stream) At(i int) token.Token { return s.toks[i] }
+
+// Kind returns just the kind of the token at i, avoiding a full Token copy in
+// the hot index-scanning helpers.
+func (s *Stream) Kind(i int) token.Kind { return s.toks[i].Kind }
+
+func (s *Stream) Set(i int, t token.Token) { s.toks[i] = t }
+
+// SetValue replaces only the Value of the token at i, keeping its kind.
+func (s *Stream) SetValue(i int, v string) { s.toks[i].Value = v }
+
+func (s *Stream) RemoveAt(i int) {
+ s.toks = append(s.toks[:i], s.toks[i+1:]...)
+}
+
+func (s *Stream) InsertAt(i int, t token.Token) {
+ s.toks = append(s.toks, token.Token{})
+ copy(s.toks[i+1:], s.toks[i:])
+ s.toks[i] = t
+}
+
+// MatchForward returns the index of the delimiter matching the opener at i
+// ("(", "{" or "["), or -1 if there is none. Strings and comments are single
+// tokens, so scanning punctuation is safe.
+func (s *Stream) MatchForward(i int) int {
+ if i < 0 || i >= len(s.toks) || s.toks[i].Kind != token.Punct {
+ return -1
+ }
+ open := s.toks[i].Value
+ var closer string
+ switch open {
+ case "(":
+ closer = ")"
+ case "{":
+ closer = "}"
+ case "[":
+ closer = "]"
+ default:
+ return -1
+ }
+ depth := 0
+ for j := i; j < len(s.toks); j++ {
+ if s.toks[j].Kind != token.Punct {
+ continue
+ }
+ switch s.toks[j].Value {
+ case open:
+ depth++
+ case closer:
+ depth--
+ if depth == 0 {
+ return j
+ }
+ }
+ }
+ return -1
+}
+
+// MatchBackward returns the index of the delimiter matching the closer at i
+// (")", "}" or "]"), or -1 if there is none.
+func (s *Stream) MatchBackward(i int) int {
+ if i < 0 || i >= len(s.toks) || s.toks[i].Kind != token.Punct {
+ return -1
+ }
+ closer := s.toks[i].Value
+ var opener string
+ switch closer {
+ case ")":
+ opener = "("
+ case "}":
+ opener = "{"
+ case "]":
+ opener = "["
+ default:
+ return -1
+ }
+ depth := 0
+ for j := i; j >= 0; j-- {
+ if s.toks[j].Kind != token.Punct {
+ continue
+ }
+ switch s.toks[j].Value {
+ case closer:
+ depth++
+ case opener:
+ depth--
+ if depth == 0 {
+ return j
+ }
+ }
+ }
+ return -1
+}
+
+// ReplaceRange swaps tokens [start, end] (inclusive) for repl.
+func (s *Stream) ReplaceRange(start, end int, repl []token.Token) {
+ tail := append([]token.Token(nil), s.toks[end+1:]...)
+ s.toks = append(s.toks[:start], repl...)
+ s.toks = append(s.toks, tail...)
+}
+
+// Render concatenates every token value back into source.
+func (s *Stream) Render() string {
+ n := 0
+ for i := range s.toks {
+ n += len(s.toks[i].Value)
+ }
+ var b strings.Builder
+ b.Grow(n)
+ for i := range s.toks {
+ b.WriteString(s.toks[i].Value)
+ }
+ return b.String()
+}
+
+// Tokens returns the underlying slice (read-only use).
+func (s *Stream) Tokens() []token.Token { return s.toks }
diff --git a/blink/internal/tokens/stream_test.go b/blink/internal/tokens/stream_test.go
new file mode 100644
index 0000000000..2ead04eb84
--- /dev/null
+++ b/blink/internal/tokens/stream_test.go
@@ -0,0 +1,65 @@
+package tokens
+
+import (
+ "testing"
+
+ "blink/internal/token"
+)
+
+func mk(vals ...string) *Stream {
+ var toks []token.Token
+ for _, v := range vals {
+ toks = append(toks, token.Token{Kind: token.Punct, Value: v})
+ }
+ return New(toks)
+}
+
+func TestInsertRemoveRender(t *testing.T) {
+ s := mk("a", "b", "d")
+ s.InsertAt(2, token.Token{Value: "c"})
+ if got := s.Render(); got != "abcd" {
+ t.Fatalf("insert: got %q", got)
+ }
+ s.RemoveAt(0)
+ if got := s.Render(); got != "bcd" {
+ t.Fatalf("remove: got %q", got)
+ }
+}
+
+func TestInsertAtEnd(t *testing.T) {
+ s := mk("a", "b")
+ s.InsertAt(s.Len(), token.Token{Value: "c"})
+ if got := s.Render(); got != "abc" {
+ t.Fatalf("append: got %q", got)
+ }
+}
+
+func TestSetValue(t *testing.T) {
+ s := mk("a", "b")
+ s.SetValue(1, "B")
+ if got := s.Render(); got != "aB" {
+ t.Fatalf("setvalue: got %q", got)
+ }
+}
+
+func TestMatchForward(t *testing.T) {
+ // ( a [ b ] c ) -> outer "(" at 0 matches ")" at 6
+ s := mk("(", "a", "[", "b", "]", "c", ")")
+ if got := s.MatchForward(0); got != 6 {
+ t.Fatalf("outer match: got %d, want 6", got)
+ }
+ if got := s.MatchForward(2); got != 4 {
+ t.Fatalf("inner match: got %d, want 4", got)
+ }
+ if got := s.MatchForward(1); got != -1 {
+ t.Fatalf("non-opener should be -1, got %d", got)
+ }
+}
+
+func TestReplaceRange(t *testing.T) {
+ s := mk("a", "b", "c", "d")
+ s.ReplaceRange(1, 2, []token.Token{{Kind: token.Punct, Value: "X"}})
+ if got := s.Render(); got != "aXd" {
+ t.Fatalf("replace: got %q", got)
+ }
+}
diff --git a/blink/main.go b/blink/main.go
new file mode 100644
index 0000000000..ae06821820
--- /dev/null
+++ b/blink/main.go
@@ -0,0 +1,155 @@
+// Command blink is a token-based coding-standard checker/fixer for PHP,
+// modeled on symplify/easy-coding-standard.
+package main
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "runtime"
+ "runtime/debug"
+ "time"
+
+ "blink/internal/config"
+ "blink/internal/fixer/rules"
+ "blink/internal/reporter"
+ "blink/internal/runner"
+)
+
+func main() {
+ // A short-lived batch process: relax the GC so it collects far less during
+ // the run instead of reclaiming memory the process is about to release anyway.
+ debug.SetGCPercent(400)
+ os.Exit(run(os.Args[1:]))
+}
+
+func run(args []string) int {
+ fix := false
+ configPath := ""
+ ecsConfigPath := ""
+ var paths []string
+
+ for i := 0; i < len(args); i++ {
+ a := args[i]
+ switch {
+ case a == "--fix":
+ fix = true
+ case a == "list-checkers":
+ return listCheckers()
+ case a == "-h" || a == "--help":
+ usage()
+ return 0
+ case a == "--config":
+ if i+1 < len(args) {
+ configPath = args[i+1]
+ i++
+ }
+ case len(a) > 9 && a[:9] == "--config=":
+ configPath = a[9:]
+ case a == "--ecs-config":
+ if i+1 < len(args) {
+ ecsConfigPath = args[i+1]
+ i++
+ }
+ case len(a) > 13 && a[:13] == "--ecs-config=":
+ ecsConfigPath = a[13:]
+ default:
+ paths = append(paths, a)
+ }
+ }
+
+ var cfg *config.Config
+ var err error
+ if ecsConfigPath != "" {
+ var resolution *config.ECSResolution
+ cfg, resolution, err = config.LoadECS(ecsConfigPath)
+ if err == nil {
+ reportECSResolution(os.Stderr, resolution)
+ }
+ } else {
+ cfg, err = loadConfig(configPath)
+ }
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "error:", err)
+ return 2
+ }
+ // CLI paths override the config; runs across all CPU cores by default
+ cfg.WithPaths(paths...)
+
+ start := time.Now()
+ progress := reporter.NewProgress(os.Stderr)
+
+ results, err := runner.Run(cfg, fix, progress)
+ progress.Finish()
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "error:", err)
+ return 2
+ }
+
+ code := reporter.Report(os.Stdout, results, fix)
+
+ var mem runtime.MemStats
+ runtime.ReadMemStats(&mem)
+ reporter.Footer(os.Stdout, progress.Total(), time.Since(start), mem.Sys)
+ return code
+}
+
+// loadConfig uses an explicit --config path, else a blink.json in the working
+// directory, else the built-in defaults (all rules).
+func loadConfig(configPath string) (*config.Config, error) {
+ if configPath != "" {
+ return config.Load(configPath)
+ }
+ if _, err := os.Stat("blink.json"); err == nil {
+ return config.Load("blink.json")
+ }
+ return config.Configure(), nil
+}
+
+// reportECSResolution prints, to w, how an ECS turbo config mapped: how many
+// rules resolved to blink fixers and everything that could not, so a turbo run
+// is never silently narrower than the ECS config it stands in for.
+func reportECSResolution(w io.Writer, resolution *config.ECSResolution) {
+ _, _ = fmt.Fprintf(w, "turbo: mapped %d of %d ECS rules to blink fixers\n", resolution.Mapped, resolution.Total)
+ if len(resolution.Unsupported) > 0 {
+ _, _ = fmt.Fprintf(w, " %d unsupported (no blink fixer), skipped:\n", len(resolution.Unsupported))
+ for _, class := range resolution.Unsupported {
+ _, _ = fmt.Fprintf(w, " - %s\n", class)
+ }
+ }
+ if len(resolution.ConfigIgnored) > 0 {
+ _, _ = fmt.Fprintf(w, " %d configured rule(s) applied with blink's built-in behaviour (config not modelled):\n", len(resolution.ConfigIgnored))
+ for _, class := range resolution.ConfigIgnored {
+ _, _ = fmt.Fprintf(w, " - %s\n", class)
+ }
+ }
+ if len(resolution.PerPathSkips) > 0 {
+ _, _ = fmt.Fprintln(w, " per-path rule skips applied project-wide (not yet honoured per path):")
+ for _, class := range resolution.PerPathSkips {
+ _, _ = fmt.Fprintf(w, " - %s\n", class)
+ }
+ }
+}
+
+func listCheckers() int {
+ fmt.Println("Registered checkers:")
+ for _, r := range rules.All() {
+ fmt.Printf(" - %s\n", r.Name())
+ }
+ return 0
+}
+
+func usage() {
+ fmt.Print(`blink - token-based PHP coding standard tool
+
+Usage:
+ blink [paths...] check paths (default: .)
+ blink --fix [paths...] fix paths in place
+ blink --config FILE ... use a blink.json config
+ blink --ecs-config FILE ... use an ECS dump-config JSON (turbo mode)
+ blink list-checkers list registered fixers
+
+Loads blink.json from the working directory when present.
+Runs across all CPU cores by default.
+`)
+}
diff --git a/blink/psr12-left.txt b/blink/psr12-left.txt
new file mode 100644
index 0000000000..991e37ef93
--- /dev/null
+++ b/blink/psr12-left.txt
@@ -0,0 +1,60 @@
+PSR-12 rules left to implement
+==============================
+
+Reference: PHP-CS-Fixer @PSR12 = @PSR2 (+ @PSR1) plus extra rules.
+Below: what remains NOT implemented in blink. 47 fixers are done (see bottom).
+
+A statement/scope layer exists: Stream.MatchForward/MatchBackward, classifyBrace
+(labels each "{"), and classMemberStarts (iterates class members). The rules
+below need more of it (member reordering, type spans) or a fuller type model.
+
+
+NEEDS FULLER AST / TYPE MODEL
+-----------------------------
+function_declaration "function" signature spacing, closures
+compact_nullable_type_declaration "? int" -> "?int" in type positions
+modifier_keywords modifier ordering (casing/presence done)
+new_with_parentheses "new Foo" -> "new Foo()" (needs class-name ctx)
+
+Notes:
+- method_argument_space is implemented for single-line comma spacing only; the
+ multi-line "one argument per line" reflow is not done.
+- ordered_class_elements is conservative: it skips any class containing comments
+ or attributes (attributes lex as # comments here), reordering only simple
+ classes. Broadening it needs first-class attribute tokens.
+
+
+LEFTOVERS (bounded / low value)
+-------------------------------
+no_break_comment add "// no break" on fallthrough (heuristic)
+binary_operator_spaces arithmetic/bitwise (+ - * / % & | ^): unary-
+ ambiguous, need operand context
+
+
+IMPLEMENTED (47)
+----------------
+casing: lowercase_keywords, constant_case, lowercase_static_reference,
+ lowercase_cast, short_scalar_cast
+spacing: no_leading_namespace_whitespace, no_singleline_whitespace_before_semicolons,
+ no_whitespace_in_blank_line, no_extra_blank_lines, space_after_semicolon,
+ binary_operator_spaces (=, =>, comparison, logical), concat_space,
+ cast_spaces, ternary_operator_spaces, blank_line_after_opening_tag,
+ no_trailing_whitespace, no_trailing_whitespace_in_comment,
+ single_blank_line_at_eof
+functions: method_argument_space (single-line), return_type_declaration,
+ no_spaces_after_function_name
+constructs: single_space_around_construct, spaces_inside_parentheses,
+ unary_operator_spaces, elseif,
+ no_leading_import_slash, declare_equal_normalize,
+ switch_case_semicolon_to_colon, switch_case_space,
+ no_multiple_statements_per_line
+php tag: full_opening_tag, no_closing_tag
+braces: braces_position
+class: visibility_required, single_trait_insert_per_statement,
+ single_class_element_per_statement, class_definition,
+ ordered_class_elements (conservative)
+imports: single_import_per_statement, ordered_imports,
+ blank_line_between_import_groups, single_line_after_imports
+structural: blank_lines_before_namespace, blank_line_after_namespace,
+ no_blank_lines_after_class_opening, indentation_type,
+ statement_indentation
diff --git a/blink/testdata/dirty.php b/blink/testdata/dirty.php
new file mode 100644
index 0000000000..4c1a5f5aaa
--- /dev/null
+++ b/blink/testdata/dirty.php
@@ -0,0 +1,12 @@
+=8.4",