complete.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558
  1. import {
  2. CompletionContext,
  3. CompletionResult,
  4. CompletionSource,
  5. ifIn,
  6. } from '@codemirror/autocomplete'
  7. import { customEndCompletions } from './completions/environments'
  8. import { customCommandCompletions } from './completions/doc-commands'
  9. import {
  10. customEnvironmentCompletions,
  11. findEnvironmentsInDoc,
  12. } from './completions/doc-environments'
  13. import { Completions } from './completions/types'
  14. import { buildReferenceCompletions } from './completions/references'
  15. import { buildPackageCompletions } from './completions/packages'
  16. import { buildLabelCompletions } from './completions/labels'
  17. import { buildIncludeCompletions } from './completions/include'
  18. import { buildBibliographyStyleCompletions } from './completions/bibliography-styles'
  19. import { buildClassCompletions } from './completions/classes'
  20. import { buildAllCompletions } from './completions'
  21. import {
  22. ifInType,
  23. cursorIsAtBeginEnvironment,
  24. cursorIsAtEndEnvironment,
  25. } from '../../utils/tree-query'
  26. import {
  27. applySnippet,
  28. extendOverUnpairedClosingBrace,
  29. } from './completions/apply'
  30. import { snippet } from './completions/data/environments'
  31. import { syntaxTree } from '@codemirror/language'
  32. import { sendMBSampled } from '@/infrastructure/event-tracking'
  33. import getMeta from '@/utils/meta'
  34. function blankCompletions(): Completions {
  35. return {
  36. bibliographies: [],
  37. bibliographyStyles: [],
  38. classes: [],
  39. commands: [],
  40. graphics: [],
  41. includes: [],
  42. labels: [],
  43. packages: [],
  44. references: [],
  45. }
  46. }
  47. export function getCompletionMatches(context: CompletionContext) {
  48. // NOTE: [^\\] is needed to match commands inside the parameters of other commands
  49. const matchBefore = context.explicit
  50. ? context.matchBefore(/(?:^|\\)[^\\]*(\[[^\]]*])?[^\\]*/) // don't require a backslash if opening on explicit "startCompletion" keypress
  51. : context.matchBefore(/\\?\\[^\\]*(\[[^\]]*])?[^\\]*/)
  52. if (!matchBefore) {
  53. return null
  54. }
  55. // ignore some matches when not opening on explicit "startCompletion" keypress
  56. if (!context.explicit) {
  57. // ignore matches that end with two backslashes. \\ shouldn't show the autocomplete as it's used for line break.
  58. if (/\\\\$/.test(matchBefore.text)) {
  59. return null
  60. }
  61. // ignore matches that end with whitespace, unless after a comma
  62. // e.g. \item with a trailing space shouldn't show the autocomplete.
  63. if (/[^,\s]\s+$/.test(matchBefore.text)) {
  64. return null
  65. }
  66. }
  67. const multipleArgumentMatcher =
  68. /^(?<before>\\(?<command>\w+)\*?(?<arguments>(\[[^\]]*?]|\{[^}]*?})+)?{)(?<existing>([^}]+\s*,\s*)+)?(?<prefix>[^}]+)?$/
  69. // If this is a command with multiple comma-separated arguments, show deduplicated available completions
  70. const match = matchBefore.text.match(multipleArgumentMatcher)
  71. return { match, matchBefore }
  72. }
  73. export function getCompletionDetails(
  74. match: RegExpMatchArray,
  75. matchBefore: {
  76. from: number
  77. to: number
  78. text: string
  79. }
  80. ) {
  81. let { before, command, existing } = match.groups as {
  82. before?: string
  83. command: string
  84. existing?: string
  85. }
  86. command = command.toLowerCase()
  87. const existingKeys = existing ? splitExistingKeys(existing) : []
  88. const from =
  89. matchBefore.from + (before?.length || 0) + (existing?.length || 0)
  90. const validFor = /[^}\s]*/
  91. return { command, existingKeys, from, validFor }
  92. }
  93. export type CompletionBuilderOptions = {
  94. context: CompletionContext
  95. completions: Completions
  96. match: RegExpMatchArray
  97. matchBefore: { from: number; to: number; text: string }
  98. existingKeys: string[]
  99. from: number
  100. validFor: RegExp
  101. before: string
  102. }
  103. export const makeArgumentCompletionSource = (
  104. ifInSpec: string[],
  105. builder: (builderOptions: CompletionBuilderOptions) => CompletionResult | null
  106. ): CompletionSource => {
  107. const completionSource: CompletionSource = (context: CompletionContext) => {
  108. const completionMatches = getCompletionMatches(context)
  109. if (!completionMatches) {
  110. return null
  111. }
  112. const completions: Completions = blankCompletions()
  113. const { match, matchBefore } = completionMatches
  114. if (!match) {
  115. return null
  116. }
  117. const { before } = match.groups as {
  118. before: string
  119. }
  120. const { existingKeys, from, validFor } = getCompletionDetails(
  121. match,
  122. matchBefore
  123. )
  124. return builder({
  125. completions,
  126. context,
  127. match,
  128. matchBefore,
  129. before,
  130. existingKeys,
  131. from,
  132. validFor,
  133. })
  134. }
  135. return ifIn(ifInSpec, completionSource)
  136. }
  137. const splitExistingKeys = (text: string) =>
  138. text
  139. .split(',')
  140. .map(key => key.trim())
  141. .filter(Boolean)
  142. export const makeMultipleArgumentCompletionSource = (
  143. ifInSpec: string[],
  144. builder: (
  145. builderOptions: Pick<
  146. CompletionBuilderOptions,
  147. 'completions' | 'context' | 'existingKeys' | 'from' | 'validFor'
  148. >
  149. ) => ReturnType<CompletionSource>
  150. ): CompletionSource => {
  151. const completionSource: CompletionSource = (context: CompletionContext) => {
  152. const token = context.tokenBefore(ifInSpec)
  153. if (!token) {
  154. return null
  155. }
  156. // match multiple comma-separated arguments, up to the last separator
  157. const existing = token.text.match(/^\{(.+\s*,\s*)?.*$/)?.[1] ?? ''
  158. return builder({
  159. completions: blankCompletions(),
  160. context,
  161. existingKeys: splitExistingKeys(existing),
  162. from: token.from + 1 + existing.length,
  163. validFor: /[^}\s]*/,
  164. })
  165. }
  166. return ifIn(ifInSpec, completionSource)
  167. }
  168. export const bibKeyArgumentCompletionSource: CompletionSource =
  169. makeMultipleArgumentCompletionSource(
  170. ['BibKeyArgument'],
  171. ({ completions, context, from, validFor, existingKeys }) => {
  172. buildReferenceCompletions(completions, context)
  173. return {
  174. from,
  175. validFor,
  176. options: completions.references.filter(
  177. item => !existingKeys.includes(item.label)
  178. ),
  179. }
  180. }
  181. )
  182. const debouncedCounter = (
  183. debounceTime: number
  184. ): {
  185. debounceTime: number
  186. counter: number
  187. increment: () => void
  188. reset: () => void
  189. } => {
  190. let timeoutId = 0
  191. let _counter = 0
  192. return {
  193. debounceTime,
  194. get counter() {
  195. return _counter
  196. },
  197. increment() {
  198. if (timeoutId !== 0) {
  199. clearTimeout(timeoutId)
  200. }
  201. timeoutId = window.setTimeout(() => {
  202. _counter += 1
  203. timeoutId = 0
  204. }, debounceTime)
  205. },
  206. reset() {
  207. clearTimeout(timeoutId)
  208. _counter = 0
  209. timeoutId = 0
  210. },
  211. }
  212. }
  213. const CITE_ANALYTICS_REPORT_TIMEOUT = 4000
  214. const analyticsSourceBuilder = (debounceTimes: number[]) => {
  215. const user = getMeta('ol-user')
  216. let timeoutId = 0
  217. const counters = debounceTimes.map(debounceTime => {
  218. if (debounceTime >= CITE_ANALYTICS_REPORT_TIMEOUT) {
  219. throw new Error(
  220. `Debounce time ${debounceTime} is greater than the report timeout ${CITE_ANALYTICS_REPORT_TIMEOUT}`
  221. )
  222. }
  223. return debouncedCounter(debounceTime)
  224. })
  225. const incrementCounters = () => {
  226. counters.forEach(counter => counter.increment())
  227. }
  228. const resetCounters = () => {
  229. counters.forEach(counter => counter.reset())
  230. }
  231. const delayedReport = () => {
  232. if (timeoutId !== 0) {
  233. clearTimeout(timeoutId)
  234. }
  235. timeoutId = window.setTimeout(() => {
  236. const result: Record<string, number | boolean | undefined> = {
  237. mendeley: Boolean(
  238. user?.features?.mendeley && user?.refProviders?.mendeley
  239. ),
  240. zotero: Boolean(user?.features?.zotero && user?.refProviders?.zotero),
  241. }
  242. counters.forEach(debouncedCounter => {
  243. result[`${debouncedCounter.debounceTime}ms`] = debouncedCounter.counter
  244. })
  245. sendMBSampled('cite-key-search', result, 0.05)
  246. timeoutId = 0
  247. resetCounters()
  248. }, CITE_ANALYTICS_REPORT_TIMEOUT)
  249. }
  250. return () => {
  251. incrementCounters()
  252. delayedReport()
  253. return null
  254. }
  255. }
  256. const citeKeyAnalyticsSource = makeMultipleArgumentCompletionSource(
  257. ['BibKeyArgument'],
  258. analyticsSourceBuilder([0, 100, 250, 500])
  259. )
  260. export const refArgumentCompletionSource: CompletionSource =
  261. makeMultipleArgumentCompletionSource(
  262. ['RefArgument'],
  263. ({ completions, context, from, validFor, existingKeys }) => {
  264. buildLabelCompletions(completions, context)
  265. return {
  266. from,
  267. validFor,
  268. options: completions.labels.filter(
  269. item => !existingKeys.includes(item.label)
  270. ),
  271. }
  272. }
  273. )
  274. export const packageArgumentCompletionSource: CompletionSource =
  275. makeMultipleArgumentCompletionSource(
  276. ['PackageArgument'],
  277. ({ completions, context, from, validFor, existingKeys }) => {
  278. buildPackageCompletions(completions, context)
  279. return {
  280. from,
  281. validFor,
  282. options: completions.packages.filter(
  283. item => !existingKeys.includes(item.label)
  284. ),
  285. }
  286. }
  287. )
  288. export const inputArgumentCompletionSource: CompletionSource =
  289. makeArgumentCompletionSource(
  290. ['InputArgument'],
  291. ({ completions, context, from }) => {
  292. buildIncludeCompletions(completions, context)
  293. return {
  294. from,
  295. validFor: /^[^}]*/,
  296. options: completions.includes,
  297. }
  298. }
  299. )
  300. export const includeArgumentCompletionSource: CompletionSource =
  301. makeArgumentCompletionSource(
  302. ['IncludeArgument'],
  303. ({ completions, context, from }) => {
  304. buildIncludeCompletions(completions, context)
  305. return {
  306. from,
  307. validFor: /^[^}]*/,
  308. options: completions.includes,
  309. }
  310. }
  311. )
  312. export const includeGraphicsArgumentCompletionSource: CompletionSource =
  313. makeArgumentCompletionSource(
  314. ['IncludeGraphicsArgument'],
  315. ({ completions, context, from }) => {
  316. buildIncludeCompletions(completions, context)
  317. return {
  318. from,
  319. validFor: /^[^}]*/,
  320. options: completions.graphics,
  321. }
  322. }
  323. )
  324. export const environmentNameCompletionSource: CompletionSource =
  325. makeArgumentCompletionSource(
  326. ['EnvNameGroup'],
  327. ({ completions, context, matchBefore, before }) => {
  328. if (cursorIsAtBeginEnvironment(context.state, context.pos)) {
  329. buildAllCompletions(completions, context)
  330. return {
  331. from: matchBefore.from,
  332. validFor: /^\\begin{\S*/,
  333. options: [
  334. ...completions.commands,
  335. ...customEnvironmentCompletions(context),
  336. ],
  337. }
  338. } else if (cursorIsAtEndEnvironment(context.state, context.pos)) {
  339. return {
  340. from: matchBefore.from + before.length,
  341. validFor: /^[^}]*/,
  342. options: customEndCompletions(context),
  343. }
  344. } else {
  345. return null
  346. }
  347. }
  348. )
  349. export const documentClassArgumentCompletionSource: CompletionSource =
  350. makeArgumentCompletionSource(
  351. ['DocumentClassArgument'],
  352. ({ completions, from }) => {
  353. buildClassCompletions(completions)
  354. return {
  355. from,
  356. validFor: /^[^}]*/,
  357. options: completions.classes,
  358. }
  359. }
  360. )
  361. export const bibliographyArgumentCompletionSource: CompletionSource =
  362. makeArgumentCompletionSource(
  363. ['BibliographyArgument'],
  364. ({ completions, context, from }) => {
  365. buildIncludeCompletions(completions, context)
  366. return {
  367. from,
  368. validFor: /^[^}]*/,
  369. options: completions.bibliographies,
  370. }
  371. }
  372. )
  373. export const bibliographyStyleArgumentCompletionSource: CompletionSource =
  374. makeArgumentCompletionSource(
  375. ['BibliographyStyleArgument'],
  376. ({ completions, from }) => {
  377. buildBibliographyStyleCompletions(completions)
  378. return {
  379. from,
  380. validFor: /^[^}]*/,
  381. options: completions.bibliographyStyles,
  382. }
  383. }
  384. )
  385. export const argumentCompletionSources: CompletionSource[] = [
  386. bibKeyArgumentCompletionSource,
  387. refArgumentCompletionSource,
  388. packageArgumentCompletionSource,
  389. inputArgumentCompletionSource,
  390. includeArgumentCompletionSource,
  391. includeGraphicsArgumentCompletionSource,
  392. environmentNameCompletionSource,
  393. documentClassArgumentCompletionSource,
  394. bibliographyArgumentCompletionSource,
  395. bibliographyStyleArgumentCompletionSource,
  396. citeKeyAnalyticsSource,
  397. ]
  398. const commandCompletionSource = (context: CompletionContext) => {
  399. const completionMatches = getCompletionMatches(context)
  400. if (!completionMatches) {
  401. return null
  402. }
  403. const { match, matchBefore } = completionMatches
  404. if (match) {
  405. // We're already in a command argument, bail out
  406. return null
  407. }
  408. const completions: Completions = blankCompletions()
  409. buildAllCompletions(completions, context)
  410. // Unknown commands
  411. const prefixMatcher = /^\\[^{\s]*$/
  412. const prefixMatch = matchBefore.text.match(prefixMatcher)
  413. if (prefixMatch) {
  414. return {
  415. from: matchBefore.from,
  416. validFor: prefixMatcher,
  417. options: [
  418. ...completions.commands,
  419. ...customCommandCompletions(context, completions.commands),
  420. ...customEnvironmentCompletions(context),
  421. ],
  422. }
  423. }
  424. // anything else (no validFor)
  425. return {
  426. from: matchBefore.to,
  427. options: [
  428. ...completions.commands,
  429. ...customCommandCompletions(context, completions.commands),
  430. ],
  431. }
  432. }
  433. export const inCommandCompletionSource: CompletionSource = ifInType(
  434. '$CtrlSeq',
  435. context => {
  436. return context.explicit ? null : commandCompletionSource(context)
  437. }
  438. )
  439. export const explicitCommandCompletionSource: CompletionSource = context => {
  440. return context.explicit ? commandCompletionSource(context) : null
  441. }
  442. /**
  443. * An additional completion source that handles two situations:
  444. *
  445. * 1. Typing the environment name within an already-complete `\begin{…}` command.
  446. * 2. After typing the closing brace of a complete `\begin{foo}` command, where the environment
  447. * isn't previously known, leaving the cursor after the closing brace.
  448. */
  449. export const beginEnvironmentCompletionSource: CompletionSource = context => {
  450. const beginEnvToken = context.tokenBefore(['BeginEnv'])
  451. if (!beginEnvToken) {
  452. return null
  453. }
  454. const beginEnv = syntaxTree(context.state).resolveInner(
  455. beginEnvToken.from,
  456. 1
  457. ).parent
  458. if (!beginEnv?.type.is('BeginEnv')) {
  459. return null
  460. }
  461. const envNameGroup = beginEnv.getChild('EnvNameGroup')
  462. if (!envNameGroup) {
  463. return null
  464. }
  465. const envName = envNameGroup.getChild('$EnvName')
  466. if (!envName) {
  467. return null
  468. }
  469. const name = context.state.sliceDoc(envName.from, envName.to)
  470. // if not directly after `\begin{…}`, exclude known environments
  471. if (context.pos !== envNameGroup.to) {
  472. const existingEnvironmentNames = findEnvironmentsInDoc(context)
  473. if (existingEnvironmentNames.has(name)) {
  474. return null
  475. }
  476. }
  477. const completion = {
  478. label: `\\begin{${name}} …`,
  479. apply: applySnippet(snippet(name)),
  480. extend: extendOverUnpairedClosingBrace,
  481. boost: -99,
  482. }
  483. return {
  484. from: beginEnvToken.from,
  485. options: [completion],
  486. }
  487. }