commands.ts 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  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. toLine: state.doc.lineAt(node.to).number,
  71. title: commandName,
  72. from: node.from,
  73. to: node.to,
  74. optionalArgCount: commandDefinitionHasOptionalArgument ? 1 : 0,
  75. requiredArgCount: argCountNumber,
  76. type: 'definition',
  77. raw: state.sliceDoc(node.from, node.to),
  78. })
  79. } else if (node.type.is('Def')) {
  80. if (!nodeIntersectsChange(node.node)) {
  81. // This should already be in `items`
  82. return
  83. }
  84. const commandName = getCommandName(node.node, state, ['Csname', 'CtrlSym'])
  85. if (commandName === null) {
  86. return
  87. }
  88. const requiredArgCount = node.node.getChildren('MacroParameter').length
  89. const optionalArgCount = node.node.getChildren(
  90. 'OptionalMacroParameter'
  91. ).length
  92. items.push({
  93. line: state.doc.lineAt(node.from).number,
  94. toLine: state.doc.lineAt(node.to).number,
  95. title: commandName,
  96. from: node.from,
  97. to: node.to,
  98. optionalArgCount,
  99. requiredArgCount,
  100. type: 'definition',
  101. raw: state.sliceDoc(node.from, node.to),
  102. })
  103. } else if (node.type.is('Let')) {
  104. if (!nodeIntersectsChange(node.node)) {
  105. // This should already be in `items`
  106. return
  107. }
  108. const commandName = getCommandName(node.node, state, ['Csname'])
  109. if (commandName === null) {
  110. return
  111. }
  112. items.push({
  113. line: state.doc.lineAt(node.from).number,
  114. toLine: state.doc.lineAt(node.to).number,
  115. title: commandName,
  116. from: node.from,
  117. to: node.to,
  118. ignoreInAutocomplete: true, // Ignoring since we don't know the argument counts
  119. optionalArgCount: undefined,
  120. requiredArgCount: undefined,
  121. type: 'definition',
  122. raw: state.sliceDoc(node.from, node.to),
  123. })
  124. } else if (
  125. node.type.is('UnknownCommand') ||
  126. node.type.is('KnownCommand') ||
  127. node.type.is('MathUnknownCommand') ||
  128. node.type.is('DefinitionFragmentUnknownCommand')
  129. ) {
  130. if (!nodeIntersectsChange(node.node)) {
  131. // This should already be in `items`
  132. return
  133. }
  134. let commandNode: SyntaxNode | null = node.node
  135. if (node.type.is('KnownCommand')) {
  136. // KnownCommands are defined as
  137. //
  138. // KnownCommand {
  139. // CommandName {
  140. // CommandCtrlSeq [args]
  141. // }
  142. // }
  143. // So for a KnownCommand, use the first child as the actual command node
  144. commandNode = commandNode.firstChild
  145. }
  146. if (!commandNode) {
  147. return
  148. }
  149. const ctrlSeq = commandNode.getChild('$CtrlSeq')
  150. if (!ctrlSeq) {
  151. return
  152. }
  153. if (ctrlSeq.type.is('$CtrlSym')) {
  154. return
  155. }
  156. const optionalArguments = commandNode.getChildren('OptionalArgument')
  157. const commandArgumentsIncludingOptional =
  158. commandNode.getChildren('$Argument')
  159. const text = state.doc.sliceString(ctrlSeq.from, ctrlSeq.to)
  160. items.push({
  161. line: state.doc.lineAt(commandNode.from).number,
  162. toLine: state.doc.lineAt(commandNode.to).number,
  163. title: text,
  164. from: commandNode.from,
  165. to: commandNode.to,
  166. optionalArgCount: optionalArguments.length,
  167. requiredArgCount:
  168. commandArgumentsIncludingOptional.length - optionalArguments.length,
  169. type: 'usage',
  170. raw: undefined,
  171. })
  172. }
  173. }
  174. const texOrPdfArgument = { tex: 0, pdf: 1 }
  175. export const texOrPdfString = (
  176. state: EditorState,
  177. node: SyntaxNode,
  178. version: keyof typeof texOrPdfArgument
  179. ) => {
  180. const commandName = getCommandName(node.node, state, ['CtrlSeq'])
  181. if (commandName === '\\texorpdfstring') {
  182. const argumentNode = node
  183. .getChildren('TextArgument')
  184. [texOrPdfArgument[version]]?.getChild('LongArg')
  185. if (argumentNode) {
  186. return state.doc.sliceString(argumentNode.from, argumentNode.to)
  187. }
  188. }
  189. }