From 110e23f8773f40806012c8ba6dbff649fd8e05bf Mon Sep 17 00:00:00 2001 From: Miniakhmetov Eduard Date: Thu, 8 Jan 2026 23:48:22 +0300 Subject: [PATCH 1/2] Add new invisible_character rule --- CHANGELOG.md | 6 + .../Models/BuiltInRules.swift | 1 + .../Rules/Lint/InvisibleCharacterRule.swift | 183 ++++++++++++++++++ .../InvisibleCharacterConfiguration.swift | 38 ++++ Tests/GeneratedTests/GeneratedTests_04.swift | 12 +- Tests/GeneratedTests/GeneratedTests_05.swift | 12 +- Tests/GeneratedTests/GeneratedTests_06.swift | 12 +- Tests/GeneratedTests/GeneratedTests_07.swift | 12 +- Tests/GeneratedTests/GeneratedTests_08.swift | 12 +- Tests/GeneratedTests/GeneratedTests_09.swift | 12 +- Tests/GeneratedTests/GeneratedTests_10.swift | 6 + .../Resources/default_rule_configurations.yml | 6 + Tests/TestHelpers/TestHelpers.swift | 46 ++++- 13 files changed, 314 insertions(+), 44 deletions(-) create mode 100644 Source/SwiftLintBuiltInRules/Rules/Lint/InvisibleCharacterRule.swift create mode 100644 Source/SwiftLintBuiltInRules/Rules/RuleConfigurations/InvisibleCharacterConfiguration.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 52cafe5b3e..f6455ddce4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,12 @@ [nadeemnali](https://github.com/nadeemnali) [#6359](https://github.com/realm/SwiftLint/issues/6359) +* 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 * Add an `ignore_attributes` option to `implicit_optional_initialization` so diff --git a/Source/SwiftLintBuiltInRules/Models/BuiltInRules.swift b/Source/SwiftLintBuiltInRules/Models/BuiltInRules.swift index 764095f976..b6341fe12e 100644 --- a/Source/SwiftLintBuiltInRules/Models/BuiltInRules.swift +++ b/Source/SwiftLintBuiltInRules/Models/BuiltInRules.swift @@ -96,6 +96,7 @@ public let builtInRules: [any Rule.Type] = [ IncompatibleConcurrencyAnnotationRule.self, IndentationWidthRule.self, InvalidSwiftLintCommandRule.self, + InvisibleCharacterRule.self, IsDisjointRule.self, JoinedDefaultParameterRule.self, LargeTupleRule.self, diff --git a/Source/SwiftLintBuiltInRules/Rules/Lint/InvisibleCharacterRule.swift b/Source/SwiftLintBuiltInRules/Rules/Lint/InvisibleCharacterRule.swift new file mode 100644 index 0000000000..7cb28cd946 --- /dev/null +++ b/Source/SwiftLintBuiltInRules/Rules/Lint/InvisibleCharacterRule.swift @@ -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 { + 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] + ?? 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 + ) + ) + ) + } + } + } + } +} diff --git a/Source/SwiftLintBuiltInRules/Rules/RuleConfigurations/InvisibleCharacterConfiguration.swift b/Source/SwiftLintBuiltInRules/Rules/RuleConfigurations/InvisibleCharacterConfiguration.swift new file mode 100644 index 0000000000..0430ee3e42 --- /dev/null +++ b/Source/SwiftLintBuiltInRules/Rules/RuleConfigurations/InvisibleCharacterConfiguration.swift @@ -0,0 +1,38 @@ +import SwiftLintCore + +@AutoConfigParser +struct InvisibleCharacterConfiguration: SeverityBasedRuleConfiguration { + static let defaultCharacterDescriptions: [UnicodeScalar: String] = [ + "\u{200B}": "U+200B (zero-width space)", + "\u{200C}": "U+200C (zero-width non-joiner)", + "\u{FEFF}": "U+FEFF (zero-width no-break space)", + ] + + @ConfigurationElement(key: "severity") + private(set) var severityConfiguration = SeverityConfiguration.error + @ConfigurationElement( + key: "additional_code_points", + postprocessor: { + $0.formUnion(defaultCharacterDescriptions.keys) + } + ) + private(set) var violatingCharacters = Set() +} + +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)) + } +} diff --git a/Tests/GeneratedTests/GeneratedTests_04.swift b/Tests/GeneratedTests/GeneratedTests_04.swift index 65ac6b2d38..182ec2dc64 100644 --- a/Tests/GeneratedTests/GeneratedTests_04.swift +++ b/Tests/GeneratedTests/GeneratedTests_04.swift @@ -121,6 +121,12 @@ final class InvalidSwiftLintCommandRuleGeneratedTests: SwiftLintTestCase { } } +final class InvisibleCharacterRuleGeneratedTests: SwiftLintTestCase { + func testWithDefaultConfiguration() { + verifyRule(InvisibleCharacterRule.description) + } +} + final class IsDisjointRuleGeneratedTests: SwiftLintTestCase { func testWithDefaultConfiguration() { verifyRule(IsDisjointRule.description) @@ -150,9 +156,3 @@ final class LeadingWhitespaceRuleGeneratedTests: SwiftLintTestCase { verifyRule(LeadingWhitespaceRule.description) } } - -final class LegacyCGGeometryFunctionsRuleGeneratedTests: SwiftLintTestCase { - func testWithDefaultConfiguration() { - verifyRule(LegacyCGGeometryFunctionsRule.description) - } -} diff --git a/Tests/GeneratedTests/GeneratedTests_05.swift b/Tests/GeneratedTests/GeneratedTests_05.swift index 4d375ad870..de1f84b7cc 100644 --- a/Tests/GeneratedTests/GeneratedTests_05.swift +++ b/Tests/GeneratedTests/GeneratedTests_05.swift @@ -7,6 +7,12 @@ @testable import SwiftLintCore import TestHelpers +final class LegacyCGGeometryFunctionsRuleGeneratedTests: SwiftLintTestCase { + func testWithDefaultConfiguration() { + verifyRule(LegacyCGGeometryFunctionsRule.description) + } +} + final class LegacyConstantRuleGeneratedTests: SwiftLintTestCase { func testWithDefaultConfiguration() { verifyRule(LegacyConstantRule.description) @@ -150,9 +156,3 @@ final class NSLocalizedStringKeyRuleGeneratedTests: SwiftLintTestCase { verifyRule(NSLocalizedStringKeyRule.description) } } - -final class NSLocalizedStringRequireBundleRuleGeneratedTests: SwiftLintTestCase { - func testWithDefaultConfiguration() { - verifyRule(NSLocalizedStringRequireBundleRule.description) - } -} diff --git a/Tests/GeneratedTests/GeneratedTests_06.swift b/Tests/GeneratedTests/GeneratedTests_06.swift index 3b33529a2e..56f4065ffc 100644 --- a/Tests/GeneratedTests/GeneratedTests_06.swift +++ b/Tests/GeneratedTests/GeneratedTests_06.swift @@ -7,6 +7,12 @@ @testable import SwiftLintCore import TestHelpers +final class NSLocalizedStringRequireBundleRuleGeneratedTests: SwiftLintTestCase { + func testWithDefaultConfiguration() { + verifyRule(NSLocalizedStringRequireBundleRule.description) + } +} + final class NSNumberInitAsFunctionReferenceRuleGeneratedTests: SwiftLintTestCase { func testWithDefaultConfiguration() { verifyRule(NSNumberInitAsFunctionReferenceRule.description) @@ -150,9 +156,3 @@ final class PatternMatchingKeywordsRuleGeneratedTests: SwiftLintTestCase { verifyRule(PatternMatchingKeywordsRule.description) } } - -final class PeriodSpacingRuleGeneratedTests: SwiftLintTestCase { - func testWithDefaultConfiguration() { - verifyRule(PeriodSpacingRule.description) - } -} diff --git a/Tests/GeneratedTests/GeneratedTests_07.swift b/Tests/GeneratedTests/GeneratedTests_07.swift index 488b716472..cf4a0b2f2c 100644 --- a/Tests/GeneratedTests/GeneratedTests_07.swift +++ b/Tests/GeneratedTests/GeneratedTests_07.swift @@ -7,6 +7,12 @@ @testable import SwiftLintCore import TestHelpers +final class PeriodSpacingRuleGeneratedTests: SwiftLintTestCase { + func testWithDefaultConfiguration() { + verifyRule(PeriodSpacingRule.description) + } +} + final class PreferAssetSymbolsRuleGeneratedTests: SwiftLintTestCase { func testWithDefaultConfiguration() { verifyRule(PreferAssetSymbolsRule.description) @@ -150,9 +156,3 @@ final class ReduceIntoRuleGeneratedTests: SwiftLintTestCase { verifyRule(ReduceIntoRule.description) } } - -final class RedundantDiscardableLetRuleGeneratedTests: SwiftLintTestCase { - func testWithDefaultConfiguration() { - verifyRule(RedundantDiscardableLetRule.description) - } -} diff --git a/Tests/GeneratedTests/GeneratedTests_08.swift b/Tests/GeneratedTests/GeneratedTests_08.swift index a18c6bdb96..52a3277c6f 100644 --- a/Tests/GeneratedTests/GeneratedTests_08.swift +++ b/Tests/GeneratedTests/GeneratedTests_08.swift @@ -7,6 +7,12 @@ @testable import SwiftLintCore import TestHelpers +final class RedundantDiscardableLetRuleGeneratedTests: SwiftLintTestCase { + func testWithDefaultConfiguration() { + verifyRule(RedundantDiscardableLetRule.description) + } +} + final class RedundantNilCoalescingRuleGeneratedTests: SwiftLintTestCase { func testWithDefaultConfiguration() { verifyRule(RedundantNilCoalescingRule.description) @@ -150,9 +156,3 @@ final class StaticOverFinalClassRuleGeneratedTests: SwiftLintTestCase { verifyRule(StaticOverFinalClassRule.description) } } - -final class StrictFilePrivateRuleGeneratedTests: SwiftLintTestCase { - func testWithDefaultConfiguration() { - verifyRule(StrictFilePrivateRule.description) - } -} diff --git a/Tests/GeneratedTests/GeneratedTests_09.swift b/Tests/GeneratedTests/GeneratedTests_09.swift index 8195d79e64..28abe745ff 100644 --- a/Tests/GeneratedTests/GeneratedTests_09.swift +++ b/Tests/GeneratedTests/GeneratedTests_09.swift @@ -7,6 +7,12 @@ @testable import SwiftLintCore import TestHelpers +final class StrictFilePrivateRuleGeneratedTests: SwiftLintTestCase { + func testWithDefaultConfiguration() { + verifyRule(StrictFilePrivateRule.description) + } +} + final class StrongIBOutletRuleGeneratedTests: SwiftLintTestCase { func testWithDefaultConfiguration() { verifyRule(StrongIBOutletRule.description) @@ -150,9 +156,3 @@ final class UnneededParenthesesInClosureArgumentRuleGeneratedTests: SwiftLintTes verifyRule(UnneededParenthesesInClosureArgumentRule.description) } } - -final class UnneededSynthesizedInitializerRuleGeneratedTests: SwiftLintTestCase { - func testWithDefaultConfiguration() { - verifyRule(UnneededSynthesizedInitializerRule.description) - } -} diff --git a/Tests/GeneratedTests/GeneratedTests_10.swift b/Tests/GeneratedTests/GeneratedTests_10.swift index b647b6a303..46bb104908 100644 --- a/Tests/GeneratedTests/GeneratedTests_10.swift +++ b/Tests/GeneratedTests/GeneratedTests_10.swift @@ -7,6 +7,12 @@ @testable import SwiftLintCore import TestHelpers +final class UnneededSynthesizedInitializerRuleGeneratedTests: SwiftLintTestCase { + func testWithDefaultConfiguration() { + verifyRule(UnneededSynthesizedInitializerRule.description) + } +} + final class UnneededThrowsRuleGeneratedTests: SwiftLintTestCase { func testWithDefaultConfiguration() { verifyRule(UnneededThrowsRule.description) diff --git a/Tests/IntegrationTests/Resources/default_rule_configurations.yml b/Tests/IntegrationTests/Resources/default_rule_configurations.yml index c78fee4371..fb30c62bd2 100644 --- a/Tests/IntegrationTests/Resources/default_rule_configurations.yml +++ b/Tests/IntegrationTests/Resources/default_rule_configurations.yml @@ -554,6 +554,12 @@ invalid_swiftlint_command: meta: opt-in: false correctable: false +invisible_character: + severity: error + additional_code_points: ["200B", "200C", "FEFF"] + meta: + opt-in: false + correctable: true is_disjoint: severity: warning meta: diff --git a/Tests/TestHelpers/TestHelpers.swift b/Tests/TestHelpers/TestHelpers.swift index 28fe065320..aff42aab87 100644 --- a/Tests/TestHelpers/TestHelpers.swift +++ b/Tests/TestHelpers/TestHelpers.swift @@ -46,6 +46,7 @@ private let info: PlatformInfo = { // swiftlint:disable file_length private let violationMarker = "↓" +private let violationMarkerChar = violationMarker.first! private extension SwiftLintFile { static func testFile(withContents contents: String, persistToDisk: Bool = false) -> SwiftLintFile { @@ -198,15 +199,21 @@ public extension Collection where Element == Example { } private func cleanedContentsAndMarkerOffsets(from contents: String) -> (String, [Int]) { - var contents = contents.bridge() var markerOffsets = [Int]() - var markerRange = contents.range(of: violationMarker) - while markerRange.location != NSNotFound { - markerOffsets.append(markerRange.location) - contents = contents.replacingCharacters(in: markerRange, with: "").bridge() - markerRange = contents.range(of: violationMarker) + var cleanedContents = "" + cleanedContents.reserveCapacity(contents.count) + + var offset = 0 + for char in contents { + if char == violationMarkerChar { + markerOffsets.append(offset) + } else { + cleanedContents.append(char) + offset += 1 + } } - return (contents.bridge(), markerOffsets.sorted()) + + return (cleanedContents, markerOffsets) } private func render(violations: [StyleViolation], in contents: String) -> String { @@ -567,7 +574,12 @@ public extension XCTestCase { continue } let file = SwiftLintFile.testFile(withContents: cleanTrigger) - let expectedLocations = markerOffsets.map { Location(file: file, characterOffset: $0) } + + // Convert grapheme cluster indices to UTF-16 offsets + let expectedLocations = markerOffsets.map { graphemeOffset -> Location in + let utf16Offset = cleanTrigger.utf16OffsetFrom(graphemeOffset: graphemeOffset) + return Location(file: file, characterOffset: utf16Offset) + } // Assert violations on unexpected location let violationsAtUnexpectedLocation = triggerViolations @@ -660,3 +672,21 @@ package extension [any Rule] { first(where: { $0 is CustomRules }) as? CustomRules } } + +private extension String { + /// Converts a grapheme cluster offset to a UTF-16 code unit offset + func utf16OffsetFrom(graphemeOffset: Int) -> Int { + var currentGraphemeIndex = 0 + var utf16Offset = 0 + + for char in self { + if currentGraphemeIndex == graphemeOffset { + return utf16Offset + } + utf16Offset += char.utf16.count + currentGraphemeIndex += 1 + } + + return utf16Offset + } +} From f9aee5c57d6c458e9875dc024b7dcc4ab5eb8b95 Mon Sep 17 00:00:00 2001 From: Edward Miniakhmetov Date: Tue, 7 Apr 2026 13:10:40 +0300 Subject: [PATCH 2/2] Remove workaround for Windows & Linux --- .../Rules/Lint/InvisibleCharacterRule.swift | 33 ++----------------- 1 file changed, 2 insertions(+), 31 deletions(-) diff --git a/Source/SwiftLintBuiltInRules/Rules/Lint/InvisibleCharacterRule.swift b/Source/SwiftLintBuiltInRules/Rules/Lint/InvisibleCharacterRule.swift index 7cb28cd946..ca1d110135 100644 --- a/Source/SwiftLintBuiltInRules/Rules/Lint/InvisibleCharacterRule.swift +++ b/Source/SwiftLintBuiltInRules/Rules/Lint/InvisibleCharacterRule.swift @@ -123,13 +123,9 @@ private extension InvisibleCharacterRule { 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 { @@ -139,40 +135,15 @@ private extension InvisibleCharacterRule { let characterName = InvisibleCharacterConfiguration.defaultCharacterDescriptions[scalar] ?? 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, + start: position, end: position.advanced(by: scalar.utf8.count), - replacement: replacement + replacement: "" ) ) )