diff --git a/KeyType.xcodeproj/project.pbxproj b/KeyType.xcodeproj/project.pbxproj index de1601f..bd75e7a 100644 --- a/KeyType.xcodeproj/project.pbxproj +++ b/KeyType.xcodeproj/project.pbxproj @@ -31,6 +31,7 @@ 4B5450012FC9600000000043 /* CompletionAcceptanceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B5450012FC9600000000042 /* CompletionAcceptanceController.swift */; }; 4B5450012FC9600000000045 /* FieldFontResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B5450012FC9600000000044 /* FieldFontResolver.swift */; }; 4B5450012FC9600000000047 /* SystemWordRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B5450012FC9600000000046 /* SystemWordRecognizer.swift */; }; + 4B5450012FC9600000000902 /* SpellingLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B5450012FC9600000000901 /* SpellingLanguage.swift */; }; 4B5450012FC9600000000501 /* CorrectionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B5450012FC9600000000500 /* CorrectionController.swift */; }; 4B5450012FC9600000000503 /* SystemSpellcheckCorrectionDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B5450012FC9600000000502 /* SystemSpellcheckCorrectionDetector.swift */; }; 4B5450012FC9600000000051 /* LlamaModelRuntime in Frameworks */ = {isa = PBXBuildFile; productRef = 4B5450012FC9600000000050 /* LlamaModelRuntime */; }; @@ -109,6 +110,7 @@ 4B5450012FC9600000000042 /* CompletionAcceptanceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionAcceptanceController.swift; sourceTree = ""; }; 4B5450012FC9600000000044 /* FieldFontResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldFontResolver.swift; sourceTree = ""; }; 4B5450012FC9600000000046 /* SystemWordRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemWordRecognizer.swift; sourceTree = ""; }; + 4B5450012FC9600000000901 /* SpellingLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpellingLanguage.swift; sourceTree = ""; }; 4B5450012FC9600000000500 /* CorrectionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CorrectionController.swift; sourceTree = ""; }; 4B5450012FC9600000000502 /* SystemSpellcheckCorrectionDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemSpellcheckCorrectionDetector.swift; sourceTree = ""; }; 4B5450012FC9600000000049 /* KeyType.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KeyType.entitlements; sourceTree = ""; }; @@ -307,6 +309,7 @@ 4B5450012FC96000000002A2 /* CompletionReuseHistory.swift */, 4B5450012FC9600000000044 /* FieldFontResolver.swift */, 4B5450012FC9600000000046 /* SystemWordRecognizer.swift */, + 4B5450012FC9600000000901 /* SpellingLanguage.swift */, ); path = Completion; sourceTree = ""; @@ -653,6 +656,7 @@ 4B5450012FC9600000000043 /* CompletionAcceptanceController.swift in Sources */, 4B5450012FC9600000000045 /* FieldFontResolver.swift in Sources */, 4B5450012FC9600000000047 /* SystemWordRecognizer.swift in Sources */, + 4B5450012FC9600000000902 /* SpellingLanguage.swift in Sources */, 4B5450012FC9600000000501 /* CorrectionController.swift in Sources */, 4B5450012FC9600000000503 /* SystemSpellcheckCorrectionDetector.swift in Sources */, 4B5450012FC9600000000311 /* FullPromptLog.swift in Sources */, diff --git a/KeyType/Logic/Completion/SpellingLanguage.swift b/KeyType/Logic/Completion/SpellingLanguage.swift new file mode 100644 index 0000000..b87966e --- /dev/null +++ b/KeyType/Logic/Completion/SpellingLanguage.swift @@ -0,0 +1,43 @@ +// +// SpellingLanguage.swift +// KeyType +// +// Shared detected-language → installed-dictionary resolution for every `NSSpellChecker` +// consumer (typo guard, dead-end guard, output filter, correction lane). See ADR-116. +// + +import Foundation + +/// Maps a detected-language tag onto an installed spelling dictionary. +/// +/// `LanguageDetector` emits *base* tags only (`"en"`, never `"en-GB"`), and macOS's generic `"en"` +/// dictionary is US-flavoured — it flags `colour`/`realise` as misspellings. So a bare base tag is +/// first refined with the user's preferred regional variant of that language +/// (`Locale.preferredLanguages`, the same OS-derived signal ADR-089 uses for prompt style) before +/// falling back to the generic dictionary. A region-qualified request keeps exact-match priority; +/// unknown languages fall back to their base and then to `nil` (checker auto-detect), so we never +/// force the checker into a language it can't handle. +enum SpellingLanguage { + static func resolve( + _ requested: String?, + availableLanguages: [String], + preferredLanguages: [String] = Locale.preferredLanguages + ) -> String? { + guard let requested, !requested.isEmpty else { return nil } + let normalized = requested.replacingOccurrences(of: "-", with: "_") + let base = String(normalized.prefix { $0 != "_" }) + // Bare base tag → prefer the user's own regional variant of that language (e.g. detected + // "en" on an en-GB system resolves to "en_GB", not the US-flavoured "en"). + if normalized == base { + for preferred in preferredLanguages { + let variant = preferred.replacingOccurrences(of: "-", with: "_") + if variant.hasPrefix("\(base)_"), availableLanguages.contains(variant) { + return variant + } + } + } + if availableLanguages.contains(normalized) { return normalized } + if availableLanguages.contains(base) { return base } + return nil + } +} diff --git a/KeyType/Logic/Completion/SystemWordRecognizer.swift b/KeyType/Logic/Completion/SystemWordRecognizer.swift index 1bbba01..3a82dcf 100644 --- a/KeyType/Logic/Completion/SystemWordRecognizer.swift +++ b/KeyType/Logic/Completion/SystemWordRecognizer.swift @@ -76,16 +76,10 @@ struct SystemWordRecognizer: WordRecognizing, SynchronousWordRecognizing { return misspelled.location == NSNotFound } - /// Map a detected-language tag (BCP-47 `"en-US"` or NSSpellChecker `"en_US"`) onto an installed - /// dictionary, falling back to the base language and then to `nil` (auto-detect) so we never - /// force a checker into a language it can't handle. + /// Map a detected-language tag onto an installed dictionary via `SpellingLanguage` (ADR-116): + /// a bare base tag ("en") is refined with the user's preferred regional variant ("en_GB") + /// before the generic dictionary, then falls back to base and `nil` (auto-detect). private static func resolveLanguage(_ requested: String?, checker: NSSpellChecker) -> String? { - guard let requested, !requested.isEmpty else { return nil } - let normalized = requested.replacingOccurrences(of: "-", with: "_") - let available = checker.availableLanguages - if available.contains(normalized) { return normalized } - let base = String(normalized.prefix { $0 != "_" }) - if available.contains(base) { return base } - return nil + SpellingLanguage.resolve(requested, availableLanguages: checker.availableLanguages) } } diff --git a/KeyType/Logic/Correction/SystemSpellcheckCorrectionDetector.swift b/KeyType/Logic/Correction/SystemSpellcheckCorrectionDetector.swift index 6868a0b..820f7f4 100644 --- a/KeyType/Logic/Correction/SystemSpellcheckCorrectionDetector.swift +++ b/KeyType/Logic/Correction/SystemSpellcheckCorrectionDetector.swift @@ -337,13 +337,9 @@ struct SystemGrammarCorrectionDetector { } private enum SystemCorrectionLanguage { + /// Resolution shared with the completion path (`SpellingLanguage`, ADR-116) so the correction + /// lane can never "correct" a regional spelling the typo guard accepts (colour → color). static func resolve(_ requested: String?, checker: NSSpellChecker) -> String? { - guard let requested, !requested.isEmpty else { return nil } - let normalized = requested.replacingOccurrences(of: "-", with: "_") - let available = checker.availableLanguages - if available.contains(normalized) { return normalized } - let base = String(normalized.prefix { $0 != "_" }) - if available.contains(base) { return base } - return nil + SpellingLanguage.resolve(requested, availableLanguages: checker.availableLanguages) } } diff --git a/docs/05-decisions.md b/docs/05-decisions.md index 017deda..efa7850 100644 --- a/docs/05-decisions.md +++ b/docs/05-decisions.md @@ -135,6 +135,7 @@ row here.** | 113 | Route prefix-only spellcheck replacements to completion | correction/completion | | 114 | Lower spellcheck correction model margin | correction/model-runtime | | 115 | Remove aggressive correction mode | correction/settings | +| 116 | Refine bare detected-language tags with the user's regional variant | generation/correction | --- @@ -3589,3 +3590,28 @@ text. Both are now closed: detector and correction validation thresholds. - Consequences: Correction behavior is simpler to reason about and the Settings UI has one fewer safety-related toggle. Previously stored user defaults for the removed key are ignored. + +## ADR-116 — Refine bare detected-language tags with the user's regional variant + +- Date: 2026-07-10 +- Status: accepted +- Context: `LanguageDetector` (NLLanguageRecognizer) emits base tags only (`"en"`, never + `"en-GB"`), and both spell-language resolvers (`SystemWordRecognizer.resolveLanguage`, the + correction lane's `SystemCorrectionLanguage.resolve`) exact-matched that bare tag against + `NSSpellChecker.availableLanguages` — resolving to the generic `"en"` dictionary, which is + US-flavoured (empirically: `colour`/`realise`/`organise` flagged as misspellings; + `completions(forPartialWordRange:)` on `"colou"` returns 0). On a British-English system this + made the in-beam typo guard (ADR-015) drop correctly-spelled British branches, the dead-end + guard (ADR-052/056) suppress completions mid-word (`currentWordHasNoValidCompletion`), and the + correction lane liable to "correct" British spellings toward US. +- Decision: Add a shared app-target resolver (`SpellingLanguage.resolve`): when the requested tag + is a bare base tag, first look for the user's preferred regional variant of that language + (`Locale.preferredLanguages` — the same OS-derived signal ADR-089 uses for prompt style) among + the installed dictionaries; only then fall back to exact match, base, and `nil` (auto-detect). + Region-qualified requests keep exact-match priority; both existing resolvers delegate to it. +- Consequences: On an `en-GB` system, detected `"en"` now resolves to `en_GB` (verified by a + compiled harness against the live checker: British words recognised, `color`/`realize` flagged, + `"colou"` yields 20 completions). US-preference systems are unchanged (`en_US` is not an + installed dictionary; resolution falls back to `"en"` as before). Non-English languages with + regional prefs (e.g. `fr-CA`) gain the same refinement. The resolver is a pure function, ready + for a KeyTypeTests unit test once app-target tests are runnable.