complete.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  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 { customEnvironmentCompletions } from './completions/doc-environments'
  10. import { Completions } from './completions/types'
  11. import { buildReferenceCompletions } from './completions/references'
  12. import { buildPackageCompletions } from './completions/packages'
  13. import { buildLabelCompletions } from './completions/labels'
  14. import { buildIncludeCompletions } from './completions/include'
  15. import { buildBibliographyStyleCompletions } from './completions/bibliography-styles'
  16. import { buildClassCompletions } from './completions/classes'
  17. import { buildAllCompletions } from './completions'
  18. import {
  19. ifInType,
  20. cursorIsAtBeginEnvironment,
  21. cursorIsAtEndEnvironment,
  22. } from '../../utils/tree-query'
  23. import {
  24. applySnippet,
  25. extendOverUnpairedClosingBrace,
  26. } from './completions/apply'
  27. import { snippet } from './completions/data/environments'
  28. import { syntaxTree } from '@codemirror/language'
  29. function blankCompletions(): Completions {
  30. return {
  31. bibliographies: [],
  32. bibliographyStyles: [],
  33. classes: [],
  34. commands: [],
  35. graphics: [],
  36. includes: [],
  37. labels: [],
  38. packages: [],
  39. references: [],
  40. }
  41. }
  42. export function getCompletionMatches(context: CompletionContext) {
  43. // NOTE: [^\\] is needed to match commands inside the parameters of other commands
  44. const matchBefore = context.explicit
  45. ? context.matchBefore(/(?:^|\\)[^\\]*(\[[^\]]*])?[^\\]*/) // don't require a backslash if opening on explicit "startCompletion" keypress
  46. : context.matchBefore(/\\?\\[^\\]*(\[[^\]]*])?[^\\]*/)
  47. if (!matchBefore) {
  48. return null
  49. }
  50. // ignore some matches when not opening on explicit "startCompletion" keypress
  51. if (!context.explicit) {
  52. // ignore matches that end with two backslashes. \\ shouldn't show the autocomplete as it's used for line break.
  53. if (/\\\\$/.test(matchBefore.text)) {
  54. return null
  55. }
  56. // ignore matches that end with whitespace, unless after a comma
  57. // e.g. \item with a trailing space shouldn't show the autocomplete.
  58. if (/[^,\s]\s+$/.test(matchBefore.text)) {
  59. return null
  60. }
  61. }
  62. const multipleArgumentMatcher =
  63. /^(?<before>\\(?<command>\w+)\*?(?<arguments>(\[[^\]]*?]|\{[^}]*?})+)?{)(?<existing>([^}]+\s*,\s*)+)?(?<prefix>[^}]+)?$/
  64. // If this is a command with multiple comma-separated arguments, show deduplicated available completions
  65. const match = matchBefore.text.match(multipleArgumentMatcher)
  66. return { match, matchBefore }
  67. }
  68. export function getCompletionDetails(
  69. match: RegExpMatchArray,
  70. matchBefore: {
  71. from: number
  72. to: number
  73. text: string
  74. }
  75. ) {
  76. let { before, command, existing } = match.groups as {
  77. before?: string
  78. command: string
  79. existing?: string
  80. }
  81. command = command.toLowerCase()
  82. const existingKeys = existing
  83. ? existing
  84. .split(',')
  85. .map(key => key.trim())
  86. .filter(Boolean)
  87. : []
  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. export const bibKeyArgumentCompletionSource: CompletionSource =
  138. makeArgumentCompletionSource(
  139. ['BibKeyArgument'],
  140. ({ completions, context, from, validFor, existingKeys }) => {
  141. buildReferenceCompletions(completions, context)
  142. return {
  143. from,
  144. validFor,
  145. options: completions.references.filter(
  146. item => !existingKeys.includes(item.label)
  147. ),
  148. }
  149. }
  150. )
  151. export const refArgumentCompletionSource: CompletionSource =
  152. makeArgumentCompletionSource(
  153. ['RefArgument'],
  154. ({ completions, context, from, validFor, existingKeys }) => {
  155. buildLabelCompletions(completions, context)
  156. return {
  157. from,
  158. validFor,
  159. options: completions.labels.filter(
  160. item => !existingKeys.includes(item.label)
  161. ),
  162. }
  163. }
  164. )
  165. export const packageArgumentCompletionSource: CompletionSource =
  166. makeArgumentCompletionSource(
  167. ['PackageArgument'],
  168. ({ completions, context, from, validFor, existingKeys }) => {
  169. buildPackageCompletions(completions, context)
  170. return {
  171. from,
  172. validFor,
  173. options: completions.packages.filter(
  174. item => !existingKeys.includes(item.label)
  175. ),
  176. }
  177. }
  178. )
  179. export const inputArgumentCompletionSource: CompletionSource =
  180. makeArgumentCompletionSource(
  181. ['InputArgument'],
  182. ({ completions, context, from }) => {
  183. buildIncludeCompletions(completions, context)
  184. return {
  185. from,
  186. validFor: /^[^}]*/,
  187. options: completions.includes,
  188. }
  189. }
  190. )
  191. export const includeArgumentCompletionSource: CompletionSource =
  192. makeArgumentCompletionSource(
  193. ['IncludeArgument'],
  194. ({ completions, context, from }) => {
  195. buildIncludeCompletions(completions, context)
  196. return {
  197. from,
  198. validFor: /^[^}]*/,
  199. options: completions.includes,
  200. }
  201. }
  202. )
  203. export const includeGraphicsArgumentCompletionSource: CompletionSource =
  204. makeArgumentCompletionSource(
  205. ['IncludeGraphicsArgument'],
  206. ({ completions, context, from }) => {
  207. buildIncludeCompletions(completions, context)
  208. return {
  209. from,
  210. validFor: /^[^}]*/,
  211. options: completions.graphics,
  212. }
  213. }
  214. )
  215. export const environmentNameCompletionSource: CompletionSource =
  216. makeArgumentCompletionSource(
  217. ['EnvNameGroup'],
  218. ({ completions, context, matchBefore, before }) => {
  219. if (cursorIsAtBeginEnvironment(context.state, context.pos)) {
  220. buildAllCompletions(completions, context)
  221. return {
  222. from: matchBefore.from,
  223. validFor: /^\\begin{\S*/,
  224. options: [
  225. ...completions.commands,
  226. ...customEnvironmentCompletions(context),
  227. ],
  228. }
  229. } else if (cursorIsAtEndEnvironment(context.state, context.pos)) {
  230. return {
  231. from: matchBefore.from + before.length,
  232. validFor: /^[^}]*/,
  233. options: customEndCompletions(context),
  234. }
  235. } else {
  236. return null
  237. }
  238. }
  239. )
  240. export const documentClassArgumentCompletionSource: CompletionSource =
  241. makeArgumentCompletionSource(
  242. ['DocumentClassArgument'],
  243. ({ completions, from }) => {
  244. buildClassCompletions(completions)
  245. return {
  246. from,
  247. validFor: /^[^}]*/,
  248. options: completions.classes,
  249. }
  250. }
  251. )
  252. export const bibliographyArgumentCompletionSource: CompletionSource =
  253. makeArgumentCompletionSource(
  254. ['BibliographyArgument'],
  255. ({ completions, context, from }) => {
  256. buildIncludeCompletions(completions, context)
  257. return {
  258. from,
  259. validFor: /^[^}]*/,
  260. options: completions.bibliographies,
  261. }
  262. }
  263. )
  264. export const bibliographyStyleArgumentCompletionSource: CompletionSource =
  265. makeArgumentCompletionSource(
  266. ['BibliographyStyleArgument'],
  267. ({ completions, from }) => {
  268. buildBibliographyStyleCompletions(completions)
  269. return {
  270. from,
  271. validFor: /^[^}]*/,
  272. options: completions.bibliographyStyles,
  273. }
  274. }
  275. )
  276. export const argumentCompletionSources: CompletionSource[] = [
  277. bibKeyArgumentCompletionSource,
  278. refArgumentCompletionSource,
  279. packageArgumentCompletionSource,
  280. inputArgumentCompletionSource,
  281. includeArgumentCompletionSource,
  282. includeGraphicsArgumentCompletionSource,
  283. environmentNameCompletionSource,
  284. documentClassArgumentCompletionSource,
  285. bibliographyArgumentCompletionSource,
  286. bibliographyStyleArgumentCompletionSource,
  287. ]
  288. const commandCompletionSource = (context: CompletionContext) => {
  289. const completionMatches = getCompletionMatches(context)
  290. if (!completionMatches) {
  291. return null
  292. }
  293. const { match, matchBefore } = completionMatches
  294. if (match) {
  295. // We're already in a command argument, bail out
  296. return null
  297. }
  298. const completions: Completions = blankCompletions()
  299. buildAllCompletions(completions, context)
  300. // Unknown commands
  301. const prefixMatcher = /^\\[^{\s]*$/
  302. const prefixMatch = matchBefore.text.match(prefixMatcher)
  303. if (prefixMatch) {
  304. return {
  305. from: matchBefore.from,
  306. validFor: prefixMatcher,
  307. options: [
  308. ...completions.commands,
  309. ...customCommandCompletions(context, completions.commands),
  310. ...customEnvironmentCompletions(context),
  311. ],
  312. }
  313. }
  314. // anything else (no validFor)
  315. return {
  316. from: matchBefore.to,
  317. options: [
  318. ...completions.commands,
  319. ...customCommandCompletions(context, completions.commands),
  320. ],
  321. }
  322. }
  323. export const inCommandCompletionSource: CompletionSource = ifInType(
  324. '$CtrlSeq',
  325. context => {
  326. return context.explicit ? null : commandCompletionSource(context)
  327. }
  328. )
  329. export const explicitCommandCompletionSource: CompletionSource = context => {
  330. return context.explicit ? commandCompletionSource(context) : null
  331. }
  332. /**
  333. * An additional completion source that handles two situations:
  334. *
  335. * 1. Typing the environment name within an already-complete `\begin{…}` command.
  336. * 2. After typing the closing brace of a complete `\begin{foo}` command, where the environment
  337. * isn't previously known, leaving the cursor after the closing brace.
  338. */
  339. export const beginEnvironmentCompletionSource: CompletionSource = context => {
  340. const beginEnvToken = context.tokenBefore(['BeginEnv'])
  341. if (!beginEnvToken) {
  342. return null
  343. }
  344. const beginEnv = syntaxTree(context.state).resolveInner(
  345. beginEnvToken.from,
  346. 1
  347. ).parent
  348. if (!beginEnv?.type.is('BeginEnv')) {
  349. return null
  350. }
  351. const envNameGroup = beginEnv.getChild('EnvNameGroup')
  352. if (!envNameGroup) {
  353. return null
  354. }
  355. // this completion source must only be active when the \begin command has a closing brace
  356. const closeBrace = envNameGroup.getChild('CloseBrace')
  357. if (!closeBrace) {
  358. return null
  359. }
  360. const envName = envNameGroup.getChild('$EnvName')
  361. if (!envName) {
  362. return null
  363. }
  364. const name = context.state.sliceDoc(envName.from, envName.to)
  365. const completion = {
  366. label: `\\begin{${name}} …`,
  367. apply: applySnippet(snippet(name)),
  368. extend: extendOverUnpairedClosingBrace,
  369. }
  370. return {
  371. from: beginEnvToken.from,
  372. options: [completion],
  373. }
  374. }