Skip to content

Commit c40896d

Browse files
Add new invisible_characters rule (#6424)
Co-authored-by: Miniakhmetov Eduard <eminiakhmetov@sportmasterlab.net>
1 parent 0316649 commit c40896d

13 files changed

Lines changed: 285 additions & 44 deletions

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@
4646
[nadeemnali](https://github.com/nadeemnali)
4747
[#6359](https://github.com/realm/SwiftLint/issues/6359)
4848

49+
* Add new default `invisible_character` rule that detects invisible characters
50+
like zero-width space (U+200B), zero-width non-joiner (U+200C),
51+
and FEFF formatting character (U+FEFF) in string literals, which can cause hard-to-debug issues.
52+
[kapitoshka438](https://github.com/kapitoshka438)
53+
[#6045](https://github.com/realm/SwiftLint/issues/6045)
54+
4955
### Bug Fixes
5056

5157
* Add an `ignore_attributes` option to `implicit_optional_initialization` so

Source/SwiftLintBuiltInRules/Models/BuiltInRules.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ public let builtInRules: [any Rule.Type] = [
9696
IncompatibleConcurrencyAnnotationRule.self,
9797
IndentationWidthRule.self,
9898
InvalidSwiftLintCommandRule.self,
99+
InvisibleCharacterRule.self,
99100
IsDisjointRule.self,
100101
JoinedDefaultParameterRule.self,
101102
LargeTupleRule.self,
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import SwiftSyntax
2+
3+
@SwiftSyntaxRule(correctable: true)
4+
struct InvisibleCharacterRule: Rule {
5+
var configuration = InvisibleCharacterConfiguration()
6+
7+
// swiftlint:disable invisible_character
8+
static let description = RuleDescription(
9+
identifier: "invisible_character",
10+
name: "Invisible Character",
11+
description: """
12+
Disallows invisible characters like zero-width space (U+200B), \
13+
zero-width non-joiner (U+200C), and FEFF formatting character (U+FEFF) \
14+
in string literals as they can cause hard-to-debug issues.
15+
""",
16+
kind: .lint,
17+
nonTriggeringExamples: [
18+
Example(#"let s = "HelloWorld""#),
19+
Example(#"let s = "Hello World""#),
20+
Example(#"let url = "https://example.com/api""#),
21+
Example(##"let s = #"Hello World"#"##),
22+
Example("""
23+
let multiline = \"\"\"
24+
Hello
25+
World
26+
\"\"\"
27+
"""),
28+
Example(#"let empty = """#),
29+
Example(#"let tab = "Hello\tWorld""#),
30+
Example(#"let newline = "Hello\nWorld""#),
31+
Example(#"let unicode = "Hello 👋 World""#),
32+
],
33+
triggeringExamples: [
34+
Example(#"let s = "Hello↓​World" // U+200B zero-width space"#),
35+
Example(#"let s = "Hello↓‌World" // U+200C zero-width non-joiner"#),
36+
Example(#"let s = "Hello↓World" // U+FEFF formatting character"#),
37+
Example(#"let url = "https://example↓​.com" // U+200B in URL"#),
38+
Example("""
39+
// U+200B in multiline string
40+
let multiline = \"\"\"
41+
Hello↓​World
42+
\"\"\"
43+
"""),
44+
Example(#"let s = "Test↓​String↓Here" // Multiple invisible characters"#),
45+
Example(#"let s = "Hel↓‌lo" + "World" // string concatenation with U+200C"#),
46+
Example(#"let s = "Hel↓‌lo \(name)" // U+200C in interpolated string"#),
47+
Example("""
48+
//
49+
// additional_code_points: ["00AD"]
50+
//
51+
let s = "Hello↓­World"
52+
""",
53+
configuration: [
54+
"additional_code_points": ["00AD"],
55+
]
56+
),
57+
Example("""
58+
//
59+
// additional_code_points: ["200D"]
60+
//
61+
let s = "Hello↓‍World"
62+
""",
63+
configuration: [
64+
"additional_code_points": ["200D"],
65+
]
66+
),
67+
],
68+
corrections: [
69+
Example(#"let s = "Hello​World""#): Example(#"let s = "HelloWorld""#),
70+
Example(#"let s = "Hello‌World""#): Example(#"let s = "HelloWorld""#),
71+
Example(#"let s = "HelloWorld""#): Example(#"let s = "HelloWorld""#),
72+
Example(#"let url = "https://example​.com""#): Example(#"let url = "https://example.com""#),
73+
Example("""
74+
let multiline = \"\"\"
75+
Hello​World
76+
\"\"\"
77+
"""): Example("""
78+
let multiline = \"\"\"
79+
HelloWorld
80+
\"\"\"
81+
"""),
82+
Example(#"let s = "Test​StringHere""#): Example(#"let s = "TestStringHere""#),
83+
Example(#"let s = "Hel‌lo" + "World""#): Example(#"let s = "Hello" + "World""#),
84+
Example(#"let s = "Hel‌lo \(name)""#): Example(#"let s = "Hello \(name)""#),
85+
Example(
86+
#"let s = "Hello­World""#,
87+
configuration: [
88+
"additional_code_points": ["00AD"],
89+
]
90+
): Example(
91+
#"let s = "HelloWorld""#,
92+
configuration: [
93+
"additional_code_points": ["00AD"],
94+
]
95+
),
96+
Example(
97+
#"let s = "Hello‍World""#,
98+
configuration: [
99+
"additional_code_points": ["200D"],
100+
]
101+
): Example(
102+
#"let s = "HelloWorld""#,
103+
configuration: [
104+
"additional_code_points": ["200D"],
105+
]
106+
),
107+
]
108+
)
109+
// swiftlint:enable invisible_character
110+
}
111+
112+
private extension InvisibleCharacterRule {
113+
final class Visitor: ViolationsSyntaxVisitor<ConfigurationType> {
114+
override func visitPost(_ node: StringLiteralExprSyntax) {
115+
let violatingCharacters = configuration.violatingCharacters
116+
for segment in node.segments {
117+
guard let stringSegment = segment.as(StringSegmentSyntax.self) else {
118+
continue
119+
}
120+
let text = stringSegment.content.text
121+
let scalars = text.unicodeScalars
122+
guard scalars.contains(where: { violatingCharacters.contains($0) }) else {
123+
continue
124+
}
125+
var utf8Offset = 0
126+
127+
for scalar in scalars {
128+
defer {
129+
utf8Offset += scalar.utf8.count
130+
}
131+
guard violatingCharacters.contains(scalar) else {
132+
continue
133+
}
134+
135+
let characterName = InvisibleCharacterConfiguration.defaultCharacterDescriptions[scalar]
136+
?? scalar.escaped(asASCII: true)
137+
138+
let position = stringSegment.content.positionAfterSkippingLeadingTrivia.advanced(by: utf8Offset)
139+
violations.append(
140+
ReasonedRuleViolation(
141+
position: position,
142+
reason: "String literal should not contain invisible character \(characterName)",
143+
correction: .init(
144+
start: position,
145+
end: position.advanced(by: scalar.utf8.count),
146+
replacement: ""
147+
)
148+
)
149+
)
150+
}
151+
}
152+
}
153+
}
154+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import SwiftLintCore
2+
3+
@AutoConfigParser
4+
struct InvisibleCharacterConfiguration: SeverityBasedRuleConfiguration {
5+
static let defaultCharacterDescriptions: [UnicodeScalar: String] = [
6+
"\u{200B}": "U+200B (zero-width space)",
7+
"\u{200C}": "U+200C (zero-width non-joiner)",
8+
"\u{FEFF}": "U+FEFF (zero-width no-break space)",
9+
]
10+
11+
@ConfigurationElement(key: "severity")
12+
private(set) var severityConfiguration = SeverityConfiguration<Parent>.error
13+
@ConfigurationElement(
14+
key: "additional_code_points",
15+
postprocessor: {
16+
$0.formUnion(defaultCharacterDescriptions.keys)
17+
}
18+
)
19+
private(set) var violatingCharacters = Set<UnicodeScalar>()
20+
}
21+
22+
extension UnicodeScalar: AcceptableByConfigurationElement {
23+
public init(fromAny value: Any, context ruleID: String) throws(Issue) {
24+
guard let hexCode = value as? String,
25+
let codePoint = UInt32(hexCode, radix: 16),
26+
let scalar = Self(codePoint) else {
27+
throw .invalidConfiguration(
28+
ruleID: ruleID,
29+
message: "\(value) is not a valid Unicode scalar code point."
30+
)
31+
}
32+
self = scalar
33+
}
34+
35+
public func asOption() -> OptionType {
36+
.string(.init(value, radix: 16, uppercase: true))
37+
}
38+
}

Tests/GeneratedTests/GeneratedTests_04.swift

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,12 @@ final class InvalidSwiftLintCommandRuleGeneratedTests: SwiftLintTestCase {
121121
}
122122
}
123123

124+
final class InvisibleCharacterRuleGeneratedTests: SwiftLintTestCase {
125+
func testWithDefaultConfiguration() {
126+
verifyRule(InvisibleCharacterRule.description)
127+
}
128+
}
129+
124130
final class IsDisjointRuleGeneratedTests: SwiftLintTestCase {
125131
func testWithDefaultConfiguration() {
126132
verifyRule(IsDisjointRule.description)
@@ -150,9 +156,3 @@ final class LeadingWhitespaceRuleGeneratedTests: SwiftLintTestCase {
150156
verifyRule(LeadingWhitespaceRule.description)
151157
}
152158
}
153-
154-
final class LegacyCGGeometryFunctionsRuleGeneratedTests: SwiftLintTestCase {
155-
func testWithDefaultConfiguration() {
156-
verifyRule(LegacyCGGeometryFunctionsRule.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 LegacyCGGeometryFunctionsRuleGeneratedTests: SwiftLintTestCase {
11+
func testWithDefaultConfiguration() {
12+
verifyRule(LegacyCGGeometryFunctionsRule.description)
13+
}
14+
}
15+
1016
final class LegacyConstantRuleGeneratedTests: SwiftLintTestCase {
1117
func testWithDefaultConfiguration() {
1218
verifyRule(LegacyConstantRule.description)
@@ -150,9 +156,3 @@ final class NSLocalizedStringKeyRuleGeneratedTests: SwiftLintTestCase {
150156
verifyRule(NSLocalizedStringKeyRule.description)
151157
}
152158
}
153-
154-
final class NSLocalizedStringRequireBundleRuleGeneratedTests: SwiftLintTestCase {
155-
func testWithDefaultConfiguration() {
156-
verifyRule(NSLocalizedStringRequireBundleRule.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 NSLocalizedStringRequireBundleRuleGeneratedTests: SwiftLintTestCase {
11+
func testWithDefaultConfiguration() {
12+
verifyRule(NSLocalizedStringRequireBundleRule.description)
13+
}
14+
}
15+
1016
final class NSNumberInitAsFunctionReferenceRuleGeneratedTests: SwiftLintTestCase {
1117
func testWithDefaultConfiguration() {
1218
verifyRule(NSNumberInitAsFunctionReferenceRule.description)
@@ -150,9 +156,3 @@ final class PatternMatchingKeywordsRuleGeneratedTests: SwiftLintTestCase {
150156
verifyRule(PatternMatchingKeywordsRule.description)
151157
}
152158
}
153-
154-
final class PeriodSpacingRuleGeneratedTests: SwiftLintTestCase {
155-
func testWithDefaultConfiguration() {
156-
verifyRule(PeriodSpacingRule.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 PeriodSpacingRuleGeneratedTests: SwiftLintTestCase {
11+
func testWithDefaultConfiguration() {
12+
verifyRule(PeriodSpacingRule.description)
13+
}
14+
}
15+
1016
final class PreferAssetSymbolsRuleGeneratedTests: SwiftLintTestCase {
1117
func testWithDefaultConfiguration() {
1218
verifyRule(PreferAssetSymbolsRule.description)
@@ -150,9 +156,3 @@ final class ReduceIntoRuleGeneratedTests: SwiftLintTestCase {
150156
verifyRule(ReduceIntoRule.description)
151157
}
152158
}
153-
154-
final class RedundantDiscardableLetRuleGeneratedTests: SwiftLintTestCase {
155-
func testWithDefaultConfiguration() {
156-
verifyRule(RedundantDiscardableLetRule.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 RedundantDiscardableLetRuleGeneratedTests: SwiftLintTestCase {
11+
func testWithDefaultConfiguration() {
12+
verifyRule(RedundantDiscardableLetRule.description)
13+
}
14+
}
15+
1016
final class RedundantNilCoalescingRuleGeneratedTests: SwiftLintTestCase {
1117
func testWithDefaultConfiguration() {
1218
verifyRule(RedundantNilCoalescingRule.description)
@@ -150,9 +156,3 @@ final class StaticOverFinalClassRuleGeneratedTests: SwiftLintTestCase {
150156
verifyRule(StaticOverFinalClassRule.description)
151157
}
152158
}
153-
154-
final class StrictFilePrivateRuleGeneratedTests: SwiftLintTestCase {
155-
func testWithDefaultConfiguration() {
156-
verifyRule(StrictFilePrivateRule.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 StrictFilePrivateRuleGeneratedTests: SwiftLintTestCase {
11+
func testWithDefaultConfiguration() {
12+
verifyRule(StrictFilePrivateRule.description)
13+
}
14+
}
15+
1016
final class StrongIBOutletRuleGeneratedTests: SwiftLintTestCase {
1117
func testWithDefaultConfiguration() {
1218
verifyRule(StrongIBOutletRule.description)
@@ -150,9 +156,3 @@ final class UnneededParenthesesInClosureArgumentRuleGeneratedTests: SwiftLintTes
150156
verifyRule(UnneededParenthesesInClosureArgumentRule.description)
151157
}
152158
}
153-
154-
final class UnneededSynthesizedInitializerRuleGeneratedTests: SwiftLintTestCase {
155-
func testWithDefaultConfiguration() {
156-
verifyRule(UnneededSynthesizedInitializerRule.description)
157-
}
158-
}

0 commit comments

Comments
 (0)