diff options
| author | Fuwn <[email protected]> | 2026-02-18 12:07:57 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-02-18 12:15:31 -0800 |
| commit | d9b8557de70d51db6037891ededd783b8787af22 (patch) | |
| tree | ce41d697e504d00ce85acf00689aff4596da1638 | |
| parent | refactor: remove duplicate settings serialisation work (diff) | |
| download | sora-testing-d9b8557de70d51db6037891ededd783b8787af22.tar.xz sora-testing-d9b8557de70d51db6037891ededd783b8787af22.zip | |
refactor: coalesce settings sync and skip unchanged writes
| -rw-r--r-- | Sora/Data/Settings/SettingsManager.swift | 223 | ||||
| -rw-r--r-- | Sora/Data/Settings/SettingsSyncCoordinator.swift | 28 | ||||
| -rw-r--r-- | Sora/Data/Settings/SettingsSyncKey.swift | 2 | ||||
| -rw-r--r-- | SoraTests/SettingsManagerSyncTests.swift | 270 | ||||
| -rw-r--r-- | SoraTests/ViewDerivedDataTests.swift | 109 | ||||
| -rw-r--r-- | SoraTests/XCTestCase+SourceAnalysis.swift | 210 |
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) + } +} |