▍ humdrum codex / glint v1.1.2
license AGPL-3.0

fix: spellcheck accepts common contractions (TASK-042)

01d8b00b071672a29b38fca3584baebdeb969ba8
humdrum <me@humdrum.me> · 2026-07-14 10:05

parent 25666a37

fix: spellcheck accepts common contractions (TASK-042)

The wordlist stores no apostrophe forms, and Known() only trimmed a
trailing possessive, so everyday contractions (isn't, won't, I'd,
they've) were flagged as misspellings. Add a curated builtin set of
~110 common contractions checked in Known(), plus curly-apostrophe
normalization so "isn't" and "isn't" both match. Case-insensitive;
fakes like "qwerty'll" stay flagged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

4 files changed

- → Spellcheck-accept-common-contractions.md +27 −0
@@ -0,0 +1,27 @@
+---
+id: TASK-042
+title: 'Spellcheck: accept common contractions'
+status: "\U0001F3C1 Done"
+assignee: []
+created_date: '2026-07-14 02:44'
+updated_date: '2026-07-14 02:45'
+labels:
+  - bug
+dependencies: []
+priority: high
+ordinal: 41000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Spellchecker flagged everyday contractions (isn't, won't, I'd, they've, y'all) because the wordlist stores no apostrophe forms and Known() only trimmed possessive 's/'. Fix: curated builtin contraction set (internal/spell/contractions.go) checked in Known(), plus curly->straight apostrophe normalization so 'isn't' and 'isn’t' both match. Kills the manual-add annoyance.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Common n't/'d/'ll/'re/'ve/'m/'s contractions not flagged
+- [ ] #2 Case-insensitive and curly-apostrophe forms accepted
+- [ ] #3 Non-contraction apostrophe tokens (qwerty'll) still flagged
+- [ ] #4 Tests cover the above
+<!-- AC:END -->
internal/spell/contractions.go +42 −0
@@ -0,0 +1,42 @@
+package spell
+
+// contractions is a curated set of common English contractions that the
+// frequency wordlist omits (it stores only apostrophe-free base words). Without
+// this, everyday forms like "isn't", "won't", and "I'd" would be flagged as
+// misspellings — the possessive trim in Known handles only "'s"/"'". Keys are
+// lowercase with a straight apostrophe; Known normalizes case and curly
+// apostrophes before the lookup, so "Isn't", "ISN'T", and "isn’t" all match.
+//
+// Curated rather than derived (base + enclitic): a fixed set has zero false
+// positives ("qwerty'll" stays flagged) and is trivial to reason about. Add a
+// line here when a legitimate contraction turns up missing.
+var contractions = map[string]struct{}{
+	// n't (negations)
+	"ain't": {}, "aren't": {}, "can't": {}, "couldn't": {}, "daren't": {},
+	"didn't": {}, "doesn't": {}, "don't": {}, "hadn't": {}, "hasn't": {},
+	"haven't": {}, "isn't": {}, "mightn't": {}, "mustn't": {}, "needn't": {},
+	"oughtn't": {}, "shan't": {}, "shouldn't": {}, "wasn't": {}, "weren't": {},
+	"won't": {}, "wouldn't": {},
+	// 'd (had / would)
+	"i'd": {}, "you'd": {}, "he'd": {}, "she'd": {}, "it'd": {}, "we'd": {},
+	"they'd": {}, "who'd": {}, "that'd": {}, "there'd": {}, "how'd": {},
+	// 'll (will / shall)
+	"i'll": {}, "you'll": {}, "he'll": {}, "she'll": {}, "it'll": {}, "we'll": {},
+	"they'll": {}, "who'll": {}, "that'll": {}, "there'll": {},
+	// 're (are)
+	"you're": {}, "we're": {}, "they're": {}, "who're": {}, "there're": {},
+	"that're": {}, "what're": {},
+	// 've (have)
+	"i've": {}, "you've": {}, "we've": {}, "they've": {}, "who've": {},
+	"would've": {}, "could've": {}, "should've": {}, "might've": {},
+	"must've": {}, "there've": {},
+	// 'm (am)
+	"i'm": {},
+	// 's (is / has / us) — many already ride the possessive trim, listed here
+	// so the intent is explicit and case/curly normalization is uniform.
+	"it's": {}, "he's": {}, "she's": {}, "that's": {}, "there's": {},
+	"here's": {}, "what's": {}, "who's": {}, "let's": {}, "how's": {},
+	"where's": {}, "when's": {}, "why's": {}, "one's": {}, "she'd've": {},
+	// miscellaneous
+	"y'all": {}, "o'clock": {}, "ma'am": {}, "'tis": {}, "'twas": {},
+}
internal/spell/dict.go +8 −0
@@ -67,10 +67,18 @@ // possessive ('s) or a simple plural/inflection so "editor's" and "editors"
 // ride on "editor".
 func (d *Dict) Known(word string) bool {
 	w := strings.ToLower(strings.TrimSpace(word))
+	// Normalize curly apostrophes to straight so contraction and possessive
+	// lookups don't depend on which quote style the buffer uses.
+	w = strings.ReplaceAll(w, "’", "'")
 	if w == "" {
 		return true
 	}
 	if d.has(w) {
+		return true
+	}
+	// Common contraction (isn't, I'd, y'all): the wordlist stores no
+	// apostrophe forms, so accept from a curated set.
+	if _, ok := contractions[w]; ok {
 		return true
 	}
 	// Possessive: trim a trailing 's or ' and re-check the base.
internal/spell/dict_test.go +30 −0
@@ -48,3 +48,33 @@ 			t.Errorf("Known(%q) = false, want true (possessive of known word)", w)
 		}
 	}
 }
+
+func TestKnownContractions(t *testing.T) {
+	d := loadT(t)
+	// Common English contractions are known regardless of case or apostrophe
+	// style (straight or curly), and are not flagged as misspellings.
+	cases := []string{
+		"isn't", "won't", "don't", "can't", "didn't", "doesn't", "wouldn't",
+		"couldn't", "shouldn't", "aren't", "wasn't", "weren't", "hasn't", "haven't",
+		"I'd", "I'll", "I'm", "I've", "you're", "we're", "they're", "they've",
+		"it's", "that's", "there's", "who's", "let's", "he'll", "she'd",
+		"y'all", "o'clock",
+		"ISN'T", "Don't", // case-insensitive
+		"isn’t", "I’d",   // curly apostrophe
+	}
+	for _, w := range cases {
+		if !d.Known(w) {
+			t.Errorf("Known(%q) = false, want true (contraction)", w)
+		}
+	}
+}
+
+func TestUnknownFakeContractions(t *testing.T) {
+	d := loadT(t)
+	// Apostrophe tokens that aren't real contractions still get flagged.
+	for _, w := range []string{"qwerty'll", "teh's", "xyzzy'd"} {
+		if d.Known(w) {
+			t.Errorf("Known(%q) = true, want false (not a real contraction)", w)
+		}
+	}
+}