I'm building a macOS app that uses WKWebView for text editing (not NSTextView). I need to provide grammar checking by calling NSSpellChecker programmatically and sending results back to the web editor.
The problem: TextEdit (which uses NSTextView) catches grammar errors like "Can I has pie?" and "These are have" — but when I call NSSpellChecker's APIs directly, those same errors are never flagged.
I've tried both APIs:
1. The unified check() API:
let results = checker.check(
text, range: range,
types: NSTextCheckingAllTypes,
options: [:],
inSpellDocumentWithTag: tag,
orthography: &orthography,
wordCount: &wordCount)
This returns only .orthography results (language detection). No .spelling, no .grammar — just orthography.
2. The dedicated checkGrammar(of:startingAt:...) API:
let sentenceRange = checker.checkGrammar(
of: text,
startingAt: offset,
language: nil,
wrap: false,
inSpellDocumentWithTag: tag,
details: &details)
This catches sentence fragments ("The.", "No.") and some agreement errors ("The is anyone.") but misses "Can I has pie?", "These are have", "This will be happened", and other subject-verb agreement errors that TextEdit highlights.
What I've confirmed through systematic testing:
- "Check Grammar With Spelling" is enabled in System Settings
- TextEdit reliably catches all these errors with green underlines
- Both APIs are called with a valid
spellDocumentTagfromuniqueSpellDocumentTag() - The text is passed as plain strings (no attributed string context)
Additional experiments I've run (all failed to produce grammar results matching TextEdit):
3. Windowed NSTextView with checkText(in:range:types:options:):
I created a real NSTextView inside an NSWindow, subclassed it to override handleTextCheckingResults(_:forRange:types:options:orthography:wordCount:), and called checkText(in:range:types:options:):
class DiagnosticTextView: NSTextView {
var capturedResults: [NSTextCheckingResult] = []
override func handleTextCheckingResults(
_ results: [NSTextCheckingResult],
forRange range: NSRange,
types checkingTypes: NSTextCheckingTypes,
options: [NSSpellChecker.OptionKey: Any],
orthography: NSOrthography,
wordCount: Int
) {
capturedResults.append(contentsOf: results)
super.handleTextCheckingResults(results, forRange: range, types: checkingTypes,
options: options, orthography: orthography, wordCount: wordCount)
}
}
let window = NSWindow(
contentRect: NSRect(x: -10000, y: -10000, width: 400, height: 200),
styleMask: [.borderless], backing: .buffered, defer: false)
let textView = DiagnosticTextView(frame: NSRect(x: 0, y: 0, width: 400, height: 200))
window.contentView = textView
textView.string = "Can I has pie? The is anyone."
textView.isGrammarCheckingEnabled = true
textView.isContinuousSpellCheckingEnabled = true
let range = NSRange(location: 0, length: (textView.string as NSString).length)
textView.checkText(in: range, types: NSTextCheckingAllTypes, options: [:])
I tried this in three configurations:
- Window behind (
orderBack): Only.orthographyresults delivered tohandleTextCheckingResults. Zero grammar. - Key window + first responder (
makeKeyAndOrderFront+makeFirstResponder): Still only.orthographyresults. Even after waiting 5 seconds for the continuous checking timer to fire. - With
isGrammarCheckingEnabled = trueandisContinuousSpellCheckingEnabled = true: No change — still orthography only.
In all cases, the handleTextCheckingResults callback delivered results with resultType.rawValue == 1 (orthography) — never .grammar (rawValue 4) or .spelling (rawValue 2).
Summary of what each API returns for "Can I has pie? The is anyone. This will be happened.":
| API | "The is anyone." | "Can I has pie?" | "This will be happened." |
|---|---|---|---|
checker.check() (unified) |
orthography only | orthography only | orthography only |
checker.checkGrammar(of:) |
"may not agree" | missed | missed |
textView.checkText(in:) |
orthography only | orthography only | orthography only |
| TextEdit (reference) | flagged | flagged | flagged |
My question: How does NSTextView's grammar checking work internally in apps like TextEdit? It clearly produces results that none of these public APIs can reproduce programmatically. I'm considering:
- Does NSTextView use the
NSTextCheckingClientprotocol /requestChecking(of:range:types:options:)withNSTextCheckingControllerin a way that differs fromcheckText(in:)? My override ofhandleTextCheckingResultsshould have captured those results, but maybe the controller delivers them through a different path. - Does
NSTextCheckingControlleruse a private grammar engine that's distinct fromNSSpellChecker.checkGrammar(of:)? The fact thatcheckGrammar(of:)catches some errors but misses others (while TextEdit catches all of them) suggests there may be a second grammar pipeline. - Is there a private/undocumented API or framework (e.g., the grammar analysis in Apple's language frameworks) that NSTextView invokes for deeper analysis?
- Does TextEdit somehow configure NSTextView differently — e.g., through NSDocument integration, rich text mode, or specific user defaults — that activates a more thorough grammar checking path?
Any insight from anyone who has dug into NSTextView's internal grammar checking mechanism would be appreciated.
NOTE: This post was composed with the help of Claude Code, which I am using to help write a word-processing application, but I am frustrated because Claude Code wants to give up and switch to a 3rd party grammar checker, like LanguageTool, and it seems to me that it should be possible to use native Apple tools to achieve this goal without requiring the user to send their data elsewhere for checking. I've spent a lot of time searching the web for answers and have found surprisingly little on this. Any pointers people might have would be very much appreciated! Thanks.