Coverage for vibefpmgui/spelling.py: 93%

144 statements  

« 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. 

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(): 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) 

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(): 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