complete.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457
  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: (
  104. builderOptions: CompletionBuilderOptions
  105. ) => CompletionResult | null | Promise<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. export const refArgumentCompletionSource: CompletionSource =
  183. makeMultipleArgumentCompletionSource(
  184. ['RefArgument'],
  185. ({ completions, context, from, validFor, existingKeys }) => {
  186. buildLabelCompletions(completions, context)
  187. return {
  188. from,
  189. validFor,
  190. options: completions.labels.filter(
  191. item => !existingKeys.includes(item.label)
  192. ),
  193. }
  194. }
  195. )
  196. export const packageArgumentCompletionSource: CompletionSource =
  197. makeMultipleArgumentCompletionSource(
  198. ['PackageArgument'],
  199. ({ completions, context, from, validFor, existingKeys }) => {
  200. buildPackageCompletions(completions, context)
  201. return {
  202. from,
  203. validFor,
  204. options: completions.packages.filter(
  205. item => !existingKeys.includes(item.label)
  206. ),
  207. }
  208. }
  209. )
  210. export const inputArgumentCompletionSource: CompletionSource =
  211. makeArgumentCompletionSource(
  212. ['InputArgument', 'IncludeArgument', 'SubfileArgument'],
  213. ({ completions, context, from }) => {
  214. buildIncludeCompletions(completions, context)
  215. return {
  216. from,
  217. validFor: /^[^}]*/,
  218. options: completions.includes,
  219. }
  220. }
  221. )
  222. export const includeGraphicsArgumentCompletionSource: CompletionSource =
  223. makeArgumentCompletionSource(
  224. ['IncludeGraphicsArgument'],
  225. ({ completions, context, from }) => {
  226. buildIncludeCompletions(completions, context)
  227. return {
  228. from,
  229. validFor: /^[^}]*/,
  230. options: completions.graphics,
  231. }
  232. }
  233. )
  234. export const environmentNameCompletionSource: CompletionSource =
  235. makeArgumentCompletionSource(
  236. ['EnvNameGroup'],
  237. ({ completions, context, matchBefore, before }) => {
  238. if (cursorIsAtBeginEnvironment(context.state, context.pos)) {
  239. buildAllCompletions(completions, context)
  240. return {
  241. from: matchBefore.from,
  242. validFor: /^\\begin{\S*/,
  243. options: [
  244. ...completions.commands,
  245. ...customEnvironmentCompletions(context),
  246. ],
  247. }
  248. } else if (cursorIsAtEndEnvironment(context.state, context.pos)) {
  249. return {
  250. from: matchBefore.from + before.length,
  251. validFor: /^[^}]*/,
  252. options: customEndCompletions(context),
  253. }
  254. } else {
  255. return null
  256. }
  257. }
  258. )
  259. export const documentClassArgumentCompletionSource: CompletionSource =
  260. makeArgumentCompletionSource(
  261. ['DocumentClassArgument'],
  262. ({ completions, from }) => {
  263. buildClassCompletions(completions)
  264. return {
  265. from,
  266. validFor: /^[^}]*/,
  267. options: completions.classes,
  268. }
  269. }
  270. )
  271. export const bibliographyArgumentCompletionSource: CompletionSource =
  272. makeArgumentCompletionSource(
  273. ['BibliographyArgument'],
  274. ({ completions, context, from }) => {
  275. buildIncludeCompletions(completions, context)
  276. return {
  277. from,
  278. validFor: /^[^}]*/,
  279. options: completions.bibliographies,
  280. }
  281. }
  282. )
  283. export const bibliographyStyleArgumentCompletionSource: CompletionSource =
  284. makeArgumentCompletionSource(
  285. ['BibliographyStyleArgument'],
  286. ({ completions, from }) => {
  287. buildBibliographyStyleCompletions(completions)
  288. return {
  289. from,
  290. validFor: /^[^}]*/,
  291. options: completions.bibliographyStyles,
  292. }
  293. }
  294. )
  295. export const argumentCompletionSources: CompletionSource[] = [
  296. bibKeyArgumentCompletionSource,
  297. refArgumentCompletionSource,
  298. packageArgumentCompletionSource,
  299. inputArgumentCompletionSource,
  300. includeGraphicsArgumentCompletionSource,
  301. environmentNameCompletionSource,
  302. documentClassArgumentCompletionSource,
  303. bibliographyArgumentCompletionSource,
  304. bibliographyStyleArgumentCompletionSource,
  305. ]
  306. const commandCompletionSource = (context: CompletionContext) => {
  307. const completionMatches = getCompletionMatches(context)
  308. if (!completionMatches) {
  309. return null
  310. }
  311. const { match, matchBefore } = completionMatches
  312. if (match) {
  313. // We're already in a command argument, bail out
  314. return null
  315. }
  316. const completions: Completions = blankCompletions()
  317. buildAllCompletions(completions, context)
  318. // Unknown commands
  319. const prefixMatcher = /^\\[^{\s]*$/
  320. const prefixMatch = matchBefore.text.match(prefixMatcher)
  321. if (prefixMatch) {
  322. return {
  323. from: matchBefore.from,
  324. validFor: prefixMatcher,
  325. options: [
  326. ...completions.commands,
  327. ...customCommandCompletions(context, completions.commands),
  328. ...customEnvironmentCompletions(context),
  329. ],
  330. }
  331. }
  332. // anything else (no validFor)
  333. return {
  334. from: matchBefore.to,
  335. options: [
  336. ...completions.commands,
  337. ...customCommandCompletions(context, completions.commands),
  338. ],
  339. }
  340. }
  341. export const inCommandCompletionSource: CompletionSource = ifInType(
  342. '$CtrlSeq',
  343. context => {
  344. return context.explicit ? null : commandCompletionSource(context)
  345. }
  346. )
  347. export const explicitCommandCompletionSource: CompletionSource = context => {
  348. return context.explicit ? commandCompletionSource(context) : null
  349. }
  350. /**
  351. * An additional completion source that handles two situations:
  352. *
  353. * 1. Typing the environment name within an already-complete `\begin{…}` command.
  354. * 2. After typing the closing brace of a complete `\begin{foo}` command, where the environment
  355. * isn't previously known, leaving the cursor after the closing brace.
  356. */
  357. export const beginEnvironmentCompletionSource: CompletionSource = context => {
  358. const beginEnvToken = context.tokenBefore(['BeginEnv'])
  359. if (!beginEnvToken) {
  360. return null
  361. }
  362. const beginEnv = syntaxTree(context.state).resolveInner(
  363. beginEnvToken.from,
  364. 1
  365. ).parent
  366. if (!beginEnv?.type.is('BeginEnv')) {
  367. return null
  368. }
  369. const envNameGroup = beginEnv.getChild('EnvNameGroup')
  370. if (!envNameGroup) {
  371. return null
  372. }
  373. const envName = envNameGroup.getChild('$EnvName')
  374. if (!envName) {
  375. return null
  376. }
  377. const name = context.state.sliceDoc(envName.from, envName.to)
  378. // if not directly after `\begin{…}`, exclude known environments
  379. if (context.pos !== envNameGroup.to) {
  380. const existingEnvironmentNames = findEnvironmentsInDoc(context)
  381. if (existingEnvironmentNames.has(name)) {
  382. return null
  383. }
  384. }
  385. const completion = {
  386. label: `\\begin{${name}} …`,
  387. apply: applySnippet(snippet(name)),
  388. extend: extendOverUnpairedClosingBrace,
  389. boost: -99,
  390. }
  391. return {
  392. from: beginEnvToken.from,
  393. options: [completion],
  394. }
  395. }