diff --git a/Sources/SstData/DictionaryStore.swift b/Sources/SstData/DictionaryStore.swift new file mode 100644 index 0000000..20072c1 --- /dev/null +++ b/Sources/SstData/DictionaryStore.swift @@ -0,0 +1,116 @@ +import Foundation +import SstCore + +/// Owns `dictionary.txt`: reads, writes atomically, and notices edits made in another editor. +@MainActor +public final class DictionaryStore { + public static let header = """ + # Sst woordenboek + # Eén invoer per regel. Een los woord is een term (voorrang bij het model). + # 'gehoord => schrijf' is een correctie, bijvoorbeeld: cloud code => Claude Code + + """ + + public let fileURL: URL + public private(set) var file = DictionaryFile() + public var onChange: (() -> Void)? + + private var lastText = "" + private var watcher: DispatchSourceFileSystemObject? + private var debounce: Task? + + public init(fileURL: URL) { + self.fileURL = fileURL + } + + public var entries: [DictionaryEntry] { file.entries } + + /// Reads the file; creates it with a short header when it does not exist yet. + public func load() throws { + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) + if !FileManager.default.fileExists(atPath: fileURL.path) { + try Self.header.write(to: fileURL, atomically: true, encoding: .utf8) + } + let text = try String(contentsOf: fileURL, encoding: .utf8) + lastText = text + file = DictionaryFile(parsing: text) + onChange?() + } + + /// Applies an edit and writes the file straight away. + public func apply(_ mutate: (inout DictionaryFile) -> Void) throws { + var updated = file + mutate(&updated) + let text = updated.serialized() + try text.write(to: fileURL, atomically: true, encoding: .utf8) + file = updated + lastText = text + onChange?() + } + + /// Re-reads the file when its text differs from what we last wrote or read. + public func reloadIfChanged() { + guard let text = try? String(contentsOf: fileURL, encoding: .utf8), text != lastText else { return } + lastText = text + file = DictionaryFile(parsing: text) + onChange?() + } + + /// Watches the folder (editors replace files instead of editing them in place). + public func startWatching() { + guard watcher == nil else { return } + let descriptor = open(fileURL.deletingLastPathComponent().path, O_EVTONLY) + guard descriptor >= 0 else { return } + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: descriptor, eventMask: .write, queue: .main) + source.setEventHandler { [weak self] in + MainActor.assumeIsolated { self?.scheduleReload() } + } + source.setCancelHandler { close(descriptor) } + source.resume() + watcher = source + } + + public func stopWatching() { + watcher?.cancel() + watcher = nil + debounce?.cancel() + } + + private func scheduleReload() { + debounce?.cancel() + debounce = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(200)) + guard !Task.isCancelled else { return } + self?.reloadIfChanged() + } + } +} + +/// Bridges the dictionary to the controller: thread-safe, can be switched off, rebuilt when the dictionary changes. +public final class DictionaryCorrector: TextCorrecting, @unchecked Sendable { + private let lock = NSLock() + private let knowledge: WordKnowledge + private var engine: CorrectionEngine + private var enabled = true + + public init(knowledge: WordKnowledge) { + self.knowledge = knowledge + self.engine = CorrectionEngine(entries: [], knowledge: knowledge) + } + + public func update(entries: [DictionaryEntry]) { + let rebuilt = CorrectionEngine(entries: entries, knowledge: knowledge) + lock.withLock { engine = rebuilt } + } + + public func setEnabled(_ value: Bool) { + lock.withLock { enabled = value } + } + + public func correct(_ text: String) -> CorrectionResult { + let (current, isOn) = lock.withLock { (engine, enabled) } + return isOn ? current.correct(text) : CorrectionResult(text: text, corrections: []) + } +} diff --git a/Tests/SstDataTests/DictionaryStoreTests.swift b/Tests/SstDataTests/DictionaryStoreTests.swift new file mode 100644 index 0000000..f614813 --- /dev/null +++ b/Tests/SstDataTests/DictionaryStoreTests.swift @@ -0,0 +1,82 @@ +import Foundation +import Testing +import SstCore +@testable import SstData + +@MainActor +struct DictionaryStoreTests { + func tempFile() -> URL { + FileManager.default.temporaryDirectory.appending(path: "sst-tests-\(UUID().uuidString)/dictionary.txt") + } + + @Test func loadCreatesAFileWithAHeaderWhenMissing() throws { + let url = tempFile() + let store = DictionaryStore(fileURL: url) + try store.load() + #expect(FileManager.default.fileExists(atPath: url.path)) + #expect(store.entries.isEmpty) + #expect(try String(contentsOf: url, encoding: .utf8).hasPrefix("# Sst woordenboek")) + } + + @Test func applyWritesAtomicallyAndKeepsComments() throws { + let url = tempFile() + let store = DictionaryStore(fileURL: url) + try store.load() + try store.apply { $0.append(.correction(heard: "cloud code", wrote: "Claude Code")) } + let text = try String(contentsOf: url, encoding: .utf8) + #expect(text.hasPrefix("# Sst woordenboek")) + #expect(text.hasSuffix("cloud code => Claude Code\n")) + #expect(store.entries == [pair("cloud code", "Claude Code")]) + } + + @Test func externalEditIsPickedUp() throws { + let url = tempFile() + let store = DictionaryStore(fileURL: url) + try store.load() + var changes = 0 + store.onChange = { changes += 1 } + try "Vercel\n".write(to: url, atomically: true, encoding: .utf8) + store.reloadIfChanged() + #expect(store.entries == [term("Vercel")]) + #expect(changes == 1) + store.reloadIfChanged() // unchanged text: no second notification + #expect(changes == 1) + } + + @Test func ourOwnWriteDoesNotTriggerAReload() throws { + let store = DictionaryStore(fileURL: tempFile()) + try store.load() + try store.apply { $0.append(.term("Supabase")) } + var changes = 0 + store.onChange = { changes += 1 } + store.reloadIfChanged() + #expect(changes == 0) + } + + @Test func watcherNoticesAnEditorSavingTheFile() async throws { + let url = tempFile() + let store = DictionaryStore(fileURL: url) + try store.load() + var changed = false + store.onChange = { changed = true } + store.startWatching() + try "Anthropic\n".write(to: url, atomically: true, encoding: .utf8) + for _ in 0..<40 where !changed { try await Task.sleep(for: .milliseconds(100)) } + store.stopWatching() + #expect(changed) + #expect(store.entries == [term("Anthropic")]) + } +} + +struct DictionaryCorrectorTests { + @Test func correctsWhenEnabledAndPassesThroughWhenDisabled() { + let corrector = DictionaryCorrector(knowledge: FakeKnowledge()) + corrector.update(entries: [pair("cloud code", "Claude Code")]) + #expect(corrector.correct("cloud code").text == "Claude Code") + corrector.setEnabled(false) + #expect(corrector.correct("cloud code").text == "cloud code") + corrector.setEnabled(true) + corrector.update(entries: []) + #expect(corrector.correct("cloud code").text == "cloud code") + } +}