commands.ts 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. import { EditorState } from '@codemirror/state'
  2. import { SyntaxNode, SyntaxNodeRef } from '@lezer/common'
  3. import { childOfNodeWithType, getOptionalArgumentText } from './common'
  4. import { NodeIntersectsChangeFn, ProjectionItem } from './projection'
  5. /**
  6. * A projection of a command in the document
  7. */
  8. export class Command extends ProjectionItem {
  9. readonly title: string = ''
  10. readonly optionalArgCount: number | undefined = 0
  11. readonly requiredArgCount: number | undefined = 0
  12. readonly type: 'usage' | 'definition' = 'usage'
  13. readonly raw: string | undefined = undefined
  14. readonly ignoreInAutocomplete?: boolean = false
  15. }
  16. const getCommandName = (
  17. node: SyntaxNode,
  18. state: EditorState,
  19. childTypes: string[]
  20. ): string | null => {
  21. const child = childOfNodeWithType(node, ...childTypes)
  22. if (child) {
  23. const commandName = state.doc.sliceString(child.from, child.to)
  24. if (commandName.length > 0) {
  25. return commandName
  26. }
  27. }
  28. return null
  29. }
  30. /**
  31. * Extracts Command instances from the syntax tree.
  32. * `\newcommand`, `\renewcommand`, `\newenvironment`, `\renewenvironment`
  33. * and `\def` are treated specially.
  34. */
  35. export const enterNode = (
  36. state: EditorState,
  37. node: SyntaxNodeRef,
  38. items: Command[],
  39. nodeIntersectsChange: NodeIntersectsChangeFn
  40. ): any => {
  41. if (node.type.is('NewCommand') || node.type.is('RenewCommand')) {
  42. if (!nodeIntersectsChange(node.node)) {
  43. // This should already be in `items`
  44. return
  45. }
  46. const commandName = getCommandName(node.node, state, [
  47. 'LiteralArgContent',
  48. 'Csname',
  49. ])
  50. if (commandName === null) {
  51. return
  52. }
  53. const optionalArguments = node.node.getChildren('OptionalArgument')
  54. let argCountNumber = 0
  55. if (optionalArguments.length > 0) {
  56. const argumentCountNode = optionalArguments[0]
  57. const argCountText = getOptionalArgumentText(state, argumentCountNode)
  58. if (argCountText) {
  59. try {
  60. argCountNumber = parseInt(argCountText, 10)
  61. } catch (err) {}
  62. }
  63. }
  64. const commandDefinitionHasOptionalArgument = optionalArguments.length === 2
  65. if (commandDefinitionHasOptionalArgument && argCountNumber > 0) {
  66. argCountNumber--
  67. }
  68. items.push({
  69. line: state.doc.lineAt(node.from).number,
  70. title: commandName,
  71. from: node.from,
  72. to: node.to,
  73. optionalArgCount: commandDefinitionHasOptionalArgument ? 1 : 0,
  74. requiredArgCount: argCountNumber,
  75. type: 'definition',
  76. raw: state.sliceDoc(node.from, node.to),
  77. })
  78. } else if (node.type.is('Def')) {
  79. if (!nodeIntersectsChange(node.node)) {
  80. // This should already be in `items`
  81. return
  82. }
  83. const commandName = getCommandName(node.node, state, ['Csname', 'CtrlSym'])
  84. if (commandName === null) {
  85. return
  86. }
  87. const requiredArgCount = node.node.getChildren('MacroParameter').length
  88. const optionalArgCount = node.node.getChildren(
  89. 'OptionalMacroParameter'
  90. ).length
  91. items.push({
  92. line: state.doc.lineAt(node.from).number,
  93. title: commandName,
  94. from: node.from,
  95. to: node.to,
  96. optionalArgCount,
  97. requiredArgCount,
  98. type: 'definition',
  99. raw: state.sliceDoc(node.from, node.to),
  100. })
  101. } else if (node.type.is('Let')) {
  102. if (!nodeIntersectsChange(node.node)) {
  103. // This should already be in `items`
  104. return
  105. }
  106. const commandName = getCommandName(node.node, state, ['Csname'])
  107. if (commandName === null) {
  108. return
  109. }
  110. items.push({
  111. line: state.doc.lineAt(node.from).number,
  112. title: commandName,
  113. from: node.from,
  114. to: node.to,
  115. ignoreInAutocomplete: true, // Ignoring since we don't know the argument counts
  116. optionalArgCount: undefined,
  117. requiredArgCount: undefined,
  118. type: 'definition',
  119. raw: state.sliceDoc(node.from, node.to),
  120. })
  121. } else if (
  122. node.type.is('UnknownCommand') ||
  123. node.type.is('KnownCommand') ||
  124. node.type.is('MathUnknownCommand') ||
  125. node.type.is('DefinitionFragmentUnknownCommand')
  126. ) {
  127. if (!nodeIntersectsChange(node.node)) {
  128. // This should already be in `items`
  129. return
  130. }
  131. let commandNode: SyntaxNode | null = node.node
  132. if (node.type.is('KnownCommand')) {
  133. // KnownCommands are defined as
  134. //
  135. // KnownCommand {
  136. // CommandName {
  137. // CommandCtrlSeq [args]
  138. // }
  139. // }
  140. // So for a KnownCommand, use the first child as the actual command node
  141. commandNode = commandNode.firstChild
  142. }
  143. if (!commandNode) {
  144. return
  145. }
  146. const ctrlSeq = commandNode.getChild('$CtrlSeq')
  147. if (!ctrlSeq) {
  148. return
  149. }
  150. if (ctrlSeq.type.is('$CtrlSym')) {
  151. return
  152. }
  153. const optionalArguments = commandNode.getChildren('OptionalArgument')
  154. const commandArgumentsIncludingOptional =
  155. commandNode.getChildren('$Argument')
  156. const text = state.doc.sliceString(ctrlSeq.from, ctrlSeq.to)
  157. items.push({
  158. line: state.doc.lineAt(commandNode.from).number,
  159. title: text,
  160. from: commandNode.from,
  161. to: commandNode.to,
  162. optionalArgCount: optionalArguments.length,
  163. requiredArgCount:
  164. commandArgumentsIncludingOptional.length - optionalArguments.length,
  165. type: 'usage',
  166. raw: undefined,
  167. })
  168. }
  169. }
  170. const texOrPdfArgument = { tex: 0, pdf: 1 }
  171. export const texOrPdfString = (
  172. state: EditorState,
  173. node: SyntaxNode,
  174. version: keyof typeof texOrPdfArgument
  175. ) => {
  176. const commandName = getCommandName(node.node, state, ['CtrlSeq'])
  177. if (commandName === '\\texorpdfstring') {
  178. const argumentNode = node
  179. .getChildren('TextArgument')
  180. [texOrPdfArgument[version]]?.getChild('LongArg')
  181. if (argumentNode) {
  182. return state.doc.sliceString(argumentNode.from, argumentNode.to)
  183. }
  184. }
  185. }