feat: add DictionaryStore with file watching and DictionaryCorrector
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Void, Never>?
|
||||
|
||||
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: [])
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user