Spell check: fewer false typos (programming terms, British spellings, possessives) (#113)

* feat(spell): Fewer false typos: programming terms, British spellings, possessives

- Bundle ~8.7k programming terms, tool and library names from cspell-dicts (MIT),
  plus a hand-kept list of tool names, file names and abbreviations. These pass on
  their own but never as part of an identifier compound.
- Accept en_GB spellings (colour, behaviour, normalise, cancelled) alongside en_US.
- Possessives of known words pass (backend's); programming terms take -ed / -ing /
  -able endings (stringified, memoizing).
- Plurals of terms no longer accept doubled or already-inflected forms (filess,
  contextes, checkings), and agent nouns aren't built from abbreviations (perfer).
- A word split from a known term is checked as the whole term (IntelliJ, PostgreSQL).
- Settings › Natural Languages lists English (USA, UK).

* chore: Regenerate bindings
This commit is contained in:
l0ng-ai authored and GitHub committed 2026-10-28 11:26:45 +08:00
1 parent 44b021392a
commit 75b5f6f972
10 files changed
+10447 -43

No files matched your search

+12 -2
View File
@@ -79,15 +79,25 @@ user's app-data directory only when the user asks; none of them ships with OpenI
unmodified, and its source is available from the repository above.
- **Eclipse JDT Language Server** (jdtls): EPL-2.0, from download.eclipse.org.
## en_US Hunspell dictionary
## en_US / en_GB Hunspell dictionaries
`crates/ide-spell/src/dict/en_US.{aff,dic}` (the spell checker's bundled dictionary) is the
SCOWL-derived en_US Hunspell dictionary (version 2020.12.07, from the `dictionary-en` npm
package 4.0.0), copied unmodified. Copyright Kevin Atkinson and the SCOWL / Hunspell
dictionary contributors; MIT- and BSD-style licences, full text in
`crates/ide-spell/src/dict/LICENSE-en_US`. It is checked with the
`crates/ide-spell/src/dict/LICENSE-en_US`. `crates/ide-spell/src/dict/en_GB-extra.dic` holds the
entries of the matching en_GB dictionary (`dictionary-en-gb` npm package 3.0.0, same SCOWL
version, licence and affix file) that en_US lacks. They are checked with the
[spellbook](https://github.com/helix-editor/spellbook) crate (MPL-2.0).
## cspell-dicts word lists
`crates/ide-spell/src/dict/cspell-terms.txt` (programming terms the spell checker accepts)
is derived from the software-terms, typescript, node, rust, fullstack, golang, java, cpp,
shell and css dictionaries of [cspell-dicts](https://github.com/streetsidesoftware/cspell-dicts)
(October 2026): lowercased, filtered and merged by OpenIDEA. Copyright Street Side Software;
MIT licence, full text in `crates/ide-spell/src/dict/LICENSE-cspell-dicts`.
## nvim-treesitter queries
`crates/ide-syntax/queries/<grammar>/locals.scm` are derived from the `locals.scm`
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ide-spell"
description = "Spell checking (Proofreading › Typo): word extraction from comments, strings and declared names, Hunspell en_US dictionary, user dictionaries"
description = "Spell checking (Proofreading › Typo): word extraction from comments, strings and declared names, Hunspell en_US / en_GB dictionary, programming terms, user dictionaries"
version.workspace = true
edition.workspace = true
license.workspace = true
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2017-2025 Street Side Software <[email protected]>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+246
View File
@@ -0,0 +1,246 @@
# Tool and file names, abbreviations and words common in code the other lists lack.
# Accepted on their own, never as part of a compound (`conf` would pass `conflift`).
# One lowercase word per line; checked case-insensitively.
accel
acked
adoc
adoptium
allman
askpass
assignees
avif
basedpyright
bytea
canceller
cand
cands
cjs
closable
clsx
codelldb
colima
commitish
condvar
conf
conftest
conns
cte
ctes
cts
customizations
dap
ddl
debuggee
decapitalize
deemphasize
defmodule
desync
dialogs
dic
disassembler
dists
dlsym
dlv
dnd
dup
emmet
envrc
eslintrc
expando
exs
fallbacks
fixup
fori
gdscript
gdshader
gfm
gitattributes
gitoxide
gix
glyphs
gomod
gopath
gopls
goroot
gpgsign
gradle
gradlew
graphql
guice
hamcrest
haspopup
hcl
hotswap
htdocs
hxx
ideavimrc
ime
inflight
inlines
inplace
integrations
intelephense
interp
inv
ipynb
jacoco
javac
javaw
javax
jdt
jdtls
jdwp
jenkinsfile
jre
jsdom
jshell
jsonl
kapt
kotlinc
kts
labelledby
latencies
lcov
lemminx
lexically
lezer
linkify
lldb
lockb
logpoint
logpoints
lombok
lossily
lossy
mapleader
matview
memmem
menlo
menuitemcheckbox
mjs
msvc
mts
mvn
mvnw
natively
nextest
nocapture
nodelay
nonmatching
noopener
noreferrer
noremap
notif
notnull
nvm
ord
osgi
pathing
pdm
pipfile
pred
preds
prunable
psvm
publickey
pwa
pyc
pycache
pyenv
pyi
pylsp
pyvenv
recency
recents
redb
redecl
reformats
reinstalls
rekey
replit
rerunnable
rescan
restorable
retarget
rfind
rollup
rsplit
rsplitn
rss
rst
runserver
sbt
scm
sdkman
segs
sel
severities
shorthands
sig
sigil
solarized
sout
specta
splitn
storable
stty
subclassed
subsequence
subsequences
subtest
subtests
supabase
sym
syms
taplo
tauri
tcpip
tcsh
temurin
texlab
tgz
timescaledb
timestamptz
toks
topo
tpl
tsdk
tsv
unclosed
unformatted
unlisten
unmarking
unmatch
unminimize
unqueue
unshelve
unsplit
unsub
unsubs
untag
untagged
uptime
vcs
vendored
vimrc
visibilities
vite
vitest
vmargs
volar
vsix
vtsls
wasi
wasmtime
webflux
webmanifest
wip
xcrun
xdebug
zig
zlogin
zls
zprofile
zshenv
zshrc
zustand
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+143 -36
View File
@@ -1,6 +1,6 @@
//! Spell checking for IDEA's "Proofreading › Typo" inspection: words of
//! comments, string literals and declared names (regions come from the
//! syntax layer), checked against the bundled en_US Hunspell dictionary, a
//! syntax layer), checked against the bundled en_US / en_GB Hunspell dictionary, a
//! list of programming terms and the user's accepted words and dictionaries.
//! Regular agent nouns of known words (`checkpointer`) pass everywhere; in
//! identifiers so do closed compounds of known words (`codebase`).
@@ -98,12 +98,17 @@ impl SpellScope {
/// At most this many typos are reported per text.
pub const MAX_TYPOS: usize = 2000;
/// The en_GB entries en_US lacks (`colour`, `behaviour`, `normalise`), in the
/// same affix scheme: either spelling passes.
const EN_GB_EXTRA: &str = include_str!("dict/en_GB-extra.dic");
/// The en_US dictionary plus [`EN_GB_EXTRA`].
fn english() -> &'static spellbook::Dictionary {
static DICT: OnceLock<spellbook::Dictionary> = OnceLock::new();
DICT.get_or_init(|| {
let aff = include_str!("dict/en_US.aff");
let dic = include_str!("dict/en_US.dic");
spellbook::Dictionary::new(aff, dic).expect("bundled en_US dictionary parses")
let dic = format!("{}{EN_GB_EXTRA}", include_str!("dict/en_US.dic"));
spellbook::Dictionary::new(aff, &dic).expect("bundled en dictionary parses")
})
}
@@ -114,6 +119,7 @@ fn lemmas() -> &'static HashSet<&'static str> {
include_str!("dict/en_US.dic")
.lines()
.skip(1)
.chain(EN_GB_EXTRA.lines())
.map(|l| l.split('/').next().unwrap_or(l).trim())
.filter(|w| !w.is_empty() && w.chars().all(|c| c.is_ascii_lowercase()))
.collect()
@@ -136,13 +142,12 @@ fn doubles_final_consonant(stem: &str) -> bool {
}
}
/// Whether dropping one letter or swapping two adjacent ones gives a dictionary
/// Whether dropping one letter or swapping two adjacent ones gives a `known`
/// word: then a compound (`writ`+`ting`) is more likely a typo of
/// it (`writing`), as Hunspell's CHECKCOMPOUNDREP reasons.
fn near_word(lower: &str) -> bool {
fn near_word(lower: &str, known: impl Fn(&str) -> bool) -> bool {
let b = lower.as_bytes();
let dict = english();
let check = |v: &[u8]| std::str::from_utf8(v).is_ok_and(|w| dict.check(w));
let check = |v: &[u8]| std::str::from_utf8(v).is_ok_and(&known);
(0..b.len()).any(|i| check(&[&b[..i], &b[i + 1..]].concat()))
|| (0..b.len().saturating_sub(1)).any(|i| {
let mut v = b.to_vec();
@@ -151,23 +156,41 @@ fn near_word(lower: &str) -> bool {
})
}
fn software_terms() -> &'static HashSet<&'static str> {
fn term_list(text: &'static str) -> HashSet<&'static str> {
text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')).collect()
}
/// Hand-picked programming terms: short ones (`url`) may be part of a compound.
fn curated_terms() -> &'static HashSet<&'static str> {
static TERMS: OnceLock<HashSet<&'static str>> = OnceLock::new();
TERMS.get_or_init(|| term_list(include_str!("dict/software.txt")))
}
/// Imported programming terms, tool and library names, and abbreviations:
/// never part of a compound.
fn other_terms() -> &'static HashSet<&'static str> {
static TERMS: OnceLock<HashSet<&'static str>> = OnceLock::new();
TERMS.get_or_init(|| {
include_str!("dict/software.txt")
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.collect()
let mut terms = term_list(include_str!("dict/cspell-terms.txt"));
terms.extend(term_list(include_str!("dict/code-words.txt")));
terms
})
}
/// A software term, or its plural (`endpoints`, `regexes`).
fn term(lower: &str) -> bool {
curated_terms().contains(lower) || other_terms().contains(lower)
}
/// A software term, or the plural of one Hunspell doesn't know (`endpoints`,
/// `regexes`; not `filess`, `contextes`, `checkings`). [`other_terms`] need
/// 4+ letters for that (`acses` stays flagged).
fn software_term(lower: &str) -> bool {
let terms = software_terms();
terms.contains(lower)
|| lower.strip_suffix('s').is_some_and(|w| terms.contains(w))
|| lower.strip_suffix("es").is_some_and(|w| terms.contains(w))
let plural_of = |w: &str| (curated_terms().contains(w) || w.len() >= MIN_PART && term(w)) && !english().check(w);
term(lower)
|| lower.strip_suffix('s').is_some_and(|w| !w.ends_with('s') && plural_of(w))
|| lower
.strip_suffix("es")
.is_some_and(|w| ["s", "x", "z", "ch", "sh"].iter().any(|e| w.ends_with(e)) && plural_of(w))
}
/// Words of a custom dictionary file: a plain word list or a Hunspell `.dic`
@@ -224,11 +247,15 @@ impl Speller {
/// Whether `word` (of prose, a comment or a string) is spelt correctly.
/// Case is forgiven the way identifiers need it: `url` passes for `URL`,
/// `paris` for `Paris`. Regular agent nouns of known words pass too
/// (`checkpointer`, `serializers`).
/// `paris` for `Paris`. Possessives (`backend's`), regular agent nouns
/// (`checkpointer`, `serializers`) and `-ed` / `-ing` / `-able` forms of
/// programming terms (`stringified`) pass too.
pub fn is_correct(&self, word: &str) -> bool {
if let Some(base) = word.strip_suffix("'s") {
return self.is_correct(base);
}
let lower = word.to_lowercase();
self.listed(word, &lower) || self.agent_noun(&lower)
self.listed(word, &lower) || self.agent_noun(&lower) || self.term_form(&lower)
}
/// [`Self::is_correct`] for a part of an identifier, which may also be two
@@ -261,30 +288,63 @@ impl Speller {
let singular = lower.strip_suffix('s').unwrap_or(lower);
let Some(before_r) = singular.strip_suffix('r') else { return false };
let Some(before_er) = before_r.strip_suffix('e') else { return false };
// (stem, root the `-or` noun would be built on)
let mut stems = vec![(before_r.to_string(), before_er)];
let mut stems = vec![before_r.to_string()];
if let Some(y) = before_er.strip_suffix('i') {
stems.push((format!("{y}y"), before_er));
stems.push(format!("{y}y"));
} else if !before_er.ends_with('e') && !doubles_final_consonant(before_er) {
stems.push((before_er.to_string(), before_er));
stems.push(before_er.to_string());
}
stems.into_iter().any(|(stem, root)| {
stem.len() >= MIN_PART
&& (lemmas().contains(stem.as_str()) || self.user_word(&stem))
&& !english().check(&format!("{root}or"))
})
let or_noun = format!("{before_er}or");
stems
.into_iter()
.any(|stem| stem.len() >= MIN_PART && (lemmas().contains(stem.as_str()) || self.term_stem(&stem)))
&& !self.listed(&or_noun, &or_noun)
}
/// A programming term or accepted word of 5+ letters Hunspell doesn't know
/// (`stringify`; not `calls` or `calc`), to derive other words from.
fn term_stem(&self, s: &str) -> bool {
s.len() > MIN_PART && self.user_word(s) && !english().check(s)
}
/// `<term>ed` / `<term>ing` / `<term>able` of a programming term or an
/// accepted word of 5+ letters: `stringified` (stringify), `memoizing`
/// (memoize), `upserted`. Dictionary words only take the endings Hunspell
/// knows: `falled`, `quiting` stay flagged. Not when one letter off a known
/// word (`preprocesssing`).
fn term_form(&self, lower: &str) -> bool {
if !lower.bytes().all(|c| c.is_ascii_lowercase()) {
return false;
}
let stem = |s: &str| self.term_stem(s);
let derived = ["ed", "ing", "able"].into_iter().any(|suffix| {
let Some(root) = lower.strip_suffix(suffix) else { return false };
let b = root.as_bytes();
(stem(root) && !doubles_final_consonant(root))
|| stem(&format!("{root}e"))
|| (suffix != "ing" && root.strip_suffix('i').is_some_and(|y| stem(&format!("{y}y"))))
|| (b.len() > 2 && b[b.len() - 1] == b[b.len() - 2] && stem(&root[..root.len() - 1]))
});
derived && !near_word(lower, |w| english().check(w) || self.user_word(w))
}
/// Two known words run together, each at least [`MIN_PART`] letters (or a
/// 3-letter programming term: `urlpath`). Proper nouns don't count.
/// hand-picked or accepted 3-letter term: `urlpath`). Proper nouns and
/// [`other_terms`] don't count (`guid` + `lines` would pass `guidlines`).
fn compound(&self, lower: &str) -> bool {
if lower.len() < 6 || !lower.bytes().all(|c| c.is_ascii_lowercase()) || NOT_COMPOUNDS.contains(&lower) {
return false;
}
let part = |p: &str| {
(p.len() >= 3 && self.user_word(p)) || (p.len() >= MIN_PART && (english().check(p) || self.agent_noun(p)))
let known = |p: &str| {
self.accepted.contains(p) || curated_terms().contains(p) || self.custom.iter().any(|d| d.contains(p))
};
(3..=lower.len() - 3).any(|i| part(&lower[..i]) && part(&lower[i..])) && !near_word(lower)
let part = |p: &str| match p.len() {
0..3 => false,
3 => known(p),
_ => known(p) || english().check(p) || self.agent_noun(p),
};
(3..=lower.len() - 3).any(|i| part(&lower[..i]) && part(&lower[i..]))
&& !near_word(lower, |w| english().check(w))
}
/// Replacements for `word`, best first, in the word's case style.
@@ -301,6 +361,14 @@ impl Speller {
out
}
/// Whether the letter run `w` was split from is a known term as a whole:
/// `IntelliJ` (`Intelli` + `J`), `PostgreSQL` (`Postgre` + `SQL`).
fn known_run(&self, text: &str, w: &WordSpan) -> bool {
let start = text[..w.start].rfind(|c: char| !c.is_alphabetic()).map_or(0, |i| i + 1);
let end = text[w.end..].find(|c: char| !c.is_alphabetic()).map_or(text.len(), |i| w.end + i);
(start, end) != (w.start, w.end) && self.user_word(&text[start..end].to_lowercase())
}
/// Misspelt words of `regions` (document order) in `text`; `None` when
/// `cancelled` fires.
pub fn check(&self, text: &str, regions: &[Region], cancelled: &dyn Fn() -> bool) -> Option<Vec<Typo>> {
@@ -319,7 +387,7 @@ impl Speller {
let ok = *verdicts
.entry((word, name))
.or_insert_with(|| if name { self.is_correct_identifier(word) } else { self.is_correct(word) });
if !ok {
if !ok && !self.known_run(text, w) {
bad.push((*w, r.kind));
}
}
@@ -451,6 +519,45 @@ mod tests {
for ok in ["breakpoint", "breakpoints", "endpoints", "datasets", "regexes"] {
assert!(s.is_correct(ok), "{ok}");
}
for ok in
["stringify", "instanceof", "keydown", "submenu", "truthy", "vitest", "gradle", "unstaged", "rebasing"]
{
assert!(s.is_correct(ok), "{ok}");
}
for bad in ["filess", "contextes", "checkings", "acses"] {
assert!(!s.is_correct(bad), "{bad}");
}
}
#[test]
fn british_spellings() {
let s = Speller::default();
for ok in ["colour", "behaviours", "normalised", "centred", "analyse", "cancelled", "dialogs"] {
assert!(s.is_correct(ok), "{ok}");
}
for bad in ["colur", "behavour", "normalsed"] {
assert!(!s.is_correct(bad), "{bad}");
}
}
#[test]
fn split_terms_checked_whole() {
let s = Speller::default();
let text = "// Open IntelliJ or PostgreSQL, not Intelli alone";
let words: Vec<_> =
s.check(text, &whole(text, RegionKind::Comment), &|| false).unwrap().into_iter().map(|t| t.word).collect();
assert_eq!(words, ["Intelli"]);
}
#[test]
fn possessives_and_term_forms() {
let s = Speller::default();
for ok in ["backend's", "commit's", "stringified", "memoizing", "upserted"] {
assert!(s.is_correct(ok), "{ok}");
}
for bad in ["recieve's", "falled", "quiting", "preprocesssing", "perfer", "callser"] {
assert!(!s.is_correct(bad), "{bad}");
}
}
#[test]
@@ -471,7 +578,7 @@ mod tests {
for ok in ["filepath", "dataframe", "username", "codebase", "pathname", "urlpath", "timestampfile"] {
assert!(s.is_correct_identifier(ok), "{ok}");
}
assert!(!s.is_correct("dataframe"));
assert!(!s.is_correct("timestampfile"));
for bad in ["eachother", "everytime", "publically", "mesage", "lenght", "recieve", "comming", "withold"] {
assert!(!s.is_correct_identifier(bad), "{bad}");
}
+1 -1
View File
@@ -1073,7 +1073,7 @@ export type AgentTask = {
branch: string | null,
/** Branch (or revision) the worktree was created from. */
base: string | null,
/** Commit the task's changes are compared against. */
/** Commit the task's changes are compared against (worktree tasks; local ones use `HEAD`). */
baseCommit: string | null,
/** Why the task failed, when it did. */
error: string | null,
@@ -15,7 +15,10 @@ import styles from "../../components/Form.module.css";
function NaturalLanguagesPage() {
return (
<Section title="Languages">
<Row label="English (USA)" hint="Bundled Hunspell dictionary (SCOWL en_US) and common programming terms.">
<Row
label="English (USA, UK)"
hint="Bundled Hunspell dictionaries (SCOWL en_US and en_GB) and common programming terms."
>
<span className={styles.hint}>
<T>Used by Proofreading › Typo</T>
</span>
+2 -2
View File
@@ -3014,8 +3014,8 @@
"Reports tables, columns and aliases in query consoles that the data source's schema does not contain.": "报告查询控制台中数据源架构不包含的表、列和别名。",
"Proofreading": "校对",
"Reports misspelt words in comments, string literals and the names declarations introduce (camelCase and snake_case parts checked separately), and in plain text and Markdown. Agent nouns of known words (checkpointer) and, in names, two known words run together (filepath) are accepted. Words can be saved to the project or application dictionary (Editor › Natural Languages › Spelling). Correcting a name renames the symbol and its references.": "报告注释、字符串字面量和声明引入的名称(camelCase 和 snake_case 的各部分分别检查)以及纯文本和 Markdown 中拼写错误的单词。已知单词的施动名词(checkpointer)以及名称中连写的两个已知单词(filepath)视为正确。单词可以保存到项目词典或应用程序词典(编辑器 › 自然语言 › 拼写)。更正名称会重命名该符号及其引用。",
"English (USA)": "英语(美国)",
"Bundled Hunspell dictionary (SCOWL en_US) and common programming terms.": "捆绑的 Hunspell 词典 (SCOWL en_US) 和常用编程术语。",
"English (USA, UK)": "英语(美国、英国)",
"Bundled Hunspell dictionaries (SCOWL en_US and en_GB) and common programming terms.": "捆绑的 Hunspell 词典 (SCOWL en_US 和 en_GB) 和常用编程术语。",
"Used by Proofreading › Typo": "由“校对 › 拼写错误”使用",
"Accepted words": "已接受的单词",
"Project dictionary": "项目词典",