Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@

### Enhancements

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

### Bug Fixes

Expand Down
1 change: 1 addition & 0 deletions Source/SwiftLintBuiltInRules/Models/BuiltInRules.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ public let builtInRules: [any Rule.Type] = [
IncompatibleConcurrencyAnnotationRule.self,
IndentationWidthRule.self,
InvalidSwiftLintCommandRule.self,
InvisibleCharacterRule.self,
IsDisjointRule.self,
JoinedDefaultParameterRule.self,
LargeTupleRule.self,
Expand Down
183 changes: 183 additions & 0 deletions Source/SwiftLintBuiltInRules/Rules/Lint/InvisibleCharacterRule.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import SwiftSyntax

@SwiftSyntaxRule(correctable: true)
struct InvisibleCharacterRule: Rule {
var configuration = InvisibleCharacterConfiguration()

// swiftlint:disable invisible_character
static let description = RuleDescription(
identifier: "invisible_character",
name: "Invisible Character",
description: """
Disallows invisible characters like zero-width space (U+200B), \
zero-width non-joiner (U+200C), and FEFF formatting character (U+FEFF) \
in string literals as they can cause hard-to-debug issues.
""",
kind: .lint,
nonTriggeringExamples: [
Example(#"let s = "HelloWorld""#),
Example(#"let s = "Hello World""#),
Example(#"let url = "https://example.com/api""#),
Example(##"let s = #"Hello World"#"##),
Example("""
let multiline = \"\"\"
Hello
World
\"\"\"
"""),
Example(#"let empty = """#),
Example(#"let tab = "Hello\tWorld""#),
Example(#"let newline = "Hello\nWorld""#),
Example(#"let unicode = "Hello 👋 World""#),
],
triggeringExamples: [
Example(#"let s = "Hello↓​World" // U+200B zero-width space"#),
Example(#"let s = "Hello↓‌World" // U+200C zero-width non-joiner"#),
Example(#"let s = "Hello↓World" // U+FEFF formatting character"#),
Example(#"let url = "https://example↓​.com" // U+200B in URL"#),
Example("""
// U+200B in multiline string
let multiline = \"\"\"
Hello↓​World
\"\"\"
"""),
Example(#"let s = "Test↓​String↓Here" // Multiple invisible characters"#),
Example(#"let s = "Hel↓‌lo" + "World" // string concatenation with U+200C"#),
Example(#"let s = "Hel↓‌lo \(name)" // U+200C in interpolated string"#),
Example("""
//
// additional_code_points: ["00AD"]
//
let s = "Hello↓­World"
""",
configuration: [
"additional_code_points": ["00AD"],
]
),
Example("""
//
// additional_code_points: ["200D"]
//
let s = "Hello↓‍World"
""",
configuration: [
"additional_code_points": ["200D"],
]
),
],
corrections: [
Example(#"let s = "Hello​World""#): Example(#"let s = "HelloWorld""#),
Example(#"let s = "Hello‌World""#): Example(#"let s = "HelloWorld""#),
Example(#"let s = "HelloWorld""#): Example(#"let s = "HelloWorld""#),
Example(#"let url = "https://example​.com""#): Example(#"let url = "https://example.com""#),
Example("""
let multiline = \"\"\"
Hello​World
\"\"\"
"""): Example("""
let multiline = \"\"\"
HelloWorld
\"\"\"
"""),
Example(#"let s = "Test​StringHere""#): Example(#"let s = "TestStringHere""#),
Example(#"let s = "Hel‌lo" + "World""#): Example(#"let s = "Hello" + "World""#),
Example(#"let s = "Hel‌lo \(name)""#): Example(#"let s = "Hello \(name)""#),
Example(
#"let s = "Hello­World""#,
configuration: [
"additional_code_points": ["00AD"],
]
): Example(
#"let s = "HelloWorld""#,
configuration: [
"additional_code_points": ["00AD"],
]
),
Example(
#"let s = "Hello‍World""#,
configuration: [
"additional_code_points": ["200D"],
]
): Example(
#"let s = "HelloWorld""#,
configuration: [
"additional_code_points": ["200D"],
]
),
]
)
// swiftlint:enable invisible_character
}

private extension InvisibleCharacterRule {
final class Visitor: ViolationsSyntaxVisitor<ConfigurationType> {
override func visitPost(_ node: StringLiteralExprSyntax) {
let violatingCharacters = configuration.violatingCharacters
for segment in node.segments {
guard let stringSegment = segment.as(StringSegmentSyntax.self) else {
continue
}
let text = stringSegment.content.text
let scalars = text.unicodeScalars
guard scalars.contains(where: { violatingCharacters.contains($0) }) else {
continue
}
var utf8Offset = 0
var previousScalar: UnicodeScalar?
var previousUtf8Size = 0

for scalar in scalars {
defer {
previousScalar = scalar
previousUtf8Size = scalar.utf8.count
utf8Offset += scalar.utf8.count
}
guard violatingCharacters.contains(scalar) else {
continue
}

let characterName = InvisibleCharacterConfiguration.defaultCharacterDescriptions[scalar.value]
?? scalar.escaped(asASCII: true)

// Check if this scalar forms a grapheme cluster with the previous one.
// This is needed on Windows and Linux where NSString operations on grapheme clusters
// can delete more than intended when removing a combining character like ZWJ.
let formsCombinedCluster: Bool
if let prev = previousScalar {
let combined = String(prev) + String(scalar)
formsCombinedCluster = combined.count == 1
} else {
formsCombinedCluster = false
}

let correctionStart: AbsolutePosition
let replacement: String

if formsCombinedCluster, let prev = previousScalar {
// Include previous scalar in the correction range and use it as replacement
correctionStart = stringSegment.content.positionAfterSkippingLeadingTrivia
.advanced(by: utf8Offset - previousUtf8Size)
replacement = String(prev)
} else {
correctionStart = stringSegment.content.positionAfterSkippingLeadingTrivia
.advanced(by: utf8Offset)
replacement = ""
}

let position = stringSegment.content.positionAfterSkippingLeadingTrivia.advanced(by: utf8Offset)
violations.append(
ReasonedRuleViolation(
position: position,
reason: "String literal should not contain invisible character \(characterName)",
correction: .init(
start: correctionStart,
end: position.advanced(by: scalar.utf8.count),
replacement: replacement
)
)
)
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import SwiftLintCore

@AutoConfigParser
struct InvisibleCharacterConfiguration: SeverityBasedRuleConfiguration {
static let defaultCharacterDescriptions: [UInt32: String] = [
0x200B: "U+200B (zero-width space)",
0x200C: "U+200C (zero-width non-joiner)",
0xFEFF: "U+FEFF (zero-width no-break space)",
]

@ConfigurationElement(key: "severity")
private(set) var severityConfiguration = SeverityConfiguration<Parent>.error
@ConfigurationElement(
key: "additional_code_points",
postprocessor: {
let defaultScalars = defaultCharacterDescriptions.keys.compactMap { UnicodeScalar($0) }
$0.formUnion(defaultScalars)
}
)
private(set) var violatingCharacters = Set<UnicodeScalar>()
}

extension UnicodeScalar: AcceptableByConfigurationElement {
public init(fromAny value: Any, context ruleID: String) throws(Issue) {
guard let hexCode = value as? String,
let codePoint = UInt32(hexCode, radix: 16),
let scalar = Self(codePoint) else {
throw .invalidConfiguration(
ruleID: ruleID,
message: "\(value) is not a valid Unicode scalar code point."
)
}
self = scalar
}

public func asOption() -> OptionType {
.string(.init(value, radix: 16, uppercase: true))
}
}
12 changes: 6 additions & 6 deletions Tests/GeneratedTests/GeneratedTests_04.swift
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,12 @@ final class InvalidSwiftLintCommandRuleGeneratedTests: SwiftLintTestCase {
}
}

final class InvisibleCharacterRuleGeneratedTests: SwiftLintTestCase {
func testWithDefaultConfiguration() {
verifyRule(InvisibleCharacterRule.description)
}
}

final class IsDisjointRuleGeneratedTests: SwiftLintTestCase {
func testWithDefaultConfiguration() {
verifyRule(IsDisjointRule.description)
Expand Down Expand Up @@ -150,9 +156,3 @@ final class LegacyCGGeometryFunctionsRuleGeneratedTests: SwiftLintTestCase {
verifyRule(LegacyCGGeometryFunctionsRule.description)
}
}

final class LegacyConstantRuleGeneratedTests: SwiftLintTestCase {
func testWithDefaultConfiguration() {
verifyRule(LegacyConstantRule.description)
}
}
12 changes: 6 additions & 6 deletions Tests/GeneratedTests/GeneratedTests_05.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@
@testable import SwiftLintCore
import TestHelpers

final class LegacyConstantRuleGeneratedTests: SwiftLintTestCase {
func testWithDefaultConfiguration() {
verifyRule(LegacyConstantRule.description)
}
}

final class LegacyConstructorRuleGeneratedTests: SwiftLintTestCase {
func testWithDefaultConfiguration() {
verifyRule(LegacyConstructorRule.description)
Expand Down Expand Up @@ -150,9 +156,3 @@ final class NSLocalizedStringRequireBundleRuleGeneratedTests: SwiftLintTestCase
verifyRule(NSLocalizedStringRequireBundleRule.description)
}
}

final class NSNumberInitAsFunctionReferenceRuleGeneratedTests: SwiftLintTestCase {
func testWithDefaultConfiguration() {
verifyRule(NSNumberInitAsFunctionReferenceRule.description)
}
}
12 changes: 6 additions & 6 deletions Tests/GeneratedTests/GeneratedTests_06.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@
@testable import SwiftLintCore
import TestHelpers

final class NSNumberInitAsFunctionReferenceRuleGeneratedTests: SwiftLintTestCase {
func testWithDefaultConfiguration() {
verifyRule(NSNumberInitAsFunctionReferenceRule.description)
}
}

final class NSObjectPreferIsEqualRuleGeneratedTests: SwiftLintTestCase {
func testWithDefaultConfiguration() {
verifyRule(NSObjectPreferIsEqualRule.description)
Expand Down Expand Up @@ -150,9 +156,3 @@ final class PeriodSpacingRuleGeneratedTests: SwiftLintTestCase {
verifyRule(PeriodSpacingRule.description)
}
}

final class PreferAssetSymbolsRuleGeneratedTests: SwiftLintTestCase {
func testWithDefaultConfiguration() {
verifyRule(PreferAssetSymbolsRule.description)
}
}
12 changes: 6 additions & 6 deletions Tests/GeneratedTests/GeneratedTests_07.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@
@testable import SwiftLintCore
import TestHelpers

final class PreferAssetSymbolsRuleGeneratedTests: SwiftLintTestCase {
func testWithDefaultConfiguration() {
verifyRule(PreferAssetSymbolsRule.description)
}
}

final class PreferConditionListRuleGeneratedTests: SwiftLintTestCase {
func testWithDefaultConfiguration() {
verifyRule(PreferConditionListRule.description)
Expand Down Expand Up @@ -150,9 +156,3 @@ final class RedundantDiscardableLetRuleGeneratedTests: SwiftLintTestCase {
verifyRule(RedundantDiscardableLetRule.description)
}
}

final class RedundantNilCoalescingRuleGeneratedTests: SwiftLintTestCase {
func testWithDefaultConfiguration() {
verifyRule(RedundantNilCoalescingRule.description)
}
}
12 changes: 6 additions & 6 deletions Tests/GeneratedTests/GeneratedTests_08.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@
@testable import SwiftLintCore
import TestHelpers

final class RedundantNilCoalescingRuleGeneratedTests: SwiftLintTestCase {
func testWithDefaultConfiguration() {
verifyRule(RedundantNilCoalescingRule.description)
}
}

final class RedundantObjcAttributeRuleGeneratedTests: SwiftLintTestCase {
func testWithDefaultConfiguration() {
verifyRule(RedundantObjcAttributeRule.description)
Expand Down Expand Up @@ -150,9 +156,3 @@ final class StrictFilePrivateRuleGeneratedTests: SwiftLintTestCase {
verifyRule(StrictFilePrivateRule.description)
}
}

final class StrongIBOutletRuleGeneratedTests: SwiftLintTestCase {
func testWithDefaultConfiguration() {
verifyRule(StrongIBOutletRule.description)
}
}
12 changes: 6 additions & 6 deletions Tests/GeneratedTests/GeneratedTests_09.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@
@testable import SwiftLintCore
import TestHelpers

final class StrongIBOutletRuleGeneratedTests: SwiftLintTestCase {
func testWithDefaultConfiguration() {
verifyRule(StrongIBOutletRule.description)
}
}

final class SuperfluousElseRuleGeneratedTests: SwiftLintTestCase {
func testWithDefaultConfiguration() {
verifyRule(SuperfluousElseRule.description)
Expand Down Expand Up @@ -150,9 +156,3 @@ final class UnneededSynthesizedInitializerRuleGeneratedTests: SwiftLintTestCase
verifyRule(UnneededSynthesizedInitializerRule.description)
}
}

final class UnneededThrowsRuleGeneratedTests: SwiftLintTestCase {
func testWithDefaultConfiguration() {
verifyRule(UnneededThrowsRule.description)
}
}
6 changes: 6 additions & 0 deletions Tests/GeneratedTests/GeneratedTests_10.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@
@testable import SwiftLintCore
import TestHelpers

final class UnneededThrowsRuleGeneratedTests: SwiftLintTestCase {
func testWithDefaultConfiguration() {
verifyRule(UnneededThrowsRule.description)
}
}

final class UnownedVariableCaptureRuleGeneratedTests: SwiftLintTestCase {
func testWithDefaultConfiguration() {
verifyRule(UnownedVariableCaptureRule.description)
Expand Down
Loading