vibefpmgui.spelling
Spell checking the editor's comments and strings, with pyspellchecker.
Only prose is checked: the comments and strings the syntax highlighter
found (all of a plain text or Markdown file). Words that look like code are
skipped: with digits, underscores or dots in them, ALL CAPS, camelCase, and
any word the file uses as a name or keyword. Fortran keywords missing from
the dictionary (FORTRAN_WORDS) and the user's own dictionary
(user_words) count as spelled right.
1"""Spell checking the editor's comments and strings, with pyspellchecker. 2 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""" 10 11from __future__ import annotations 12 13import re 14from bisect import bisect_right 15from functools import cache 16from pathlib import Path 17from typing import NamedTuple 18 19try: 20 from spellchecker import SpellChecker 21except ImportError: # (optional: no spell checking without it) 22 SpellChecker = None 23 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) 27 28WORDS_PATH = Path(__file__).with_name("resources") / "spelling-words.txt" 29 30 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("#")} 37 38 39# Fortran keywords pyspellchecker's dictionary doesn't have ("enddo", "namelist"…). 40FORTRAN_WORDS = _read_words(WORDS_PATH) 41 42user_words: set[str] = set() # the user's dictionary (lowercase), kept in the settings by the window 43 44 45def available() -> bool: 46 return SpellChecker is not None 47 48 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) 54 55 56def languages() -> list[str]: 57 """The codes of the dictionaries pyspellchecker has (none without it).""" 58 return list(SpellChecker.languages()) if available() else [] 59 60 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() 69 70 71@cache 72def _checker(code: str = "en"): 73 return SpellChecker(language=code) 74 75 76@cache 77def _short_checker(code: str = "en"): 78 return SpellChecker(language=code, distance=1) 79 80 81_known: dict[str, bool] = {} # lowercase word -> in the dictionary 82 83 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 92 93 94@cache 95def suggestions(word: str) -> tuple[str, ...]: 96 """Likely corrections, most likely first, in the word's capitalization.""" 97 if not available(): 98 return () 99 w = word.lower() 100 found = _short_checker(language).candidates(w) or set() 101 if not found and len(w) <= LONG_WORD: 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) 108 109 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" 122 123 @property 124 def message(self) -> str: 125 return f"“{self.word}” isn't in the dictionary" 126 127 @property 128 def severity(self) -> str: 129 return "spelling" 130 131 @property 132 def fixable(self) -> bool: 133 return False 134 135 def suggestions(self) -> tuple[str, ...]: 136 return suggestions(self.word) 137 138 def describe(self) -> str: 139 return f"spelling: {self.message}" 140 141 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) 148 149 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 168 169 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 175 176 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)) 181 182 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(): 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: 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
57def languages() -> list[str]: 58 """The codes of the dictionaries pyspellchecker has (none without it).""" 59 return list(SpellChecker.languages()) if available() else []
The codes of the dictionaries pyspellchecker has (none without it).
62def set_language(code: str): 63 """Check with another language's dictionary (from the next check on).""" 64 global language 65 if code == language or code not in languages(): 66 return 67 language = code 68 _known.clear() 69 suggestions.cache_clear()
Check with another language's dictionary (from the next check on).
95@cache 96def suggestions(word: str) -> tuple[str, ...]: 97 """Likely corrections, most likely first, in the word's capitalization.""" 98 if not available(): 99 return () 100 w = word.lower() 101 found = _short_checker(language).candidates(w) or set() 102 if not found and len(w) <= LONG_WORD: 103 found = _checker(language).candidates(w) or set() 104 found.discard(w) 105 ranked = sorted(found, key=lambda c: (-_checker(language).word_usage_frequency(c), c))[:MAX_SUGGESTIONS] 106 if word[:1].isupper(): 107 ranked = [c[:1].upper() + c[1:] for c in ranked] 108 return tuple(ranked)
Likely corrections, most likely first, in the word's capitalization.
111class SpellingIssue(NamedTuple): 112 """A word that isn't in the dictionary, shown like fortitude's issues.""" 113 line: int # 1-based 114 column: int # 1-based 115 end_line: int 116 end_column: int 117 word: str 118 code: str = "spelling" 119 fix: str = "" 120 fix_safe: bool = False 121 edits: tuple = () 122 source: str = "spelling" 123 124 @property 125 def message(self) -> str: 126 return f"“{self.word}” isn't in the dictionary" 127 128 @property 129 def severity(self) -> str: 130 return "spelling" 131 132 @property 133 def fixable(self) -> bool: 134 return False 135 136 def suggestions(self) -> tuple[str, ...]: 137 return suggestions(self.word) 138 139 def describe(self) -> str: 140 return f"spelling: {self.message}"
A word that isn't in the dictionary, shown like fortitude's issues.
Create new instance of SpellingIssue(line, column, end_line, end_column, word, code, fix, fix_safe, edits, source)
184def check(text: str, prose: list[tuple[int, int]], names: set[str] = frozenset()) -> list[SpellingIssue]: 185 """The misspelled words in ``text``'s ``prose`` ((position, length) of 186 each comment or string). ``names``: words the file uses as names 187 or keywords (lowercase), which aren't misspellings.""" 188 if not available(): 189 return [] 190 state = (language, frozenset(user_words)) 191 if _segments.get(None) != state: # (another dictionary: what's known changed) 192 _segments.clear() 193 _segments[None] = state 194 found: list[tuple[int, str]] = [] 195 for pos, length in prose: 196 segment = text[pos:pos + length] 197 unknown = _segments.get(segment) 198 if unknown is None: 199 if len(_segments) > MAX_SEGMENTS: 200 _segments.clear() 201 _segments[None] = state 202 unknown = _segments[segment] = _unknown_words(segment) 203 for offset, word in unknown: 204 if word.lower() not in names: 205 found.append((pos + offset, word)) 206 if len(found) >= MAX_ISSUES: 207 break 208 if len(found) >= MAX_ISSUES: 209 break 210 if not found: 211 return [] 212 line_starts = [0] + [m.end() for m in re.finditer("\n", text)] 213 issues = [] 214 for pos, word in found: 215 line = bisect_right(line_starts, pos) 216 column = pos - line_starts[line - 1] + 1 217 issues.append(SpellingIssue(line, column, line, column + len(word), word)) 218 return issues
The misspelled words in text's prose ((position, length) of
each comment or string). names: words the file uses as names
or keywords (lowercase), which aren't misspellings.