Skip to content

Commit ceccd1d

Browse files
sm-eminiakhmetovkapitoshka438
authored andcommitted
Add new invisible_characters rule
1 parent afc957a commit ceccd1d

File tree

11 files changed

+154
-37
lines changed

11 files changed

+154
-37
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
@@ -95,6 +95,7 @@ public let builtInRules: [any Rule.Type] = [
9595
IncompatibleConcurrencyAnnotationRule.self,
9696
IndentationWidthRule.self,
9797
InvalidSwiftLintCommandRule.self,
98+
InvisibleCharactersRule.self,
9899
IsDisjointRule.self,
99100
JoinedDefaultParameterRule.self,
100101
LargeTupleRule.self,
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
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+
Example(#"let empty = """#),
26+
Example(#"let tab = "Hello\tWorld""#),
27+
Example(#"let newline = "Hello\nWorld""#),
28+
Example(#"let unicode = "Hello 👋 World""#),
29+
],
30+
triggeringExamples: [
31+
// swiftlint:disable invisible_characters
32+
Example(#"let s = "Hello↓​World""#), // U+200B zero-width space
33+
Example(#"let s = "Hello↓World""#), // U+FEFF formatting character
34+
Example(#"let url = "https://example↓​.com""#), // U+200B in URL
35+
Example("""
36+
let multiline = \"\"\"
37+
Hello↓​World
38+
\"\"\"
39+
"""), // U+200B in multiline string
40+
Example(#"let s = "Test↓​String↓Here""#), // Multiple invisible characters
41+
// swiftlint:enable invisible_characters
42+
]
43+
)
44+
45+
private static let invisibleCharacters: Set<Unicode.Scalar> = [
46+
"\u{200B}", // Zero-width space
47+
"\u{FEFF}", // Zero-width no-break space (BOM/FEFF)
48+
]
49+
}
50+
51+
private extension InvisibleCharactersRule {
52+
final class Visitor: ViolationsSyntaxVisitor<ConfigurationType> {
53+
override func visitPost(_ node: StringLiteralExprSyntax) {
54+
for segment in node.segments {
55+
guard let stringSegment = segment.as(StringSegmentSyntax.self) else {
56+
continue
57+
}
58+
59+
let text = stringSegment.content.text
60+
61+
// Early exit if no invisible characters present
62+
guard text.unicodeScalars.contains(where: {
63+
InvisibleCharactersRule.invisibleCharacters.contains($0)
64+
}) else {
65+
continue
66+
}
67+
68+
// Find all invisible characters and their positions
69+
var utf8Offset = 0
70+
for scalar in text.unicodeScalars {
71+
if InvisibleCharactersRule.invisibleCharacters.contains(scalar) {
72+
// Calculate absolute position of the invisible character
73+
let invisibleCharPosition = stringSegment.content.positionAfterSkippingLeadingTrivia
74+
.advanced(by: utf8Offset)
75+
76+
let charName = characterName(for: scalar)
77+
let reason = "String literal should not contain invisible character: \(charName)"
78+
violations.append(
79+
ReasonedRuleViolation(
80+
position: invisibleCharPosition,
81+
reason: reason
82+
)
83+
)
84+
}
85+
utf8Offset += String(scalar).utf8.count
86+
}
87+
}
88+
}
89+
90+
private func characterName(for scalar: Unicode.Scalar) -> String {
91+
switch scalar {
92+
case "\u{200B}":
93+
return "U+200B (zero-width space)"
94+
case "\u{FEFF}":
95+
return "U+FEFF (zero-width no-break space)"
96+
default:
97+
return "U+\(String(scalar.value, radix: 16, uppercase: true))"
98+
}
99+
}
100+
}
101+
}

Tests/GeneratedTests/GeneratedTests_04.swift

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,12 @@ final class InvalidSwiftLintCommandRuleGeneratedTests: SwiftLintTestCase {
115115
}
116116
}
117117

118+
final class InvisibleCharactersRuleGeneratedTests: SwiftLintTestCase {
119+
func testWithDefaultConfiguration() {
120+
verifyRule(InvisibleCharactersRule.description)
121+
}
122+
}
123+
118124
final class IsDisjointRuleGeneratedTests: SwiftLintTestCase {
119125
func testWithDefaultConfiguration() {
120126
verifyRule(IsDisjointRule.description)
@@ -150,9 +156,3 @@ final class LegacyCGGeometryFunctionsRuleGeneratedTests: SwiftLintTestCase {
150156
verifyRule(LegacyCGGeometryFunctionsRule.description)
151157
}
152158
}
153-
154-
final class LegacyConstantRuleGeneratedTests: SwiftLintTestCase {
155-
func testWithDefaultConfiguration() {
156-
verifyRule(LegacyConstantRule.description)
157-
}
158-
}

Tests/GeneratedTests/GeneratedTests_05.swift

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@
77
@testable import SwiftLintCore
88
import TestHelpers
99

10+
final class LegacyConstantRuleGeneratedTests: SwiftLintTestCase {
11+
func testWithDefaultConfiguration() {
12+
verifyRule(LegacyConstantRule.description)
13+
}
14+
}
15+
1016
final class LegacyConstructorRuleGeneratedTests: SwiftLintTestCase {
1117
func testWithDefaultConfiguration() {
1218
verifyRule(LegacyConstructorRule.description)
@@ -150,9 +156,3 @@ final class NSLocalizedStringRequireBundleRuleGeneratedTests: SwiftLintTestCase
150156
verifyRule(NSLocalizedStringRequireBundleRule.description)
151157
}
152158
}
153-
154-
final class NSNumberInitAsFunctionReferenceRuleGeneratedTests: SwiftLintTestCase {
155-
func testWithDefaultConfiguration() {
156-
verifyRule(NSNumberInitAsFunctionReferenceRule.description)
157-
}
158-
}

Tests/GeneratedTests/GeneratedTests_06.swift

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@
77
@testable import SwiftLintCore
88
import TestHelpers
99

10+
final class NSNumberInitAsFunctionReferenceRuleGeneratedTests: SwiftLintTestCase {
11+
func testWithDefaultConfiguration() {
12+
verifyRule(NSNumberInitAsFunctionReferenceRule.description)
13+
}
14+
}
15+
1016
final class NSObjectPreferIsEqualRuleGeneratedTests: SwiftLintTestCase {
1117
func testWithDefaultConfiguration() {
1218
verifyRule(NSObjectPreferIsEqualRule.description)
@@ -150,9 +156,3 @@ final class PeriodSpacingRuleGeneratedTests: SwiftLintTestCase {
150156
verifyRule(PeriodSpacingRule.description)
151157
}
152158
}
153-
154-
final class PreferAssetSymbolsRuleGeneratedTests: SwiftLintTestCase {
155-
func testWithDefaultConfiguration() {
156-
verifyRule(PreferAssetSymbolsRule.description)
157-
}
158-
}

Tests/GeneratedTests/GeneratedTests_07.swift

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@
77
@testable import SwiftLintCore
88
import TestHelpers
99

10+
final class PreferAssetSymbolsRuleGeneratedTests: SwiftLintTestCase {
11+
func testWithDefaultConfiguration() {
12+
verifyRule(PreferAssetSymbolsRule.description)
13+
}
14+
}
15+
1016
final class PreferConditionListRuleGeneratedTests: SwiftLintTestCase {
1117
func testWithDefaultConfiguration() {
1218
verifyRule(PreferConditionListRule.description)
@@ -150,9 +156,3 @@ final class RedundantDiscardableLetRuleGeneratedTests: SwiftLintTestCase {
150156
verifyRule(RedundantDiscardableLetRule.description)
151157
}
152158
}
153-
154-
final class RedundantNilCoalescingRuleGeneratedTests: SwiftLintTestCase {
155-
func testWithDefaultConfiguration() {
156-
verifyRule(RedundantNilCoalescingRule.description)
157-
}
158-
}

Tests/GeneratedTests/GeneratedTests_08.swift

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@
77
@testable import SwiftLintCore
88
import TestHelpers
99

10+
final class RedundantNilCoalescingRuleGeneratedTests: SwiftLintTestCase {
11+
func testWithDefaultConfiguration() {
12+
verifyRule(RedundantNilCoalescingRule.description)
13+
}
14+
}
15+
1016
final class RedundantObjcAttributeRuleGeneratedTests: SwiftLintTestCase {
1117
func testWithDefaultConfiguration() {
1218
verifyRule(RedundantObjcAttributeRule.description)
@@ -150,9 +156,3 @@ final class StrictFilePrivateRuleGeneratedTests: SwiftLintTestCase {
150156
verifyRule(StrictFilePrivateRule.description)
151157
}
152158
}
153-
154-
final class StrongIBOutletRuleGeneratedTests: SwiftLintTestCase {
155-
func testWithDefaultConfiguration() {
156-
verifyRule(StrongIBOutletRule.description)
157-
}
158-
}

Tests/GeneratedTests/GeneratedTests_09.swift

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@
77
@testable import SwiftLintCore
88
import TestHelpers
99

10+
final class StrongIBOutletRuleGeneratedTests: SwiftLintTestCase {
11+
func testWithDefaultConfiguration() {
12+
verifyRule(StrongIBOutletRule.description)
13+
}
14+
}
15+
1016
final class SuperfluousElseRuleGeneratedTests: SwiftLintTestCase {
1117
func testWithDefaultConfiguration() {
1218
verifyRule(SuperfluousElseRule.description)
@@ -150,9 +156,3 @@ final class UnneededSynthesizedInitializerRuleGeneratedTests: SwiftLintTestCase
150156
verifyRule(UnneededSynthesizedInitializerRule.description)
151157
}
152158
}
153-
154-
final class UnneededThrowsRuleGeneratedTests: SwiftLintTestCase {
155-
func testWithDefaultConfiguration() {
156-
verifyRule(UnneededThrowsRule.description)
157-
}
158-
}

Tests/GeneratedTests/GeneratedTests_10.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@
77
@testable import SwiftLintCore
88
import TestHelpers
99

10+
final class UnneededThrowsRuleGeneratedTests: SwiftLintTestCase {
11+
func testWithDefaultConfiguration() {
12+
verifyRule(UnneededThrowsRule.description)
13+
}
14+
}
15+
1016
final class UnownedVariableCaptureRuleGeneratedTests: SwiftLintTestCase {
1117
func testWithDefaultConfiguration() {
1218
verifyRule(UnownedVariableCaptureRule.description)

0 commit comments

Comments
 (0)