Skip to content

Commit eef6f03

Browse files
Make the rule correctable
1 parent 9033a6d commit eef6f03

File tree

2 files changed

+103
-25
lines changed

2 files changed

+103
-25
lines changed
Lines changed: 92 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import SwiftSyntax
22

3-
@SwiftSyntaxRule
3+
@SwiftSyntaxRule(explicitRewriter: true)
44
struct InvisibleCharacterRule: Rule {
55
var configuration = SeverityConfiguration<Self>(.error)
66

7+
// swiftlint:disable invisible_character
78
static let description = RuleDescription(
89
identifier: "invisible_character",
910
name: "Invisible Character",
@@ -30,7 +31,6 @@ struct InvisibleCharacterRule: Rule {
3031
Example(#"let unicode = "Hello 👋 World""#),
3132
],
3233
triggeringExamples: [
33-
// swiftlint:disable invisible_character
3434
Example(#"let s = "Hello↓​World" // U+200B zero-width space"#),
3535
Example(#"let s = "Hello↓‌World" // U+200C zero-width non-joiner"#),
3636
Example(#"let s = "Hello↓World" // U+FEFF formatting character"#),
@@ -44,51 +44,118 @@ struct InvisibleCharacterRule: Rule {
4444
Example(#"let s = "Test↓​String↓Here" // Multiple invisible characters"#),
4545
Example(#"let s = "Hel↓‌lo" + "World" // string concatenation with U+200C"#),
4646
Example(#"let s = "Hel↓‌lo \(name)" // U+200C in interpolated string"#),
47-
// swiftlint:enable invisible_character
47+
],
48+
corrections: [
49+
Example(#"let s = "Hello​World""#): Example(#"let s = "HelloWorld""#),
50+
Example(#"let s = "Hello‌World""#): Example(#"let s = "HelloWorld""#),
51+
Example(#"let s = "HelloWorld""#): Example(#"let s = "HelloWorld""#),
52+
Example(#"let url = "https://example​.com""#): Example(#"let url = "https://example.com""#),
53+
Example("""
54+
let multiline = \"\"\"
55+
Hello​World
56+
\"\"\"
57+
"""): Example("""
58+
let multiline = \"\"\"
59+
HelloWorld
60+
\"\"\"
61+
"""),
62+
Example(#"let s = "Test​StringHere""#): Example(#"let s = "TestStringHere""#),
63+
Example(#"let s = "Hel‌lo" + "World""#): Example(#"let s = "Hello" + "World""#),
64+
Example(#"let s = "Hel‌lo \(name)""#): Example(#"let s = "Hello \(name)""#),
4865
]
4966
)
67+
// swiftlint:enable invisible_character
5068

5169
private static let invisibleCharacters: [Unicode.Scalar: String] = [
5270
"\u{200B}": "U+200B (zero-width space)",
5371
"\u{200C}": "U+200C (zero-width non-joiner)",
5472
"\u{FEFF}": "U+FEFF (zero-width no-break space)",
5573
]
74+
75+
private static let invisibleCharacterSet: Set<Unicode.Scalar> = Set(invisibleCharacters.keys)
5676
}
5777

5878
private extension InvisibleCharacterRule {
79+
static func violatingSegments(
80+
in node: StringLiteralExprSyntax
81+
) -> [SyntaxIdentifier: (segment: StringSegmentSyntax, text: String)] {
82+
var result = [SyntaxIdentifier: (StringSegmentSyntax, String)]()
83+
for segment in node.segments {
84+
guard let stringSegment = segment.as(StringSegmentSyntax.self) else {
85+
continue
86+
}
87+
let text = stringSegment.content.text
88+
if text.unicodeScalars.contains(where: { invisibleCharacterSet.contains($0) }) {
89+
result[stringSegment.id] = (stringSegment, text)
90+
}
91+
}
92+
return result
93+
}
94+
5995
final class Visitor: ViolationsSyntaxVisitor<ConfigurationType> {
6096
override func visitPost(_ node: StringLiteralExprSyntax) {
61-
let invisibleCharactersKeys = InvisibleCharacterRule.invisibleCharacters.keys
62-
63-
for segment in node.segments {
64-
guard let stringSegment = segment.as(StringSegmentSyntax.self) else {
65-
continue
97+
for (segment, text) in violatingSegments(in: node).values {
98+
var utf8Offset = 0
99+
for scalar in text.unicodeScalars {
100+
defer { utf8Offset += scalar.utf8Length }
101+
guard let characterName = invisibleCharacters[scalar] else {
102+
continue
103+
}
104+
violations.append(
105+
ReasonedRuleViolation(
106+
position: segment.content.positionAfterSkippingLeadingTrivia
107+
.advanced(by: utf8Offset),
108+
reason: "String literal should not contain invisible character \(characterName)"
109+
)
110+
)
66111
}
112+
}
113+
}
114+
}
67115

68-
let text = stringSegment.content.text
116+
final class Rewriter: ViolationsSyntaxRewriter<ConfigurationType> {
117+
override func visit(_ node: StringLiteralExprSyntax) -> ExprSyntax {
118+
guard !isDisabled(atStartPositionOf: node) else {
119+
return super.visit(node)
120+
}
69121

70-
// Early exit if no invisible characters present
71-
guard text.unicodeScalars.contains(where: {
72-
invisibleCharactersKeys.contains($0)
73-
}) else {
74-
continue
122+
let segmentsToFix = violatingSegments(in: node)
123+
guard segmentsToFix.isNotEmpty else {
124+
return super.visit(node)
125+
}
126+
127+
var correctionCount = 0
128+
129+
let newSegments = node.segments.map { segment -> StringLiteralSegmentListSyntax.Element in
130+
guard let stringSegment = segment.as(StringSegmentSyntax.self),
131+
let (_, text) = segmentsToFix[stringSegment.id] else {
132+
return segment
75133
}
76134

77-
// Find all invisible characters and their positions
78-
var utf8Offset = 0
135+
var cleanedScalars = [Unicode.Scalar]()
136+
cleanedScalars.reserveCapacity(text.unicodeScalars.count)
137+
var removedCount = 0
138+
79139
for scalar in text.unicodeScalars {
80-
if let character = InvisibleCharacterRule.invisibleCharacters[scalar] {
81-
violations.append(
82-
ReasonedRuleViolation(
83-
position: stringSegment.content.positionAfterSkippingLeadingTrivia
84-
.advanced(by: utf8Offset),
85-
reason: "String literal should not contain invisible character \(character)"
86-
)
87-
)
140+
if invisibleCharacterSet.contains(scalar) {
141+
removedCount += 1
142+
} else {
143+
cleanedScalars.append(scalar)
88144
}
89-
utf8Offset += String(scalar).utf8.count
90145
}
146+
147+
correctionCount += removedCount
148+
let cleanedText = String(String.UnicodeScalarView(cleanedScalars))
149+
let newSegment = stringSegment.with(
150+
\.content,
151+
stringSegment.content.with(\.tokenKind, .stringSegment(cleanedText))
152+
)
153+
return .stringSegment(newSegment)
91154
}
155+
156+
numberOfCorrections += correctionCount
157+
let newNode = node.with(\.segments, StringLiteralSegmentListSyntax(newSegments))
158+
return super.visit(newNode)
92159
}
93160
}
94161
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
public extension Unicode.Scalar {
2+
/// Returns the number of bytes needed to encode this scalar in UTF-8.
3+
var utf8Length: Int {
4+
switch value {
5+
case 0x00...0x7F: 1
6+
case 0x80...0x7FF: 2
7+
case 0x800...0xFFFF: 3
8+
default: 4
9+
}
10+
}
11+
}

0 commit comments

Comments
 (0)