summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-02-18 12:07:57 -0800
committerFuwn <[email protected]>2026-02-18 12:15:31 -0800
commitd9b8557de70d51db6037891ededd783b8787af22 (patch)
treece41d697e504d00ce85acf00689aff4596da1638
parentrefactor: remove duplicate settings serialisation work (diff)
downloadsora-testing-d9b8557de70d51db6037891ededd783b8787af22.tar.xz
sora-testing-d9b8557de70d51db6037891ededd783b8787af22.zip
refactor: coalesce settings sync and skip unchanged writes
-rw-r--r--Sora/Data/Settings/SettingsManager.swift223
-rw-r--r--Sora/Data/Settings/SettingsSyncCoordinator.swift28
-rw-r--r--Sora/Data/Settings/SettingsSyncKey.swift2
-rw-r--r--SoraTests/SettingsManagerSyncTests.swift270
-rw-r--r--SoraTests/ViewDerivedDataTests.swift109
-rw-r--r--SoraTests/XCTestCase+SourceAnalysis.swift210
6 files changed, 441 insertions, 401 deletions
diff --git a/Sora/Data/Settings/SettingsManager.swift b/Sora/Data/Settings/SettingsManager.swift
index f42030f..d7bfc44 100644
--- a/Sora/Data/Settings/SettingsManager.swift
+++ b/Sora/Data/Settings/SettingsManager.swift
@@ -52,6 +52,7 @@ class SettingsManager: ObservableObject { // swiftlint:disable:this type_body_l
private var thumbnailQualityCache: BooruPostFileType = .preview
private var isUpdatingCache = false
private var pendingSyncKeys: Set<SettingsSyncKey> = []
+ private let syncCoordinator = SettingsSyncCoordinator()
// MARK: - Codable Properties
@AppStorage("bookmarks")
@@ -531,15 +532,32 @@ class SettingsManager: ObservableObject { // swiftlint:disable:this type_body_l
pendingSyncKeys.removeAll()
- Task.detached { [weak self] in
- await self?.performBatchedSync(for: keysToSync)
+ Task { @MainActor [weak self] in
+ guard let self else { return }
+
+ let shouldStartDraining = await syncCoordinator.enqueue(keysToSync)
+
+ guard shouldStartDraining else { return }
+
+ while true {
+ let nextBatch = await syncCoordinator.dequeueBatch()
+
+ guard !nextBatch.isEmpty else { break }
+
+ performBatchedSync(for: nextBatch)
+ }
}
}
- // swiftlint:disable:next async_without_await
- private func performBatchedSync(for keys: Set<SettingsSyncKey>) async {
+ private func performBatchedSync(for keys: Set<SettingsSyncKey>) {
+ var didChange = false
+
for key in keys {
- triggerSyncIfNeeded(for: key)
+ didChange = triggerSyncIfNeeded(for: key) || didChange
+ }
+
+ if didChange {
+ objectWillChange.send()
}
}
@@ -641,28 +659,21 @@ class SettingsManager: ObservableObject { // swiftlint:disable:this type_body_l
}.value
}
- // swiftlint:disable:next cyclomatic_complexity
- private func triggerSyncIfNeeded(for key: SettingsSyncKey) {
- guard enableSync else { return }
-
- Task.detached { [weak self] in
- guard let self else { return }
-
- switch key {
- case .bookmarks:
- var iCloudBookmarks: [SettingsBookmark] = []
+ // swiftlint:disable cyclomatic_complexity
+ @discardableResult
+ private func triggerSyncIfNeeded(for key: SettingsSyncKey) -> Bool {
+ guard enableSync else { return false }
- if let iCloudData = NSUbiquitousKeyValueStore.default.data(forKey: "bookmarks") {
- iCloudBookmarks =
- await Self
- .decode([SettingsBookmark].self, from: iCloudData) ?? []
- }
+ var didChange = false
- let localBookmarks =
- await Self.decode([SettingsBookmark].self, from: bookmarksData) ?? []
- let mergedBookmarksDict = (localBookmarks + iCloudBookmarks).reduce(
- into: [UUID: SettingsBookmark]()
- ) { dict, bookmark in
+ switch key {
+ case .bookmarks:
+ let iCloudData = NSUbiquitousKeyValueStore.default.data(forKey: "bookmarks")
+ let iCloudBookmarks =
+ iCloudData.flatMap { Self.decode([SettingsBookmark].self, from: $0) } ?? []
+ let localBookmarks = Self.decode([SettingsBookmark].self, from: bookmarksData) ?? []
+ let mergedBookmarks = (localBookmarks + iCloudBookmarks)
+ .reduce(into: [UUID: SettingsBookmark]()) { dict, bookmark in
if let existing = dict[bookmark.id] {
if bookmark.date > existing.date {
dict[bookmark.id] = bookmark
@@ -671,28 +682,36 @@ class SettingsManager: ObservableObject { // swiftlint:disable:this type_body_l
dict[bookmark.id] = bookmark
}
}
- let mergedBookmarks = Array(mergedBookmarksDict.values).sorted { $0.date > $1.date }
-
- if let encoded = await Self.encode(mergedBookmarks) {
- NSUbiquitousKeyValueStore.default.set(encoded, forKey: "bookmarks")
-
- await MainActor.run {
- self.bookmarksData = encoded
+ .values
+ .sorted { lhs, rhs in
+ if lhs.date == rhs.date {
+ return lhs.id.uuidString < rhs.id.uuidString
}
+
+ return lhs.date > rhs.date
}
- case .searchHistory:
- var iCloudHistory: [BooruSearchQuery] = []
+ bookmarksCache = mergedBookmarks
- if let iCloudData = NSUbiquitousKeyValueStore.default.data(forKey: "searchHistory") {
- iCloudHistory = await Self.decode([BooruSearchQuery].self, from: iCloudData) ?? []
+ if let encoded = Self.encode(mergedBookmarks) {
+ if iCloudData != encoded {
+ NSUbiquitousKeyValueStore.default.set(encoded, forKey: "bookmarks")
+ didChange = true
}
- let localHistory =
- await Self.decode([BooruSearchQuery].self, from: searchHistoryData) ?? []
- let mergedHistoryDict = (localHistory + iCloudHistory).reduce(
- into: [UUID: BooruSearchQuery]()
- ) { dict, entry in
+ if bookmarksData != encoded {
+ bookmarksData = encoded
+ didChange = true
+ }
+ }
+
+ case .searchHistory:
+ let iCloudData = NSUbiquitousKeyValueStore.default.data(forKey: "searchHistory")
+ let iCloudHistory =
+ iCloudData.flatMap { Self.decode([BooruSearchQuery].self, from: $0) } ?? []
+ let localHistory = Self.decode([BooruSearchQuery].self, from: searchHistoryData) ?? []
+ let mergedHistory = (localHistory + iCloudHistory)
+ .reduce(into: [UUID: BooruSearchQuery]()) { dict, entry in
if let existing = dict[entry.id] {
if entry.date > existing.date {
dict[entry.id] = entry
@@ -701,58 +720,62 @@ class SettingsManager: ObservableObject { // swiftlint:disable:this type_body_l
dict[entry.id] = entry
}
}
- let mergedHistory = Array(mergedHistoryDict.values).sorted { $0.date > $1.date }
-
- if let encoded = await Self.encode(mergedHistory) {
- NSUbiquitousKeyValueStore.default.set(encoded, forKey: "searchHistory")
-
- await MainActor.run {
- self.searchHistoryData = encoded
+ .values
+ .sorted { lhs, rhs in
+ if lhs.date == rhs.date {
+ return lhs.id.uuidString < rhs.id.uuidString
}
+
+ return lhs.date > rhs.date
}
- case .customProviders:
- var iCloudProviders: [BooruProviderCustom] = []
+ searchHistoryCache = mergedHistory
- if let iCloudData = NSUbiquitousKeyValueStore.default.data(forKey: "customProviders") {
- iCloudProviders =
- await Self
- .decode([BooruProviderCustom].self, from: iCloudData) ?? []
+ if let encoded = Self.encode(mergedHistory) {
+ if iCloudData != encoded {
+ NSUbiquitousKeyValueStore.default.set(encoded, forKey: "searchHistory")
+ didChange = true
}
- let localProviders =
- await Self.decode([BooruProviderCustom].self, from: customProvidersData) ?? []
- let mergedProvidersDict = (localProviders + iCloudProviders).reduce(
- into: [UUID: BooruProviderCustom]()
- ) { dict, provider in
+ if searchHistoryData != encoded {
+ searchHistoryData = encoded
+ didChange = true
+ }
+ }
+
+ case .customProviders:
+ let iCloudData = NSUbiquitousKeyValueStore.default.data(forKey: "customProviders")
+ let iCloudProviders =
+ iCloudData.flatMap { Self.decode([BooruProviderCustom].self, from: $0) } ?? []
+ let localProviders = Self.decode([BooruProviderCustom].self, from: customProvidersData) ?? []
+ let mergedProviders = (localProviders + iCloudProviders)
+ .reduce(into: [UUID: BooruProviderCustom]()) { dict, provider in
if dict[provider.id] == nil {
dict[provider.id] = provider
}
}
- let mergedProviders = Array(mergedProvidersDict.values)
+ .values
+ .sorted { $0.id.uuidString < $1.id.uuidString }
- if let encoded = await Self.encode(mergedProviders) {
+ if let encoded = Self.encode(mergedProviders) {
+ if iCloudData != encoded {
NSUbiquitousKeyValueStore.default.set(encoded, forKey: "customProviders")
-
- await MainActor.run {
- self.customProvidersData = encoded
- }
+ didChange = true
}
- case .favorites:
- var iCloudFavorites: [SettingsFavoritePost] = []
-
- if let iCloudData = NSUbiquitousKeyValueStore.default.data(forKey: "favorites") {
- iCloudFavorites =
- await Self
- .decode([SettingsFavoritePost].self, from: iCloudData) ?? []
+ if customProvidersData != encoded {
+ customProvidersData = encoded
+ didChange = true
}
+ }
- let localFavorites =
- await Self.decode([SettingsFavoritePost].self, from: favoritesData) ?? []
- let mergedFavoritesDict = (localFavorites + iCloudFavorites).reduce(
- into: [UUID: SettingsFavoritePost]()
- ) { dict, favorite in
+ case .favorites:
+ let iCloudData = NSUbiquitousKeyValueStore.default.data(forKey: "favorites")
+ let iCloudFavorites =
+ iCloudData.flatMap { Self.decode([SettingsFavoritePost].self, from: $0) } ?? []
+ let localFavorites = Self.decode([SettingsFavoritePost].self, from: favoritesData) ?? []
+ let mergedFavorites = (localFavorites + iCloudFavorites)
+ .reduce(into: [UUID: SettingsFavoritePost]()) { dict, favorite in
if let existing = dict[favorite.id] {
if favorite.date > existing.date {
dict[favorite.id] = favorite
@@ -761,25 +784,33 @@ class SettingsManager: ObservableObject { // swiftlint:disable:this type_body_l
dict[favorite.id] = favorite
}
}
- let mergedFavorites = Array(mergedFavoritesDict.values).sorted { $0.date > $1.date }
+ .values
+ .sorted { lhs, rhs in
+ if lhs.date == rhs.date {
+ return lhs.id.uuidString < rhs.id.uuidString
+ }
- if let encoded = await Self.encode(mergedFavorites) {
- NSUbiquitousKeyValueStore.default.set(encoded, forKey: "favorites")
+ return lhs.date > rhs.date
+ }
- await MainActor.run {
- self.favoritesData = encoded
- }
+ favoritesCache = mergedFavorites
+
+ if let encoded = Self.encode(mergedFavorites) {
+ if iCloudData != encoded {
+ NSUbiquitousKeyValueStore.default.set(encoded, forKey: "favorites")
+ didChange = true
}
- }
- await MainActor.run {
- self.loadBookmarksCache()
- self.loadFavoritesCache()
- self.loadSearchHistoryCache()
- self.objectWillChange.send()
+ if favoritesData != encoded {
+ favoritesData = encoded
+ didChange = true
+ }
}
}
+
+ return didChange
}
+ // swiftlint:enable cyclomatic_complexity
// MARK: Cache Loaders
private func loadCache<T: Decodable & Sendable>(
@@ -896,10 +927,16 @@ class SettingsManager: ObservableObject { // swiftlint:disable:this type_body_l
}
func triggerSyncIfNeededForAll() {
- self.triggerSyncIfNeeded(for: .bookmarks)
- self.triggerSyncIfNeeded(for: .favorites)
- self.triggerSyncIfNeeded(for: .searchHistory)
- self.triggerSyncIfNeeded(for: .customProviders)
+ let keysToSync: [SettingsSyncKey] = [.bookmarks, .favorites, .searchHistory, .customProviders]
+ var didChange = false
+
+ for keyToSync in keysToSync {
+ didChange = triggerSyncIfNeeded(for: keyToSync) || didChange
+ }
+
+ if didChange {
+ objectWillChange.send()
+ }
}
// MARK: Bookmark Management
diff --git a/Sora/Data/Settings/SettingsSyncCoordinator.swift b/Sora/Data/Settings/SettingsSyncCoordinator.swift
new file mode 100644
index 0000000..4771b37
--- /dev/null
+++ b/Sora/Data/Settings/SettingsSyncCoordinator.swift
@@ -0,0 +1,28 @@
+import Foundation
+
+actor SettingsSyncCoordinator {
+ private var pendingKeys: Set<SettingsSyncKey> = []
+ private var isDraining = false
+
+ func enqueue(_ keys: Set<SettingsSyncKey>) -> Bool {
+ pendingKeys.formUnion(keys)
+
+ guard !isDraining else { return false }
+
+ isDraining = true
+
+ return true
+ }
+
+ func dequeueBatch() -> Set<SettingsSyncKey> {
+ guard !pendingKeys.isEmpty else {
+ isDraining = false
+ return []
+ }
+
+ let nextBatch = pendingKeys
+ pendingKeys.removeAll()
+
+ return nextBatch
+ }
+}
diff --git a/Sora/Data/Settings/SettingsSyncKey.swift b/Sora/Data/Settings/SettingsSyncKey.swift
index 1cfbd1d..904226b 100644
--- a/Sora/Data/Settings/SettingsSyncKey.swift
+++ b/Sora/Data/Settings/SettingsSyncKey.swift
@@ -1,3 +1,3 @@
-enum SettingsSyncKey {
+enum SettingsSyncKey: Sendable {
case bookmarks, customProviders, favorites, searchHistory
}
diff --git a/SoraTests/SettingsManagerSyncTests.swift b/SoraTests/SettingsManagerSyncTests.swift
index 72cf2ad..50106dc 100644
--- a/SoraTests/SettingsManagerSyncTests.swift
+++ b/SoraTests/SettingsManagerSyncTests.swift
@@ -1,7 +1,5 @@
-import Foundation
import XCTest
-// swiftlint:disable type_body_length
final class SettingsManagerSyncTests: XCTestCase {
func testBookmarkMutationPathReusesEncodedPayload() throws {
let source = try loadSource(at: "Sora/Data/Settings/SettingsManager.swift")
@@ -120,210 +118,86 @@ final class SettingsManagerSyncTests: XCTestCase {
)
}
- private func loadSource(at relativePath: String) throws -> String {
- let currentFile = URL(fileURLWithPath: #filePath)
- let repositoryRoot = currentFile.deletingLastPathComponent().deletingLastPathComponent()
- let fileURL = repositoryRoot.appendingPathComponent(relativePath)
-
- return try String(contentsOf: fileURL, encoding: .utf8)
- }
-
- // swiftlint:disable:next cyclomatic_complexity
- private func extractFunction(named signature: String, from source: String) throws -> String {
- guard let signatureRange = source.range(of: signature) else {
- throw NSError(domain: "SettingsManagerSyncTests", code: 1)
- }
- guard let openingBrace = source[signatureRange.upperBound...].firstIndex(of: "{") else {
- throw NSError(domain: "SettingsManagerSyncTests", code: 2)
- }
-
- let characters = Array(source)
- let startOffset = source.distance(from: source.startIndex, to: signatureRange.lowerBound)
- var currentOffset = source.distance(from: source.startIndex, to: openingBrace)
-
- var braceDepth = 0
- var inLineComment = false
- var blockCommentDepth = 0
- var inString = false
- var isEscaped = false
-
- while currentOffset < characters.count {
- let current = characters[currentOffset]
- let next: Character? = currentOffset + 1 < characters.count ? characters[currentOffset + 1] : nil
-
- if inLineComment {
- if current == "\n" {
- inLineComment = false
- }
- currentOffset += 1
- continue
- }
-
- if blockCommentDepth > 0 {
- if current == "/", next == "*" {
- blockCommentDepth += 1
- currentOffset += 2
- continue
- }
-
- if current == "*", next == "/" {
- blockCommentDepth -= 1
- currentOffset += 2
- continue
- }
-
- currentOffset += 1
- continue
- }
-
- if inString {
- if isEscaped {
- isEscaped = false
- } else if current == "\\" {
- isEscaped = true
- } else if current == "\"" {
- inString = false
- }
-
- currentOffset += 1
- continue
- }
-
- if current == "/", next == "/" {
- inLineComment = true
- currentOffset += 2
- continue
- }
-
- if current == "/", next == "*" {
- blockCommentDepth = 1
- currentOffset += 2
- continue
- }
-
- if current == "\"" {
- inString = true
- currentOffset += 1
- continue
- }
-
- if current == "{" {
- braceDepth += 1
- } else if current == "}" {
- braceDepth -= 1
-
- if braceDepth == 0 {
- let endIndex = source.index(source.startIndex, offsetBy: currentOffset + 1)
- let startIndex = source.index(source.startIndex, offsetBy: startOffset)
-
- return String(source[startIndex..<endIndex])
- }
- }
+ func testBatchedSyncUsesCoordinatorQueue() throws {
+ let source = try loadSource(at: "Sora/Data/Settings/SettingsManager.swift")
+ let triggerBatchSection = try extractFunction(
+ named: "private func triggerBatchedSync()",
+ from: source
+ )
+ let normalizedSection = strippingCommentsAndStrings(from: triggerBatchSection)
- currentOffset += 1
- }
+ let coordinatorEnqueueCount = tokenCount(
+ matching: #"\bsyncCoordinator\s*\.\s*enqueue\s*\("#,
+ in: normalizedSection
+ )
+ let detachedCount = tokenCount(
+ matching: #"\bTask\s*\.\s*detached\b"#,
+ in: normalizedSection
+ )
- throw NSError(domain: "SettingsManagerSyncTests", code: 3)
+ // swiftlint:disable:next prefer_nimble
+ XCTAssertGreaterThan(
+ coordinatorEnqueueCount,
+ 0,
+ "Batched sync should enqueue through a single sync coordinator."
+ )
+ // swiftlint:disable:next prefer_nimble
+ XCTAssertEqual(
+ detachedCount,
+ 0,
+ "Batched sync should not launch detached tasks directly."
+ )
}
- // swiftlint:disable:next cyclomatic_complexity
- private func strippingCommentsAndStrings(from source: String) -> String {
- let characters = Array(source)
- var result: [Character] = []
- result.reserveCapacity(characters.count)
-
- var currentOffset = 0
- var inLineComment = false
- var blockCommentDepth = 0
- var inString = false
- var isEscaped = false
-
- while currentOffset < characters.count {
- let current = characters[currentOffset]
- let next: Character? = currentOffset + 1 < characters.count ? characters[currentOffset + 1] : nil
-
- if inLineComment {
- if current == "\n" {
- inLineComment = false
- result.append("\n")
- } else {
- result.append(" ")
- }
- currentOffset += 1
- continue
- }
-
- if blockCommentDepth > 0 {
- if current == "/", next == "*" {
- blockCommentDepth += 1
- result.append(" ")
- result.append(" ")
- currentOffset += 2
- continue
- }
-
- if current == "*", next == "/" {
- blockCommentDepth -= 1
- result.append(" ")
- result.append(" ")
- currentOffset += 2
- continue
- }
-
- result.append(current == "\n" ? "\n" : " ")
- currentOffset += 1
- continue
- }
-
- if inString {
- if isEscaped {
- isEscaped = false
- } else if current == "\\" {
- isEscaped = true
- } else if current == "\"" {
- inString = false
- }
-
- result.append(current == "\n" ? "\n" : " ")
- currentOffset += 1
- continue
- }
-
- if current == "/", next == "/" {
- inLineComment = true
- result.append(" ")
- result.append(" ")
- currentOffset += 2
- continue
- }
-
- if current == "/", next == "*" {
- blockCommentDepth = 1
- result.append(" ")
- result.append(" ")
- currentOffset += 2
- continue
- }
-
- if current == "\"" {
- inString = true
- result.append(" ")
- currentOffset += 1
- continue
- }
+ func testPerKeySyncPathAvoidsDetachedFanOut() throws {
+ let source = try loadSource(at: "Sora/Data/Settings/SettingsManager.swift")
+ let triggerSyncSection = try extractFunction(
+ named: "private func triggerSyncIfNeeded(for key: SettingsSyncKey)",
+ from: source
+ )
+ let normalizedSection = strippingCommentsAndStrings(from: triggerSyncSection)
- result.append(current)
- currentOffset += 1
- }
+ let detachedCount = tokenCount(
+ matching: #"\bTask\s*\.\s*detached\b"#,
+ in: normalizedSection
+ )
- return String(result)
+ // swiftlint:disable:next prefer_nimble
+ XCTAssertEqual(
+ detachedCount,
+ 0,
+ "Per-key sync should run on the coordinator path without detached fan-out."
+ )
}
- private func tokenCount(matching pattern: String, in source: String) -> Int {
- guard let regex = try? NSRegularExpression(pattern: pattern) else { return 0 }
- let range = NSRange(source.startIndex..<source.endIndex, in: source)
+ func testManualFullSyncAggregatesChangeNotification() throws {
+ let source = try loadSource(at: "Sora/Data/Settings/SettingsManager.swift")
+ let fullSyncSection = try extractFunction(
+ named: "func triggerSyncIfNeededForAll()",
+ from: source
+ )
+ let normalizedSection = strippingCommentsAndStrings(from: fullSyncSection)
- return regex.numberOfMatches(in: source, range: range)
+ let bookmarksKeyCount = tokenCount(matching: #"\.bookmarks\b"#, in: normalizedSection)
+ let favoritesKeyCount = tokenCount(matching: #"\.favorites\b"#, in: normalizedSection)
+ let searchHistoryKeyCount = tokenCount(matching: #"\.searchHistory\b"#, in: normalizedSection)
+ let customProvidersKeyCount = tokenCount(matching: #"\.customProviders\b"#, in: normalizedSection)
+ let objectWillChangeCount = tokenCount(
+ matching: #"\bobjectWillChange\s*\.\s*send\s*\("#,
+ in: normalizedSection
+ )
+
+ // swiftlint:disable:next prefer_nimble
+ XCTAssertEqual(
+ bookmarksKeyCount + favoritesKeyCount + searchHistoryKeyCount + customProvidersKeyCount,
+ 4,
+ "Full sync should evaluate all supported sync keys."
+ )
+ // swiftlint:disable:next prefer_nimble
+ XCTAssertGreaterThan(
+ objectWillChangeCount,
+ 0,
+ "Full sync should emit a consolidated objectWillChange notification when merged state changes."
+ )
}
}
-// swiftlint:enable type_body_length
diff --git a/SoraTests/ViewDerivedDataTests.swift b/SoraTests/ViewDerivedDataTests.swift
index 6737265..b8a4bba 100644
--- a/SoraTests/ViewDerivedDataTests.swift
+++ b/SoraTests/ViewDerivedDataTests.swift
@@ -1,4 +1,3 @@
-import Foundation
import XCTest
final class ViewDerivedDataTests: XCTestCase {
@@ -56,107 +55,6 @@ final class ViewDerivedDataTests: XCTestCase {
)
}
- private func loadSource(at relativePath: String) throws -> String {
- let currentFile = URL(fileURLWithPath: #filePath)
- let repositoryRoot = currentFile.deletingLastPathComponent().deletingLastPathComponent()
- let fileURL = repositoryRoot.appendingPathComponent(relativePath)
-
- return try String(contentsOf: fileURL, encoding: .utf8)
- }
-
- // swiftlint:disable:next cyclomatic_complexity
- private func strippingCommentsAndStrings(from source: String) -> String {
- let characters = Array(source)
- var result: [Character] = []
- result.reserveCapacity(characters.count)
-
- var currentOffset = 0
- var inLineComment = false
- var blockCommentDepth = 0
- var inString = false
- var isEscaped = false
-
- while currentOffset < characters.count {
- let current = characters[currentOffset]
- let next: Character? = currentOffset + 1 < characters.count ? characters[currentOffset + 1] : nil
-
- if inLineComment {
- if current == "\n" {
- inLineComment = false
- result.append("\n")
- } else {
- result.append(" ")
- }
- currentOffset += 1
- continue
- }
-
- if blockCommentDepth > 0 {
- if current == "/", next == "*" {
- blockCommentDepth += 1
- result.append(" ")
- result.append(" ")
- currentOffset += 2
- continue
- }
-
- if current == "*", next == "/" {
- blockCommentDepth -= 1
- result.append(" ")
- result.append(" ")
- currentOffset += 2
- continue
- }
-
- result.append(current == "\n" ? "\n" : " ")
- currentOffset += 1
- continue
- }
-
- if inString {
- if isEscaped {
- isEscaped = false
- } else if current == "\\" {
- isEscaped = true
- } else if current == "\"" {
- inString = false
- }
-
- result.append(current == "\n" ? "\n" : " ")
- currentOffset += 1
- continue
- }
-
- if current == "/", next == "/" {
- inLineComment = true
- result.append(" ")
- result.append(" ")
- currentOffset += 2
- continue
- }
-
- if current == "/", next == "*" {
- blockCommentDepth = 1
- result.append(" ")
- result.append(" ")
- currentOffset += 2
- continue
- }
-
- if current == "\"" {
- inString = true
- result.append(" ")
- currentOffset += 1
- continue
- }
-
- result.append(current)
- currentOffset += 1
- }
-
- return String(result)
- }
-
private func referenceCount(for symbol: String, in source: String) -> Int {
let totalMatches = tokenCount(
matching: #"\b\#(symbol)\b"#,
@@ -182,11 +80,4 @@ final class ViewDerivedDataTests: XCTestCase {
return max(0, totalMatches - declarationMatches)
}
-
- private func tokenCount(matching pattern: String, in source: String) -> Int {
- guard let regex = try? NSRegularExpression(pattern: pattern) else { return 0 }
- let range = NSRange(source.startIndex..<source.endIndex, in: source)
-
- return regex.numberOfMatches(in: source, range: range)
- }
}
diff --git a/SoraTests/XCTestCase+SourceAnalysis.swift b/SoraTests/XCTestCase+SourceAnalysis.swift
new file mode 100644
index 0000000..4cd907f
--- /dev/null
+++ b/SoraTests/XCTestCase+SourceAnalysis.swift
@@ -0,0 +1,210 @@
+import Foundation
+import XCTest
+
+extension XCTestCase {
+ func loadSource(at relativePath: String) throws -> String {
+ let currentFile = URL(fileURLWithPath: #filePath)
+ let repositoryRoot = currentFile.deletingLastPathComponent().deletingLastPathComponent()
+ let fileURL = repositoryRoot.appendingPathComponent(relativePath)
+
+ return try String(contentsOf: fileURL, encoding: .utf8)
+ }
+
+ // swiftlint:disable:next cyclomatic_complexity
+ func extractFunction(named signature: String, from source: String) throws -> String {
+ guard let signatureRange = source.range(of: signature) else {
+ throw NSError(domain: "SourceAnalysisTests", code: 1)
+ }
+ guard let openingBrace = source[signatureRange.upperBound...].firstIndex(of: "{") else {
+ throw NSError(domain: "SourceAnalysisTests", code: 2)
+ }
+
+ let characters = Array(source)
+ let startOffset = source.distance(from: source.startIndex, to: signatureRange.lowerBound)
+ var currentOffset = source.distance(from: source.startIndex, to: openingBrace)
+
+ var braceDepth = 0
+ var inLineComment = false
+ var blockCommentDepth = 0
+ var inString = false
+ var isEscaped = false
+
+ while currentOffset < characters.count {
+ let current = characters[currentOffset]
+ let next: Character? = currentOffset + 1 < characters.count ? characters[currentOffset + 1] : nil
+
+ if inLineComment {
+ if current == "\n" {
+ inLineComment = false
+ }
+ currentOffset += 1
+ continue
+ }
+
+ if blockCommentDepth > 0 {
+ if current == "/", next == "*" {
+ blockCommentDepth += 1
+ currentOffset += 2
+ continue
+ }
+
+ if current == "*", next == "/" {
+ blockCommentDepth -= 1
+ currentOffset += 2
+ continue
+ }
+
+ currentOffset += 1
+ continue
+ }
+
+ if inString {
+ if isEscaped {
+ isEscaped = false
+ } else if current == "\\" {
+ isEscaped = true
+ } else if current == "\"" {
+ inString = false
+ }
+
+ currentOffset += 1
+ continue
+ }
+
+ if current == "/", next == "/" {
+ inLineComment = true
+ currentOffset += 2
+ continue
+ }
+
+ if current == "/", next == "*" {
+ blockCommentDepth = 1
+ currentOffset += 2
+ continue
+ }
+
+ if current == "\"" {
+ inString = true
+ currentOffset += 1
+ continue
+ }
+
+ if current == "{" {
+ braceDepth += 1
+ } else if current == "}" {
+ braceDepth -= 1
+
+ if braceDepth == 0 {
+ let endIndex = source.index(source.startIndex, offsetBy: currentOffset + 1)
+ let startIndex = source.index(source.startIndex, offsetBy: startOffset)
+
+ return String(source[startIndex..<endIndex])
+ }
+ }
+
+ currentOffset += 1
+ }
+
+ throw NSError(domain: "SourceAnalysisTests", code: 3)
+ }
+
+ // swiftlint:disable:next cyclomatic_complexity
+ func strippingCommentsAndStrings(from source: String) -> String {
+ let characters = Array(source)
+ var result: [Character] = []
+ result.reserveCapacity(characters.count)
+
+ var currentOffset = 0
+ var inLineComment = false
+ var blockCommentDepth = 0
+ var inString = false
+ var isEscaped = false
+
+ while currentOffset < characters.count {
+ let current = characters[currentOffset]
+ let next: Character? = currentOffset + 1 < characters.count ? characters[currentOffset + 1] : nil
+
+ if inLineComment {
+ if current == "\n" {
+ inLineComment = false
+ result.append("\n")
+ } else {
+ result.append(" ")
+ }
+ currentOffset += 1
+ continue
+ }
+
+ if blockCommentDepth > 0 {
+ if current == "/", next == "*" {
+ blockCommentDepth += 1
+ result.append(" ")
+ result.append(" ")
+ currentOffset += 2
+ continue
+ }
+
+ if current == "*", next == "/" {
+ blockCommentDepth -= 1
+ result.append(" ")
+ result.append(" ")
+ currentOffset += 2
+ continue
+ }
+
+ result.append(current == "\n" ? "\n" : " ")
+ currentOffset += 1
+ continue
+ }
+
+ if inString {
+ if isEscaped {
+ isEscaped = false
+ } else if current == "\\" {
+ isEscaped = true
+ } else if current == "\"" {
+ inString = false
+ }
+
+ result.append(current == "\n" ? "\n" : " ")
+ currentOffset += 1
+ continue
+ }
+
+ if current == "/", next == "/" {
+ inLineComment = true
+ result.append(" ")
+ result.append(" ")
+ currentOffset += 2
+ continue
+ }
+
+ if current == "/", next == "*" {
+ blockCommentDepth = 1
+ result.append(" ")
+ result.append(" ")
+ currentOffset += 2
+ continue
+ }
+
+ if current == "\"" {
+ inString = true
+ result.append(" ")
+ currentOffset += 1
+ continue
+ }
+
+ result.append(current)
+ currentOffset += 1
+ }
+
+ return String(result)
+ }
+
+ func tokenCount(matching pattern: String, in source: String) -> Int {
+ guard let regex = try? NSRegularExpression(pattern: pattern) else { return 0 }
+ let range = NSRange(source.startIndex..<source.endIndex, in: source)
+
+ return regex.numberOfMatches(in: source, range: range)
+ }
+}