spellchecker.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428
  1. import { addMisspelledWords, misspelledWordsField } from './misspelled-words'
  2. import { ignoredWordsField, resetSpellChecker } from './ignored-words'
  3. import { cacheField, addWordToCache, WordCacheValue } from './cache'
  4. import { WORD_REGEX } from './helpers'
  5. import OError from '@overleaf/o-error'
  6. import { EditorView, ViewUpdate } from '@codemirror/view'
  7. import { ChangeSet, Line, Range, RangeValue } from '@codemirror/state'
  8. import { IgnoredWords } from '../../../dictionary/ignored-words'
  9. import { getNormalTextSpansFromLine } from '../../utils/tree-query'
  10. import { waitForParser } from '../wait-for-parser'
  11. import { debugConsole } from '@/utils/debugging'
  12. import type { HunspellManager } from '../../hunspell/HunspellManager'
  13. import { captureException } from '@/infrastructure/error-reporter'
  14. /*
  15. * Spellchecker, handles updates, schedules spelling checks
  16. */
  17. export class SpellChecker {
  18. private abortController?: AbortController | null = null
  19. private timeout: number | null = null
  20. private firstCheck = true
  21. private waitingForParser = false
  22. private firstCheckPending = false
  23. private trackedChanges: ChangeSet
  24. private readonly segmenter?: Intl.Segmenter
  25. // eslint-disable-next-line no-useless-constructor
  26. constructor(
  27. private readonly language: string,
  28. private hunspellManager?: HunspellManager
  29. ) {
  30. debugConsole.log('SpellChecker', language, hunspellManager)
  31. this.trackedChanges = ChangeSet.empty(0)
  32. const locale = language.replace(/_/, '-')
  33. try {
  34. if (Intl.Segmenter) {
  35. const supportedLocales = Intl.Segmenter.supportedLocalesOf([locale], {
  36. localeMatcher: 'lookup',
  37. })
  38. if (supportedLocales.includes(locale)) {
  39. this.segmenter = new Intl.Segmenter(locale, {
  40. localeMatcher: 'lookup',
  41. granularity: 'word',
  42. })
  43. }
  44. }
  45. } catch (error) {
  46. // ignore, not supported for some reason
  47. debugConsole.error(error)
  48. }
  49. if (this.segmenter) {
  50. debugConsole.log(`Using Intl.Segmenter for ${locale}`)
  51. } else {
  52. debugConsole.warn(`Not using Intl.Segmenter for ${locale}`)
  53. }
  54. }
  55. destroy() {
  56. this._clearPendingSpellCheck()
  57. }
  58. _abortRequest() {
  59. if (this.abortController) {
  60. this.abortController.abort()
  61. this.abortController = null
  62. }
  63. }
  64. handleUpdate(update: ViewUpdate) {
  65. if (update.docChanged) {
  66. this.trackedChanges = this.trackedChanges.compose(update.changes)
  67. this.scheduleSpellCheck(update.view)
  68. } else if (update.viewportChanged) {
  69. this.trackedChanges = ChangeSet.empty(0)
  70. this.scheduleSpellCheck(update.view)
  71. } else if (
  72. update.transactions.some(tr => {
  73. return tr.effects.some(effect => effect.is(resetSpellChecker))
  74. })
  75. ) {
  76. // for tests
  77. this.trackedChanges = ChangeSet.empty(0)
  78. this.spellCheckAsap(update.view)
  79. }
  80. // At the point that the spellchecker is initialized, the editor may not
  81. // yet be editable, and the parser may not be ready. Therefore, to do the
  82. // initial spellcheck, watch for changes in the editability of the editor
  83. // and kick off the process that performs a spellcheck once the parser is
  84. // ready. CM6 dispatches a transaction after every chunk of parser work and
  85. // when the editability changes, which means the spell checker is
  86. // initialized as soon as possible.
  87. else if (
  88. this.firstCheck &&
  89. !this.firstCheckPending &&
  90. update.state.facet(EditorView.editable)
  91. ) {
  92. this.firstCheckPending = true
  93. this.spellCheckAsap(update.view)
  94. }
  95. }
  96. _performSpellCheck(view: EditorView) {
  97. const wordsToCheck = this.getWordsToCheck(view)
  98. if (wordsToCheck.length === 0) {
  99. return
  100. }
  101. const cache = view.state.field(cacheField)
  102. const { knownMisspelledWords, unknownWords } = cache.checkWords(
  103. this.language,
  104. wordsToCheck
  105. )
  106. const processResult = (
  107. misspellings: { index: number; suggestions?: string[] }[]
  108. ) => {
  109. this.trackedChanges = ChangeSet.empty(0)
  110. if (this.firstCheck) {
  111. this.firstCheck = false
  112. this.firstCheckPending = false
  113. }
  114. const result = buildSpellCheckResult(
  115. knownMisspelledWords,
  116. unknownWords,
  117. misspellings
  118. )
  119. view.dispatch({
  120. effects: compileEffects(result),
  121. })
  122. }
  123. if (unknownWords.length === 0) {
  124. processResult([])
  125. } else {
  126. this._abortRequest()
  127. this.abortController = new AbortController()
  128. if (this.hunspellManager) {
  129. const signal = this.abortController.signal
  130. this.hunspellManager.send(
  131. {
  132. type: 'spell',
  133. words: unknownWords.map(word => word.text),
  134. },
  135. result => {
  136. if (!signal.aborted) {
  137. if ('error' in result) {
  138. debugConsole.error(result.error)
  139. captureException(
  140. new Error('Error running spellcheck for word'),
  141. { tags: { ol_spell_check_language: this.language } }
  142. )
  143. } else {
  144. processResult(result.misspellings)
  145. }
  146. }
  147. }
  148. )
  149. }
  150. }
  151. }
  152. suggest(word: string) {
  153. return new Promise<{ suggestions: string[] }>((resolve, reject) => {
  154. if (this.hunspellManager) {
  155. this.hunspellManager.send({ type: 'suggest', word }, result => {
  156. if ('error' in result) {
  157. reject(new Error('Error finding spelling suggestions for word'))
  158. } else {
  159. resolve(result)
  160. }
  161. })
  162. }
  163. })
  164. }
  165. addWord(word: string) {
  166. return new Promise<void>((resolve, reject) => {
  167. if (this.hunspellManager) {
  168. this.hunspellManager.send({ type: 'add_word', word }, result => {
  169. if ('error' in result) {
  170. reject(new Error('Error adding word to spellcheck'))
  171. } else {
  172. resolve()
  173. }
  174. })
  175. }
  176. })
  177. }
  178. removeWord(word: string) {
  179. return new Promise<void>((resolve, reject) => {
  180. if (this.hunspellManager) {
  181. this.hunspellManager.send({ type: 'remove_word', word }, result => {
  182. if ('error' in result) {
  183. reject(new Error('Error removing word from spellcheck'))
  184. } else {
  185. resolve()
  186. }
  187. })
  188. }
  189. })
  190. }
  191. _spellCheckWhenParserReady(view: EditorView) {
  192. if (this.waitingForParser) {
  193. return
  194. }
  195. this.waitingForParser = true
  196. waitForParser(view, view => view.viewport.to).then(() => {
  197. this.waitingForParser = false
  198. this._performSpellCheck(view)
  199. })
  200. }
  201. _clearPendingSpellCheck() {
  202. if (this.timeout) {
  203. window.clearTimeout(this.timeout)
  204. this.timeout = null
  205. }
  206. this._abortRequest()
  207. }
  208. _asyncSpellCheck(view: EditorView, delay: number) {
  209. this._clearPendingSpellCheck()
  210. this.timeout = window.setTimeout(() => {
  211. this._spellCheckWhenParserReady(view)
  212. this.timeout = null
  213. }, delay)
  214. }
  215. spellCheckAsap(view: EditorView) {
  216. this._asyncSpellCheck(view, 0)
  217. }
  218. scheduleSpellCheck(view: EditorView) {
  219. this._asyncSpellCheck(view, 1000)
  220. }
  221. getWordsToCheck(view: EditorView) {
  222. const wordsToCheck: Word[] = []
  223. const { from, to } = view.viewport
  224. const changedLineNumbers = new Set<number>()
  225. if (this.trackedChanges.length > 0) {
  226. this.trackedChanges.iterChangedRanges((_fromA, _toA, fromB, toB) => {
  227. if (fromB <= to && toB >= from) {
  228. const fromLine = view.state.doc.lineAt(fromB).number
  229. const toLine = view.state.doc.lineAt(toB).number
  230. for (let i = fromLine; i <= toLine; i++) {
  231. changedLineNumbers.add(i)
  232. }
  233. }
  234. })
  235. } else {
  236. const fromLine = view.state.doc.lineAt(from).number
  237. const toLine = view.state.doc.lineAt(to).number
  238. for (let i = fromLine; i <= toLine; i++) {
  239. changedLineNumbers.add(i)
  240. }
  241. }
  242. const ignoredWords = this.hunspellManager
  243. ? null
  244. : view.state.field(ignoredWordsField)
  245. for (const i of changedLineNumbers) {
  246. const line = view.state.doc.line(i)
  247. wordsToCheck.push(
  248. ...getWordsFromLine(
  249. view,
  250. line,
  251. ignoredWords,
  252. this.language,
  253. this.segmenter
  254. )
  255. )
  256. }
  257. return wordsToCheck
  258. }
  259. }
  260. export class Word {
  261. public text: string
  262. public from: number
  263. public to: number
  264. public lineNumber: number
  265. public lang: string
  266. public suggestions?: WordCacheValue
  267. constructor(options: {
  268. text: string
  269. from: number
  270. to: number
  271. lineNumber: number
  272. lang: string
  273. }) {
  274. const { text, from, to, lineNumber, lang } = options
  275. if (
  276. text == null ||
  277. from == null ||
  278. to == null ||
  279. lineNumber == null ||
  280. lang == null
  281. ) {
  282. throw new OError('Spellcheck: invalid word').withInfo({ options })
  283. }
  284. this.text = text
  285. this.from = from
  286. this.to = to
  287. this.lineNumber = lineNumber
  288. this.lang = lang
  289. }
  290. }
  291. export const buildSpellCheckResult = (
  292. knownMisspelledWords: Word[],
  293. unknownWords: Word[],
  294. misspellings: { index: number; suggestions?: string[] }[]
  295. ) => {
  296. const cacheAdditions: [Word, string[] | boolean][] = []
  297. // Put known misspellings into cache
  298. const misspelledWords = misspellings.map(item => {
  299. const word = {
  300. ...unknownWords[item.index],
  301. }
  302. word.suggestions = item.suggestions
  303. if (word.suggestions) {
  304. cacheAdditions.push([word, word.suggestions])
  305. }
  306. return word
  307. })
  308. // if word was not misspelled, put it in the cache
  309. for (const word of unknownWords) {
  310. if (!misspelledWords.find(mw => mw.text === word.text)) {
  311. cacheAdditions.push([word, true])
  312. }
  313. }
  314. return {
  315. cacheAdditions,
  316. misspelledWords: misspelledWords.concat(knownMisspelledWords),
  317. }
  318. }
  319. export const compileEffects = (results: {
  320. cacheAdditions: [Word, string[] | boolean][]
  321. misspelledWords: Word[]
  322. }) => {
  323. const { cacheAdditions, misspelledWords } = results
  324. return [
  325. addMisspelledWords.of(misspelledWords),
  326. ...cacheAdditions.map(([word, value]) => {
  327. return addWordToCache.of({
  328. lang: word.lang,
  329. wordText: word.text,
  330. value,
  331. })
  332. }),
  333. ]
  334. }
  335. export function* getWordsFromLine(
  336. view: EditorView,
  337. line: Line,
  338. ignoredWords: IgnoredWords | null,
  339. lang: string,
  340. segmenter?: Intl.Segmenter
  341. ) {
  342. for (const span of getNormalTextSpansFromLine(view, line)) {
  343. if (segmenter) {
  344. for (const value of segmenter.segment(span.text)) {
  345. if (value.isWordLike && !ignoredWords?.has(value.segment)) {
  346. const word = value.segment
  347. const from = span.from + value.index
  348. yield new Word({
  349. text: word,
  350. from,
  351. to: from + word.length,
  352. lineNumber: line.number,
  353. lang,
  354. })
  355. }
  356. }
  357. } else {
  358. for (const match of span.text.matchAll(WORD_REGEX)) {
  359. let word = match[0].replace(/'+$/, '')
  360. let from = span.from + match.index
  361. while (word.startsWith("'")) {
  362. word = word.slice(1)
  363. from++
  364. }
  365. if (!ignoredWords?.has(word)) {
  366. yield new Word({
  367. text: word,
  368. from,
  369. to: from + word.length,
  370. lineNumber: line.number,
  371. lang,
  372. })
  373. }
  374. }
  375. }
  376. }
  377. }
  378. export type Mark = Range<RangeValue & { spec: { word: Word } }>
  379. export const getMarkAtPosition = (
  380. view: EditorView,
  381. position: number
  382. ): Mark | null => {
  383. const marks = view.state.field(misspelledWordsField)
  384. let targetMark: Mark | null = null
  385. marks.between(view.viewport.from, view.viewport.to, (from, to, value) => {
  386. if (position >= from && position <= to) {
  387. targetMark = { from, to, value }
  388. return false
  389. }
  390. })
  391. return targetMark
  392. }