src/eric7/QScintilla/SpellChecker.py

branch
eric7
changeset 9209
b99e7fd55fd3
parent 8881
54e42bc2437a
child 9221
bf71ee032bb4
equal deleted inserted replaced
9208:3fc8dfeb6ebe 9209:b99e7fd55fd3
1 # -*- coding: utf-8 -*-
2
3 # Copyright (c) 2008 - 2022 Detlev Offenbach <detlev@die-offenbachs.de>
4 #
5
6 """
7 Module implementing the spell checker for the editor component.
8
9 The spell checker is based on pyenchant.
10 """
11
12 import os
13 import contextlib
14
15 from PyQt6.QtCore import QTimer, QObject
16
17 import Preferences
18 import Utilities
19
20 with contextlib.suppress(ImportError, AttributeError, OSError):
21 import enchant
22
23
24 class 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

eric ide

mercurial