Coverage for vibefpmgui/spelling.py: 93%
144 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
1"""Spell checking the editor's comments and strings, with pyspellchecker.
3Only prose is checked: the comments and strings the syntax highlighter
4found (all of a plain text or Markdown file). Words that look like code are
5skipped: with digits, underscores or dots in them, ALL CAPS, camelCase, and
6any word the file uses as a name or keyword. Fortran keywords missing from
7the dictionary (`FORTRAN_WORDS`) and the user's own dictionary
8(`user_words`) count as spelled right.
9"""
11from __future__ import annotations
13import re
14from bisect import bisect_right
15from functools import cache
16from pathlib import Path
17from typing import NamedTuple
19try:
20 from spellchecker import SpellChecker
21except ImportError: # (optional: no spell checking without it)
22 SpellChecker = None
24MAX_ISSUES = 2000 # per file (a file in another language would flag every word)
25MAX_SUGGESTIONS = 5
26LONG_WORD = 10 # longer than this: suggestions one edit away only (two is slow for long words)
28WORDS_PATH = Path(__file__).with_name("resources") / "spelling-words.txt"
31def _read_words(path: Path) -> set[str]:
32 try:
33 lines = path.read_text(encoding="utf-8").splitlines()
34 except OSError:
35 return set()
36 return {line.strip().lower() for line in lines if line.strip() and not line.startswith("#")}
39# Fortran keywords pyspellchecker's dictionary doesn't have ("enddo", "namelist"…).
40FORTRAN_WORDS = _read_words(WORDS_PATH)
42user_words: set[str] = set() # the user's dictionary (lowercase), kept in the settings by the window
45def available() -> bool:
46 return SpellChecker is not None
49# pyspellchecker's dictionaries (see `languages`), by their codes.
50LANGUAGE_NAMES = {"en": "English", "es": "Spanish", "fr": "French", "it": "Italian", "pt": "Portuguese",
51 "de": "German", "ru": "Russian", "ar": "Arabic", "lv": "Latvian", "eu": "Basque",
52 "nl": "Dutch"}
53language = "en" # the dictionary in use (View → Spelling Language; see set_language)
56def languages() -> list[str]:
57 """The codes of the dictionaries pyspellchecker has (none without it)."""
58 return list(SpellChecker.languages()) if available() else []
61def set_language(code: str):
62 """Check with another language's dictionary (from the next check on)."""
63 global language
64 if code == language or code not in languages():
65 return
66 language = code
67 _known.clear()
68 suggestions.cache_clear()
71@cache
72def _checker(code: str = "en"):
73 return SpellChecker(language=code)
76@cache
77def _short_checker(code: str = "en"):
78 return SpellChecker(language=code, distance=1)
81_known: dict[str, bool] = {} # lowercase word -> in the dictionary
84def is_known(word: str) -> bool:
85 w = word.lower()
86 if w in user_words or w in FORTRAN_WORDS:
87 return True
88 known = _known.get(w)
89 if known is None:
90 known = _known[w] = bool(_checker(language).known([w]))
91 return known
94@cache
95def suggestions(word: str) -> tuple[str, ...]:
96 """Likely corrections, most likely first, in the word's capitalization."""
97 if not available(): 97 ↛ 98line 97 didn't jump to line 98 because the condition on line 97 was never true
98 return ()
99 w = word.lower()
100 found = _short_checker(language).candidates(w) or set()
101 if not found and len(w) <= LONG_WORD: 101 ↛ 102line 101 didn't jump to line 102 because the condition on line 101 was never true
102 found = _checker(language).candidates(w) or set()
103 found.discard(w)
104 ranked = sorted(found, key=lambda c: (-_checker(language).word_usage_frequency(c), c))[:MAX_SUGGESTIONS]
105 if word[:1].isupper():
106 ranked = [c[:1].upper() + c[1:] for c in ranked]
107 return tuple(ranked)
110class SpellingIssue(NamedTuple):
111 """A word that isn't in the dictionary, shown like fortitude's issues."""
112 line: int # 1-based
113 column: int # 1-based
114 end_line: int
115 end_column: int
116 word: str
117 code: str = "spelling"
118 fix: str = ""
119 fix_safe: bool = False
120 edits: tuple = ()
121 source: str = "spelling"
123 @property
124 def message(self) -> str:
125 return f"“{self.word}” isn't in the dictionary"
127 @property
128 def severity(self) -> str:
129 return "spelling"
131 @property
132 def fixable(self) -> bool:
133 return False
135 def suggestions(self) -> tuple[str, ...]:
136 return suggestions(self.word)
138 def describe(self) -> str:
139 return f"spelling: {self.message}"
142# A comment that's really an instruction to the compiler: OpenMP, OpenACC,
143# compiler directives ("!$omp parallel do", "!DIR$ IVDEP", "!GCC$ ATTRIBUTES").
144DIRECTIVE_RE = re.compile(r"^\s*[!cC*]\s*(\$|dir\$|dec\$|gcc\$|ibm\*|acc\$|pgi\$)", re.IGNORECASE)
145CHUNK_RE = re.compile(r"\S+")
146EDGE = "\"'`‘’“”()[]{}<>,.;:!?*~#|=+&^%$@\\/"
147WORD_CHARS = re.compile(r"^[^\W\d_]+(?:['’][^\W\d_]+)*$") # (letters of any language)
150def _words(segment: str):
151 """(offset, word) of each word in a piece of prose worth checking."""
152 for m in CHUNK_RE.finditer(segment):
153 chunk = m.group()
154 start = m.start()
155 stripped = chunk.lstrip(EDGE)
156 start += len(chunk) - len(stripped)
157 stripped = stripped.rstrip(EDGE)
158 offset = start
159 for part in stripped.split("-"): # ("well-known": both halves)
160 word = part
161 for suffix in ("'s", "’s"):
162 if word.endswith(suffix):
163 word = word[:-2]
164 if (len(word) >= 3 and WORD_CHARS.match(word) and not word.isupper()
165 and not any(c.isupper() for c in word[1:])): # (not ALL CAPS or camelCase)
166 yield offset, word
167 offset += len(part) + 1
170# A comment's or string's words not in the dictionary, by its text: checked
171# again after each edit, a big file's tens of thousands of them took a
172# quarter of a second, though few had changed. (None: the dictionary they're for.)
173_segments: dict = {}
174MAX_SEGMENTS = 200_000
177def _unknown_words(segment: str) -> tuple[tuple[int, str], ...]:
178 if DIRECTIVE_RE.match(segment):
179 return ()
180 return tuple((offset, word) for offset, word in _words(segment) if not is_known(word))
183def check(text: str, prose: list[tuple[int, int]], names: set[str] = frozenset()) -> list[SpellingIssue]:
184 """The misspelled words in ``text``'s ``prose`` ((position, length) of
185 each comment or string). ``names``: words the file uses as names
186 or keywords (lowercase), which aren't misspellings."""
187 if not available(): 187 ↛ 188line 187 didn't jump to line 188 because the condition on line 187 was never true
188 return []
189 state = (language, frozenset(user_words))
190 if _segments.get(None) != state: # (another dictionary: what's known changed)
191 _segments.clear()
192 _segments[None] = state
193 found: list[tuple[int, str]] = []
194 for pos, length in prose:
195 segment = text[pos:pos + length]
196 unknown = _segments.get(segment)
197 if unknown is None:
198 if len(_segments) > MAX_SEGMENTS: 198 ↛ 199line 198 didn't jump to line 199 because the condition on line 198 was never true
199 _segments.clear()
200 _segments[None] = state
201 unknown = _segments[segment] = _unknown_words(segment)
202 for offset, word in unknown:
203 if word.lower() not in names:
204 found.append((pos + offset, word))
205 if len(found) >= MAX_ISSUES:
206 break
207 if len(found) >= MAX_ISSUES:
208 break
209 if not found:
210 return []
211 line_starts = [0] + [m.end() for m in re.finditer("\n", text)]
212 issues = []
213 for pos, word in found:
214 line = bisect_right(line_starts, pos)
215 column = pos - line_starts[line - 1] + 1
216 issues.append(SpellingIssue(line, column, line, column + len(word), word))
217 return issues