Files
sst/docs/superpowers/plans/2026-10-01-app-2-data.md
T

55 KiB
Raw Blame History

App fase 2 — SstData (woordenboek, correcties, geschiedenis) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: De gegevenslaag van de app: het woordenboek (bestand, correctieronde, waarschuwingen, voorrangslijst) en de transcriptiegeschiedenis (opslag, zoeken, groeperen), allemaal los van de interface en volledig getest.

Architecture: Eén nieuw target SstData dat alleen van SstCore afhangt (voor AppliedCorrection, CorrectionResult, TextCorrecting, DictationResult). Pure logica (DictionaryFile, CorrectionEngine, EntryWarnings, VocabularyBuilder, HistorySearch, HistoryGrouping) is hardware- en UI-vrij; bestandstoegang zit in DictionaryStore en HistoryStore. Woordkennis zit achter het protocol WordKnowledge zodat tests deterministisch zijn.

Tech Stack: Swift 6.4 (language mode 5), Swift Testing, Foundation, NaturalLanguage, AppKit (spellingcontrole).

Spec: docs/superpowers/specs/2026-10-01-macos-app-design.md (secties "Woordenboek — het ontwerp in detail", "Geschiedenis")

Voorwaarde: fase 1 (2026-10-01-app-1-engine-hooks.md) is afgerond: AppliedCorrection, CorrectionResult, TextCorrecting en DictationResult bestaan in SstCore.

Global Constraints

  • Dictionary-bestand: UTF-8, één invoer per regel; # = opmerking; gehoord => schrijf = correctiepaar; een regel zonder => is een term. Opmerkingen, lege regels en volgorde blijven bij opslaan behouden; onleesbare regels blijven staan (invalid).
  • Correctie: hele woorden, hoofdletter-onafhankelijk, één doorgang van links naar rechts, langste overeenkomst eerst (gelijke lengte: eerste in het bestand), vervangen tekst wordt niet opnieuw gescand, vervanging is letterlijk. Delen van een bron worden verbonden door nul of meer spaties/koppeltekens ([\s\-‐‑–]*). Woordgrenzen: niet direct naast een letter of cijfer (\p{L}, \p{N}).
  • Een term van één woord dat een gewoon woord is (spellingcontrole of Engelse woordenlijst) krijgt géén automatische hoofdletterregel (wel voorrang bij het model).
  • Voorrangslijst: eerst termen (bestandsvolgorde), dan unieke rechterkanten van paren; maximaal 24 woorden.
  • Geschiedenis: JSON { "version": 1, "entries": [...] }, ISO-8601 datums, nieuwste eerst, atomisch schrijven met 300 ms debounce; beschadigd bestand wordt hernoemd naar history.corrupt-<datum>.json.
  • Zoeken: hoofdletter- en diakriet-ongevoelig; alle zoekwoorden moeten voorkomen in de uiteindelijke of oorspronkelijke tekst.
  • Elke commit eindigt met de trailer Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> (tweede -m).

Bestandsstructuur

Package.swift                                  (wijzig)  target SstData + SstDataTests
Sources/SstData/WordKnowledge.swift            (nieuw)   WordKnowledge, EntryKey, CommonWords, SystemWordKnowledge
Sources/SstData/DictionaryFile.swift           (nieuw)   DictionaryEntry, DictionaryLine, DictionaryFile
Sources/SstData/CorrectionEngine.swift         (nieuw)
Sources/SstData/EntryWarnings.swift            (nieuw)
Sources/SstData/VocabularyBuilder.swift        (nieuw)
Sources/SstData/History.swift                  (nieuw)   HistoryEntry, HistoryStore, HistorySearch, HistoryGrouping
Sources/SstData/DictionaryStore.swift          (nieuw)   DictionaryStore, DictionaryCorrector
Tests/SstDataTests/Fakes.swift, DictionaryFileTests.swift, SystemWordKnowledgeTests.swift,
                   CorrectionEngineTests.swift, EntryWarningsTests.swift, VocabularyBuilderTests.swift,
                   HistoryTests.swift, DictionaryStoreTests.swift

Task 1: Target, woordkennis en DictionaryFile

Files:

  • Modify: Package.swift
  • Create: Sources/SstData/WordKnowledge.swift, Sources/SstData/DictionaryFile.swift
  • Test: Tests/SstDataTests/Fakes.swift, Tests/SstDataTests/DictionaryFileTests.swift, Tests/SstDataTests/SystemWordKnowledgeTests.swift

Interfaces:

  • Produces (gebruikt door Task 2–5 en de latere fasen):

    • protocol WordKnowledge { func isWord(_:) -> Bool; func isFunctionWord(_:) -> Bool }
    • final class SystemWordKnowledge: WordKnowledge (init())
    • enum EntryKey { static func parts(_:) -> [String]; static func key(_:) -> String } (intern)
    • struct DictionaryEntry: Identifiable, Equatable met id, kind: Kind (.term(String) | .correction(heard:wrote:)), source, wrote
    • enum DictionaryLine { entry, comment, blank, invalid }
    • struct DictionaryFile met init(parsing:), serialized(), entries, append(_:) -> UUID, update(id:kind:), remove(id:)
  • Step 1: Targets toevoegen

In Package.swift, voeg bij targets toe (naast de bestaande regels):

        .target(name: "SstData", dependencies: ["SstCore"]),

en bij de testtargets:

        .testTarget(name: "SstDataTests", dependencies: ["SstData", "SstCore"]),
  • Step 2: Schrijf de falende tests

Tests/SstDataTests/Fakes.swift:

import Foundation
@testable import SstData

/// Deterministic stand-in for the macOS spell checker.
struct FakeKnowledge: WordKnowledge {
    var words: Set<String> = ["cloud", "code", "some", "thing", "something", "reef", "slack", "in", "de", "the", "of"]
    var functionWords: Set<String> = ["in", "de", "the", "of"]
    func isWord(_ word: String) -> Bool { words.contains(word.lowercased()) }
    func isFunctionWord(_ word: String) -> Bool { functionWords.contains(word.lowercased()) }
}

func term(_ text: String) -> DictionaryEntry { DictionaryEntry(kind: .term(text)) }
func pair(_ heard: String, _ wrote: String) -> DictionaryEntry { DictionaryEntry(kind: .correction(heard: heard, wrote: wrote)) }

Tests/SstDataTests/DictionaryFileTests.swift:

import Testing
@testable import SstData

struct DictionaryFileTests {
    let sample = "# Opmerking\nAnthropic\n\ncloud code => Claude Code\n"

    @Test func parsesTermsPairsCommentsAndBlanks() {
        let file = DictionaryFile(parsing: sample)
        #expect(file.lines.count == 4)
        #expect(file.entries == [term("Anthropic"), pair("cloud code", "Claude Code")])
        #expect(file.lines[0] == .comment("# Opmerking"))
        #expect(file.lines[2] == .blank)
    }

    @Test func roundTripKeepsCommentsBlankLinesAndOrder() {
        #expect(DictionaryFile(parsing: sample).serialized() == sample)
    }

    @Test func normalisesSpacingAroundTheArrow() {
        #expect(DictionaryFile(parsing: "a=>b\n").serialized() == "a => b\n")
        #expect(DictionaryFile(parsing: "  cloud code   =>   Claude Code  \n").entries == [pair("cloud code", "Claude Code")])
    }

    @Test func unreadableLinesAreKeptAsInvalid() {
        let file = DictionaryFile(parsing: "oops =>\n=> nothing\nok\n")
        #expect(file.lines[0] == .invalid("oops =>"))
        #expect(file.lines[1] == .invalid("=> nothing"))
        #expect(file.serialized() == "oops =>\n=> nothing\nok\n")
    }

    @Test func handlesWindowsLineEndingsAndMissingFinalNewline() {
        let file = DictionaryFile(parsing: "a\r\nb => c")
        #expect(file.entries == [term("a"), pair("b", "c")])
        #expect(file.serialized() == "a\nb => c\n")
    }

    @Test func emptyTextIsAValidEmptyFile() {
        #expect(DictionaryFile(parsing: "").entries.isEmpty)
        #expect(DictionaryFile(parsing: "").serialized() == "")
    }

    @Test func appendUpdateAndRemove() {
        var file = DictionaryFile(parsing: "# c\n")
        let id = file.append(.term("Vercel"))
        #expect(file.entries == [term("Vercel")])
        file.update(id: id, kind: .correction(heard: "versel", wrote: "Vercel"))
        #expect(file.entries == [pair("versel", "Vercel")])
        file.remove(id: id)
        #expect(file.entries.isEmpty)
        #expect(file.serialized() == "# c\n")
    }
}

Tests/SstDataTests/SystemWordKnowledgeTests.swift:

import Testing
@testable import SstData

struct SystemWordKnowledgeTests {
    let knowledge = SystemWordKnowledge()

    @Test func recognisesEnglishAndDutchWords() {
        #expect(knowledge.isWord("cloud"))
        #expect(knowledge.isWord("Cloud"))
        #expect(knowledge.isWord("vandaag"))
    }

    @Test func doesNotTreatProductNamesAsWords() {
        #expect(!knowledge.isWord("vercel"))
        #expect(!knowledge.isWord("supabase"))
        #expect(!knowledge.isWord("anthropic"))
    }

    @Test func functionWordsComeFromTheBuiltInList() {
        #expect(knowledge.isFunctionWord("de"))
        #expect(knowledge.isFunctionWord("The"))
        #expect(!knowledge.isFunctionWord("vercel"))
    }
}
  • Step 3: Draai de tests, moeten falen

Run: swift test --filter SstDataTests Expected: compilatiefouten (cannot find 'DictionaryFile', cannot find type 'WordKnowledge').

  • Step 4: Implementatie

Sources/SstData/WordKnowledge.swift:

import AppKit
import Foundation
import NaturalLanguage

public protocol WordKnowledge {
    /// A common word in Dutch or English (case-insensitive). Rare words, names and product names are not.
    func isWord(_ word: String) -> Bool
    /// A very frequent function word ("in", "de", "the", "of").
    func isFunctionWord(_ word: String) -> Bool
}

/// Splitting and normalising the left-hand side of an entry, shared by the engine and the warnings.
enum EntryKey {
    static func parts(_ source: String) -> [String] {
        source.split(whereSeparator: { $0.isWhitespace || "-‐‑–".contains($0) }).map(String.init)
    }

    /// Spacing, hyphens and case do not matter: "Cloud-Code", "cloud code" and "cloudcode" are the same source.
    static func key(_ source: String) -> String {
        parts(source).joined().lowercased()
    }
}

/// The most frequent Dutch and English function words.
enum CommonWords {
    static let english: Set<String> = [
        "a", "about", "after", "all", "also", "an", "and", "any", "are", "as", "at", "be", "because", "been", "but",
        "by", "can", "could", "did", "do", "does", "for", "from", "had", "has", "have", "he", "her", "here", "him",
        "his", "how", "i", "if", "in", "into", "is", "it", "its", "just", "me", "more", "most", "my", "no", "not",
        "now", "of", "on", "one", "only", "or", "other", "our", "out", "over", "she", "so", "some", "such", "than",
        "that", "the", "their", "them", "then", "there", "these", "they", "this", "those", "to", "up", "us", "very",
        "was", "we", "were", "what", "when", "which", "who", "will", "with", "would", "you", "your",
    ]
    static let dutch: Set<String> = [
        "aan", "al", "als", "ben", "bij", "dan", "dat", "de", "den", "der", "deze", "die", "dit", "doen", "door",
        "dus", "een", "en", "er", "ga", "had", "heb", "heeft", "hem", "het", "hier", "hij", "hoe", "ik", "in", "is",
        "ja", "je", "jij", "kan", "kon", "maar", "me", "mij", "mijn", "moet", "na", "naar", "niet", "nog", "nu",
        "of", "om", "ons", "onze", "ook", "op", "over", "te", "ten", "ter", "toch", "toen", "tot", "u", "uit", "van",
        "veel", "voor", "was", "wat", "we", "wel", "werd", "wij", "wil", "worden", "wordt", "zal", "ze", "zei",
        "zich", "zij", "zijn", "zo", "zou",
    ]
    static let all: Set<String> = english.union(dutch)
}

/// English: the on-device word list of `NaturalLanguage` (common words only, so "cloud" and "reef" are in, "anthropic" and "vercel" are not).
/// Dutch has no such list, so the spell checker decides. Function words come from the built-in list.
public final class SystemWordKnowledge: WordKnowledge, @unchecked Sendable {
    private let englishWords = NLEmbedding.wordEmbedding(for: .english)

    public init() {}

    public func isWord(_ word: String) -> Bool {
        let lowered = word.lowercased()
        guard !lowered.isEmpty else { return false }
        if let englishWords {
            if englishWords.contains(lowered) { return true }
        } else if spellChecks(lowered, language: "en") {
            return true
        }
        return spellChecks(lowered, language: "nl")
    }

    public func isFunctionWord(_ word: String) -> Bool {
        CommonWords.all.contains(word.lowercased())
    }

    private func spellChecks(_ word: String, language: String) -> Bool {
        NSSpellChecker.shared.checkSpelling(
            of: word, startingAt: 0, language: language, wrap: false,
            inSpellDocumentWithTag: 0, wordCount: nil).location == NSNotFound
    }
}

Sources/SstData/DictionaryFile.swift:

import Foundation

public struct DictionaryEntry: Identifiable, Equatable, Sendable {
    public enum Kind: Equatable, Sendable {
        case term(String)
        case correction(heard: String, wrote: String)
    }

    public let id: UUID
    public var kind: Kind

    public init(id: UUID = UUID(), kind: Kind) {
        self.id = id
        self.kind = kind
    }

    /// Equality ignores `id`: two entries with the same content are the same entry.
    public static func == (lhs: DictionaryEntry, rhs: DictionaryEntry) -> Bool { lhs.kind == rhs.kind }

    /// The text the correction pass listens for (a term listens for itself).
    public var source: String {
        switch kind {
        case .term(let term): term
        case .correction(let heard, _): heard
        }
    }

    /// The text that ends up in the transcript.
    public var wrote: String {
        switch kind {
        case .term(let term): term
        case .correction(_, let wrote): wrote
        }
    }
}

public enum DictionaryLine: Equatable, Sendable {
    case entry(DictionaryEntry)
    case comment(String)
    case blank
    case invalid(String)
}

/// The dictionary as a list of lines, so comments, blank lines and order survive an edit.
public struct DictionaryFile: Equatable, Sendable {
    public var lines: [DictionaryLine]

    public init(lines: [DictionaryLine] = []) {
        self.lines = lines
    }

    public init(parsing text: String) {
        var raw = text.replacingOccurrences(of: "\r\n", with: "\n")
            .split(separator: "\n", omittingEmptySubsequences: false)
            .map(String.init)
        if raw.last == "" { raw.removeLast() }
        lines = raw.map(Self.parseLine)
    }

    static func parseLine(_ line: String) -> DictionaryLine {
        let trimmed = line.trimmingCharacters(in: .whitespaces)
        if trimmed.isEmpty { return .blank }
        if trimmed.hasPrefix("#") { return .comment(line) }
        if let arrow = trimmed.range(of: "=>") {
            let heard = trimmed[..<arrow.lowerBound].trimmingCharacters(in: .whitespaces)
            let wrote = trimmed[arrow.upperBound...].trimmingCharacters(in: .whitespaces)
            guard !heard.isEmpty, !wrote.isEmpty else { return .invalid(line) }
            return .entry(DictionaryEntry(kind: .correction(heard: heard, wrote: wrote)))
        }
        return .entry(DictionaryEntry(kind: .term(trimmed)))
    }

    public func serialized() -> String {
        let out = lines.map { line -> String in
            switch line {
            case .entry(let entry):
                switch entry.kind {
                case .term(let term): return term
                case .correction(let heard, let wrote): return "\(heard) => \(wrote)"
                }
            case .comment(let text), .invalid(let text): return text
            case .blank: return ""
            }
        }
        return out.isEmpty ? "" : out.joined(separator: "\n") + "\n"
    }

    public var entries: [DictionaryEntry] {
        lines.compactMap { line in
            if case .entry(let entry) = line { return entry }
            return nil
        }
    }

    @discardableResult
    public mutating func append(_ kind: DictionaryEntry.Kind) -> UUID {
        let entry = DictionaryEntry(kind: kind)
        lines.append(.entry(entry))
        return entry.id
    }

    public mutating func update(id: UUID, kind: DictionaryEntry.Kind) {
        guard let index = lines.firstIndex(where: { if case .entry(let e) = $0 { return e.id == id } else { return false } }) else { return }
        lines[index] = .entry(DictionaryEntry(id: id, kind: kind))
    }

    public mutating func remove(id: UUID) {
        lines.removeAll { if case .entry(let e) = $0 { return e.id == id } else { return false } }
    }
}
  • Step 5: Draai de tests

Run: swift test --filter SstDataTests Expected: alle tests PASS (7 DictionaryFile, 3 SystemWordKnowledge).

De Engelse woordenlijst komt uit NLEmbedding.wordEmbedding(for: .english). Is die op de machine niet beschikbaar, dan valt isWord terug op de Engelse spellingcontrole; de tests verwachten dan dat "anthropic" óók een woord is en kunnen falen. Meld dat als BLOCKED met de uitvoer van swift test, wijzig de test niet stilletjes.

  • Step 6: Commit
git add Package.swift Sources/SstData Tests/SstDataTests
git commit -m "feat: add SstData with DictionaryFile and word knowledge" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"

Task 2: CorrectionEngine

Files:

  • Create: Sources/SstData/CorrectionEngine.swift
  • Test: Tests/SstDataTests/CorrectionEngineTests.swift

Interfaces:

  • Consumes: Task 1 (DictionaryEntry, WordKnowledge, EntryKey) en SstCore.TextCorrecting/CorrectionResult/AppliedCorrection.

  • Produces: struct CorrectionEngine: TextCorrecting met init(entries: [DictionaryEntry], knowledge: WordKnowledge) en correct(_:) -> CorrectionResult.

  • Step 1: Schrijf de falende tests

Tests/SstDataTests/CorrectionEngineTests.swift:

import Foundation
import Testing
import SstCore
@testable import SstData

struct CorrectionEngineTests {
    let knowledge = FakeKnowledge()

    func engine(_ entries: [DictionaryEntry]) -> CorrectionEngine {
        CorrectionEngine(entries: entries, knowledge: knowledge)
    }

    @Test func replacesAPairAndReportsIt() {
        let result = engine([pair("cloud code", "Claude Code")]).correct("ik gebruik cloud code vandaag")
        #expect(result.text == "ik gebruik Claude Code vandaag")
        #expect(result.corrections.count == 1)
        let c = result.corrections[0]
        #expect(c.heard == "cloud code" && c.wrote == "Claude Code" && c.rule == "cloud code")
        #expect((result.text as NSString).substring(with: NSRange(location: c.start, length: c.length)) == "Claude Code")
    }

    @Test func findsGluedHyphenatedAndSpacedVariants() {
        let e = engine([pair("cloud code", "Claude Code")])
        for variant in ["CloudCode", "Cloud-Code", "CLOUD  CODE", "cloud\u{2011}code", "cloud code"] {
            #expect(e.correct(variant).text == "Claude Code", "variant \(variant)")
        }
    }

    @Test func neverTouchesRealWordsThatMerelyStartTheSame() {
        let e = engine([pair("cloud code", "Claude Code")])
        #expect(e.correct("Cloudflare is handig").text == "Cloudflare is handig")
        #expect(e.correct("cloud codex").text == "cloud codex")
        #expect(e.correct("de cloud en de code").text == "de cloud en de code")
        #expect(e.correct("the cloud").corrections.isEmpty)
    }

    @Test func respectsPunctuationBoundaries() {
        let e = engine([pair("cloud code", "Claude Code")])
        #expect(e.correct("(cloud code)").text == "(Claude Code)")
        #expect(e.correct("cloud code.").text == "Claude Code.")
        #expect(e.correct("Cloud Code, Cloud Code").text == "Claude Code, Claude Code")
    }

    @Test func longestMatchWins() {
        let e = engine([pair("cloud code", "Claude Code"), pair("cloud code review", "Claude Code Review")])
        #expect(e.correct("cloud code review nu").text == "Claude Code Review nu")
        #expect(e.correct("cloud-code-review").text == "Claude Code Review")
    }

    @Test func replacedTextIsNeverRescanned() {
        let e = engine([pair("a", "b"), pair("b", "c")])
        #expect(e.correct("a b").text == "b c")
    }

    @Test func termsFixCasingAndSpacing() {
        let e = engine([term("Anthropic"), term("Claude Code")])
        #expect(e.correct("anthropic ANTHROPIC").text == "Anthropic Anthropic")
        #expect(e.correct("claudecode en claude-code").text == "Claude Code en Claude Code")
    }

    @Test func termAlreadyRightIsNotReportedAsACorrection() {
        let result = engine([term("Anthropic")]).correct("Anthropic bouwt Claude")
        #expect(result.text == "Anthropic bouwt Claude")
        #expect(result.corrections.isEmpty)
    }

    @Test func singleWordTermThatIsARealWordGetsNoCasingRule() {
        let e = engine([term("Reef"), term("Slack")])
        #expect(e.correct("het reef en slack").text == "het reef en slack")
    }

    @Test func firstEntryWinsOnDuplicateSources() {
        let e = engine([pair("cloud code", "Claude Code"), pair("Cloud-Code", "Other")])
        #expect(e.correct("cloudcode").text == "Claude Code")
    }

    @Test func replacementIsLiteral() {
        let e = engine([pair("price", "US$1 \\0 $1")])
        #expect(e.correct("the price").text == "the US$1 \\0 $1")
    }

    @Test func handlesDiacritics() {
        let e = engine([term("Café")])
        #expect(e.correct("een CAFÉ om de hoek").text == "een Café om de hoek")
    }

    @Test func offsetsAreRelativeToTheCorrectedText() {
        let result = engine([pair("a", "xxx"), pair("b", "y")]).correct("a b a")
        #expect(result.text == "xxx y xxx")
        #expect(result.corrections.map(\.start) == [0, 4, 6])
        #expect(result.corrections.map(\.length) == [3, 1, 3])
    }

    @Test func emptyDictionaryAndEmptyTextAreNoOps() {
        #expect(engine([]).correct("hallo").text == "hallo")
        #expect(engine([pair("a", "b")]).correct("").text == "")
    }
}
  • Step 2: Draai de tests, moeten falen

Run: swift test --filter CorrectionEngineTests Expected: compilatiefout cannot find 'CorrectionEngine' in scope.

  • Step 3: Implementatie

Sources/SstData/CorrectionEngine.swift:

import Foundation
import SstCore

/// Whole-word, case-insensitive, single-pass replacement. Longest match wins, replaced text is never rescanned.
public struct CorrectionEngine: TextCorrecting {
    private struct Rule {
        let source: String
        let wrote: String
    }

    private let rules: [Rule]
    private let regex: NSRegularExpression?

    public init(entries: [DictionaryEntry], knowledge: WordKnowledge) {
        var seen = Set<String>()
        var built: [(rule: Rule, order: Int)] = []
        for (order, entry) in entries.enumerated() {
            let source = entry.source.trimmingCharacters(in: .whitespacesAndNewlines)
            let key = EntryKey.key(source)
            guard !key.isEmpty, !seen.contains(key) else { continue }
            // A single-word term that is also a real word only biases the model; capitalising it everywhere would corrupt normal text.
            if case .term(let term) = entry.kind, EntryKey.parts(term).count == 1, knowledge.isWord(term) { continue }
            seen.insert(key)
            built.append((Rule(source: source, wrote: entry.wrote), order))
        }
        let ordered = built.sorted { a, b in
            let la = a.rule.source.filter(\.isLetter).count
            let lb = b.rule.source.filter(\.isLetter).count
            return la != lb ? la > lb : a.order < b.order
        }.map(\.rule)
        rules = ordered
        if ordered.isEmpty {
            regex = nil
        } else {
            let alternatives = ordered.map { "(\(Self.pattern($0.source)))" }.joined(separator: "|")
            regex = try? NSRegularExpression(
                pattern: "(?<![\\p{L}\\p{N}])(?:\(alternatives))(?![\\p{L}\\p{N}])",
                options: [.caseInsensitive]
            )
        }
    }

    /// Parts joined by "zero or more spaces/hyphens", so "cloud code" also finds "cloudcode" and "Cloud-Code".
    private static func pattern(_ source: String) -> String {
        EntryKey.parts(source)
            .map { NSRegularExpression.escapedPattern(for: $0) }
            .joined(separator: "[\\s\\-‐‑–]*")
    }

    public func correct(_ text: String) -> CorrectionResult {
        guard let regex, !text.isEmpty else { return CorrectionResult(text: text, corrections: []) }
        let ns = text as NSString
        var output = ""
        var outputLength = 0
        var last = 0
        var applied: [AppliedCorrection] = []

        for match in regex.matches(in: text, range: NSRange(location: 0, length: ns.length)) {
            let gap = ns.substring(with: NSRange(location: last, length: match.range.location - last))
            output += gap
            outputLength += gap.utf16.count

            let heard = ns.substring(with: match.range)
            let group = (1..<match.numberOfRanges).first { match.range(at: $0).location != NSNotFound } ?? 1
            let rule = rules[group - 1]

            output += rule.wrote
            if heard != rule.wrote {
                applied.append(AppliedCorrection(
                    heard: heard, wrote: rule.wrote, rule: rule.source,
                    start: outputLength, length: rule.wrote.utf16.count))
            }
            outputLength += rule.wrote.utf16.count
            last = match.range.location + match.range.length
        }
        output += ns.substring(from: last)
        return CorrectionResult(text: output, corrections: applied)
    }
}
  • Step 4: Draai de tests

Run: swift test --filter CorrectionEngineTests Expected: 14 tests PASS.

  • Step 5: Commit
git add Sources/SstData Tests/SstDataTests
git commit -m "feat: add CorrectionEngine (whole-word, longest-first, single pass)" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"

Task 3: EntryWarnings en VocabularyBuilder

Files:

  • Create: Sources/SstData/EntryWarnings.swift, Sources/SstData/VocabularyBuilder.swift
  • Test: Tests/SstDataTests/EntryWarningsTests.swift, Tests/SstDataTests/VocabularyBuilderTests.swift

Interfaces:

  • Consumes: Task 1.

  • Produces:

    • struct EntryWarning { level: Level (.note < .warning < .error); message: String }
    • EntryWarnings.check(_ kind: DictionaryEntry.Kind, among others: [DictionaryEntry], knowledge: WordKnowledge) -> [EntryWarning] (meest ernstig eerst; others = alle andere invoeren)
    • VocabularyBuilder.build(from: [DictionaryEntry], maxTerms: Int = 24) -> [String]
  • Step 1: Schrijf de falende tests

Tests/SstDataTests/EntryWarningsTests.swift:

import Testing
@testable import SstData

struct EntryWarningsTests {
    let knowledge = FakeKnowledge()

    func check(_ kind: DictionaryEntry.Kind, others: [DictionaryEntry] = []) -> [EntryWarning] {
        EntryWarnings.check(kind, among: others, knowledge: knowledge)
    }

    @Test func singleCommonWordIsAWarning() {
        let w = check(.correction(heard: "cloud", wrote: "Claude"))
        #expect(w.contains { $0.level == .warning && $0.message.contains("cloud") })
    }

    @Test func veryShortSourceIsAWarning() {
        #expect(check(.correction(heard: "ab", wrote: "AB")).contains { $0.level == .warning })
    }

    @Test func functionWordInsideAPhraseIsAWarning() {
        #expect(check(.correction(heard: "in de", wrote: "indeed")).contains { $0.level == .warning })
    }

    @Test func gluedFormThatIsARealWordIsAWarning() {
        let w = check(.correction(heard: "some thing", wrote: "Something"))
        #expect(w.contains { $0.level == .warning && $0.message.contains("something") })
    }

    @Test func phraseOfCommonWordsOnlyGetsANote() {
        let w = check(.correction(heard: "cloud code", wrote: "Claude Code"))
        #expect(w.count == 1)
        #expect(w[0].level == .note)
    }

    @Test func distinctiveEntriesGetNoWarnings() {
        #expect(check(.correction(heard: "versel", wrote: "Vercel")).isEmpty)
        #expect(check(.term("Supabase")).isEmpty)
    }

    @Test func duplicateSourceIsAnErrorEvenWithDifferentSpelling() {
        let others = [pair("cloud code", "Claude Code")]
        let w = check(.correction(heard: "Cloud-Code", wrote: "X"), others: others)
        #expect(w.first?.level == .error)
    }

    @Test func termThatIsARealWordGetsANoteAboutBiasOnly() {
        let w = check(.term("Reef"))
        #expect(w.count == 1)
        #expect(w[0].level == .note)
    }

    @Test func emptySourceIsAnError() {
        #expect(check(.term("  ")).first?.level == .error)
    }

    @Test func warningsAreSortedMostSevereFirst() {
        let w = check(.correction(heard: "cloud", wrote: "Claude"), others: [pair("cloud", "x")])
        #expect(w.map(\.level) == w.map(\.level).sorted(by: >))
    }
}

Tests/SstDataTests/VocabularyBuilderTests.swift:

import Testing
@testable import SstData

struct VocabularyBuilderTests {
    @Test func termsComeFirstThenCorrectionTargetsWithoutDuplicates() {
        let entries = [pair("cloud code", "Claude Code"), term("Anthropic"), term("anthropic"), pair("versel", "Vercel"), term("Supabase")]
        #expect(VocabularyBuilder.build(from: entries) == ["Anthropic", "Supabase", "Claude Code", "Vercel"])
    }

    @Test func listIsCapped() {
        let entries = (1...40).map { term("Woord\($0)") }
        #expect(VocabularyBuilder.build(from: entries).count == 24)
        #expect(VocabularyBuilder.build(from: entries, maxTerms: 5).count == 5)
    }

    @Test func emptyDictionaryGivesNoVocabulary() {
        #expect(VocabularyBuilder.build(from: []).isEmpty)
    }
}
  • Step 2: Draai de tests, moeten falen

Run: swift test --filter "EntryWarningsTests|VocabularyBuilderTests" Expected: compilatiefouten (cannot find 'EntryWarnings', cannot find 'VocabularyBuilder').

  • Step 3: Implementatie

Sources/SstData/EntryWarnings.swift:

import Foundation

public struct EntryWarning: Equatable, Sendable {
    public enum Level: Int, Comparable, Sendable {
        case note = 0, warning, error
        public static func < (a: Level, b: Level) -> Bool { a.rawValue < b.rawValue }
    }

    public let level: Level
    public let message: String

    public init(level: Level, message: String) {
        self.level = level
        self.message = message
    }
}

/// Flags dictionary entries that are likely to corrupt normal text.
public enum EntryWarnings {
    /// - Parameter others: all entries except the one being checked.
    public static func check(_ kind: DictionaryEntry.Kind, among others: [DictionaryEntry], knowledge: WordKnowledge) -> [EntryWarning] {
        let source: String
        let isCorrection: Bool
        switch kind {
        case .term(let term): (source, isCorrection) = (term, false)
        case .correction(let heard, _): (source, isCorrection) = (heard, true)
        }

        let parts = EntryKey.parts(source)
        guard !parts.isEmpty else { return [EntryWarning(level: .error, message: "Leeg: vul een woord in.")] }

        var result: [EntryWarning] = []
        if others.contains(where: { EntryKey.key($0.source) == EntryKey.key(source) }) {
            result.append(EntryWarning(level: .error, message: "Dubbele invoer: deze bron staat er al, de eerste telt."))
        }

        if !isCorrection {
            if parts.count == 1, knowledge.isWord(source) {
                result.append(EntryWarning(level: .note, message: "Gewoon woord: krijgt alleen voorrang bij het model, geen automatische hoofdletterregel."))
            }
            return result.sorted { $0.level > $1.level }
        }

        if parts.count == 1, knowledge.isWord(parts[0]) {
            result.append(EntryWarning(level: .warning, message: "'\(parts[0])' is een gewoon woord en wordt overal in je tekst vervangen."))
        }
        if source.filter(\.isLetter).count < 3 {
            result.append(EntryWarning(level: .warning, message: "Erg kort (minder dan 3 letters): veel kans op ongewenste vervangingen."))
        }
        if parts.count > 1 {
            var flagged = false
            if let common = parts.first(where: { knowledge.isFunctionWord($0) }) {
                result.append(EntryWarning(level: .warning, message: "Bevat het veelvoorkomende woord '\(common)'; dit kan gewone zinnen raken."))
                flagged = true
            }
            let glued = parts.joined()
            if knowledge.isWord(glued) {
                result.append(EntryWarning(level: .warning, message: "Aaneengeschreven ('\(glued)') is een bestaand woord en wordt ook vervangen."))
                flagged = true
            }
            if !flagged, parts.allSatisfy({ knowledge.isWord($0) }) {
                result.append(EntryWarning(level: .note, message: "Alle delen zijn gewone woorden: alleen de hele combinatie wordt vervangen."))
            }
        }
        return result.sorted { $0.level > $1.level }
    }
}

Sources/SstData/VocabularyBuilder.swift:

import Foundation

/// The short word list handed to the speech engine as context.
public enum VocabularyBuilder {
    /// Terms first (file order), then the unique right-hand sides of correction pairs; at most `maxTerms` words.
    public static func build(from entries: [DictionaryEntry], maxTerms: Int = 24) -> [String] {
        var out: [String] = []
        var seen = Set<String>()
        func add(_ word: String) {
            let key = word.lowercased()
            guard !word.isEmpty, !seen.contains(key) else { return }
            seen.insert(key)
            out.append(word)
        }
        for entry in entries { if case .term(let term) = entry.kind { add(term) } }
        for entry in entries { if case .correction(_, let wrote) = entry.kind { add(wrote) } }
        return Array(out.prefix(maxTerms))
    }
}
  • Step 4: Draai de tests

Run: swift test --filter "EntryWarningsTests|VocabularyBuilderTests" Expected: 10 + 3 tests PASS.

  • Step 5: Commit
git add Sources/SstData Tests/SstDataTests
git commit -m "feat: add EntryWarnings and VocabularyBuilder" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"

Task 4: Geschiedenis — opslag, zoeken, groeperen

Files:

  • Create: Sources/SstData/History.swift
  • Test: Tests/SstDataTests/HistoryTests.swift

Interfaces:

  • Consumes: SstCore.DictationResult, SstCore.AppliedCorrection.

  • Produces:

    • struct HistoryEntry: Codable, Equatable, Identifiable (id, date, duration, rawText, finalText, corrections; init(result:))
    • @MainActor final class HistoryStore met init(fileURL:), entries (nieuwste eerst), load() -> LoadOutcome (.empty | .loaded | .recoveredCorrupt(backup:)), add(_:), remove(id:), removeAll(), flush(), onChange, lastError
    • HistorySearch.filter(_:query:) -> [HistoryEntry]
    • HistoryGrouping.byDay(_:calendar:) -> [HistoryDay] (HistoryDay: day: Date, entries)
  • Step 1: Schrijf de falende tests

Tests/SstDataTests/HistoryTests.swift:

import Foundation
import Testing
import SstCore
@testable import SstData

@MainActor
struct HistoryStoreTests {
    func tempFile() -> URL {
        FileManager.default.temporaryDirectory.appending(path: "sst-tests-\(UUID().uuidString)/history.json")
    }

    func entry(_ text: String, at seconds: TimeInterval) -> HistoryEntry {
        HistoryEntry(date: Date(timeIntervalSince1970: 1_700_000_000 + seconds), duration: 2, rawText: text, finalText: text)
    }

    @Test func newestEntryComesFirstAndSurvivesARoundTrip() {
        let url = tempFile()
        let store = HistoryStore(fileURL: url)
        store.add(entry("eerste", at: 0))
        store.add(HistoryEntry(
            date: Date(timeIntervalSince1970: 1_700_000_100), duration: 3, rawText: "cloud code", finalText: "Claude Code",
            corrections: [AppliedCorrection(heard: "cloud code", wrote: "Claude Code", rule: "cloud code", start: 0, length: 11)]))
        store.flush()

        let reloaded = HistoryStore(fileURL: url)
        #expect(reloaded.load() == .loaded)
        #expect(reloaded.entries.map(\.finalText) == ["Claude Code", "eerste"])
        #expect(reloaded.entries == store.entries)
    }

    @Test func missingFileIsEmpty() {
        let store = HistoryStore(fileURL: tempFile())
        #expect(store.load() == .empty)
        #expect(store.entries.isEmpty)
    }

    @Test func removeAndRemoveAll() {
        let store = HistoryStore(fileURL: tempFile())
        let a = entry("a", at: 0), b = entry("b", at: 1)
        store.add(a); store.add(b)
        store.remove(id: a.id)
        #expect(store.entries.map(\.finalText) == ["b"])
        store.removeAll()
        #expect(store.entries.isEmpty)
    }

    @Test func corruptFileIsMovedAsideAndStoreStartsFresh() throws {
        let url = tempFile()
        try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
        try "dit is geen json".write(to: url, atomically: true, encoding: .utf8)
        let store = HistoryStore(fileURL: url)
        guard case .recoveredCorrupt(let backup) = store.load() else {
            Issue.record("expected recoveredCorrupt"); return
        }
        #expect(FileManager.default.fileExists(atPath: backup.path))
        #expect(!FileManager.default.fileExists(atPath: url.path))
        #expect(store.entries.isEmpty)
    }

    @Test func onChangeFiresForEdits() {
        let store = HistoryStore(fileURL: tempFile())
        var calls = 0
        store.onChange = { calls += 1 }
        store.add(entry("a", at: 0))
        store.removeAll()
        #expect(calls == 2)
    }

    @Test func entryFromDictationResultKeepsEverything() {
        let result = DictationResult(
            date: Date(timeIntervalSince1970: 1_700_000_000), duration: 4.5, rawText: "raw", finalText: "final",
            corrections: [AppliedCorrection(heard: "a", wrote: "b", rule: "a", start: 0, length: 1)])
        let e = HistoryEntry(result: result)
        #expect(e.rawText == "raw" && e.finalText == "final" && e.duration == 4.5 && e.corrections.count == 1)
    }
}

struct HistorySearchTests {
    let entries = [
        HistoryEntry(date: .now, duration: 1, rawText: "ik ga naar het café", finalText: "Ik ga naar het Café"),
        HistoryEntry(date: .now, duration: 1, rawText: "cloud code is handig", finalText: "Claude Code is handig"),
    ]

    @Test func emptyQueryReturnsEverything() {
        #expect(HistorySearch.filter(entries, query: "  ").count == 2)
    }

    @Test func ignoresCaseAndDiacritics() {
        #expect(HistorySearch.filter(entries, query: "CAFE").count == 1)
        #expect(HistorySearch.filter(entries, query: "café").count == 1)
    }

    @Test func allWordsMustMatchInAnyOrder() {
        #expect(HistorySearch.filter(entries, query: "handig claude").count == 1)
        #expect(HistorySearch.filter(entries, query: "handig café").isEmpty)
    }

    @Test func searchesTheOriginalTextToo() {
        #expect(HistorySearch.filter(entries, query: "cloud").count == 1)
    }
}

struct HistoryGroupingTests {
    @Test func groupsByDayKeepingOrder() {
        var calendar = Calendar(identifier: .gregorian)
        calendar.timeZone = TimeZone(identifier: "UTC")!
        func at(_ day: Int, _ hour: Int) -> Date {
            calendar.date(from: DateComponents(year: 2026, month: 10, day: day, hour: hour))!
        }
        let entries = [at(2, 15), at(2, 9), at(1, 20)].map {
            HistoryEntry(date: $0, duration: 1, rawText: "x", finalText: "x")
        }
        let days = HistoryGrouping.byDay(entries, calendar: calendar)
        #expect(days.count == 2)
        #expect(days[0].entries.count == 2)
        #expect(days[1].entries.count == 1)
        #expect(days[0].day > days[1].day)
    }
}
  • Step 2: Draai de tests, moeten falen

Run: swift test --filter "HistoryStoreTests|HistorySearchTests|HistoryGroupingTests" Expected: compilatiefout cannot find 'HistoryStore' in scope.

  • Step 3: Implementatie

Sources/SstData/History.swift:

import Foundation
import SstCore

public struct HistoryEntry: Codable, Equatable, Identifiable, Sendable {
    public var id: UUID
    public var date: Date
    public var duration: TimeInterval
    public var rawText: String
    public var finalText: String
    public var corrections: [AppliedCorrection]

    public init(
        id: UUID = UUID(), date: Date, duration: TimeInterval,
        rawText: String, finalText: String, corrections: [AppliedCorrection] = []
    ) {
        self.id = id
        self.date = date
        self.duration = duration
        self.rawText = rawText
        self.finalText = finalText
        self.corrections = corrections
    }

    public init(result: DictationResult) {
        self.init(
            date: result.date, duration: result.duration, rawText: result.rawText,
            finalText: result.finalText, corrections: result.corrections)
    }
}

/// Newest first, stored as one JSON file; saves are debounced.
@MainActor
public final class HistoryStore {
    public enum LoadOutcome: Equatable {
        case empty
        case loaded
        case recoveredCorrupt(backup: URL)
    }

    private struct Document: Codable {
        var version: Int
        var entries: [HistoryEntry]
    }

    public private(set) var entries: [HistoryEntry] = []
    public private(set) var lastError: String?
    public var onChange: (() -> Void)?

    private let fileURL: URL
    private var saveTask: Task<Void, Never>?

    public init(fileURL: URL) {
        self.fileURL = fileURL
    }

    private static func makeEncoder() -> JSONEncoder {
        let encoder = JSONEncoder()
        encoder.dateEncodingStrategy = .iso8601
        encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
        return encoder
    }

    private static func makeDecoder() -> JSONDecoder {
        let decoder = JSONDecoder()
        decoder.dateDecodingStrategy = .iso8601
        return decoder
    }

    @discardableResult
    public func load() -> LoadOutcome {
        guard let data = try? Data(contentsOf: fileURL) else {
            entries = []
            return .empty
        }
        do {
            entries = try Self.makeDecoder().decode(Document.self, from: data).entries
            return .loaded
        } catch {
            let stamp = ISO8601DateFormatter().string(from: Date()).replacingOccurrences(of: ":", with: "-")
            let backup = fileURL.deletingLastPathComponent().appending(path: "history.corrupt-\(stamp).json")
            try? FileManager.default.moveItem(at: fileURL, to: backup)
            entries = []
            return .recoveredCorrupt(backup: backup)
        }
    }

    public func add(_ entry: HistoryEntry) {
        entries.insert(entry, at: 0)
        changed()
    }

    public func remove(id: UUID) {
        entries.removeAll { $0.id == id }
        changed()
    }

    public func removeAll() {
        entries = []
        changed()
    }

    /// Writes immediately (also call this when the app quits).
    public func flush() {
        saveTask?.cancel()
        saveTask = nil
        do {
            try FileManager.default.createDirectory(
                at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true)
            let data = try Self.makeEncoder().encode(Document(version: 1, entries: entries))
            try data.write(to: fileURL, options: .atomic)
            lastError = nil
        } catch {
            lastError = error.localizedDescription
        }
    }

    private func changed() {
        onChange?()
        saveTask?.cancel()
        saveTask = Task { [weak self] in
            try? await Task.sleep(for: .milliseconds(300))
            guard !Task.isCancelled else { return }
            self?.flush()
        }
    }
}

public enum HistorySearch {
    static func fold(_ text: String) -> String {
        text.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: nil)
    }

    /// Every query word must occur in the final or the original text (case- and diacritic-insensitive).
    public static func filter(_ entries: [HistoryEntry], query: String) -> [HistoryEntry] {
        let words = fold(query).split(whereSeparator: \.isWhitespace).map(String.init)
        guard !words.isEmpty else { return entries }
        return entries.filter { entry in
            let haystack = fold(entry.finalText + " " + entry.rawText)
            return words.allSatisfy { haystack.contains($0) }
        }
    }
}

public struct HistoryDay: Identifiable, Equatable, Sendable {
    public let day: Date
    public let entries: [HistoryEntry]
    public var id: Date { day }
}

public enum HistoryGrouping {
    /// Groups newest-first entries by calendar day, keeping their order.
    public static func byDay(_ entries: [HistoryEntry], calendar: Calendar = .current) -> [HistoryDay] {
        var days: [HistoryDay] = []
        for entry in entries {
            let start = calendar.startOfDay(for: entry.date)
            if let last = days.last, last.day == start {
                days[days.count - 1] = HistoryDay(day: start, entries: last.entries + [entry])
            } else {
                days.append(HistoryDay(day: start, entries: [entry]))
            }
        }
        return days
    }
}
  • Step 4: Draai de tests

Run: swift test --filter "HistoryStoreTests|HistorySearchTests|HistoryGroupingTests" Expected: 6 + 4 + 1 tests PASS.

  • Step 5: Commit
git add Sources/SstData Tests/SstDataTests
git commit -m "feat: add history store, search and day grouping" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"

Task 5: DictionaryStore en DictionaryCorrector

Files:

  • Create: Sources/SstData/DictionaryStore.swift
  • Test: Tests/SstDataTests/DictionaryStoreTests.swift

Interfaces:

  • Consumes: Task 1 (DictionaryFile), Task 2 (CorrectionEngine).

  • Produces:

    • @MainActor final class DictionaryStore met init(fileURL:), fileURL, file, entries, load() throws, apply(_ mutate: (inout DictionaryFile) -> Void) throws, reloadIfChanged(), startWatching(), stopWatching(), onChange, static header
    • final class DictionaryCorrector: TextCorrecting met init(knowledge:), update(entries:), setEnabled(_:)
  • Step 1: Schrijf de falende tests

Tests/SstDataTests/DictionaryStoreTests.swift:

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")
    }
}
  • Step 2: Draai de tests, moeten falen

Run: swift test --filter "DictionaryStoreTests|DictionaryCorrectorTests" Expected: compilatiefout cannot find 'DictionaryStore' in scope.

  • Step 3: Implementatie

Sources/SstData/DictionaryStore.swift:

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: [])
    }
}
  • Step 4: Draai alle tests

Run: swift test Expected: alle tests PASS (engine-tests uit fase 1 + 54 in SstDataTests). De test watcherNoticesAnEditorSavingTheFile wacht maximaal 4 s; faalt hij sporadisch, draai hem apart opnieuw voordat je de implementatie wijzigt.

  • Step 5: Commit
git add Sources/SstData Tests/SstDataTests
git commit -m "feat: add DictionaryStore with file watching and DictionaryCorrector" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"

Self-review

  • Spec-dekking: bestandsformaat en behoud van opmerkingen/volgorde/ongeldige regels (Task 1), alle correctieregels uit de spec — hele woorden, glue/koppelteken, langste eerst, geen ketens, termen die gewone woorden zijn, letterlijke vervanging, Cloudflare/cloud codex (Task 2), waarschuwingstabel (Task 3), voorrangslijst met limiet 24 (Task 3), geschiedenis met zoeken, groeperen, beschadigd bestand (Task 4), bestandsmonitor en uit/aan-schakelaar (Task 5). Niet in deze fase: de plaats van de bestanden in Application Support, de koppeling aan DictationController en de UI (fase 4 en 5).
  • Placeholders: geen; alle code is uit een werkende, geteste versie overgenomen.
  • Typeconsistentie: DictionaryEntry.Kind (.term, .correction(heard:wrote:)), CorrectionEngine(entries:knowledge:), EntryWarnings.check(_:among:knowledge:), HistoryEntry(result:) en DictionaryStore.apply komen overeen met wat Task 2–5 en de latere fasen gebruiken.
  • Afwijking van de spec (bewust): "gewoon woord" gebruikt de Engelse woordenlijst van NaturalLanguage (alleen gewone woorden) plus de Nederlandse spellingcontrole, niet de Engelse spellingcontrole: die rekent "anthropic" tot de woorden en zou jouw eigen voorbeeldterm onterecht uitsluiten van de hoofdletterregel.