complete.ts 14 KB

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