From 2d1281d6afd5b163a76cd9c51cd98cb6fb56d57c Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 3 Mar 2026 12:49:23 -0700 Subject: [PATCH 01/10] Fix NSRange length calculations using String.count instead of UTF-16 count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NSRange operates on UTF-16 code units, but Swift's String.count returns grapheme clusters. For strings with emoji, accented characters, or CJK text, these values differ โ€” causing wrong attributes, missed regex matches, or potential crashes. This fixes ~20 occurrences across 10 files by using .utf16.count or .length (for NSAttributedString) instead of .count when constructing NSRange values. Also fixes two off-by-one errors where `count - 1` incorrectly skipped the last character. --- .../Ranges/FormattableNoticonRange.swift | 4 +-- .../Sources/WordPressKit/NonceRetrieval.swift | 2 +- .../Utility/RichContentFormatter.swift | 26 +++++++++---------- .../NSAttributedString+StyledHTML.swift | 2 +- .../BloggingRemindersScheduleFormatter.swift | 2 +- .../DestructiveAlertHelper.swift | 2 +- .../Insights/StatsTotalInsightsCell.swift | 6 ++--- .../Period Stats/Overview/OverviewCell.swift | 4 +-- .../ViewRelated/System/FilterTabBar.swift | 2 +- .../Sources/Services/ShareExtractor.swift | 2 +- 10 files changed, 26 insertions(+), 26 deletions(-) diff --git a/Modules/Sources/FormattableContentKit/Ranges/FormattableNoticonRange.swift b/Modules/Sources/FormattableContentKit/Ranges/FormattableNoticonRange.swift index f36bf68e87d3..fed0fafcd7f8 100644 --- a/Modules/Sources/FormattableContentKit/Ranges/FormattableNoticonRange.swift +++ b/Modules/Sources/FormattableContentKit/Ranges/FormattableNoticonRange.swift @@ -21,10 +21,10 @@ public class FormattableNoticonRange: FormattableContentRange { let shiftedRange = rangeShifted(by: shift) insertIcon(to: string, at: shiftedRange) - let longerRange = NSMakeRange(shiftedRange.location, shiftedRange.length + noticon.count) + let longerRange = NSMakeRange(shiftedRange.location, shiftedRange.length + noticon.utf16.count) apply(styles, to: string, at: longerRange) - return noticon.count + return noticon.utf16.count } func insertIcon(to string: NSMutableAttributedString, at shiftedRange: NSRange) { diff --git a/Modules/Sources/WordPressKit/NonceRetrieval.swift b/Modules/Sources/WordPressKit/NonceRetrieval.swift index 14e13b15d338..33f33c41143f 100644 --- a/Modules/Sources/WordPressKit/NonceRetrieval.swift +++ b/Modules/Sources/WordPressKit/NonceRetrieval.swift @@ -48,7 +48,7 @@ enum NonceRetrievalMethod { private func scrapNonceFromNewPost(html: String) -> String? { guard let regex = try? NSRegularExpression(pattern: "apiFetch.createNonceMiddleware\\(\\s*['\"](?\\w+)['\"]\\s*\\)", options: []), - let match = regex.firstMatch(in: html, options: [], range: NSRange(location: 0, length: html.count)) else { + let match = regex.firstMatch(in: html, options: [], range: NSRange(location: 0, length: html.utf16.count)) else { return nil } let nsrange = match.range(withName: "nonce") diff --git a/Modules/Sources/WordPressShared/Utility/RichContentFormatter.swift b/Modules/Sources/WordPressShared/Utility/RichContentFormatter.swift index f23cf53b4ca1..6404134e3ba4 100644 --- a/Modules/Sources/WordPressShared/Utility/RichContentFormatter.swift +++ b/Modules/Sources/WordPressShared/Utility/RichContentFormatter.swift @@ -77,17 +77,17 @@ import WordPressSharedObjC content = RegEx.styleTags.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.count), + range: NSRange(location: 0, length: content.utf16.count), withTemplate: "") content = RegEx.scriptTags.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.count), + range: NSRange(location: 0, length: content.utf16.count), withTemplate: "") content = RegEx.gutenbergComments.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.count), + range: NSRange(location: 0, length: content.utf16.count), withTemplate: "") return content @@ -111,23 +111,23 @@ import WordPressSharedObjC // Convert div tags to p tags content = RegEx.divTagsStart.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.count), + range: NSRange(location: 0, length: content.utf16.count), withTemplate: openPTag) content = RegEx.divTagsEnd.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.count), + range: NSRange(location: 0, length: content.utf16.count), withTemplate: closePTag) // Remove duplicate/redundant p tags. content = RegEx.pTagsStart.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.count), + range: NSRange(location: 0, length: content.utf16.count), withTemplate: openPTag) content = RegEx.pTagsEnd.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.count), + range: NSRange(location: 0, length: content.utf16.count), withTemplate: closePTag) content = filterNewLines(content) @@ -141,11 +141,11 @@ import WordPressSharedObjC var ranges = [NSRange]() // We don't want to remove new lines from preformatted tag blocks, // so get the ranges of such blocks. - let matches = RegEx.preTags.matches(in: content, options: .reportCompletion, range: NSRange(location: 0, length: content.count)) + let matches = RegEx.preTags.matches(in: content, options: .reportCompletion, range: NSRange(location: 0, length: content.utf16.count)) if matches.count == 0 { // No blocks found, so we'll parse the whole string. - ranges.append(NSRange(location: 0, length: content.count)) + ranges.append(NSRange(location: 0, length: content.utf16.count)) } else { @@ -161,7 +161,7 @@ import WordPressSharedObjC location = match.range.location + match.range.length } - length = content.count - location + length = content.utf16.count - location ranges.append(NSRange(location: location, length: length)) } @@ -191,7 +191,7 @@ import WordPressSharedObjC content = RegEx.styleAttr.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.count), + range: NSRange(location: 0, length: content.utf16.count), withTemplate: "") return content @@ -242,7 +242,7 @@ import WordPressSharedObjC mImageStr.replaceOccurrences(of: srcImgURLStr, with: modifiedURL.absoluteString, options: .literal, - range: NSRange(location: 0, length: imgElementStr.count)) + range: NSRange(location: 0, length: imgElementStr.utf16.count)) mContent.replaceCharacters(in: match.range, with: mImageStr as String) } @@ -287,7 +287,7 @@ import WordPressSharedObjC } var content = string.trim() - let matches = RegEx.trailingBRTags.matches(in: content, options: .reportCompletion, range: NSRange(location: 0, length: content.count)) + let matches = RegEx.trailingBRTags.matches(in: content, options: .reportCompletion, range: NSRange(location: 0, length: content.utf16.count)) if let match = matches.first { let index = content.index(content.startIndex, offsetBy: match.range.location) content = String(content.prefix(upTo: index)) diff --git a/WordPress/Classes/Extensions/NSAttributedString+StyledHTML.swift b/WordPress/Classes/Extensions/NSAttributedString+StyledHTML.swift index 10727ac592c3..04c56a268e1a 100644 --- a/WordPress/Classes/Extensions/NSAttributedString+StyledHTML.swift +++ b/WordPress/Classes/Extensions/NSAttributedString+StyledHTML.swift @@ -45,7 +45,7 @@ extension NSAttributedString { attributedString.addAttribute(.paragraphStyle, value: paragraphStyle, - range: NSMakeRange(0, attributedString.string.count - 1)) + range: NSMakeRange(0, attributedString.length)) return NSAttributedString(attributedString: attributedString) } diff --git a/WordPress/Classes/Utility/Blogging Reminders/BloggingRemindersScheduleFormatter.swift b/WordPress/Classes/Utility/Blogging Reminders/BloggingRemindersScheduleFormatter.swift index 5de223f97fcd..2ce115aba51c 100644 --- a/WordPress/Classes/Utility/Blogging Reminders/BloggingRemindersScheduleFormatter.swift +++ b/WordPress/Classes/Utility/Blogging Reminders/BloggingRemindersScheduleFormatter.swift @@ -189,6 +189,6 @@ private extension String { guard let expression = try? NSRegularExpression(pattern: "", options: .caseInsensitive) else { return self } - return expression.stringByReplacingMatches(in: self, range: NSMakeRange(0, self.count), withTemplate: String()) + return expression.stringByReplacingMatches(in: self, range: NSMakeRange(0, self.utf16.count), withTemplate: String()) } } diff --git a/WordPress/Classes/ViewRelated/Blog/Site Management/DestructiveAlertHelper.swift b/WordPress/Classes/ViewRelated/Blog/Site Management/DestructiveAlertHelper.swift index f5d34c42e9dc..e827af229b84 100644 --- a/WordPress/Classes/ViewRelated/Blog/Site Management/DestructiveAlertHelper.swift +++ b/WordPress/Classes/ViewRelated/Blog/Site Management/DestructiveAlertHelper.swift @@ -18,7 +18,7 @@ class DestructiveAlertHelper: DestructiveAlertHelperLogic { let attributedValue: NSMutableAttributedString = NSMutableAttributedString(string: valueToConfirm) let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.lineBreakMode = .byCharWrapping - attributedValue.addAttribute(.paragraphStyle, value: paragraphStyle, range: NSMakeRange(0, attributedValue.string.count - 1)) + attributedValue.addAttribute(.paragraphStyle, value: paragraphStyle, range: NSMakeRange(0, attributedValue.length)) attributedMessage.append(attributedValue) let alert = UIAlertController(title: title, message: nil, preferredStyle: .alert) diff --git a/WordPress/Classes/ViewRelated/Stats/Insights/StatsTotalInsightsCell.swift b/WordPress/Classes/ViewRelated/Stats/Insights/StatsTotalInsightsCell.swift index 56ff84ae323d..27a956cf65d5 100644 --- a/WordPress/Classes/ViewRelated/Stats/Insights/StatsTotalInsightsCell.swift +++ b/WordPress/Classes/ViewRelated/Stats/Insights/StatsTotalInsightsCell.swift @@ -91,17 +91,17 @@ struct StatsTotalInsightsData: Equatable { let attributedString = NSMutableAttributedString(string: formattedString) - let textRange = NSMakeRange(0, formattedString.count) + let textRange = NSMakeRange(0, formattedString.utf16.count) attributedString.addAttribute(.font, value: UIFont.preferredFont(forTextStyle: .subheadline), range: textRange) attributedString.addAttribute(.foregroundColor, value: UIColor.label, range: textRange) let titlePlaceholderRange = (text as NSString).range(of: "%1$@") - let titleRange = NSMakeRange(titlePlaceholderRange.location, title.count) + let titleRange = NSMakeRange(titlePlaceholderRange.location, title.utf16.count) attributedString.addAttribute(.foregroundColor, value: UIAppColor.primary, range: titleRange) let formattedTitleString = String.localizedStringWithFormat(text, title, "%2$@") let countPlaceholderRange = (formattedTitleString as NSString).range(of: "%2$@") - let countRange = NSMakeRange(countPlaceholderRange.location, countString.count) + let countRange = NSMakeRange(countPlaceholderRange.location, countString.utf16.count) attributedString.addAttribute(.font, value: UIFont.preferredFont(forTextStyle: .subheadline).bold(), range: countRange) return attributedString diff --git a/WordPress/Classes/ViewRelated/Stats/Period Stats/Overview/OverviewCell.swift b/WordPress/Classes/ViewRelated/Stats/Period Stats/Overview/OverviewCell.swift index 8928454a9e1f..cbe4d8b493f7 100644 --- a/WordPress/Classes/ViewRelated/Stats/Period Stats/Overview/OverviewCell.swift +++ b/WordPress/Classes/ViewRelated/Stats/Period Stats/Overview/OverviewCell.swift @@ -39,7 +39,7 @@ struct OverviewTabData: FilterTabBarItem, Hashable { let attributedTitle = NSMutableAttributedString(string: tabTitle.localizedUppercase) attributedTitle.addAttributes([.font: WPStyleGuide.Stats.overviewCardFilterTitleFont], - range: NSMakeRange(0, attributedTitle.string.count)) + range: NSMakeRange(0, attributedTitle.length)) let dataString: String = { if let tabDataStub { @@ -50,7 +50,7 @@ struct OverviewTabData: FilterTabBarItem, Hashable { let attributedData = NSMutableAttributedString(string: dataString) attributedData.addAttributes([.font: WPStyleGuide.Stats.overviewCardFilterDataFont], - range: NSMakeRange(0, attributedData.string.count)) + range: NSMakeRange(0, attributedData.length)) attributedTitle.append(NSAttributedString(string: "\n")) attributedTitle.append(attributedData) diff --git a/WordPress/Classes/ViewRelated/System/FilterTabBar.swift b/WordPress/Classes/ViewRelated/System/FilterTabBar.swift index c6c6d6491e1d..e92126d6b70a 100644 --- a/WordPress/Classes/ViewRelated/System/FilterTabBar.swift +++ b/WordPress/Classes/ViewRelated/System/FilterTabBar.swift @@ -344,7 +344,7 @@ public class FilterTabBar: UIControl { } let mutableString = NSMutableAttributedString(attributedString: attributedString) - mutableString.addAttributes([.foregroundColor: color], range: NSMakeRange(0, mutableString.string.count)) + mutableString.addAttributes([.foregroundColor: color], range: NSMakeRange(0, mutableString.length)) return mutableString } diff --git a/WordPress/WordPressShareExtension/Sources/Services/ShareExtractor.swift b/WordPress/WordPressShareExtension/Sources/Services/ShareExtractor.swift index 834375e39451..4909390d5c1c 100644 --- a/WordPress/WordPressShareExtension/Sources/Services/ShareExtractor.swift +++ b/WordPress/WordPressShareExtension/Sources/Services/ShareExtractor.swift @@ -578,7 +578,7 @@ private struct PlainTextExtractor: TypeBasedExtensionContentExtractor { // the selected text โ€” we just want to make sure shared URLs are handled). let types: NSTextCheckingResult.CheckingType = [.link] let detector = try? NSDataDetector(types: types.rawValue) - if let match = detector?.firstMatch(in: payload, options: [], range: NSMakeRange(0, payload.count)), + if let match = detector?.firstMatch(in: payload, options: [], range: NSMakeRange(0, payload.utf16.count)), match.resultType == .link, let url = match.url, url.absoluteString.count == payload.count { From a7f03af07293a5e20476ed8a1b198fc41eda5e23 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 3 Mar 2026 13:06:19 -0700 Subject: [PATCH 02/10] Add regression tests for NSRange emoji handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 5 test cases to RichContentFormatterTests that use the family emoji (๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ), which is 1 grapheme cluster but 11 UTF-16 code units. These tests place HTML tags after emoji content, so the tags fall outside the NSRange when String.count is used instead of utf16.count. The tests would fail (or crash) with the old .count code and pass with the new .utf16.count code, proving the fix is necessary. Also fixes a related crash in removeTrailingBreakTags where match.range.location (a UTF-16 offset) was incorrectly used with String.index(startIndex, offsetBy:) (which expects grapheme cluster offsets). Replaced with Range(match.range, in:) for correct UTF-16 to String.Index conversion. --- .../Utility/RichContentFormatter.swift | 5 +-- .../RichContentFormatterTests.swift | 42 +++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/Modules/Sources/WordPressShared/Utility/RichContentFormatter.swift b/Modules/Sources/WordPressShared/Utility/RichContentFormatter.swift index 6404134e3ba4..7ca62af5ef6c 100644 --- a/Modules/Sources/WordPressShared/Utility/RichContentFormatter.swift +++ b/Modules/Sources/WordPressShared/Utility/RichContentFormatter.swift @@ -288,9 +288,8 @@ import WordPressSharedObjC var content = string.trim() let matches = RegEx.trailingBRTags.matches(in: content, options: .reportCompletion, range: NSRange(location: 0, length: content.utf16.count)) - if let match = matches.first { - let index = content.index(content.startIndex, offsetBy: match.range.location) - content = String(content.prefix(upTo: index)) + if let match = matches.first, let range = Range(match.range, in: content) { + content = String(content[content.startIndex..test

" + let expected = "

Hello ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ

test

" + let result = RichContentFormatter.removeInlineStyles(input) + XCTAssertEqual(result, expected, "Inline styles after emoji should be removed") + } + + func testNormalizeParagraphsWithEmoji() { + let input = "
Hello ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ
test
" + let expected = "

Hello ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ

test

" + let result = RichContentFormatter.normalizeParagraphs(input) + XCTAssertEqual(result, expected, "Div-to-p conversion after emoji should work") + } + + func testFilterNewLinesWithEmoji() { + let input = "

Hello ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ

\n

World

\n" + let expected = "

Hello ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ

World

" + let result = RichContentFormatter.filterNewLines(input) + XCTAssertEqual(result, expected, "Newlines after emoji should be filtered") + } + + func testRemoveTrailingBreakTagsWithEmoji() { + let input = "

Hello ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ World



" + let expected = "

Hello ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ World

" + let result = RichContentFormatter.removeTrailingBreakTags(input) + XCTAssertEqual(result, expected, "Trailing BR tags after emoji should be removed") + } + func testFormatVideoTags() { let str1 = "

Some text.

Some text.

" let sanitizedStr1 = RichContentFormatter.formatVideoTags(str1) as NSString From 53d074fc766b4a1eabcfed5493c1d4057573d2c8 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 3 Mar 2026 13:38:32 -0700 Subject: [PATCH 03/10] Add applyAttribute/applyAttributes helpers and SwiftLint rule Introduce NSMutableAttributedString.applyAttribute(_:value:) and applyAttributes(_:) to eliminate manual NSRange construction when applying attributes to an entire string. This prevents the String.count vs utf16.count footgun that causes bugs with emoji and other multi-code-unit characters. Migrate all full-range addAttribute/addAttributes call sites across the codebase to use the new helpers. Also add a SwiftLint custom rule to flag future uses of the old pattern. Co-Authored-By: Claude Opus 4.6 --- .swiftlint.yml | 6 +++++ .../NSMutableAttributedString+Helpers.swift | 25 +++++++++++++++++-- .../NSAttributedString+StyledHTML.swift | 5 ++-- .../Views/JetpackLandingScreenView.swift | 3 ++- .../DiffAbstractValue+Attributes.swift | 2 +- ...emindersFlowCompletionViewController.swift | 2 +- ...gRemindersFlowSettingsViewController.swift | 2 +- .../DestructiveAlertHelper.swift | 3 ++- .../SiteMonitoringEntryDetailsView.swift | 7 +++--- .../CommentDetailViewController.swift | 2 +- ...tpackFullscreenOverlayViewController.swift | 2 +- .../JetpackScanThreatViewModel.swift | 3 +-- .../Post/Views/PostListItemViewModel.swift | 5 ++-- .../ReaderStreamViewController+Helper.swift | 2 +- .../Detail/Views/ReaderReadMoreView.swift | 3 ++- .../Insights/StatsTotalInsightsCell.swift | 7 +++--- .../Period Stats/Overview/OverviewCell.swift | 6 ++--- .../ViewRelated/System/FilterTabBar.swift | 3 ++- 18 files changed, 59 insertions(+), 29 deletions(-) diff --git a/.swiftlint.yml b/.swiftlint.yml index 8d9ef1eb50c7..ec1bd3e0d268 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -119,3 +119,9 @@ custom_rules: message: "Using `LocalizedStringKey` is incompatible with our tooling and doesn't allow you to provide a hint/context comment for translators either. Please use `NSLocalizedString` instead, even with SwiftUI code." severity: error excluded: '.*Widgets/.*' + + full_range_attributed_string_attribute: + name: "Full-Range Attributed String Attribute" + regex: '\.addAttributes?\([^\n]*range:\s*NS(MakeRange\(0,|Range\(location:\s*0,\s*length:)' + message: "Use `applyAttribute(_:value:)` or `applyAttributes(_:)` instead of manually constructing a full-range NSRange. Manual ranges are error-prone with emoji and other multi-code-unit characters." + severity: warning diff --git a/Modules/Sources/WordPressUI/Extensions/NSMutableAttributedString+Helpers.swift b/Modules/Sources/WordPressUI/Extensions/NSMutableAttributedString+Helpers.swift index be4a7a9e3bd8..2289f8ccfe5f 100644 --- a/Modules/Sources/WordPressUI/Extensions/NSMutableAttributedString+Helpers.swift +++ b/Modules/Sources/WordPressUI/Extensions/NSMutableAttributedString+Helpers.swift @@ -5,10 +5,31 @@ import UIKit // extension NSMutableAttributedString { + /// Applies a single attribute to the entire string. + /// + /// Prefer this over manually constructing an `NSRange` with `NSMakeRange(0, โ€ฆ)`, + /// which is error-prone when mixing `String.count` (grapheme clusters) with + /// `NSRange` (UTF-16 code units). + /// + public func applyAttribute(_ key: NSAttributedString.Key, value: Any) { + // swiftlint:disable:next full_range_attributed_string_attribute + addAttribute(key, value: value, range: NSRange(location: 0, length: length)) + } + + /// Applies a collection of attributes to the entire string. + /// + /// Prefer this over manually constructing an `NSRange` with `NSMakeRange(0, โ€ฆ)`, + /// which is error-prone when mixing `String.count` (grapheme clusters) with + /// `NSRange` (UTF-16 code units). + /// + public func applyAttributes(_ attrs: [NSAttributedString.Key: Any]) { + // swiftlint:disable:next full_range_attributed_string_attribute + addAttributes(attrs, range: NSRange(location: 0, length: length)) + } + /// Applies the specified foreground color to the full length of the receiver. /// public func applyForegroundColor(_ color: UIColor) { - let range = NSRange(location: 0, length: length) - addAttribute(.foregroundColor, value: color, range: range) + applyAttribute(.foregroundColor, value: color) } } diff --git a/WordPress/Classes/Extensions/NSAttributedString+StyledHTML.swift b/WordPress/Classes/Extensions/NSAttributedString+StyledHTML.swift index 04c56a268e1a..c2605a0804df 100644 --- a/WordPress/Classes/Extensions/NSAttributedString+StyledHTML.swift +++ b/WordPress/Classes/Extensions/NSAttributedString+StyledHTML.swift @@ -1,4 +1,5 @@ import UIKit +import WordPressUI extension NSAttributedString { /// Creates an `NSAttributedString` with the styles defined in `attributes` applied. @@ -43,9 +44,7 @@ extension NSAttributedString { paragraphStyle.paragraphSpacing = 0 paragraphStyle.paragraphSpacingBefore = 0 - attributedString.addAttribute(.paragraphStyle, - value: paragraphStyle, - range: NSMakeRange(0, attributedString.length)) + attributedString.applyAttribute(.paragraphStyle, value: paragraphStyle) return NSAttributedString(attributedString: attributedString) } diff --git a/WordPress/Classes/Jetpack/NUX/LandingScreen/Views/JetpackLandingScreenView.swift b/WordPress/Classes/Jetpack/NUX/LandingScreen/Views/JetpackLandingScreenView.swift index 14636f661729..9670a45e8c8e 100644 --- a/WordPress/Classes/Jetpack/NUX/LandingScreen/Views/JetpackLandingScreenView.swift +++ b/WordPress/Classes/Jetpack/NUX/LandingScreen/Views/JetpackLandingScreenView.swift @@ -1,4 +1,5 @@ import UIKit +import WordPressUI final class JetpackLandingScreenView: UIView { @@ -72,7 +73,7 @@ final class JetpackLandingScreenView: UIView { return nil } let attributedString = NSMutableAttributedString(string: text) - attributedString.addAttributes(attributesForLabel(atIndex: index, traits: traits), range: NSMakeRange(0, text.utf16.count)) + attributedString.applyAttributes(attributesForLabel(atIndex: index, traits: traits)) return attributedString } diff --git a/WordPress/Classes/Models/Revisions/DiffAbstractValue+Attributes.swift b/WordPress/Classes/Models/Revisions/DiffAbstractValue+Attributes.swift index 4f426e60dbbd..01e2b5e24ea9 100644 --- a/WordPress/Classes/Models/Revisions/DiffAbstractValue+Attributes.swift +++ b/WordPress/Classes/Models/Revisions/DiffAbstractValue+Attributes.swift @@ -39,7 +39,7 @@ extension Array where Element == DiffAbstractValue { let attribute = NSMutableAttributedString(string: value) if let attributes = right.attributes { - attribute.addAttributes(attributes, range: NSRange(location: 0, length: value.count)) + attribute.applyAttributes(attributes) } left.append(attribute) return left diff --git a/WordPress/Classes/ViewRelated/Blog/BloggingReminders/BloggingRemindersFlowCompletionViewController.swift b/WordPress/Classes/ViewRelated/Blog/BloggingReminders/BloggingRemindersFlowCompletionViewController.swift index 09b7081e7ff8..c87473451134 100644 --- a/WordPress/Classes/ViewRelated/Blog/BloggingReminders/BloggingRemindersFlowCompletionViewController.swift +++ b/WordPress/Classes/ViewRelated/Blog/BloggingReminders/BloggingRemindersFlowCompletionViewController.swift @@ -177,7 +177,7 @@ final class BloggingRemindersFlowCompletionViewController: UIViewController { let promptText = NSMutableAttributedString(attributedString: formatter.longScheduleDescription(for: schedule, time: scheduler.scheduledTime(for: blog).toLocalTime())) - promptText.addAttributes(defaultAttributes, range: NSRange(location: 0, length: promptText.length)) + promptText.applyAttributes(defaultAttributes) promptLabel.attributedText = promptText } diff --git a/WordPress/Classes/ViewRelated/Blog/BloggingReminders/BloggingRemindersFlowSettingsViewController.swift b/WordPress/Classes/ViewRelated/Blog/BloggingReminders/BloggingRemindersFlowSettingsViewController.swift index 61d8c31763b3..1f21d679a469 100644 --- a/WordPress/Classes/ViewRelated/Blog/BloggingReminders/BloggingRemindersFlowSettingsViewController.swift +++ b/WordPress/Classes/ViewRelated/Blog/BloggingReminders/BloggingRemindersFlowSettingsViewController.swift @@ -490,7 +490,7 @@ private extension BloggingRemindersFlowSettingsViewController { let frequencyDescription = scheduleFormatter.shortScheduleDescription(for: .weekdays(weekdays)) let attributedText = NSMutableAttributedString(attributedString: frequencyDescription) - attributedText.addAttributes(defaultAttributes, range: NSRange(location: 0, length: attributedText.length)) + attributedText.applyAttributes(defaultAttributes) frequencyLabel.attributedText = attributedText frequencyLabel.sizeToFit() diff --git a/WordPress/Classes/ViewRelated/Blog/Site Management/DestructiveAlertHelper.swift b/WordPress/Classes/ViewRelated/Blog/Site Management/DestructiveAlertHelper.swift index e827af229b84..0872b6fffcd9 100644 --- a/WordPress/Classes/ViewRelated/Blog/Site Management/DestructiveAlertHelper.swift +++ b/WordPress/Classes/ViewRelated/Blog/Site Management/DestructiveAlertHelper.swift @@ -1,4 +1,5 @@ import UIKit +import WordPressUI protocol DestructiveAlertHelperLogic { var valueToConfirm: String? { get } @@ -18,7 +19,7 @@ class DestructiveAlertHelper: DestructiveAlertHelperLogic { let attributedValue: NSMutableAttributedString = NSMutableAttributedString(string: valueToConfirm) let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.lineBreakMode = .byCharWrapping - attributedValue.addAttribute(.paragraphStyle, value: paragraphStyle, range: NSMakeRange(0, attributedValue.length)) + attributedValue.applyAttribute(.paragraphStyle, value: paragraphStyle) attributedMessage.append(attributedValue) let alert = UIAlertController(title: title, message: nil, preferredStyle: .alert) diff --git a/WordPress/Classes/ViewRelated/Blog/Site Monitoring/SiteMonitoringEntryDetailsView.swift b/WordPress/Classes/ViewRelated/Blog/Site Monitoring/SiteMonitoringEntryDetailsView.swift index cf5cb91ca3dc..64cb3c81e5ae 100644 --- a/WordPress/Classes/ViewRelated/Blog/Site Monitoring/SiteMonitoringEntryDetailsView.swift +++ b/WordPress/Classes/ViewRelated/Blog/Site Monitoring/SiteMonitoringEntryDetailsView.swift @@ -1,6 +1,7 @@ import SwiftUI import WordPressData import WordPressKit +import WordPressUI struct SiteMonitoringEntryDetailsView: View { let text: NSAttributedString @@ -77,12 +78,12 @@ private func makeAttributedText(metadata: [(String, String?)], message: String? if let message { output.append(NSAttributedString(string: "\n" + message, attributes: [.font: regular])) } - output.addAttribute(.paragraphStyle, value: { + output.applyAttribute(.paragraphStyle, value: { let style = NSMutableParagraphStyle() style.lineSpacing = 3 return style - }(), range: NSRange(location: 0, length: output.length)) - output.addAttribute(.foregroundColor, value: UIColor.label, range: NSRange(location: 0, length: output.length)) + }()) + output.applyAttribute(.foregroundColor, value: UIColor.label) return output } diff --git a/WordPress/Classes/ViewRelated/Comments/Controllers/CommentDetailViewController.swift b/WordPress/Classes/ViewRelated/Comments/Controllers/CommentDetailViewController.swift index 07748d35943c..ffc500bdab25 100644 --- a/WordPress/Classes/ViewRelated/Comments/Controllers/CommentDetailViewController.swift +++ b/WordPress/Classes/ViewRelated/Comments/Controllers/CommentDetailViewController.swift @@ -84,7 +84,7 @@ public class CommentDetailViewController: UIViewController, NoResultsViewHost { let attributedString = NSMutableAttributedString() attributedString.append(NSAttributedString(attachment: iconAttachment)) attributedString.append(.init(string: " " + .replyIndicatorLabelText)) - attributedString.addAttributes(Style.ReplyIndicator.textAttributes, range: NSMakeRange(0, attributedString.length)) + attributedString.applyAttributes(Style.ReplyIndicator.textAttributes) // reverse the attributed strings in RTL direction. if view.effectiveUserInterfaceLayoutDirection == .rightToLeft { diff --git a/WordPress/Classes/ViewRelated/Jetpack/Branding/Fullscreen Overlay/JetpackFullscreenOverlayViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Branding/Fullscreen Overlay/JetpackFullscreenOverlayViewController.swift index 449cbf2e03f8..2f35fd9de6ac 100644 --- a/WordPress/Classes/ViewRelated/Jetpack/Branding/Fullscreen Overlay/JetpackFullscreenOverlayViewController.swift +++ b/WordPress/Classes/ViewRelated/Jetpack/Branding/Fullscreen Overlay/JetpackFullscreenOverlayViewController.swift @@ -147,7 +147,7 @@ class JetpackFullscreenOverlayViewController: UIViewController { .kern: Metrics.titleKern ] let attributedString = NSMutableAttributedString(string: viewModel.title) - attributedString.addAttributes(defaultAttributes, range: NSRange(location: 0, length: attributedString.length)) + attributedString.applyAttributes(defaultAttributes) titleLabel.attributedText = attributedString } diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/View Models/JetpackScanThreatViewModel.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/View Models/JetpackScanThreatViewModel.swift index 77d087e17874..da59fd663dcd 100644 --- a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/View Models/JetpackScanThreatViewModel.swift +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/View Models/JetpackScanThreatViewModel.swift @@ -464,8 +464,7 @@ private extension JetpackThreatContext { numberAttr.setAttributes(config.highlightedNumberAttributes, range: NSRange(location: 0, length: numberStr.count)) - contentsAttr.addAttributes(config.highlightedContentsAttributes, - range: NSRange(location: 0, length: contentsStr.count)) + contentsAttr.applyAttributes(config.highlightedContentsAttributes) for highlight in highlights { let location = highlight.location diff --git a/WordPress/Classes/ViewRelated/Post/Views/PostListItemViewModel.swift b/WordPress/Classes/ViewRelated/Post/Views/PostListItemViewModel.swift index d7e8a1cbd287..597c3aa0918b 100644 --- a/WordPress/Classes/ViewRelated/Post/Views/PostListItemViewModel.swift +++ b/WordPress/Classes/ViewRelated/Post/Views/PostListItemViewModel.swift @@ -1,6 +1,7 @@ import Foundation import WordPressData import WordPressShared +import WordPressUI final class PostListItemViewModel { let post: Post @@ -76,7 +77,7 @@ private func makeTitleString(for post: Post, isDisabled: Bool) -> NSAttributedSt paragraphStyle.lineBreakMode = .byTruncatingTail let string = NSMutableAttributedString(string: title, attributes: attributes) - string.addAttribute(.paragraphStyle, value: paragraphStyle, range: NSRange(location: 0, length: string.length)) + string.applyAttribute(.paragraphStyle, value: paragraphStyle) return string } @@ -95,7 +96,7 @@ private func makeExcerptString(for post: Post, isDisabled: Bool) -> NSAttributed paragraphStyle.lineBreakMode = .byTruncatingTail let string = NSMutableAttributedString(string: excerpt, attributes: attributes) - string.addAttribute(.paragraphStyle, value: paragraphStyle, range: NSRange(location: 0, length: string.length)) + string.applyAttribute(.paragraphStyle, value: paragraphStyle) return string } diff --git a/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderStreamViewController+Helper.swift b/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderStreamViewController+Helper.swift index 40c90db83388..a644b0288c5f 100644 --- a/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderStreamViewController+Helper.swift +++ b/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderStreamViewController+Helper.swift @@ -185,7 +185,7 @@ extension ReaderStreamViewController { ]) let icon = UIImage.gridicon(.bookmarkOutline, size: CGSize(width: 18, height: 18)) string.replace("[bookmark-outline]", with: icon) - string.addAttribute(.foregroundColor, value: UIColor.secondaryLabel, range: NSRange(location: 0, length: string.length)) + string.applyAttribute(.foregroundColor, value: UIColor.secondaryLabel) return string } } diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderReadMoreView.swift b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderReadMoreView.swift index 7b461532c119..7b9aad1a9be8 100644 --- a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderReadMoreView.swift +++ b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderReadMoreView.swift @@ -1,6 +1,7 @@ import UIKit import SafariServices import WordPressData +import WordPressUI // [โ€ฆ] final class ReaderReadMoreView: UIView, UIAdaptivePresentationControllerDelegate, UIPopoverPresentationControllerDelegate { @@ -25,7 +26,7 @@ final class ReaderReadMoreView: UIView, UIAdaptivePresentationControllerDelegate .font: UIFont.preferredFont(forTextStyle: .body) ]) if let postURL = post.permaLink.flatMap(URL.init) { - string.addAttribute(.link, value: postURL, range: NSRange(location: 0, length: string.length)) + string.applyAttribute(.link, value: postURL) self.postURL = postURL } textView.attributedText = string diff --git a/WordPress/Classes/ViewRelated/Stats/Insights/StatsTotalInsightsCell.swift b/WordPress/Classes/ViewRelated/Stats/Insights/StatsTotalInsightsCell.swift index 27a956cf65d5..4880d27641a8 100644 --- a/WordPress/Classes/ViewRelated/Stats/Insights/StatsTotalInsightsCell.swift +++ b/WordPress/Classes/ViewRelated/Stats/Insights/StatsTotalInsightsCell.swift @@ -91,9 +91,10 @@ struct StatsTotalInsightsData: Equatable { let attributedString = NSMutableAttributedString(string: formattedString) - let textRange = NSMakeRange(0, formattedString.utf16.count) - attributedString.addAttribute(.font, value: UIFont.preferredFont(forTextStyle: .subheadline), range: textRange) - attributedString.addAttribute(.foregroundColor, value: UIColor.label, range: textRange) + attributedString.applyAttributes([ + .font: UIFont.preferredFont(forTextStyle: .subheadline), + .foregroundColor: UIColor.label + ]) let titlePlaceholderRange = (text as NSString).range(of: "%1$@") let titleRange = NSMakeRange(titlePlaceholderRange.location, title.utf16.count) diff --git a/WordPress/Classes/ViewRelated/Stats/Period Stats/Overview/OverviewCell.swift b/WordPress/Classes/ViewRelated/Stats/Period Stats/Overview/OverviewCell.swift index cbe4d8b493f7..96ac9195e55d 100644 --- a/WordPress/Classes/ViewRelated/Stats/Period Stats/Overview/OverviewCell.swift +++ b/WordPress/Classes/ViewRelated/Stats/Period Stats/Overview/OverviewCell.swift @@ -38,8 +38,7 @@ struct OverviewTabData: FilterTabBarItem, Hashable { var attributedTitle: NSAttributedString? { let attributedTitle = NSMutableAttributedString(string: tabTitle.localizedUppercase) - attributedTitle.addAttributes([.font: WPStyleGuide.Stats.overviewCardFilterTitleFont], - range: NSMakeRange(0, attributedTitle.length)) + attributedTitle.applyAttributes([.font: WPStyleGuide.Stats.overviewCardFilterTitleFont]) let dataString: String = { if let tabDataStub { @@ -49,8 +48,7 @@ struct OverviewTabData: FilterTabBarItem, Hashable { }() let attributedData = NSMutableAttributedString(string: dataString) - attributedData.addAttributes([.font: WPStyleGuide.Stats.overviewCardFilterDataFont], - range: NSMakeRange(0, attributedData.length)) + attributedData.applyAttributes([.font: WPStyleGuide.Stats.overviewCardFilterDataFont]) attributedTitle.append(NSAttributedString(string: "\n")) attributedTitle.append(attributedData) diff --git a/WordPress/Classes/ViewRelated/System/FilterTabBar.swift b/WordPress/Classes/ViewRelated/System/FilterTabBar.swift index e92126d6b70a..99a23da75f61 100644 --- a/WordPress/Classes/ViewRelated/System/FilterTabBar.swift +++ b/WordPress/Classes/ViewRelated/System/FilterTabBar.swift @@ -1,5 +1,6 @@ import UIKit import WordPressShared +import WordPressUI /// Filter Tab Bar is a tabbed control (much like a segmented control), but /// has an appearance similar to Android tabs. @@ -344,7 +345,7 @@ public class FilterTabBar: UIControl { } let mutableString = NSMutableAttributedString(attributedString: attributedString) - mutableString.addAttributes([.foregroundColor: color], range: NSMakeRange(0, mutableString.length)) + mutableString.applyAttributes([.foregroundColor: color]) return mutableString } From b7f0395a5275b9f17e5220767def380eac60a188 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:13:44 -0700 Subject: [PATCH 04/10] Fix range conversion helpers and regex utilities to use UTF-16 counts Replace manual index arithmetic in `range(from:)` and `nsRange(from:)` with Foundation's built-in `Range(nsRange, in:)` and `NSRange(range, in:)`, and fix `endOfStringNSRange()` and String+RegEx helpers to use `utf16.count`. Also remove redundant accessibility element entries that duplicate items already contained in their tableView, and add comprehensive tests for range conversion with emoji. Co-Authored-By: Claude Opus 4.6 --- .../Utility/String+RangeConveresion.swift | 16 +-- .../Utility/String+RegEx.swift | 6 +- .../StringRangeConversionTests.swift | 123 ++++++++++++++++++ .../ViewRelated/2FA/TwoFAViewController.swift | 1 - .../Password/PasswordViewController.swift | 1 - .../SiteAddressViewController.swift | 1 - .../SiteCredentialsViewController.swift | 1 - 7 files changed, 132 insertions(+), 17 deletions(-) create mode 100644 Modules/Tests/WordPressSharedTests/StringRangeConversionTests.swift diff --git a/Modules/Sources/WordPressShared/Utility/String+RangeConveresion.swift b/Modules/Sources/WordPressShared/Utility/String+RangeConveresion.swift index f5e7a1ce7fd1..77e737d6bacd 100644 --- a/Modules/Sources/WordPressShared/Utility/String+RangeConveresion.swift +++ b/Modules/Sources/WordPressShared/Utility/String+RangeConveresion.swift @@ -48,10 +48,10 @@ extension String { /// - Returns: the requested `Range` /// func range(from nsRange: NSRange) -> Range { - let lowerBound = index(startIndex, offsetBy: nsRange.location) - let upperBound = index(lowerBound, offsetBy: nsRange.length) - - return lowerBound ..< upperBound + guard let range = Range(nsRange, in: self) else { + preconditionFailure("Invalid NSRange \(nsRange) for string of UTF-16 length \(utf16.count)") + } + return range } func range(fromUTF16NSRange utf16NSRange: NSRange) -> Range { @@ -160,11 +160,7 @@ extension String { /// - Returns: the requested `NSRange`. /// func nsRange(from range: Range) -> NSRange { - - let location = distance(from: startIndex, to: range.lowerBound) - let length = distance(from: range.lowerBound, to: range.upperBound) - - return NSRange(location: location, length: length) + NSRange(range, in: self) } /// Converts a `Range` into an UTF16 NSRange. @@ -190,7 +186,7 @@ extension String { /// Returns a NSRange with a starting location at the very end of the string /// func endOfStringNSRange() -> NSRange { - return NSRange(location: count, length: 0) + return NSRange(location: utf16.count, length: 0) } func indexFromLocation(_ location: Int) -> String.Index? { diff --git a/Modules/Sources/WordPressShared/Utility/String+RegEx.swift b/Modules/Sources/WordPressShared/Utility/String+RegEx.swift index 3a9e3270aeae..00d960edf737 100644 --- a/Modules/Sources/WordPressShared/Utility/String+RegEx.swift +++ b/Modules/Sources/WordPressShared/Utility/String+RegEx.swift @@ -16,7 +16,7 @@ extension String { public func replacingMatches(of regex: String, options: NSRegularExpression.Options = [], using block: (String, [String]) -> String) -> String { let regex = try! NSRegularExpression(pattern: regex, options: options) - let fullRange = NSRange(location: 0, length: count) + let fullRange = NSRange(location: 0, length: utf16.count) let matches = regex.matches(in: self, options: [], range: fullRange) var newString = self @@ -49,7 +49,7 @@ extension String { /// public func matches(regex: String, options: NSRegularExpression.Options = []) -> [NSTextCheckingResult] { let regex = try! NSRegularExpression(pattern: regex, options: options) - let fullRange = NSRange(location: 0, length: count) + let fullRange = NSRange(location: 0, length: utf16.count) return regex.matches(in: self, options: [], range: fullRange) } @@ -66,7 +66,7 @@ extension String { public func replacingMatches(of regex: String, with template: String, options: NSRegularExpression.Options = []) -> String { let regex = try! NSRegularExpression(pattern: regex, options: options) - let fullRange = NSRange(location: 0, length: count) + let fullRange = NSRange(location: 0, length: utf16.count) return regex.stringByReplacingMatches(in: self, options: [], diff --git a/Modules/Tests/WordPressSharedTests/StringRangeConversionTests.swift b/Modules/Tests/WordPressSharedTests/StringRangeConversionTests.swift new file mode 100644 index 000000000000..7bb6cc798846 --- /dev/null +++ b/Modules/Tests/WordPressSharedTests/StringRangeConversionTests.swift @@ -0,0 +1,123 @@ +import Testing +import Foundation +import UIKit + +@testable import WordPressShared + +struct StringRangeConversionTests { + + // MARK: - range(from nsRange: NSRange) -> Range + + @Test("range(from:) converts NSRange correctly for ASCII strings") + func rangeFromNSRangeASCII() { + let string = "Hello, world!" + let nsRange = NSRange(location: 7, length: 5) + let range = string.range(from: nsRange) + #expect(String(string[range]) == "world") + } + + @Test("range(from:) converts NSRange correctly for strings with emoji") + func rangeFromNSRangeWithEmoji() { + // ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ is 1 grapheme cluster but 11 UTF-16 code units + let string = "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ Hello" + let nsRange = NSRange(location: 12, length: 5) // UTF-16 offset past emoji + space + let range = string.range(from: nsRange) + #expect(String(string[range]) == "Hello") + } + + @Test("range(from:) handles multi-byte characters in the middle") + func rangeFromNSRangeWithEmojiInMiddle() { + // "AB๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆCD" โ€” 'A'=1, 'B'=1, emoji=11, 'C'=1, 'D'=1 UTF-16 units + let string = "AB๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆCD" + let nsRange = NSRange(location: 13, length: 2) // "CD" + let range = string.range(from: nsRange) + #expect(String(string[range]) == "CD") + } + + // MARK: - nsRange(from range: Range) -> NSRange + + @Test("nsRange(from:) produces correct UTF-16 NSRange for ASCII strings") + func nsRangeFromSwiftRangeASCII() { + let string = "Hello, world!" + let swiftRange = string.range(of: "world")! + let nsRange = string.nsRange(from: swiftRange) + #expect(nsRange.location == 7) + #expect(nsRange.length == 5) + } + + @Test("nsRange(from:) produces correct UTF-16 NSRange with emoji") + func nsRangeFromSwiftRangeWithEmoji() { + // ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ is 1 grapheme cluster but 11 UTF-16 code units + let string = "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ Hello" + let swiftRange = string.range(of: "Hello")! + let nsRange = string.nsRange(from: swiftRange) + // "Hello" starts at UTF-16 offset 12 (11 for emoji + 1 for space) + #expect(nsRange.location == 12) + #expect(nsRange.length == 5) + } + + @Test("nsRange(from:) result works correctly with NSAttributedString") + func nsRangeFromSwiftRangeWorksWithAttributedString() { + let string = "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ Bold text" + let swiftRange = string.range(of: "Bold")! + let nsRange = string.nsRange(from: swiftRange) + let attributed = NSMutableAttributedString(string: string) + // This should not crash โ€” if nsRange used grapheme offsets, this would + // produce an out-of-bounds range for NSAttributedString + attributed.addAttribute(.font, value: UIFont.boldSystemFont(ofSize: 14), range: nsRange) + let attrs = attributed.attributes(at: nsRange.location, effectiveRange: nil) + #expect(attrs[.font] != nil) + } + + // MARK: - nsRange(of:) -> NSRange? + + @Test("nsRange(of:) produces correct UTF-16 NSRange for substring after emoji") + func nsRangeOfSubstringAfterEmoji() { + let string = "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ world" + let nsRange = string.nsRange(of: "world") + #expect(nsRange != nil) + #expect(nsRange!.location == 12) // 11 for emoji + 1 for space + #expect(nsRange!.length == 5) + } + + @Test("nsRange(of:) result can be used with NSRegularExpression range") + func nsRangeOfWorksWithRegex() { + let string = "๐ŸŽ‰ test@example.com" + // Find "test@example.com" via nsRange(of:) and verify it matches UTF-16 offsets + let nsRange = string.nsRange(of: "test@example.com")! + let nsString = string as NSString + let extracted = nsString.substring(with: nsRange) + #expect(extracted == "test@example.com") + } + + // MARK: - endOfStringNSRange() + + @Test("endOfStringNSRange() uses UTF-16 count for emoji strings") + func endOfStringNSRangeWithEmoji() { + // ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ is 1 grapheme cluster but 11 UTF-16 code units + let string = "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ" + let nsRange = string.endOfStringNSRange() + #expect(nsRange.location == 11) // UTF-16 count, not grapheme count (1) + #expect(nsRange.length == 0) + } + + @Test("endOfStringNSRange() is consistent with NSString length") + func endOfStringNSRangeConsistentWithNSString() { + let string = "Hello ๐ŸŒ World ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ!" + let nsRange = string.endOfStringNSRange() + let nsString = string as NSString + #expect(nsRange.location == nsString.length) + } + + // MARK: - Round-trip consistency + + @Test("NSRange โ†’ Range โ†’ NSRange round-trip preserves values with emoji") + func roundTripWithEmoji() { + let string = "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ Hello ๐ŸŒ World" + let original = NSRange(location: 12, length: 5) // "Hello" + let swiftRange = string.range(from: original) + let roundTripped = string.nsRange(from: swiftRange) + #expect(roundTripped.location == original.location) + #expect(roundTripped.length == original.length) + } +} diff --git a/Sources/WordPressAuthenticator/Helpers/UnifiedAuth/ViewRelated/2FA/TwoFAViewController.swift b/Sources/WordPressAuthenticator/Helpers/UnifiedAuth/ViewRelated/2FA/TwoFAViewController.swift index d28708fa6a7d..138a00aa99af 100644 --- a/Sources/WordPressAuthenticator/Helpers/UnifiedAuth/ViewRelated/2FA/TwoFAViewController.swift +++ b/Sources/WordPressAuthenticator/Helpers/UnifiedAuth/ViewRelated/2FA/TwoFAViewController.swift @@ -599,7 +599,6 @@ private extension TwoFAViewController { /// func configureForAccessibility() { view.accessibilityElements = [ - codeField as Any, tableView as Any, submitButton as Any ] diff --git a/Sources/WordPressAuthenticator/Helpers/UnifiedAuth/ViewRelated/Password/PasswordViewController.swift b/Sources/WordPressAuthenticator/Helpers/UnifiedAuth/ViewRelated/Password/PasswordViewController.swift index bdeb49a8e5db..37ac8a7fd52c 100644 --- a/Sources/WordPressAuthenticator/Helpers/UnifiedAuth/ViewRelated/Password/PasswordViewController.swift +++ b/Sources/WordPressAuthenticator/Helpers/UnifiedAuth/ViewRelated/Password/PasswordViewController.swift @@ -523,7 +523,6 @@ private extension PasswordViewController { /// func configureForAccessibility() { view.accessibilityElements = [ - passwordField as Any, tableView as Any, submitButton as Any ] diff --git a/Sources/WordPressAuthenticator/Helpers/UnifiedAuth/ViewRelated/SiteAddress/SiteAddressViewController.swift b/Sources/WordPressAuthenticator/Helpers/UnifiedAuth/ViewRelated/SiteAddress/SiteAddressViewController.swift index 33ac6331b4e6..544b19c97637 100644 --- a/Sources/WordPressAuthenticator/Helpers/UnifiedAuth/ViewRelated/SiteAddress/SiteAddressViewController.swift +++ b/Sources/WordPressAuthenticator/Helpers/UnifiedAuth/ViewRelated/SiteAddress/SiteAddressViewController.swift @@ -141,7 +141,6 @@ final class SiteAddressViewController: LoginViewController { /// private func configureForAccessibility() { view.accessibilityElements = [ - siteURLField as Any, tableView as Any, submitButton as Any ] diff --git a/Sources/WordPressAuthenticator/Helpers/UnifiedAuth/ViewRelated/SiteAddress/SiteCredentialsViewController.swift b/Sources/WordPressAuthenticator/Helpers/UnifiedAuth/ViewRelated/SiteAddress/SiteCredentialsViewController.swift index 89b4d15d8eaa..232637b15952 100644 --- a/Sources/WordPressAuthenticator/Helpers/UnifiedAuth/ViewRelated/SiteAddress/SiteCredentialsViewController.swift +++ b/Sources/WordPressAuthenticator/Helpers/UnifiedAuth/ViewRelated/SiteAddress/SiteCredentialsViewController.swift @@ -133,7 +133,6 @@ final class SiteCredentialsViewController: LoginViewController { /// private func configureForAccessibility() { view.accessibilityElements = [ - usernameField as Any, tableView as Any, submitButton as Any ] From 597f2b58d35458cee25db7c19840515973972cf9 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:27:13 -0700 Subject: [PATCH 05/10] Fix RSD link extraction failing on HTML with multi-byte characters `extractRSDURLFromHTML` used `html.count` (grapheme clusters) to build the NSRange for the regex search, which is too short when the HTML contains emoji or other multi-code-unit characters before the RSD link. Use `html.utf16.count` instead, matching what NSRegularExpression expects. Co-Authored-By: Claude Opus 4.6 --- .../WordPressOrgXMLRPCValidator.swift | 2 +- .../WordPressOrgXMLRPCValidatorTests.swift | 70 +++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/Modules/Sources/WordPressKit/WordPressOrgXMLRPCValidator.swift b/Modules/Sources/WordPressKit/WordPressOrgXMLRPCValidator.swift index 84e9b65dd5e6..e0f1b276d2e4 100644 --- a/Modules/Sources/WordPressKit/WordPressOrgXMLRPCValidator.swift +++ b/Modules/Sources/WordPressKit/WordPressOrgXMLRPCValidator.swift @@ -317,7 +317,7 @@ open class WordPressOrgXMLRPCValidator: NSObject { let matches = rsdURLRegExp.matches(in: html, options: NSRegularExpression.MatchingOptions(), - range: NSRange(location: 0, length: html.count)) + range: NSRange(location: 0, length: html.utf16.count)) if matches.count <= 0 { return nil } diff --git a/Tests/WordPressKitTests/CoreAPITests/WordPressOrgXMLRPCValidatorTests.swift b/Tests/WordPressKitTests/CoreAPITests/WordPressOrgXMLRPCValidatorTests.swift index a66c00d7abd1..c39e20266ec3 100644 --- a/Tests/WordPressKitTests/CoreAPITests/WordPressOrgXMLRPCValidatorTests.swift +++ b/Tests/WordPressKitTests/CoreAPITests/WordPressOrgXMLRPCValidatorTests.swift @@ -368,6 +368,76 @@ final class WordPressOrgXMLRPCValidatorTests: XCTestCase { wait(for: [failure], timeout: 0.3) } + // Regression test: emoji in HTML before the RSD link caused the regex + // range to be too short because `String.count` (grapheme clusters) was + // used instead of `String.utf16.count` (UTF-16 code units for NSRange). + func testSuccessWithRSDLinkAfterEmoji() throws { + let responseInvalidPath = try XCTUnwrap(xmlrpcResponseInvalidPath) + stub(condition: isHost("www.apple.com") && isPath("/blog/xmlrpc.php")) { _ in + return fixture(filePath: responseInvalidPath, status: 403, headers: nil) + } + + stub(condition: isHost("www.apple.com") && isPath("/blog")) { _ in + // The family emoji ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ is 1 grapheme cluster but 11 UTF-16 code units. + // Placing many of them *before* the RSD link tag means the gap between + // String.count and utf16.count is large enough that an NSRange built + // with String.count will be too short to cover the link tag. + let emoji = String(repeating: "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ", count: 50) + let html = """ + + + + \(emoji) + + + hello world + + """ + return HTTPStubsResponse(data: html.data(using: .utf8)!, statusCode: 200, headers: nil) + } + + stub(condition: isAbsoluteURLString("https://www.apple.com/blog/rsd")) { _ in + let xml = """ + + + WordPress + https://wordpress.org/ + https://developer.wordpress.org + + + + + + """ + return HTTPStubsResponse( + data: xml.data(using: .utf8)!, + statusCode: 200, + headers: ["Content-Type": "application/xml"] + ) + } + + let responseList = try XCTUnwrap( + OHPathForFileInBundle("xmlrpc-response-list-methods.xml", Bundle.coreAPITestsBundle) + ) + stub(condition: isHost("www.apple.com") && isPath("/blog-xmlrpc.php")) { _ in + fixture( + filePath: responseList, + status: 200, + headers: ["Content-Type": "application/xml"] + ) + } + + let success = self.expectation(description: "success result") + let validator = WordPressOrgXMLRPCValidator() + validator.guessXMLRPCURLForSite("https://www.apple.com/blog", userAgent: "test/1.0", success: { + XCTAssertEqual($0.absoluteString, "https://www.apple.com/blog-xmlrpc.php") + success.fulfill() + }) { + XCTFail("Unexpected result: \($0)") + } + wait(for: [success], timeout: 0.3) + } + let xmlrpcResponseInvalidPath = OHPathForFileInBundle( "xmlrpc-response-invalid.html", Bundle.coreAPITestsBundle From 6e6a16ce32b2a086d4eeb30e65479cb694549f3e Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:40:40 -0700 Subject: [PATCH 06/10] Fix pingback link range to use UTF-16 count and document domain name safety Change `pingbackReadMoreGroup` from `private` to `internal` and fix its range construction to use `text.utf16.count` so localized translations with multi-byte characters produce a correct NSRange. Add a unit test that asserts the link range covers the full text. Also document why `AddressTableViewCell.processName` is safe: domain names are ASCII-only (internationalized domains use Punycode). Co-Authored-By: Claude Opus 4.6 --- .../FormattableContentGroupTests.swift | 17 +++++++++++++++++ .../Groups/BodyContentGroup.swift | 4 ++-- .../Web Address/AddressTableViewCell.swift | 2 ++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/Tests/KeystoneTests/Tests/Features/Notifications/FormattableContentGroupTests.swift b/Tests/KeystoneTests/Tests/Features/Notifications/FormattableContentGroupTests.swift index dce60ea28a8d..05922a7b298e 100644 --- a/Tests/KeystoneTests/Tests/Features/Notifications/FormattableContentGroupTests.swift +++ b/Tests/KeystoneTests/Tests/Features/Notifications/FormattableContentGroupTests.swift @@ -50,6 +50,23 @@ final class FormattableContentGroupTests: CoreDataTestCase { XCTAssertNil(obtainedBlock) } + // MARK: - pingbackReadMoreGroup + + func testPingbackReadMoreGroupLinkRangeCoversFullText() { + let url = URL(string: "https://example.com")! + let group = BodyContentGroup.pingbackReadMoreGroup(for: url) + let block = group.blocks.first as! FormattableTextContent + let text = block.text! + + // Find the link range (the NotificationContentRange with kind .link) + let linkRange = block.ranges.first { $0.kind == .link }! + + // The range must cover the full string using UTF-16 counts, + // which is what NSAttributedString expects. + XCTAssertEqual(linkRange.range.location, 0) + XCTAssertEqual(linkRange.range.length, text.utf16.count) + } + private func mockContent() throws -> FormattableTextContent { let text = try mockActivity()["text"] as? String ?? "" return FormattableTextContent(text: text, ranges: [], actions: []) diff --git a/WordPress/Classes/ViewRelated/Notifications/FormattableContent/Groups/BodyContentGroup.swift b/WordPress/Classes/ViewRelated/Notifications/FormattableContent/Groups/BodyContentGroup.swift index adced12f1874..8985dfe80ac9 100644 --- a/WordPress/Classes/ViewRelated/Notifications/FormattableContent/Groups/BodyContentGroup.swift +++ b/WordPress/Classes/ViewRelated/Notifications/FormattableContent/Groups/BodyContentGroup.swift @@ -85,9 +85,9 @@ class BodyContentGroup: FormattableContentGroup { } } - private class func pingbackReadMoreGroup(for url: URL) -> FormattableContentGroup { + static func pingbackReadMoreGroup(for url: URL) -> FormattableContentGroup { let text = NSLocalizedString("Read the source post", comment: "Displayed at the footer of a Pingback Notification.") - let textRange = NSRange(location: 0, length: text.count) + let textRange = NSRange(location: 0, length: text.utf16.count) let zeroRange = NSRange(location: 0, length: 0) var properties = NotificationContentRange.Properties(range: textRange) diff --git a/WordPress/Classes/ViewRelated/Site Creation/Web Address/AddressTableViewCell.swift b/WordPress/Classes/ViewRelated/Site Creation/Web Address/AddressTableViewCell.swift index cf57c0886935..6dabe49de8de 100644 --- a/WordPress/Classes/ViewRelated/Site Creation/Web Address/AddressTableViewCell.swift +++ b/WordPress/Classes/ViewRelated/Site Creation/Web Address/AddressTableViewCell.swift @@ -311,6 +311,8 @@ extension AddressTableViewCell { } let completeDomainName = NSMutableAttributedString(string: name, attributes: TextStyleAttributes.defaults) + // Domain names are ASCII-only (internationalized domains use Punycode), + // so .count == .utf16.count here and this is safe. let rangeOfCustomName = NSRange(location: 0, length: customName.count) completeDomainName.setAttributes(TextStyleAttributes.customName, range: rangeOfCustomName) From c6bc238928584795e2410f476d37bcf1e49bd4ae Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:45:59 -0700 Subject: [PATCH 07/10] Add regression test for full-text link range with multi-byte characters Directly constructs a NotificationContentRange with emoji + CJK text and verifies the range length matches NSAttributedString.length (UTF-16) and that applying it as a link attribute doesn't crash. Co-Authored-By: Claude Opus 4.6 --- .../FormattableContentGroupTests.swift | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/Tests/KeystoneTests/Tests/Features/Notifications/FormattableContentGroupTests.swift b/Tests/KeystoneTests/Tests/Features/Notifications/FormattableContentGroupTests.swift index 05922a7b298e..ace406c0dd0b 100644 --- a/Tests/KeystoneTests/Tests/Features/Notifications/FormattableContentGroupTests.swift +++ b/Tests/KeystoneTests/Tests/Features/Notifications/FormattableContentGroupTests.swift @@ -67,6 +67,36 @@ final class FormattableContentGroupTests: CoreDataTestCase { XCTAssertEqual(linkRange.range.length, text.utf16.count) } + /// Regression test: building a full-text link range with `String.count` + /// instead of `String.utf16.count` produces the wrong NSRange when the + /// text contains multi-byte characters (e.g. emoji). The range must use + /// UTF-16 counts because NSAttributedString is backed by UTF-16. + func testFullTextLinkRangeWithEmojiUsesUTF16Count() { + // ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ is 1 grapheme cluster but 11 UTF-16 code units. + let text = "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ ใ‚ฝใƒผใ‚นใฎๆŠ•็จฟใ‚’่ชญใ‚€" + let url = URL(string: "https://example.com")! + + let textRange = NSRange(location: 0, length: text.utf16.count) + var properties = NotificationContentRange.Properties(range: textRange) + properties.url = url + + let linkRange = NotificationContentRange(kind: .link, properties: properties) + + // The range must match NSAttributedString's length (UTF-16 based) + let attributed = NSMutableAttributedString(string: text) + XCTAssertEqual(linkRange.range.length, attributed.length, + "Link range length should equal NSAttributedString.length (UTF-16)") + + // Applying the range to an attributed string must not crash + attributed.addAttribute(.link, value: url, range: linkRange.range) + + // Verify the link covers the full string + var effectiveRange = NSRange() + let value = attributed.attribute(.link, at: 0, effectiveRange: &effectiveRange) + XCTAssertNotNil(value) + XCTAssertEqual(effectiveRange.length, attributed.length) + } + private func mockContent() throws -> FormattableTextContent { let text = try mockActivity()["text"] as? String ?? "" return FormattableTextContent(text: text, ranges: [], actions: []) From bc269276b91600581e255d10449d70bc52a0a4ec Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:57:59 -0700 Subject: [PATCH 08/10] Fix threat context renderer to use applyAttributes for full-range styling Replace manual NSRange construction using `String.count` with `applyAttributes` helper in `JetpackThreatContext.attributedString(with:)`. The `contentsStr` can contain multi-byte characters from scanned files, making the previous `String.count`-based ranges incorrect for NSAttributedString (which uses UTF-16). Make the extension internal so it can be tested, and add tests with emoji and CJK content. Co-Authored-By: Claude Opus 4.6 --- .../JetpackScanThreatContextTests.swift | 48 +++++++++++++++++++ .../JetpackScanThreatViewModel.swift | 13 ++--- 2 files changed, 53 insertions(+), 8 deletions(-) create mode 100644 Tests/KeystoneTests/Tests/Features/JetpackScanThreatContextTests.swift diff --git a/Tests/KeystoneTests/Tests/Features/JetpackScanThreatContextTests.swift b/Tests/KeystoneTests/Tests/Features/JetpackScanThreatContextTests.swift new file mode 100644 index 000000000000..c72014c7a2a1 --- /dev/null +++ b/Tests/KeystoneTests/Tests/Features/JetpackScanThreatContextTests.swift @@ -0,0 +1,48 @@ +import XCTest +@testable import WordPressKit +@testable import WordPress + +final class JetpackScanThreatContextTests: XCTestCase { + + private let config = JetpackThreatContext.JetpackThreatContextRendererConfig( + numberAttributes: [.foregroundColor: UIColor.gray], + highlightedNumberAttributes: [.foregroundColor: UIColor.red], + contentsAttributes: [.foregroundColor: UIColor.label], + highlightedContentsAttributes: [.foregroundColor: UIColor.label], + highlightedSectionAttributes: [.backgroundColor: UIColor.yellow] + ) + + /// Regression test: file contents with multi-byte characters (emoji, CJK) + /// should not crash or produce incorrect attributed strings. + /// Previously, `String.count` was used instead of `String.utf16.count` + /// for NSRange construction, which could produce out-of-bounds ranges. + func testAttributedStringWithMultiByteContents() { + let lines: [JetpackThreatContext.ThreatContextLine] = [ + .init(lineNumber: 1, contents: " Date: Tue, 3 Mar 2026 20:29:15 -0700 Subject: [PATCH 09/10] Add tests for applyAttribute/applyAttributes helpers with emoji Verify that applyAttribute, applyAttributes, and applyForegroundColor correctly cover the full string length when the string contains multi-byte characters like emoji. Co-Authored-By: Claude Opus 4.6 --- ...SMutableAttributedStringHelpersTests.swift | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 Modules/Tests/WordPressUIUnitTests/Extensions/NSMutableAttributedStringHelpersTests.swift diff --git a/Modules/Tests/WordPressUIUnitTests/Extensions/NSMutableAttributedStringHelpersTests.swift b/Modules/Tests/WordPressUIUnitTests/Extensions/NSMutableAttributedStringHelpersTests.swift new file mode 100644 index 000000000000..7b26428b1e63 --- /dev/null +++ b/Modules/Tests/WordPressUIUnitTests/Extensions/NSMutableAttributedStringHelpersTests.swift @@ -0,0 +1,60 @@ +import Testing +import UIKit + +@testable import WordPressUI + +struct NSMutableAttributedStringHelpersTests { + + // MARK: - applyAttribute(_:value:) + + @Test("applyAttribute covers the full string including emoji") + func applyAttributeWithEmoji() { + // ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ is 1 grapheme cluster but 11 UTF-16 code units + let string = NSMutableAttributedString(string: "Hello ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ World") + string.applyAttribute(.foregroundColor, value: UIColor.red) + + var effectiveRange = NSRange() + let value = string.attribute(.foregroundColor, at: 0, effectiveRange: &effectiveRange) + + #expect(value != nil) + #expect(effectiveRange.location == 0) + #expect(effectiveRange.length == string.length) + } + + // MARK: - applyAttributes(_:) + + @Test("applyAttributes covers the full string including emoji") + func applyAttributesWithEmoji() { + let string = NSMutableAttributedString(string: "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ ใƒ†ใ‚นใƒˆ") + let attrs: [NSAttributedString.Key: Any] = [ + .foregroundColor: UIColor.blue, + .font: UIFont.systemFont(ofSize: 14) + ] + string.applyAttributes(attrs) + + var colorRange = NSRange() + let color = string.attribute(.foregroundColor, at: 0, effectiveRange: &colorRange) + + var fontRange = NSRange() + let font = string.attribute(.font, at: 0, effectiveRange: &fontRange) + + #expect(color != nil) + #expect(colorRange.length == string.length) + #expect(font != nil) + #expect(fontRange.length == string.length) + } + + // MARK: - applyForegroundColor(_:) + + @Test("applyForegroundColor covers the full string including emoji") + func applyForegroundColorWithEmoji() { + let string = NSMutableAttributedString(string: "๐ŸŒ Hello ๐ŸŒ") + string.applyForegroundColor(.green) + + var effectiveRange = NSRange() + let value = string.attribute(.foregroundColor, at: 0, effectiveRange: &effectiveRange) + + #expect(value as? UIColor == .green) + #expect(effectiveRange.length == string.length) + } +} From c365360961eea5bb3ca94b0346b7ba2f0cf871c8 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 3 Mar 2026 20:44:06 -0700 Subject: [PATCH 10/10] Add regression test for FormattableNoticonRange UTF-16 shift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests that apply() returns the correct UTF-16 code unit count (12) rather than grapheme cluster count (2) when the noticon value is a multi-byte emoji like ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ. --- .../FormattableNotIconTests.swift | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/Tests/KeystoneTests/Tests/Features/Notifications/FormattableNotIconTests.swift b/Tests/KeystoneTests/Tests/Features/Notifications/FormattableNotIconTests.swift index b0c616ca4bbb..8d18c86a2134 100644 --- a/Tests/KeystoneTests/Tests/Features/Notifications/FormattableNotIconTests.swift +++ b/Tests/KeystoneTests/Tests/Features/Notifications/FormattableNotIconTests.swift @@ -34,6 +34,35 @@ final class FormattableNotIconTests: XCTestCase { XCTAssertEqual(subject?.value, Constants.icon) } + // MARK: - apply(_:to:withShift:) + + func testApplyReturnsUTF16ShiftForMultiByteNoticon() { + // ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ is 1 grapheme cluster but 11 UTF-16 code units. + // The noticon string is value + " ", so 12 UTF-16 code units total. + // With the old .count code, the shift would have been 2 (1 emoji + 1 space). + let familyEmoji = "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ" + let noticonRange = FormattableNoticonRange(value: familyEmoji, range: NSRange(location: 0, length: 0)) + + let baseText = "Hello World" + let string = NSMutableAttributedString(string: baseText) + + let styles = SnippetsContentStyles(rangeStylesMap: [ + .noticon: [.foregroundColor: UIColor.red] + ]) + + let shift = noticonRange.apply(styles, to: string, withShift: 0) + + // The noticon is familyEmoji + " " = 12 UTF-16 code units + let expectedShift = (familyEmoji + " ").utf16.count + XCTAssertEqual(shift, expectedShift, "Shift should equal the UTF-16 count of the noticon, not the grapheme cluster count") + + // Verify styles were applied to the full noticon range + var effectiveRange = NSRange() + let color = string.attribute(.foregroundColor, at: 0, effectiveRange: &effectiveRange) + XCTAssertNotNil(color) + XCTAssertEqual(effectiveRange.length, expectedShift, "Style should cover the full UTF-16 range of the inserted noticon") + } + private func mockProperties() -> NotificationContentRange.Properties { return NotificationContentRange.Properties(range: Constants.range) }