complete.ts 12 KB

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