From b8a4e0161f9eeea8a41c24266ca9d24dbb6132d0 Mon Sep 17 00:00:00 2001 From: Richard Piazza Date: Thu, 23 Oct 2025 11:57:10 -0500 Subject: [PATCH] Fixed substitution detection to account for positional arguments --- .../Extensions/String+IO.swift | 57 ++++++++------- .../StringReplacementTests.swift | 70 ++++++++++++++++++- 2 files changed, 100 insertions(+), 27 deletions(-) diff --git a/Sources/TranslationCatalogIO/Extensions/String+IO.swift b/Sources/TranslationCatalogIO/Extensions/String+IO.swift index 0e1777d..778027b 100644 --- a/Sources/TranslationCatalogIO/Extensions/String+IO.swift +++ b/Sources/TranslationCatalogIO/Extensions/String+IO.swift @@ -1,8 +1,15 @@ import Foundation extension String { - private static let darwinStringReplacement = "%@" - private static let posixStringReplacement = "%s" + /// Regex pattern for inline substitutions. + /// + /// Matches: + /// * **token**: Starts with `%` + /// * **position** Optional positional indication `1$` + /// * **type** Ends with one of `s` (string), `ld` (int), `llu` (unsigned int), `lf` (float), `@` (Darwin object) + private static let substitutionPattern = #"(?%)(?\d+\$)?(?s|ld|llu|lf|@)"# + private static let darwinStringToken = "@" + private static let posixStringToken = "s" func simpleAppleDictionaryEscaped() -> String { let replacements: [(Character, String)] = [ @@ -38,41 +45,41 @@ extension String { return updated } - var hasMultipleReplacements: Bool { - (numberOfInstances("%@") > 1) || (numberOfInstances("%s") > 1) + var substitutionRanges: [Range] { + get throws { + let regex = try Regex(Self.substitutionPattern) + return ranges(of: regex) + } } - func numberOfInstances(_ substring: String) -> Int { - guard !isEmpty else { - return 0 + var hasMultipleReplacements: Bool { + do { + return try substitutionRanges.count > 1 + } catch { + return false } + } - var count = 0 + func replaceToken(_ token: String, with replacement: String) throws -> String { + let ranges = try substitutionRanges + var modified = self - var range: Range? - while let match = self.range(of: substring, options: [], range: range) { - count += 1 - range = Range(uncheckedBounds: (lower: match.upperBound, upper: endIndex)) + for range in ranges.reversed() { + let substring = modified[range] + if substring.hasSuffix(token) { + let lowerBound = modified.index(range.upperBound, offsetBy: -token.count) + modified.replaceSubrange(lowerBound ..< range.upperBound, with: replacement) + } } - return count + return modified } func decodingDarwinStrings() throws -> String { - try replaceIn(self, pattern: Self.darwinStringReplacement, with: Self.posixStringReplacement) + try replaceToken(Self.darwinStringToken, with: Self.posixStringToken) } func encodingDarwinStrings() throws -> String { - try replaceIn(self, pattern: Self.posixStringReplacement, with: Self.darwinStringReplacement) - } - - private func replaceIn(_ value: String, pattern: String, with: String) throws -> String { - let regex = try Regex(pattern) - var output = value - let ranges = value.ranges(of: regex).reversed() - for range in ranges { - output.replaceSubrange(range, with: with) - } - return output + try replaceToken(Self.posixStringToken, with: Self.darwinStringToken) } } diff --git a/Tests/TranslationCatalogTests/StringReplacementTests.swift b/Tests/TranslationCatalogTests/StringReplacementTests.swift index c1682b4..d11588c 100644 --- a/Tests/TranslationCatalogTests/StringReplacementTests.swift +++ b/Tests/TranslationCatalogTests/StringReplacementTests.swift @@ -21,11 +21,77 @@ final class StringReplacementTests: XCTestCase { func testDecodeDarwinStrings() throws { XCTAssertEqual(try "Hello %@".decodingDarwinStrings(), "Hello %s") - XCTAssertEqual(try "Hello $1%@".decodingDarwinStrings(), "Hello $1%s") + XCTAssertEqual(try "Hello %1$@".decodingDarwinStrings(), "Hello %1$s") } func testEncodeDarwinStrings() throws { XCTAssertEqual(try "Hello %s".encodingDarwinStrings(), "Hello %@") - XCTAssertEqual(try "Hello $1%s".encodingDarwinStrings(), "Hello $1%@") + XCTAssertEqual(try "Hello %1$s".encodingDarwinStrings(), "Hello %1$@") + } + + func testSubstitutionDetection() throws { + var string = "Posix String %s Replacement" + var ranges = try string.substitutionRanges + XCTAssertEqual(ranges.count, 1) + XCTAssertEqual(ranges.first?.lowerBound, string.index(string.startIndex, offsetBy: 13)) + XCTAssertEqual(ranges.first?.upperBound, string.index(string.startIndex, offsetBy: 15)) + + string = "Posix String %1$s Replacement" + ranges = try string.substitutionRanges + XCTAssertEqual(ranges.count, 1) + XCTAssertEqual(ranges.first?.lowerBound, string.index(string.startIndex, offsetBy: 13)) + XCTAssertEqual(ranges.first?.upperBound, string.index(string.startIndex, offsetBy: 17)) + + string = "Darwin String %@ Replacement" + ranges = try string.substitutionRanges + XCTAssertEqual(ranges.count, 1) + XCTAssertEqual(ranges.first?.lowerBound, string.index(string.startIndex, offsetBy: 14)) + XCTAssertEqual(ranges.first?.upperBound, string.index(string.startIndex, offsetBy: 16)) + + string = "Darwin String %1$@ Replacement" + ranges = try string.substitutionRanges + XCTAssertEqual(ranges.count, 1) + XCTAssertEqual(ranges.first?.lowerBound, string.index(string.startIndex, offsetBy: 14)) + XCTAssertEqual(ranges.first?.upperBound, string.index(string.startIndex, offsetBy: 18)) + + string = "Posix Int %ld Replacement" + ranges = try string.substitutionRanges + XCTAssertEqual(ranges.count, 1) + XCTAssertEqual(ranges.first?.lowerBound, string.index(string.startIndex, offsetBy: 10)) + XCTAssertEqual(ranges.first?.upperBound, string.index(string.startIndex, offsetBy: 13)) + + string = "Posix Int %1$ld Replacement" + ranges = try string.substitutionRanges + XCTAssertEqual(ranges.count, 1) + XCTAssertEqual(ranges.first?.lowerBound, string.index(string.startIndex, offsetBy: 10)) + XCTAssertEqual(ranges.first?.upperBound, string.index(string.startIndex, offsetBy: 15)) + + string = "Posix Unsigned-Int %llu Replacement" + ranges = try string.substitutionRanges + XCTAssertEqual(ranges.count, 1) + XCTAssertEqual(ranges.first?.lowerBound, string.index(string.startIndex, offsetBy: 19)) + XCTAssertEqual(ranges.first?.upperBound, string.index(string.startIndex, offsetBy: 23)) + + string = "Posix Unsigned-Int %1$llu Replacement" + ranges = try string.substitutionRanges + XCTAssertEqual(ranges.count, 1) + XCTAssertEqual(ranges.first?.lowerBound, string.index(string.startIndex, offsetBy: 19)) + XCTAssertEqual(ranges.first?.upperBound, string.index(string.startIndex, offsetBy: 25)) + + string = "Posix Float %lf Replacement" + ranges = try string.substitutionRanges + XCTAssertEqual(ranges.count, 1) + XCTAssertEqual(ranges.first?.lowerBound, string.index(string.startIndex, offsetBy: 12)) + XCTAssertEqual(ranges.first?.upperBound, string.index(string.startIndex, offsetBy: 15)) + + string = "Posix Float %1$lf Replacement" + ranges = try string.substitutionRanges + XCTAssertEqual(ranges.count, 1) + XCTAssertEqual(ranges.first?.lowerBound, string.index(string.startIndex, offsetBy: 12)) + XCTAssertEqual(ranges.first?.upperBound, string.index(string.startIndex, offsetBy: 17)) + + string = "Multi-match %s. Int %ld, could equal %llu, but does not equal %lf." + ranges = try string.substitutionRanges + XCTAssertEqual(ranges.count, 4) } }