Skip to content

Commit fdca638

Browse files
p-linnaneclaude
andauthored
refactor(scanner): migrate to Swift Regex and remove NSString bridges (#288)
Replace `NSRegularExpression` with Swift `Regex` literals and `Regex(pattern)` across the scanner pipeline. Fall back to `NSRegularExpression` in `BinaryStringScanner` for patterns using lookbehind (unsupported by Swift Regex). Replace `(string as NSString).lastPathComponent` casts with `URL(fileURLWithPath:)` in `IPSWExtractor`. Signed-off-by: Patrick Linnane <patrick@linnane.io> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f9c9a3b commit fdca638

5 files changed

Lines changed: 65 additions & 73 deletions

File tree

Sources/macOSdbKit/Scanner/BinaryStringScanner.swift

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,18 @@ public enum BinaryStringScanner {
3737
matching pattern: String,
3838
minLength: Int = defaultMinLength
3939
) -> String? {
40-
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
41-
42-
let strings = extractStrings(from: data, minLength: minLength)
43-
for string in strings {
44-
let range = NSRange(string.startIndex..., in: string)
45-
if let match = regex.firstMatch(in: string, range: range) {
46-
if let matchRange = Range(match.range, in: string) {
40+
// Swift Regex doesn't support lookbehind; fall back to NSRegularExpression
41+
if let regex = try? Regex(pattern) {
42+
for string in extractStrings(from: data, minLength: minLength) {
43+
if let match = string.firstMatch(of: regex) {
44+
return String(string[match.range])
45+
}
46+
}
47+
} else if let nsRegex = try? NSRegularExpression(pattern: pattern) {
48+
for string in extractStrings(from: data, minLength: minLength) {
49+
let range = NSRange(string.startIndex..., in: string)
50+
if let match = nsRegex.firstMatch(in: string, range: range),
51+
let matchRange = Range(match.range, in: string) {
4752
return String(string[matchRange])
4853
}
4954
}
@@ -56,15 +61,20 @@ public enum BinaryStringScanner {
5661
matching pattern: String,
5762
minLength: Int = defaultMinLength
5863
) -> [String] {
59-
guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] }
60-
6164
var results: [String] = []
62-
let strings = extractStrings(from: data, minLength: minLength)
63-
for string in strings {
64-
let range = NSRange(string.startIndex..., in: string)
65-
regex.enumerateMatches(in: string, range: range) { match, _, _ in
66-
if let match, let matchRange = Range(match.range, in: string) {
67-
results.append(String(string[matchRange]))
65+
if let regex = try? Regex(pattern) {
66+
for string in extractStrings(from: data, minLength: minLength) {
67+
for match in string.matches(of: regex) {
68+
results.append(String(string[match.range]))
69+
}
70+
}
71+
} else if let nsRegex = try? NSRegularExpression(pattern: pattern) {
72+
for string in extractStrings(from: data, minLength: minLength) {
73+
let range = NSRange(string.startIndex..., in: string)
74+
nsRegex.enumerateMatches(in: string, range: range) { match, _, _ in
75+
if let match, let matchRange = Range(match.range, in: string) {
76+
results.append(String(string[matchRange]))
77+
}
6878
}
6979
}
7080
}

Sources/macOSdbKit/Scanner/IPSWExtractor.swift

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ public actor IPSWExtractor {
8080
var result = ClassifiedEntries()
8181
for entry in archive {
8282
let name = entry.path
83-
let basename = (name as NSString).lastPathComponent
83+
let basename = URL(fileURLWithPath: name).lastPathComponent
8484
if basename.hasPrefix("kernelcache") {
8585
result.kernelcaches.append(entry)
8686
} else if name.hasSuffix(".dmg") || name.hasSuffix(".dmg.aea") {
@@ -144,7 +144,7 @@ public actor IPSWExtractor {
144144

145145
var paths: [URL] = []
146146
for entry in entries {
147-
let basename = (entry.path as NSString).lastPathComponent
147+
let basename = URL(fileURLWithPath: entry.path).lastPathComponent
148148
let destPath = kernelsDir.appendingPathComponent(basename)
149149
_ = try archive.extract(entry, to: destPath)
150150
paths.append(destPath)
@@ -164,7 +164,7 @@ public actor IPSWExtractor {
164164
}
165165

166166
let entryByFilename = Dictionary(
167-
dmgEntries.map { ((($0.path as NSString).lastPathComponent), $0) },
167+
dmgEntries.map { ((URL(fileURLWithPath: $0.path).lastPathComponent), $0) },
168168
uniquingKeysWith: { first, _ in first }
169169
)
170170

@@ -184,14 +184,14 @@ public actor IPSWExtractor {
184184
Self.logger.warning("No manifest DMG mapping; using largest DMG as system image")
185185
}
186186

187-
let systemBasename = (systemEntry.path as NSString).lastPathComponent
187+
let systemBasename = URL(fileURLWithPath: systemEntry.path).lastPathComponent
188188
let systemPath = workDir.appendingPathComponent(systemBasename)
189189
Self.logger.info("Extracting system DMG: \(systemBasename) (\(systemEntry.uncompressedSize) bytes)")
190190
_ = try archive.extract(systemEntry, to: systemPath)
191191

192192
var cryptexPath: URL?
193193
if let cryptexEntry {
194-
let cryptexBasename = (cryptexEntry.path as NSString).lastPathComponent
194+
let cryptexBasename = URL(fileURLWithPath: cryptexEntry.path).lastPathComponent
195195
let path = workDir.appendingPathComponent(cryptexBasename)
196196
Self.logger.info("Extracting cryptex DMG: \(cryptexBasename) (\(cryptexEntry.uncompressedSize) bytes)")
197197
_ = try archive.extract(cryptexEntry, to: path)
@@ -249,7 +249,7 @@ public actor IPSWExtractor {
249249
let info = roleDict["Info"] as? [String: Any],
250250
let filePath = info["Path"] as? String,
251251
filePath.hasSuffix(".dmg") || filePath.hasSuffix(".dmg.aea") {
252-
let filename = (filePath as NSString).lastPathComponent
252+
let filename = URL(fileURLWithPath: filePath).lastPathComponent
253253
dmgRoles[roleName] = filename
254254
}
255255
}
@@ -265,7 +265,7 @@ public actor IPSWExtractor {
265265
let kernelPath = kernelInfo["Path"] as? String else {
266266
continue
267267
}
268-
let kernelFilename = (kernelPath as NSString).lastPathComponent
268+
let kernelFilename = URL(fileURLWithPath: kernelPath).lastPathComponent
269269
kernelDeviceMap[kernelFilename, default: []].insert(productType)
270270
}
271271
}
@@ -304,15 +304,10 @@ public actor IPSWExtractor {
304304
/// Parse OS version and build from IPSW filename as a last resort.
305305
/// Example: "UniversalMac_15.6.1_24G90_Restore.ipsw" → ("15.6.1", "24G90")
306306
private func parseFromFilename(_ filename: String) -> (osVersion: String, buildNumber: String) {
307-
let pattern = #"UniversalMac_([0-9]+\.[0-9]+(?:\.[0-9]+)?)_([A-Za-z0-9]+)_Restore"#
308-
guard let regex = try? NSRegularExpression(pattern: pattern),
309-
let match = regex.firstMatch(
310-
in: filename, range: NSRange(filename.startIndex..., in: filename)
311-
),
312-
let versionRange = Range(match.range(at: 1), in: filename),
313-
let buildRange = Range(match.range(at: 2), in: filename) else {
307+
let regex = /UniversalMac_(\d+\.\d+(?:\.\d+)?)_([A-Za-z0-9]+)_Restore/
308+
guard let match = filename.firstMatch(of: regex) else {
314309
return ("", "")
315310
}
316-
return (String(filename[versionRange]), String(filename[buildRange]))
311+
return (String(match.1), String(match.2))
317312
}
318313
}

Sources/macOSdbKit/Scanner/KernelParser.swift

Lines changed: 9 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -66,42 +66,30 @@ public enum KernelParser {
6666
// MARK: - Version extraction
6767

6868
private static func findDarwinVersion(in strings: [String]) -> String {
69-
let pattern = #"Darwin Kernel Version ([0-9]+\.[0-9]+\.[0-9]+)"#
70-
guard let regex = try? NSRegularExpression(pattern: pattern) else { return "" }
71-
69+
let regex = /Darwin Kernel Version (\d+\.\d+\.\d+)/
7270
for string in strings {
73-
let range = NSRange(string.startIndex..., in: string)
74-
if let match = regex.firstMatch(in: string, range: range),
75-
let captureRange = Range(match.range(at: 1), in: string) {
76-
return String(string[captureRange])
71+
if let match = string.firstMatch(of: regex) {
72+
return String(match.1)
7773
}
7874
}
7975
return ""
8076
}
8177

8278
private static func findXNUVersion(in strings: [String]) -> String? {
83-
let pattern = #"xnu-([0-9]+\.[0-9]+\.[0-9]+(?:\.[0-9]+)*)"#
84-
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
85-
79+
let regex = /xnu-(\d+\.\d+\.\d+(?:\.\d+)*)/
8680
for string in strings {
87-
let range = NSRange(string.startIndex..., in: string)
88-
if let match = regex.firstMatch(in: string, range: range),
89-
let captureRange = Range(match.range(at: 1), in: string) {
90-
return String(string[captureRange])
81+
if let match = string.firstMatch(of: regex) {
82+
return String(match.1)
9183
}
9284
}
9385
return nil
9486
}
9587

9688
private static func findArchSuffix(in strings: [String]) -> String {
97-
let pattern = #"RELEASE_ARM64_([A-Za-z0-9]+)"#
98-
guard let regex = try? NSRegularExpression(pattern: pattern) else { return "" }
99-
89+
let regex = /RELEASE_ARM64_([A-Za-z0-9]+)/
10090
for string in strings {
101-
let range = NSRange(string.startIndex..., in: string)
102-
if let match = regex.firstMatch(in: string, range: range),
103-
let captureRange = Range(match.range(at: 1), in: string) {
104-
return String(string[captureRange])
91+
if let match = string.firstMatch(of: regex) {
92+
return String(match.1)
10593
}
10694
}
10795
return ""

Sources/macOSdbKit/Scanner/SDKMetadataParser.swift

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -57,16 +57,12 @@ enum SDKMetadataParser {
5757
return extractMultiDefineVersion(from: content, defines: definition.pattern)
5858
}
5959

60-
guard let regex = try? NSRegularExpression(pattern: definition.pattern),
61-
let match = regex.firstMatch(
62-
in: content,
63-
range: NSRange(content.startIndex..., in: content)
64-
),
65-
let matchRange = Range(match.range, in: content) else {
60+
guard let regex = try? Regex(definition.pattern),
61+
let match = content.firstMatch(of: regex) else {
6662
return nil
6763
}
6864

69-
return definition.normalize(String(content[matchRange]))
65+
return definition.normalize(String(content[match.range]))
7066
}
7167

7268
/// Extract version from multiple `#define NAME value` lines and join as "major.minor.patch".
@@ -76,16 +72,16 @@ enum SDKMetadataParser {
7672

7773
for name in names {
7874
let pattern = #"#\s*define\s+"# + name + #"\s+(\S+)"#
79-
guard let regex = try? NSRegularExpression(pattern: pattern),
80-
let match = regex.firstMatch(
81-
in: content,
82-
range: NSRange(content.startIndex..., in: content)
83-
),
84-
match.numberOfRanges > 1,
85-
let valueRange = Range(match.range(at: 1), in: content) else {
75+
guard let regex = try? Regex(pattern),
76+
let match = content.firstMatch(of: regex),
77+
match.count > 1 else {
78+
return nil
79+
}
80+
if let value = match[1].substring {
81+
parts.append(String(value))
82+
} else {
8683
return nil
8784
}
88-
parts.append(String(content[valueRange]))
8985
}
9086

9187
return parts.joined(separator: ".")

Tests/macOSdbKitTests/ScannerTests.swift

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -323,8 +323,12 @@ struct ScannerConfigTests {
323323
func allPatternsCompile() {
324324
for def in filesystemComponents + dyldCacheComponents + toolchainComponents
325325
+ developerComponents + frameworkComponents {
326-
let regex = try? NSRegularExpression(pattern: def.pattern)
327-
#expect(regex != nil, "Pattern failed to compile for \(def.name): \(def.pattern)")
326+
let swiftRegex = try? Regex(def.pattern)
327+
let nsRegex = try? NSRegularExpression(pattern: def.pattern)
328+
#expect(
329+
swiftRegex != nil || nsRegex != nil,
330+
"Pattern failed to compile for \(def.name): \(def.pattern)"
331+
)
328332
}
329333
}
330334

@@ -380,13 +384,12 @@ struct ScannerConfigTests {
380384
continue
381385
}
382386

383-
let regex = try NSRegularExpression(pattern: def.pattern)
384-
let range = NSRange(input.startIndex..., in: input)
385-
let match = regex.firstMatch(in: input, range: range)
387+
let regex = try Regex(def.pattern)
388+
let match = input.firstMatch(of: regex)
386389
#expect(match != nil, "Pattern for \(name) did not match '\(input)'")
387390

388-
if let match, let matchRange = Range(match.range, in: input) {
389-
#expect(String(input[matchRange]) == expected)
391+
if let match {
392+
#expect(String(input[match.range]) == expected)
390393
}
391394
}
392395
}

0 commit comments

Comments
 (0)