spellchecker.ts 12 KB

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