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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion Modules/Sources/WordPressKit/NonceRetrieval.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ enum NonceRetrievalMethod {

private func scrapNonceFromNewPost(html: String) -> String? {
guard let regex = try? NSRegularExpression(pattern: "apiFetch.createNonceMiddleware\\(\\s*['\"](?<nonce>\\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 {
Copy link
Contributor

@kean kean Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The changes are repetitive, so I focused on this first scenario. I generated a set of unit tests for it, verified the original issue and the fact that the change fixes it.

(nit) An idiomatic way to create NSRange from String is with a specialized init method that abstracts the underlying representation away:

NSRange(html.startIndex..., in: html)

(nit) Since it's String in and String out, the line 55 just below requires another conversion from NSRange back to Swift.Range. It might be worth taking it a step further and replacing it with Regex that will also add some compile-time guarantees for the expression.

return nil
}
let nsrange = match.range(withName: "nonce")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
31 changes: 15 additions & 16 deletions Modules/Sources/WordPressShared/Utility/RichContentFormatter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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 {

Expand All @@ -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))
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -287,10 +287,9 @@ import WordPressSharedObjC
}

var content = string.trim()
let matches = RegEx.trailingBRTags.matches(in: content, options: .reportCompletion, range: NSRange(location: 0, length: content.count))
if let match = matches.first {
let index = content.index(content.startIndex, offsetBy: match.range.location)
content = String(content.prefix(upTo: index))
let matches = RegEx.trailingBRTags.matches(in: content, options: .reportCompletion, range: NSRange(location: 0, length: content.utf16.count))
if let match = matches.first, let range = Range(match.range, in: content) {
content = String(content[content.startIndex..<range.lowerBound])
}

return content
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,10 @@ extension String {
/// - Returns: the requested `Range<String.Index>`
///
func range(from nsRange: NSRange) -> Range<String.Index> {
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)")
Copy link
Contributor

@kean kean Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might result in a crash in production. The ideal change would be to eliminate String+Conversion.swift as it seems to contain code that is now provided by the system or could be replaced with Regex.

}
return range
}

func range(fromUTF16NSRange utf16NSRange: NSRange) -> Range<String.Index> {
Expand Down Expand Up @@ -160,11 +160,7 @@ extension String {
/// - Returns: the requested `NSRange`.
///
func nsRange(from range: Range<String.Index>) -> 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<String.Index>` into an UTF16 NSRange.
Expand All @@ -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? {
Expand Down
6 changes: 3 additions & 3 deletions Modules/Sources/WordPressShared/Utility/String+RegEx.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
}
Expand All @@ -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: [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(nit) I'd suggest naming it addAttribute to match the original method. The apply is ambiguous – is it set or is it add?

// 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))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(nit) It doesn't seem to be one of the documented rules and there don't seem to be any custom rules in the .yml` file. It might be the case SwiftLint is behind. I'm not familiar with this rule though.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, it's a new one – source is here

}

/// 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)
}
}
42 changes: 42 additions & 0 deletions Modules/Tests/WordPressSharedTests/RichContentFormatterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,48 @@ class RichContentFormatterTests: XCTestCase {
XCTAssertTrue(range.location != NSNotFound)
}

// MARK: - Emoji / Multi-byte Character Tests
//
// These tests verify that string operations work correctly with emoji
// and other characters where String.count (grapheme clusters) differs
// from String.utf16.count (UTF-16 code units used by NSRange).
// The family emoji 👨‍👩‍👧‍👦 is 1 grapheme cluster but 11 UTF-16 code units.

func testRemoveForbiddenTagsWithEmoji() {
let input = "<p>Hello 👨‍👩‍👧‍👦👨‍👩‍👧‍👦 World</p><script>evil</script>"
let expected = "<p>Hello 👨‍👩‍👧‍👦👨‍👩‍👧‍👦 World</p>"
let result = RichContentFormatter.removeForbiddenTags(input)
XCTAssertEqual(result, expected, "Script tags after emoji should be removed")
}

func testRemoveInlineStylesWithEmoji() {
let input = "<p>Hello 👨‍👩‍👧‍👦👨‍👩‍👧‍👦</p><p style=\"color:red;\">test</p>"
let expected = "<p>Hello 👨‍👩‍👧‍👦👨‍👩‍👧‍👦</p><p>test</p>"
let result = RichContentFormatter.removeInlineStyles(input)
XCTAssertEqual(result, expected, "Inline styles after emoji should be removed")
}

func testNormalizeParagraphsWithEmoji() {
let input = "<div>Hello 👨‍👩‍👧‍👦👨‍👩‍👧‍👦</div><div>test</div>"
let expected = "<p>Hello 👨‍👩‍👧‍👦👨‍👩‍👧‍👦</p><p>test</p>"
let result = RichContentFormatter.normalizeParagraphs(input)
XCTAssertEqual(result, expected, "Div-to-p conversion after emoji should work")
}

func testFilterNewLinesWithEmoji() {
let input = "<p>Hello 👨‍👩‍👧‍👦👨‍👩‍👧‍👦</p>\n<p>World</p>\n"
let expected = "<p>Hello 👨‍👩‍👧‍👦👨‍👩‍👧‍👦</p><p>World</p>"
let result = RichContentFormatter.filterNewLines(input)
XCTAssertEqual(result, expected, "Newlines after emoji should be filtered")
}

func testRemoveTrailingBreakTagsWithEmoji() {
let input = "<p>Hello 👨‍👩‍👧‍👦👨‍👩‍👧‍👦 World</p><br><br> "
let expected = "<p>Hello 👨‍👩‍👧‍👦👨‍👩‍👧‍👦 World</p>"
let result = RichContentFormatter.removeTrailingBreakTags(input)
XCTAssertEqual(result, expected, "Trailing BR tags after emoji should be removed")
}

func testFormatVideoTags() {
let str1 = "<p>Some text.</p><video></video><p>Some text.</p>"
let sanitizedStr1 = RichContentFormatter.formatVideoTags(str1) as NSString
Expand Down
Loading