From 12dd5f5a1ab8f691815a7ee39111895f2f7fc8ff Mon Sep 17 00:00:00 2001 From: Jeff Mark Date: Sat, 18 Apr 2026 07:28:02 -0700 Subject: [PATCH] Fix explicit_self false positives around string interpolation SourceKit can report instance-member refs for text inside string literals, at the open paren of \(...), or with offsets that do not match a simple '.' check. Combine SwiftSyntax static segment ranges with source-text verification for explicit self. spans, and skip bogus offsets at string interpolation open parens. Add non-triggering, triggering, and correction examples for implicit member access inside string interpolation. --- CHANGELOG.md | 4 + .../Rules/Style/ExplicitSelfRule.swift | 149 +++++++++++++++++- .../Style/ExplicitSelfRuleExamples.swift | 89 +++++++++++ 3 files changed, 241 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df3ac1957c..e679957839 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Source/SwiftLintBuiltInRules/Rules/Style/ExplicitSelfRule.swift b/Source/SwiftLintBuiltInRules/Rules/Style/ExplicitSelfRule.swift index a85c4a5a45..502c076368 100644 --- a/Source/SwiftLintBuiltInRules/Rules/Style/ExplicitSelfRule.swift +++ b/Source/SwiftLintBuiltInRules/Rules/Style/ExplicitSelfRule.swift @@ -1,5 +1,6 @@ import Foundation import SourceKittenFramework +import SwiftSyntax struct ExplicitSelfRule: CorrectableRule, AnalyzerRule { var configuration = SeverityConfiguration(.warning) @@ -63,6 +64,7 @@ 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 { @@ -70,6 +72,30 @@ struct ExplicitSelfRule: CorrectableRule, AnalyzerRule { 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)) } } @@ -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 } } @@ -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.` 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 + } +} diff --git a/Source/SwiftLintBuiltInRules/Rules/Style/ExplicitSelfRuleExamples.swift b/Source/SwiftLintBuiltInRules/Rules/Style/ExplicitSelfRuleExamples.swift index 8dad43365f..570dbb121a 100644 --- a/Source/SwiftLintBuiltInRules/Rules/Style/ExplicitSelfRuleExamples.swift +++ b/Source/SwiftLintBuiltInRules/Rules/Style/ExplicitSelfRuleExamples.swift @@ -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 = [ @@ -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 = [ @@ -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)}"# + } + } + """), ] }