spellchecker.ts 12 KB

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