Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions KeyType.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -109,6 +110,7 @@
4B5450012FC9600000000042 /* CompletionAcceptanceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionAcceptanceController.swift; sourceTree = "<group>"; };
4B5450012FC9600000000044 /* FieldFontResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldFontResolver.swift; sourceTree = "<group>"; };
4B5450012FC9600000000046 /* SystemWordRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemWordRecognizer.swift; sourceTree = "<group>"; };
4B5450012FC9600000000901 /* SpellingLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpellingLanguage.swift; sourceTree = "<group>"; };
4B5450012FC9600000000500 /* CorrectionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CorrectionController.swift; sourceTree = "<group>"; };
4B5450012FC9600000000502 /* SystemSpellcheckCorrectionDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemSpellcheckCorrectionDetector.swift; sourceTree = "<group>"; };
4B5450012FC9600000000049 /* KeyType.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KeyType.entitlements; sourceTree = "<group>"; };
Expand Down Expand Up @@ -307,6 +309,7 @@
4B5450012FC96000000002A2 /* CompletionReuseHistory.swift */,
4B5450012FC9600000000044 /* FieldFontResolver.swift */,
4B5450012FC9600000000046 /* SystemWordRecognizer.swift */,
4B5450012FC9600000000901 /* SpellingLanguage.swift */,
);
path = Completion;
sourceTree = "<group>";
Expand Down Expand Up @@ -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 */,
Expand Down
43 changes: 43 additions & 0 deletions KeyType/Logic/Completion/SpellingLanguage.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
14 changes: 4 additions & 10 deletions KeyType/Logic/Completion/SystemWordRecognizer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
26 changes: 26 additions & 0 deletions docs/05-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---

Expand Down Expand Up @@ -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.