Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 155 additions & 12 deletions blink/internal/fixer/rules/concat_space.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package rules

import (
"strings"

"blink/internal/fixer"
"blink/internal/token"
"blink/internal/tokens"
Expand Down Expand Up @@ -35,28 +37,169 @@ func (f ConcatSpace) Fix(s *tokens.Stream) bool {
t := s.At(i)
return t.Kind == token.Punct && t.Value == "."
}
changed := false
if !f.None {
return normalizeSpaceAround(s, isConcat)
changed = normalizeSpaceAround(s, isConcat)
} else {
for i := s.Len() - 1; i >= 0; i-- {
if !isConcat(s, i) {
continue
}
if i+1 < s.Len() && s.At(i+1).Kind == token.Whitespace && !hasNewline(s.At(i+1).Value) &&
!concatSpaceKeepsSpace(s, nextSignificantIndex(s, i)) {
s.RemoveAt(i + 1)
changed = true
}
if i >= 1 && s.At(i-1).Kind == token.Whitespace && !hasNewline(s.At(i-1).Value) &&
!concatSpaceKeepsSpace(s, prevSignificantIndex(s, i)) {
s.RemoveAt(i - 1)
changed = true
}
}
}
changed := false
for i := s.Len() - 1; i >= 0; i-- {
if !isConcat(s, i) {
// Attributes are lexed as one opaque token, so the loop above never sees the
// "." inside them; normalize their concat spacing by rewriting the token text.
for j := 0; j < s.Len(); j++ {
t := s.At(j)
if t.Kind != token.Comment || !strings.HasPrefix(t.Value, "#[") {
continue
}
if i+1 < s.Len() && s.At(i+1).Kind == token.Whitespace && !hasNewline(s.At(i+1).Value) &&
!concatSpaceKeepsSpace(s, nextSignificantIndex(s, i)) {
s.RemoveAt(i + 1)
changed = true
}
if i >= 1 && s.At(i-1).Kind == token.Whitespace && !hasNewline(s.At(i-1).Value) &&
!concatSpaceKeepsSpace(s, prevSignificantIndex(s, i)) {
s.RemoveAt(i - 1)
if nv, ch := spaceAttributeConcat(t.Value, f.None); ch {
s.SetValue(j, nv)
changed = true
}
}
return changed
}

// spaceAttributeConcat normalizes same-line spacing around each top-level "."
// concat operator in an attribute token's text. Dots inside string literals,
// "..." spreads and float literals are left alone, and newlines are preserved.
func spaceAttributeConcat(value string, none bool) (string, bool) {
var b []byte
changed := false
var quote byte
for i := 0; i < len(value); i++ {
c := value[i]
if quote != 0 {
b = append(b, c)
if c == '\\' && i+1 < len(value) {
i++
b = append(b, value[i])
continue
}
if c == quote {
quote = 0
}
continue
}
if c == '\'' || c == '"' {
quote = c
b = append(b, c)
continue
}
if c != '.' || !isConcatDot(value, i) {
b = append(b, c)
continue
}

// trailing same-line whitespace already written before the "."
start := len(b)
for start > 0 && (b[start-1] == ' ' || b[start-1] == '\t') {
start--
}
leadNewline := start > 0 && (b[start-1] == '\n' || b[start-1] == '\r')
// leading same-line whitespace after the "."
j := i + 1
for j < len(value) && (value[j] == ' ' || value[j] == '\t') {
j++
}
trailNewline := j >= len(value) || value[j] == '\n' || value[j] == '\r'

if none {
if start > 0 && !leadNewline && len(b)-start > 0 {
if isDigitByte(b[start-1]) { // guard against number.number re-lexing
if len(b)-start != 1 {
b = append(b[:start], ' ')
changed = true
}
} else {
b = b[:start]
changed = true
}
}
b = append(b, '.')
if !trailNewline && j > i+1 {
if isDigitByte(value[j]) {
b = append(b, ' ')
if j-(i+1) != 1 {
changed = true
}
} else {
changed = true
}
i = j - 1
}
continue
}

// none == false: force a single same-line space on each side
if start > 0 && !leadNewline {
if len(b)-start != 1 || b[start] != ' ' {
b = append(b[:start], ' ')
changed = true
}
}
b = append(b, '.')
if !trailNewline {
b = append(b, ' ')
if j-(i+1) != 1 {
changed = true
}
i = j - 1
}
}
return string(b), changed
}

// isConcatDot reports whether the "." at index i is a concatenation operator,
// not part of a "..." spread or a float literal (e.g. 1.5, .5, 1.).
func isConcatDot(value string, i int) bool {
if i+1 < len(value) && value[i+1] == '.' {
return false
}
if i > 0 && value[i-1] == '.' {
return false
}
if i > 0 && isDigitByte(value[i-1]) {
k := i - 1
for k > 0 && isDigitByte(value[k-1]) {
k--
}
// a digit run not preceded by a name char is a number -> "." is a float dot
if k == 0 || !isIdentByte(value[k-1]) {
return false
}
}
if i+1 < len(value) && isDigitByte(value[i+1]) && (i == 0 || !isDigitByte(value[i-1])) {
p := i - 1
for p >= 0 && (value[p] == ' ' || value[p] == '\t') {
p--
}
// ".5" is a float unless it follows an operand (identifier, string, ) or ])
if p < 0 {
return false
}
c := value[p]
if !isIdentByte(c) && c != '\'' && c != '"' && c != ')' && c != ']' {
return false
}
}
return true
}

func isDigitByte(c byte) bool { return c >= '0' && c <= '9' }

// concatSpaceKeepsSpace reports whether the operand at idx must stay spaced from
// "." (a number would lex as a float, a comment would swallow the operator).
func concatSpaceKeepsSpace(s *tokens.Stream, idx int) bool {
Expand Down
6 changes: 6 additions & 0 deletions blink/internal/fixer/rules/concat_space_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ func TestConcatSpaceConfigCases(t *testing.T) {
{"default one", ConcatSpace{}, "<?php $a = 'a'.'b';", "<?php $a = 'a' . 'b';"},
{"one explicit", ConcatSpace{}.WithConfig(map[string]any{"spacing": "one"}).(fixerRule), "<?php $a = 'a'.'b';", "<?php $a = 'a' . 'b';"},
{"none", ConcatSpace{}.WithConfig(map[string]any{"spacing": "none"}).(fixerRule), "<?php $a = 'a' . 'b';", "<?php $a = 'a'.'b';"},
{"attribute concat spaced", ConcatSpace{}, "<?php #[WithEnv('K', __DIR__.'/x.php')]\nclass C {}", "<?php #[WithEnv('K', __DIR__ . '/x.php')]\nclass C {}"},
{"attribute concat none", ConcatSpace{}.WithConfig(map[string]any{"spacing": "none"}).(fixerRule), "<?php #[WithEnv('K', __DIR__ . '/x.php')]\nclass C {}", "<?php #[WithEnv('K', __DIR__.'/x.php')]\nclass C {}"},
{"dot inside attribute string left alone", ConcatSpace{}, "<?php #[X('a.b . c')]\nclass C {}", "<?php #[X('a.b . c')]\nclass C {}"},
{"float literal in attribute left alone", ConcatSpace{}, "<?php #[X(1.5)]\nclass C {}", "<?php #[X(1.5)]\nclass C {}"},
{"spread in attribute left alone", ConcatSpace{}, "<?php #[X(...$a)]\nclass C {}", "<?php #[X(...$a)]\nclass C {}"},
{"scope resolution in attribute left alone", ConcatSpace{}, "<?php #[X(self::A.'b')]\nclass C {}", "<?php #[X(self::A . 'b')]\nclass C {}"},
}
for _, c := range cases {
got, _ := apply(t, c.rule, c.src)
Expand Down
Loading