"
+ let result = RichContentFormatter.removeInlineStyles(input)
+ XCTAssertEqual(result, expected, "Inline styles after emoji should be removed")
+ }
+
+ func testNormalizeParagraphsWithEmoji() {
+ let input = "
"
+ 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)
}