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
MAX_ISSUES = 2000
MAX_SUGGESTIONS = 5
LONG_WORD = 10
WORDS_PATH = PosixPath('/home/runner/work/VibeFPMGUI/VibeFPMGUI/vibefpmgui/resources/spelling-words.txt')
FORTRAN_WORDS = {'endwhere', 'deallocate', 'elseif', 'doubleprecision', 'endselect', 'enddo', 'namelist', 'endassociate', 'selectcase', 'enum', 'endblock', 'inout', 'endfile', 'blockdata', 'endif', 'endinterface', 'endforall', 'endfunction', 'fpm', 'nopass', 'goto', 'codimension', 'endblockdata', 'selecttype', 'endenum', 'submodule', 'fortran', 'endtype', 'endcritical', 'forall', 'len', 'endsubroutine', 'endmodule', 'endprogram', 'endsubmodule', 'endprocedure'}
user_words: set[str] = set()
def available() -> bool:
46def available() -> bool:
47    return SpellChecker is not None
LANGUAGE_NAMES = {'en': 'English', 'es': 'Spanish', 'fr': 'French', 'it': 'Italian', 'pt': 'Portuguese', 'de': 'German', 'ru': 'Russian', 'ar': 'Arabic', 'lv': 'Latvian', 'eu': 'Basque', 'nl': 'Dutch'}
language = 'en'
def languages() -> list[str]:
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).

def set_language(code: str):
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).

def is_known(word: str) -> bool:
85def is_known(word: str) -> bool:
86    w = word.lower()
87    if w in user_words or w in FORTRAN_WORDS:
88        return True
89    known = _known.get(w)
90    if known is None:
91        known = _known[w] = bool(_checker(language).known([w]))
92    return known
@cache
def suggestions(word: str) -> tuple[str, ...]:
 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.

class SpellingIssue(typing.NamedTuple):
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.

SpellingIssue( line: int, column: int, end_line: int, end_column: int, word: str, code: str = 'spelling', fix: str = '', fix_safe: bool = False, edits: tuple = (), source: str = 'spelling')

Create new instance of SpellingIssue(line, column, end_line, end_column, word, code, fix, fix_safe, edits, source)

line: int

Alias for field number 0

column: int

Alias for field number 1

end_line: int

Alias for field number 2

end_column: int

Alias for field number 3

word: str

Alias for field number 4

code: str

Alias for field number 5

fix: str

Alias for field number 6

fix_safe: bool

Alias for field number 7

edits: tuple

Alias for field number 8

source: str

Alias for field number 9

message: str
124    @property
125    def message(self) -> str:
126        return f"“{self.word}” isn't in the dictionary"
severity: str
128    @property
129    def severity(self) -> str:
130        return "spelling"
fixable: bool
132    @property
133    def fixable(self) -> bool:
134        return False
def suggestions(self) -> tuple[str, ...]:
136    def suggestions(self) -> tuple[str, ...]:
137        return suggestions(self.word)
def describe(self) -> str:
139    def describe(self) -> str:
140        return f"spelling: {self.message}"
DIRECTIVE_RE = re.compile('^\\s*[!cC*]\\s*(\\$|dir\\$|dec\\$|gcc\\$|ibm\\*|acc\\$|pgi\\$)', re.IGNORECASE)
CHUNK_RE = re.compile('\\S+')
EDGE = '"\'`‘’“”()[]{}<>,.;:!?*~#|=+&^%$@\\/'
WORD_CHARS = re.compile("^[^\\W\\d_]+(?:['’][^\\W\\d_]+)*$")
MAX_SEGMENTS = 200000
def check( text: str, prose: list[tuple[int, int]], names: set[str] = frozenset()) -> list[SpellingIssue]:
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.