Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,10 @@
[theamodhshetty](https://github.com/theamodhshetty)
[#5917](https://github.com/realm/SwiftLint/issues/5917)

* Fix `explicit_self` false positives around string interpolation.
[jffmrk](https://github.com/jffmrk)
[#6611](https://github.com/realm/SwiftLint/issues/6611)

## 0.63.2: High-Speed Extraction

### Breaking
Expand Down
149 changes: 148 additions & 1 deletion Source/SwiftLintBuiltInRules/Rules/Style/ExplicitSelfRule.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Foundation
import SourceKittenFramework
import SwiftSyntax

struct ExplicitSelfRule: CorrectableRule, AnalyzerRule {
var configuration = SeverityConfiguration<Self>(.warning)
Expand Down Expand Up @@ -63,13 +64,38 @@ struct ExplicitSelfRule: CorrectableRule, AnalyzerRule {
}

let contents = file.stringView
let staticStringLiteralTextRanges = byteRangesOfStaticStringLiteralText(in: file)

return cursorsMissingExplicitSelf.compactMap { cursorInfo in
guard let byteOffset = (cursorInfo["swiftlint.offset"] as? Int64).flatMap(ByteCount.init) else {
Issue.genericWarning("Cannot convert offsets in '\(Self.identifier)' rule.").print()
return nil
}

// SourceKit’s index can attach member refs to identifiers that appear only as text inside a string literal
// (e.g. `{foo:` before `\(self.foo)`). Those are not real member accesses and must be ignored.
if staticStringLiteralTextRanges.contains(where: { $0.contains(byteOffset) }) {
return nil
}

// The index sometimes reports a member ref at the `(` of `\(` / `\#(…)`; correcting inserts `self.`
// before `(` and breaks the interpolation (`\self.(…)` / `\#self.(…)`). Drop these bogus offsets.
if contents.isStringInterpolationOpenParen(at: byteOffset) {
return nil
}

// SourceKit can also attach refs to string literal delimiters and `\` before `\(` inside `"\(a)\(b)"`-style
// literals. Those offsets are not identifier starts; inserting `self.` corrupts the literal. Real implicit
// `self` fixes always begin at the first character of a member name (letter, `_`, or `$`).
if !contents.isOffsetAtPlausibleImplicitMemberIdentifierHead(byteOffset) {
return nil
}

let sourceKittenDictionary = SourceKittenDictionary(cursorInfo)
if contents.sourceTextShowsExplicitSelfMember(cursorInfo: sourceKittenDictionary, at: byteOffset) {
return nil
}

return contents.byteRangeToNSRange(ByteRange(location: byteOffset, length: 0))
}
}
Expand Down Expand Up @@ -111,7 +137,19 @@ private extension SwiftLintFile {
}

private func isExplicitAccess(at location: ByteCount) -> Bool {
stringView.substringWithByteRange(ByteRange(location: location - 1, length: 1))! == "."
guard location > 0 else { return false }
let view = stringView
// Standard member access: `.foo`
if view.substringWithByteRange(ByteRange(location: location - 1, length: 1)) == "." {
return true
}
// SourceKit offset for `foo` in string interpolations like `\(self.foo)` can disagree with the
// character immediately preceding the identifier, so also accept an explicit `self.` prefix.
let explicitSelfDot = "self."
let explicitSelfDotLength = ByteCount(explicitSelfDot.utf8.count)
guard location >= explicitSelfDotLength else { return false }
let range = ByteRange(location: location - explicitSelfDotLength, length: explicitSelfDotLength)
return view.substringWithByteRange(range) == explicitSelfDot
}
}

Expand Down Expand Up @@ -140,3 +178,112 @@ private func binaryOffsets(file: SwiftLintFile, compilerArguments: [String]) thr
let binaryOffsets = file.stringView.recursiveByteOffsets(index)
return binaryOffsets.sorted()
}

/// Byte ranges of static text in string literals (excluding `\(...)` interpolation expressions).
private func byteRangesOfStaticStringLiteralText(in file: SwiftLintFile) -> [ByteRange] {
let visitor = StringLiteralStaticTextVisitor()
visitor.walk(file.syntaxTree)
return visitor.byteRanges
}

private final class StringLiteralStaticTextVisitor: SyntaxVisitor {
private(set) var byteRanges: [ByteRange] = []

init() {
super.init(viewMode: .sourceAccurate)
}

override func visitPost(_ node: StringSegmentSyntax) {
let token = node.content
let start = token.positionAfterSkippingLeadingTrivia
let end = token.endPositionBeforeTrailingTrivia
let length = end.utf8Offset - start.utf8Offset
guard length > 0 else {
return
}
byteRanges.append(ByteRange(location: ByteCount(start.utf8Offset), length: ByteCount(length)))
}
}

private extension StringView {
/// `true` when `offset` starts an identifier that implicit-`self` correction prefixes with `self.`
/// (`_`, `$`, or a letter). Drops bogus SourceKit offsets on string literal punctuation.
func isOffsetAtPlausibleImplicitMemberIdentifierHead(_ offset: ByteCount) -> Bool {
guard let firstChar = firstCharacter(startingAtByteOffset: offset) else {
return false
}
return firstChar.isPlausibleImplicitSelfIdentifierHead
}

/// Reads the first `Character` beginning at `offset` (UTF-8); `offset` must be on a character boundary.
func firstCharacter(startingAtByteOffset offset: ByteCount) -> Character? {
guard let prefix = substringWithByteRange(ByteRange(location: offset, length: ByteCount(16))) else {
return nil
}
return prefix.first
}

/// `true` when `offset` is the `(` that begins string interpolation: `\(` or raw-string `\#…(` (any run of `#`).
func isStringInterpolationOpenParen(at offset: ByteCount) -> Bool {
guard offset > 0 else {
return false
}
guard substringWithByteRange(ByteRange(location: offset, length: 1)) == "(" else {
return false
}
var idx = offset - 1
while idx >= 0, substringWithByteRange(ByteRange(location: idx, length: 1)) == "#" {
idx -= 1
}
guard idx >= 0 else {
return false
}
return substringWithByteRange(ByteRange(location: idx, length: 1)) == "\\"
}

/// True when the source at `memberStart` is the member of an explicit `self.<member>` access.
/// Uses `key.length` from cursor info so the identifier matches the indexed slice
/// (not `key.name`, which can differ).
func sourceTextShowsExplicitSelfMember(cursorInfo: SourceKittenDictionary, at memberStart: ByteCount) -> Bool {
guard let length = cursorInfo.length, length.value > 0,
let identifier = substringWithByteRange(ByteRange(location: memberStart, length: length)) else {
return false
}
let memberText = "self." + identifier
guard let memberBytes = memberText.data(using: .utf8), !memberBytes.isEmpty else {
return false
}
let memberLength = ByteCount(memberBytes.count)
let selfDotLength = ByteCount("self.".utf8.count)
guard memberStart >= selfDotLength else {
return false
}
let spanStart = memberStart - selfDotLength
return substringWithByteRange(ByteRange(location: spanStart, length: memberLength)) == memberText
}
}

private extension ByteRange {
func contains(_ offset: ByteCount) -> Bool {
offset >= location && offset < location + length
}
}

private extension Character {
/// First character of an instance member name referenced without `self.` (including `$foo` / `_foo` wrappers).
var isPlausibleImplicitSelfIdentifierHead: Bool {
if self == "_" || self == "$" {
return true
}
if isLetter {
return true
}
// Bogus index offsets in string literals are almost always ASCII punctuation (`"`, `\`, delimiters).
if isASCII {
return false
}
// Rare non-ASCII identifier heads that are not “letters” in Unicode sense: allow rather than risk false
// negatives for valid Unicode identifiers.
return true
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,43 @@ struct ExplicitSelfRuleExamples {
A(p1: 10).$p1
}
"""),
Example("""
class StringInterpolation {
let foo = "foo"

var description: String {
return "StringInterpolation{foo: \\(self.foo)}"
}
}
"""),
Example("""
class StringInterpolationRawStringLiteral {
let foo = "foo"

var description: String {
return #"StringInterpolation{foo: \\#(self.foo)}"#
}
}
"""),
Example("""
class LocalStringInterpolation {
var bar: String

init() {
let a = "a"
let b = "b"
self.bar = "\\(a)\\(b)".uppercased()
}
}
"""),
Example("""
class StringConcatenation {
var description: String {
let number = 1
return "\\(number)" + " count"
}
}
"""),
]

static let triggeringExamples = [
Expand Down Expand Up @@ -81,6 +118,24 @@ struct ExplicitSelfRuleExamples {
A(p1: 10).$p1
}
"""),
Example("""
class StringInterpolation {
let foo = "foo"

var description: String {
return "StringInterpolation{foo: \\(↓foo)}"
}
}
"""),
Example("""
class StringInterpolationRawStringLiteral {
let foo = "foo"

var description: String {
return #"StringInterpolation{foo: \\#(↓foo)}"#
}
}
"""),
]

static let corrections = [
Expand Down Expand Up @@ -169,5 +224,39 @@ struct ExplicitSelfRuleExamples {
A(p1: 10).$p1
}
"""),
Example("""
class StringInterpolation {
let foo = "foo"

var description: String {
return "StringInterpolation{foo: \\(↓foo)}"
}
}
"""): Example("""
class StringInterpolation {
let foo = "foo"

var description: String {
return "StringInterpolation{foo: \\(self.foo)}"
}
}
"""),
Example("""
class StringInterpolationRawStringLiteral {
let foo = "foo"

var description: String {
return #"StringInterpolation{foo: \\#(↓foo)}"#
}
}
"""): Example("""
class StringInterpolationRawStringLiteral {
let foo = "foo"

var description: String {
return #"StringInterpolation{foo: \\#(self.foo)}"#
}
}
"""),
]
}