Skip to content

Commit 21d78ba

Browse files
Add new invisible_characters rule
1 parent d4585d4 commit 21d78ba

File tree

4 files changed

+144
-1
lines changed

4 files changed

+144
-1
lines changed

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@
1212

1313
### Enhancements
1414

15-
* None.
15+
* Add new default `invisible_characters` rule that detects invisible characters
16+
like zero-width space (U+200B) and FEFF formatting character (U+FEFF) in
17+
string literals, which can cause hard-to-debug issues.
18+
[kapitoshka438](https://github.com/kapitoshka438)
19+
[#6416](https://github.com/realm/SwiftLint/issues/6045)
1620

1721
### Bug Fixes
1822

Source/SwiftLintBuiltInRules/Models/BuiltInRules.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ public let builtInRules: [any Rule.Type] = [
9292
ImplicitReturnRule.self,
9393
ImplicitlyUnwrappedOptionalRule.self,
9494
InclusiveLanguageRule.self,
95+
InvisibleCharactersRule.self,
9596
IncompatibleConcurrencyAnnotationRule.self,
9697
IndentationWidthRule.self,
9798
InvalidSwiftLintCommandRule.self,
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import SwiftSyntax
2+
3+
@SwiftSyntaxRule
4+
struct InvisibleCharactersRule: Rule {
5+
var configuration = SeverityConfiguration<Self>(.error)
6+
7+
static let description = RuleDescription(
8+
identifier: "invisible_characters",
9+
name: "Invisible Characters",
10+
description: "Disallows invisible characters like zero-width space (U+200B) " +
11+
"and FEFF formatting character (U+FEFF) " +
12+
"in string literals as they can cause hard-to-debug issues",
13+
kind: .lint,
14+
nonTriggeringExamples: [
15+
Example(#"let s = "HelloWorld""#),
16+
Example(#"let s = "Hello World""#),
17+
Example(#"let url = "https://example.com/api""#),
18+
Example(##"let s = #"Hello World"#"##),
19+
Example("""
20+
let multiline = \"\"\"
21+
Hello
22+
World
23+
\"\"\"
24+
"""),
25+
],
26+
triggeringExamples: [
27+
Example(#"let s = "Hello↓​World""#), // U+200B zero-width space
28+
Example(#"let s = "Hello↓World""#), // U+FEFF formatting character
29+
Example(#"let url = "https://example↓​.com""#), // U+200B in URL
30+
Example("""
31+
let multiline = \"\"\"
32+
Hello↓​World
33+
\"\"\"
34+
"""), // U+200B in multiline string
35+
]
36+
)
37+
38+
private static let invisibleCharacters: Set<Unicode.Scalar> = [
39+
"\u{200B}", // Zero-width space
40+
"\u{FEFF}", // Zero-width no-break space (BOM/FEFF)
41+
]
42+
}
43+
44+
private extension InvisibleCharactersRule {
45+
final class Visitor: ViolationsSyntaxVisitor<ConfigurationType> {
46+
override func visitPost(_ node: StringLiteralExprSyntax) {
47+
for segment in node.segments {
48+
guard let stringSegment = segment.as(StringSegmentSyntax.self) else {
49+
continue
50+
}
51+
52+
let text = stringSegment.content.text
53+
54+
// Early exit if no invisible characters present
55+
guard text.unicodeScalars.contains(where: { InvisibleCharactersRule.invisibleCharacters.contains($0) }) else {
56+
continue
57+
}
58+
59+
// Find all invisible characters and their positions
60+
var utf8Offset = 0
61+
for scalar in text.unicodeScalars {
62+
if InvisibleCharactersRule.invisibleCharacters.contains(scalar) {
63+
// Calculate absolute position of the invisible character
64+
let invisibleCharPosition = stringSegment.content.positionAfterSkippingLeadingTrivia
65+
.advanced(by: utf8Offset)
66+
67+
let charName = characterName(for: scalar)
68+
let reason = "String literal should not contain invisible character: \(charName)"
69+
violations.append(
70+
ReasonedRuleViolation(
71+
position: invisibleCharPosition,
72+
reason: reason
73+
)
74+
)
75+
}
76+
utf8Offset += String(scalar).utf8.count
77+
}
78+
}
79+
}
80+
81+
private func characterName(for scalar: Unicode.Scalar) -> String {
82+
switch scalar {
83+
case "\u{200B}":
84+
return "U+200B (zero-width space)"
85+
case "\u{FEFF}":
86+
return "U+FEFF (zero-width no-break space)"
87+
default:
88+
return "U+\(String(scalar.value, radix: 16, uppercase: true))"
89+
}
90+
}
91+
}
92+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
@testable import SwiftLintBuiltInRules
2+
import TestHelpers
3+
4+
final class InvisibleCharactersRuleTests: SwiftLintTestCase {
5+
func testInvisibleCharacters() {
6+
let nonTriggeringExamples = [
7+
Example(#"let s = "HelloWorld""#),
8+
Example(#"let s = "Hello World""#),
9+
Example(#"let url = "https://example.com/api""#),
10+
Example(##"let s = #"Hello World"#"##),
11+
Example("""
12+
let multiline = \"\"\"
13+
Hello
14+
World
15+
\"\"\"
16+
"""),
17+
Example(#"let empty = """#),
18+
Example(#"let tab = "Hello\tWorld""#),
19+
Example(#"let newline = "Hello\nWorld""#),
20+
Example(#"let unicode = "Hello 👋 World""#),
21+
]
22+
23+
let triggeringExamples = [
24+
// Zero-width space (U+200B)
25+
Example(#"let s = "Hello↓​World""#),
26+
// Zero-width no-break space / BOM (U+FEFF)
27+
Example(#"let s = "Hello↓World""#),
28+
// Zero-width space in URL
29+
Example(#"let url = "https://example↓​.com""#),
30+
// Zero-width space in multiline string
31+
Example("""
32+
let multiline = \"\"\"
33+
Hello↓​World
34+
\"\"\"
35+
"""),
36+
// Multiple invisible characters
37+
Example(#"let s = "Test↓​String↓Here""#),
38+
]
39+
40+
let description = InvisibleCharactersRule.description
41+
.with(triggeringExamples: triggeringExamples)
42+
.with(nonTriggeringExamples: nonTriggeringExamples)
43+
44+
verifyRule(description)
45+
}
46+
}

0 commit comments

Comments
 (0)