1# -*- coding: utf-8 -*-
2
3# Copyright (c) 2008 - 2021 Detlev Offenbach <detlev@die-offenbachs.de>
4#
5
6"""
7Module implementing the spell checker for the editor component.
8
9The spell checker is based on pyenchant.
10"""
11
12import os
13import contextlib
14
15from PyQt5.QtCore import QTimer, QObject
16
17import Preferences
18import Utilities
19
20with contextlib.suppress(ImportError, AttributeError, OSError):
21    import enchant
22
23
24class SpellChecker(QObject):
25    """
26    Class implementing a pyenchant based spell checker.
27    """
28    # class attributes to be used as defaults
29    _spelling_lang = None
30    _spelling_dict = None
31
32    def __init__(self, editor, indicator, defaultLanguage=None,
33                 checkRegion=None):
34        """
35        Constructor
36
37        @param editor reference to the editor object (QScintilla.Editor)
38        @param indicator spell checking indicator
39        @param defaultLanguage the language to be used as the default
40            (string). The string should be in language locale format
41            (e.g. en_US, de).
42        @param checkRegion reference to a function to check for a valid
43            region
44        """
45        super().__init__(editor)
46
47        self.editor = editor
48        self.indicator = indicator
49        if defaultLanguage is not None:
50            self.setDefaultLanguage(defaultLanguage)
51        if checkRegion is not None:
52            self.__checkRegion = checkRegion
53        else:
54            self.__checkRegion = lambda r: True
55        self.minimumWordSize = 3
56        self.lastCheckedLine = -1
57
58        self.__ignoreWords = []
59        self.__replaceWords = {}
60
61    @classmethod
62    def getAvailableLanguages(cls):
63        """
64        Class method to get all available languages.
65
66        @return list of available languages (list of strings)
67        """
68        with contextlib.suppress(NameError):
69            return enchant.list_languages()
70        return []
71
72    @classmethod
73    def isAvailable(cls):
74        """
75        Class method to check, if spellchecking is available.
76
77        @return flag indicating availability (boolean)
78        """
79        if Preferences.getEditor("SpellCheckingEnabled"):
80            with contextlib.suppress(NameError, AttributeError):
81                return len(enchant.list_languages()) > 0
82        return False
83
84    @classmethod
85    def getDefaultPath(cls, isException=False):
86        """
87        Class method to get the default path names of the user dictionaries.
88
89        @param isException flag indicating to return the name of the default
90            exception dictionary (boolean)
91        @return file name of the default user dictionary or the default user
92            exception dictionary (string)
93        """
94        if isException:
95            return os.path.join(
96                Utilities.getConfigDir(), "spelling", "pel.dic")
97        else:
98            return os.path.join(
99                Utilities.getConfigDir(), "spelling", "pwl.dic")
100
101    @classmethod
102    def getUserDictionaryPath(cls, isException=False):
103        """
104        Class method to get the path name of a user dictionary file.
105
106        @param isException flag indicating to return the name of the user
107            exception dictionary (boolean)
108        @return file name of the user dictionary or the user exception
109            dictionary (string)
110        """
111        if isException:
112            dicFile = Preferences.getEditor("SpellCheckingPersonalExcludeList")
113            if not dicFile:
114                dicFile = SpellChecker.getDefaultPath(True)
115        else:
116            dicFile = Preferences.getEditor("SpellCheckingPersonalWordList")
117            if not dicFile:
118                dicFile = SpellChecker.getDefaultPath()
119        return dicFile
120
121    @classmethod
122    def _getDict(cls, lang, pwl="", pel=""):
123        """
124        Protected class method to get a new dictionary.
125
126        @param lang the language to be used as the default (string).
127            The string should be in language locale format (e.g. en_US, de).
128        @param pwl name of the personal/project word list (string)
129        @param pel name of the personal/project exclude list (string)
130        @return reference to the dictionary (enchant.Dict)
131        """
132        if not pwl:
133            pwl = SpellChecker.getUserDictionaryPath()
134            d = os.path.dirname(pwl)
135            if not os.path.exists(d):
136                os.makedirs(d)
137
138        if not pel:
139            pel = SpellChecker.getUserDictionaryPath(False)
140            d = os.path.dirname(pel)
141            if not os.path.exists(d):
142                os.makedirs(d)
143
144        try:
145            d = enchant.DictWithPWL(lang, pwl, pel)
146        except Exception:
147            # Catch all exceptions, because if pyenchant isn't available, you
148            # can't catch the enchant.DictNotFound error.
149            d = None
150        return d
151
152    @classmethod
153    def setDefaultLanguage(cls, language):
154        """
155        Class method to set the default language.
156
157        @param language the language to be used as the default (string).
158            The string should be in language locale format (e.g. en_US, de).
159        """
160        cls._spelling_lang = language
161        cls._spelling_dict = cls._getDict(language)
162
163    def setLanguage(self, language, pwl="", pel=""):
164        """
165        Public method to set the current language.
166
167        @param language the language to be used as the default (string).
168            The string should be in language locale format (e.g. en_US, de).
169        @param pwl name of the personal/project word list (string)
170        @param pel name of the personal/project exclude list (string)
171        """
172        self._spelling_lang = language
173        self._spelling_dict = self._getDict(language, pwl=pwl,
174                                            pel=pel)
175
176    def getLanguage(self):
177        """
178        Public method to get the current language.
179
180        @return current language in language locale format (string)
181        """
182        return self._spelling_lang
183
184    def setMinimumWordSize(self, size):
185        """
186        Public method to set the minimum word size.
187
188        @param size minimum word size (integer)
189        """
190        if size > 0:
191            self.minimumWordSize = size
192
193    def __getNextWord(self, pos, endPosition):
194        """
195        Private method to get the next word in the text after the given
196        position.
197
198        @param pos position to start word extraction (integer)
199        @param endPosition position to stop word extraction (integer)
200        @return tuple of three values (the extracted word (string),
201            start position (integer), end position (integer))
202        """
203        if pos < 0 or pos >= endPosition:
204            return "", -1, -1
205
206        ch = self.editor.charAt(pos)
207        # 1. skip non-word characters
208        while pos < endPosition and not ch.isalnum():
209            pos = self.editor.positionAfter(pos)
210            ch = self.editor.charAt(pos)
211        if pos == endPosition:
212            return "", -1, -1
213        startPos = pos
214
215        # 2. extract the word
216        word = ""
217        while pos < endPosition and ch.isalnum():
218            word += ch
219            pos = self.editor.positionAfter(pos)
220            ch = self.editor.charAt(pos)
221        endPos = pos
222        if word.isdigit():
223            return self.__getNextWord(endPos, endPosition)
224        else:
225            return word, startPos, endPos
226
227    def getContext(self, wordStart, wordEnd):
228        """
229        Public method to get the context of a faulty word.
230
231        @param wordStart the starting position of the word (integer)
232        @param wordEnd the ending position of the word (integer)
233        @return tuple of the leading and trailing context (string, string)
234        """
235        sline, sindex = self.editor.lineIndexFromPosition(wordStart)
236        eline, eindex = self.editor.lineIndexFromPosition(wordEnd)
237        text = self.editor.text(sline)
238        return (text[:sindex], text[eindex:])
239
240    def getError(self):
241        """
242        Public method to get information about the last error found.
243
244        @return tuple of last faulty word (string), starting position of the
245            faulty word (integer) and ending position of the faulty word
246            (integer)
247        """
248        return (self.word, self.wordStart, self.wordEnd)
249
250    def initCheck(self, startPos, endPos):
251        """
252        Public method to initialize a spell check.
253
254        @param startPos position to start at (integer)
255        @param endPos position to end at (integer)
256        @return flag indicating successful initialization (boolean)
257        """
258        if startPos == endPos:
259            return False
260
261        spell = self._spelling_dict
262        if spell is None:
263            return False
264
265        self.editor.clearIndicatorRange(
266            self.indicator, startPos, endPos - startPos)
267
268        self.pos = startPos
269        self.endPos = endPos
270        self.word = ""
271        self.wordStart = -1
272        self.wordEnd = -1
273        return True
274
275    def __checkDocumentPart(self, startPos, endPos):
276        """
277        Private method to check some part of the document.
278
279        @param startPos position to start at (integer)
280        @param endPos position to end at (integer)
281        """
282        if not self.initCheck(startPos, endPos):
283            return
284
285        while True:
286            try:
287                next(self)
288                self.editor.setIndicatorRange(self.indicator, self.wordStart,
289                                              self.wordEnd - self.wordStart)
290            except StopIteration:
291                break
292
293    def __incrementalCheck(self):
294        """
295        Private method to check the document incrementally.
296        """
297        if self.lastCheckedLine < 0:
298            return
299
300        linesChunk = Preferences.getEditor("AutoSpellCheckChunkSize")
301        self.checkLines(self.lastCheckedLine,
302                        self.lastCheckedLine + linesChunk)
303        self.lastCheckedLine = self.lastCheckedLine + linesChunk + 1
304        if self.lastCheckedLine >= self.editor.lines():
305            self.lastCheckedLine = -1
306        else:
307            QTimer.singleShot(0, self.__incrementalCheck)
308
309    def checkWord(self, pos, atEnd=False):
310        """
311        Public method to check the word at position pos.
312
313        @param pos position to check at (integer)
314        @param atEnd flag indicating the position is at the end of the word
315            to check (boolean)
316        """
317        spell = self._spelling_dict
318        if spell is None:
319            return
320
321        if atEnd:
322            pos = self.editor.positionBefore(pos)
323
324        if pos >= 0 and self.__checkRegion(pos):
325            pos0 = pos
326            pos1 = 0xffffffff
327            if not self.editor.charAt(pos).isalnum():
328                line, index = self.editor.lineIndexFromPosition(pos)
329                self.editor.clearIndicator(
330                    self.indicator, line, index, line, index + 1)
331                pos1 = self.editor.positionAfter(pos)
332                pos0 = self.editor.positionBefore(pos)
333
334            for pos in [pos0, pos1]:
335                if self.editor.charAt(pos).isalnum():
336                    line, index = self.editor.lineIndexFromPosition(pos)
337                    word = self.editor.getWord(line, index, useWordChars=False)
338                    if len(word) >= self.minimumWordSize:
339                        try:
340                            ok = spell.check(word)
341                        except enchant.errors.Error:
342                            ok = True
343                    else:
344                        ok = True
345                    start, end = self.editor.getWordBoundaries(
346                        line, index, useWordChars=False)
347                    if ok:
348                        self.editor.clearIndicator(
349                            self.indicator, line, start, line, end)
350                    else:
351                        # spell check indicated an error
352                        self.editor.setIndicator(
353                            self.indicator, line, start, line, end)
354
355    def checkLines(self, firstLine, lastLine):
356        """
357        Public method to check some lines of text.
358
359        @param firstLine line number of first line to check (integer)
360        @param lastLine line number of last line to check (integer)
361        """
362        startPos = self.editor.positionFromLineIndex(firstLine, 0)
363
364        if lastLine >= self.editor.lines():
365            lastLine = self.editor.lines() - 1
366        endPos = self.editor.lineEndPosition(lastLine)
367
368        self.__checkDocumentPart(startPos, endPos)
369
370    def checkDocument(self):
371        """
372        Public method to check the complete document.
373        """
374        self.__checkDocumentPart(0, self.editor.length())
375
376    def checkDocumentIncrementally(self):
377        """
378        Public method to check the document incrementally.
379        """
380        spell = self._spelling_dict
381        if spell is None:
382            return
383
384        if Preferences.getEditor("AutoSpellCheckingEnabled"):
385            self.lastCheckedLine = 0
386            QTimer.singleShot(0, self.__incrementalCheck)
387
388    def stopIncrementalCheck(self):
389        """
390        Public method to stop an incremental check.
391        """
392        self.lastCheckedLine = -1
393
394    def checkSelection(self):
395        """
396        Public method to check the current selection.
397        """
398        selStartLine, selStartIndex, selEndLine, selEndIndex = (
399            self.editor.getSelection()
400        )
401        self.__checkDocumentPart(
402            self.editor.positionFromLineIndex(selStartLine, selStartIndex),
403            self.editor.positionFromLineIndex(selEndLine, selEndIndex)
404        )
405
406    def checkCurrentPage(self):
407        """
408        Public method to check the currently visible page.
409        """
410        startLine = self.editor.firstVisibleLine()
411        endLine = startLine + self.editor.linesOnScreen()
412        self.checkLines(startLine, endLine)
413
414    def clearAll(self):
415        """
416        Public method to clear all spelling markers.
417        """
418        self.editor.clearIndicatorRange(
419            self.indicator, 0, self.editor.length())
420
421    def getSuggestions(self, word):
422        """
423        Public method to get suggestions for the given word.
424
425        @param word word to get suggestions for (string)
426        @return list of suggestions (list of strings)
427        """
428        suggestions = []
429        spell = self._spelling_dict
430        if spell and len(word) >= self.minimumWordSize:
431            with contextlib.suppress(enchant.errors.Error):
432                suggestions = spell.suggest(word)
433        return suggestions
434
435    def add(self, word=None):
436        """
437        Public method to add a word to the personal word list.
438
439        @param word word to add (string)
440        """
441        spell = self._spelling_dict
442        if spell:
443            if word is None:
444                word = self.word
445            spell.add(word)
446
447    def remove(self, word):
448        """
449        Public method to add a word to the personal exclude list.
450
451        @param word word to add (string)
452        """
453        spell = self._spelling_dict
454        if spell:
455            spell.remove(word)
456
457    def ignoreAlways(self, word=None):
458        """
459        Public method to tell the checker, to always ignore the given word
460        or the current word.
461
462        @param word word to be ignored (string)
463        """
464        if word is None:
465            word = self.word
466        if word not in self.__ignoreWords:
467            self.__ignoreWords.append(word)
468
469    def replace(self, replacement):
470        """
471        Public method to tell the checker to replace the current word with
472        the replacement string.
473
474        @param replacement replacement string (string)
475        """
476        sline, sindex = self.editor.lineIndexFromPosition(self.wordStart)
477        eline, eindex = self.editor.lineIndexFromPosition(self.wordEnd)
478        self.editor.setSelection(sline, sindex, eline, eindex)
479        self.editor.beginUndoAction()
480        self.editor.removeSelectedText()
481        self.editor.insert(replacement)
482        self.editor.endUndoAction()
483        self.pos += len(replacement) - len(self.word)
484
485    def replaceAlways(self, replacement):
486        """
487        Public method to tell the checker to always replace the current word
488        with the replacement string.
489
490        @param replacement replacement string (string)
491        """
492        self.__replaceWords[self.word] = replacement
493        self.replace(replacement)
494
495    ##################################################################
496    ## Methods below implement the iterator protocol
497    ##################################################################
498
499    def __iter__(self):
500        """
501        Special method to create an iterator.
502
503        @return self
504        """
505        return self
506
507    def __next__(self):
508        """
509        Special method to advance to the next error.
510
511        @return self
512        @exception StopIteration raised to indicate the end of the iteration
513        """
514        spell = self._spelling_dict
515        if spell:
516            while self.pos < self.endPos and self.pos >= 0:
517                word, wordStart, wordEnd = self.__getNextWord(
518                    self.pos, self.endPos)
519                self.pos = wordEnd
520                if (
521                    (wordEnd - wordStart) >= self.minimumWordSize and
522                    self.__checkRegion(wordStart)
523                ):
524                    with contextlib.suppress(enchant.errors.Error):
525                        if spell.check(word):
526                            continue
527                    if word in self.__ignoreWords:
528                        continue
529                    self.word = word
530                    self.wordStart = wordStart
531                    self.wordEnd = wordEnd
532                    if word in self.__replaceWords:
533                        self.replace(self.__replaceWords[word])
534                        continue
535                    return self
536
537        raise StopIteration
538