environments.ts 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  1. import { ensureSyntaxTree } from '@codemirror/language'
  2. import { EditorState } from '@codemirror/state'
  3. import { SyntaxNode, SyntaxNodeRef, Tree } from '@lezer/common'
  4. import { previousSiblingIs } from './common'
  5. import { NodeIntersectsChangeFn, ProjectionItem } from './projection'
  6. import { FigureData } from '../../extensions/figure-modal'
  7. const HUNDRED_MS = 100
  8. export class EnvironmentName extends ProjectionItem {
  9. readonly title: string = ''
  10. }
  11. export const enterNode = (
  12. state: EditorState,
  13. node: SyntaxNodeRef,
  14. items: EnvironmentName[],
  15. nodeIntersectsChange: NodeIntersectsChangeFn
  16. ): any => {
  17. if (node.type.is('EnvNameGroup')) {
  18. if (!nodeIntersectsChange(node.node)) {
  19. return false
  20. }
  21. if (!node.node.prevSibling?.type.is('Begin')) {
  22. return false
  23. }
  24. const openBraceNode = node.node.getChild('OpenBrace')
  25. if (!openBraceNode) {
  26. return false
  27. }
  28. const envNameNode = openBraceNode.node.nextSibling
  29. if (!envNameNode) {
  30. return false
  31. }
  32. const envNameText = state.doc.sliceString(envNameNode.from, envNameNode.to)
  33. if (envNameText.length < 1) {
  34. return false
  35. }
  36. const thisEnvironmentName = {
  37. title: envNameText,
  38. from: envNameNode.from,
  39. to: envNameNode.to,
  40. line: state.doc.lineAt(envNameNode.from).number,
  41. }
  42. items.push(thisEnvironmentName)
  43. } else if (
  44. node.type.is('NewEnvironment') ||
  45. node.type.is('RenewEnvironment')
  46. ) {
  47. if (!nodeIntersectsChange(node.node)) {
  48. // This should already be in `items`
  49. return false
  50. }
  51. const envNameNode = node.node.getChild('LiteralArgContent')
  52. if (!envNameNode) {
  53. return
  54. }
  55. const envNameText = state.doc.sliceString(envNameNode.from, envNameNode.to)
  56. if (!envNameText) {
  57. return
  58. }
  59. const thisEnvironmentName = {
  60. title: envNameText,
  61. from: envNameNode.from,
  62. to: envNameNode.to,
  63. line: state.doc.lineAt(envNameNode.from).number,
  64. }
  65. items.push(thisEnvironmentName)
  66. }
  67. }
  68. export const cursorIsAtBeginEnvironment = (
  69. state: EditorState,
  70. pos: number
  71. ): boolean | undefined => {
  72. const tree = ensureSyntaxTree(state, pos, HUNDRED_MS)
  73. if (!tree) {
  74. return
  75. }
  76. let thisNode = tree.resolve(pos)
  77. if (!thisNode) {
  78. return
  79. }
  80. if (
  81. thisNode.type.is('EnvNameGroup') &&
  82. previousSiblingIs(state, pos, 'Begin')
  83. ) {
  84. return true
  85. } else if (
  86. thisNode.type.is('$Environment') ||
  87. (thisNode.type.is('LaTeX') && pos === state.doc.length) // We're at the end of the document
  88. ) {
  89. // We're at a malformed `\begin{`, resolve leftward
  90. thisNode = tree.resolve(pos, -1)
  91. if (!thisNode) {
  92. return
  93. }
  94. // TODO: may need to handle various envnames
  95. if (thisNode.type.is('OpenBrace') || thisNode.type.is('$EnvName')) {
  96. return true
  97. }
  98. }
  99. }
  100. export const cursorIsAtEndEnvironment = (
  101. state: EditorState,
  102. pos: number
  103. ): boolean | undefined => {
  104. const tree = ensureSyntaxTree(state, pos, HUNDRED_MS)
  105. if (!tree) {
  106. return
  107. }
  108. let thisNode = tree.resolve(pos)
  109. if (!thisNode) {
  110. return
  111. }
  112. if (
  113. thisNode.type.is('EnvNameGroup') &&
  114. previousSiblingIs(state, pos, 'End')
  115. ) {
  116. return true
  117. } else if (thisNode.type.is('$Environment') || thisNode.type.is('Content')) {
  118. // We're at a malformed `\end{`, resolve leftward
  119. thisNode = tree.resolve(pos, -1)
  120. if (!thisNode) {
  121. return
  122. }
  123. // TODO: may need to handle various envnames
  124. if (thisNode.type.is('OpenBrace') || thisNode.type.is('EnvName')) {
  125. return true
  126. }
  127. }
  128. }
  129. const findStartOfDocumentEnvironment = (tree: Tree): number | null => {
  130. const docEnvironment = findNodeInDocument(tree, 'DocumentEnvironment')
  131. return docEnvironment?.getChild('Content')?.from || null
  132. }
  133. const findStartOfAbstractEnvironment = (
  134. tree: Tree,
  135. state: EditorState
  136. ): number | null => {
  137. const abstractEnvironment = findNodeInDocument(
  138. tree,
  139. (nodeRef: SyntaxNodeRef) => {
  140. return Boolean(
  141. nodeRef.type.is('$Environment') &&
  142. getEnvironmentName(nodeRef.node, state) === 'abstract'
  143. )
  144. }
  145. )
  146. return abstractEnvironment?.getChild('Content')?.from || null
  147. }
  148. const findMaketitleCommand = (tree: Tree): number | null => {
  149. const maketitle = findNodeInDocument(tree, 'Maketitle')
  150. return maketitle?.to ?? null
  151. }
  152. const findNodeInDocument = (
  153. tree: Tree,
  154. predicate: number | string | ((node: SyntaxNodeRef) => boolean)
  155. ): SyntaxNode | null => {
  156. let node: SyntaxNode | null = null
  157. const predicateFn =
  158. typeof predicate !== 'function'
  159. ? (nodeRef: SyntaxNodeRef) => {
  160. return nodeRef.type.is(predicate)
  161. }
  162. : predicate
  163. tree?.iterate({
  164. enter(nodeRef) {
  165. if (node !== null) {
  166. return false
  167. }
  168. if (predicateFn(nodeRef)) {
  169. node = nodeRef.node
  170. return false
  171. }
  172. },
  173. })
  174. return node
  175. }
  176. export const findStartOfDocumentContent = (
  177. state: EditorState
  178. ): number | null => {
  179. const tree = ensureSyntaxTree(state, state.doc.length, HUNDRED_MS)
  180. if (!tree) {
  181. return null
  182. }
  183. return (
  184. findStartOfAbstractEnvironment(tree, state) ??
  185. findMaketitleCommand(tree) ??
  186. findStartOfDocumentEnvironment(tree)
  187. )
  188. }
  189. /**
  190. *
  191. * @param node A node of type `$Environment`, `BeginEnv`, or `EndEnv`
  192. * @param state The editor state to read the name from
  193. * @returns The editor name or null if a name cannot be found
  194. */
  195. export function getEnvironmentName(
  196. node: SyntaxNode | null,
  197. state: EditorState
  198. ): string | null {
  199. if (node?.type.is('$Environment')) {
  200. node = node.getChild('BeginEnv')
  201. }
  202. if (!node?.type.is('BeginEnv') && !node?.type.is('EndEnv')) {
  203. return null
  204. }
  205. const nameNode = node
  206. ?.getChild('EnvNameGroup')
  207. ?.getChild('OpenBrace')?.nextSibling
  208. if (!nameNode) {
  209. return null
  210. }
  211. // the name node is a parameter in the grammar, so we have no good way to
  212. // target the specific type
  213. if (nameNode.type.is('CloseBrace')) {
  214. return null
  215. }
  216. return state.sliceDoc(nameNode.from, nameNode.to)
  217. }
  218. export const getUnstarredEnvironmentName = (
  219. node: SyntaxNode | null,
  220. state: EditorState
  221. ): string | undefined => getEnvironmentName(node, state)?.replace(/\*$/, '')
  222. export function getEnvironmentArguments(environmentNode: SyntaxNode) {
  223. return environmentNode.getChild('BeginEnv')?.getChildren('TextArgument')
  224. }
  225. export function parseFigureData(
  226. figureEnvironmentNode: SyntaxNode,
  227. state: EditorState
  228. ): FigureData | null {
  229. let caption: FigureData['caption'] = null
  230. let label: FigureData['label'] = null
  231. let file: FigureData['file'] | undefined
  232. let width: FigureData['width']
  233. let unknownGraphicsArguments: FigureData['unknownGraphicsArguments']
  234. let graphicsCommand: FigureData['graphicsCommand'] | undefined
  235. let graphicsCommandArguments: FigureData['graphicsCommandArguments'] = null
  236. const from = figureEnvironmentNode.from
  237. const to = figureEnvironmentNode.to
  238. let error = false
  239. figureEnvironmentNode.cursor().iterate((node: SyntaxNodeRef) => {
  240. if (error) {
  241. return false
  242. }
  243. if (node.type.is('Caption')) {
  244. if (caption) {
  245. // Multiple captions
  246. error = true
  247. return false
  248. }
  249. caption = {
  250. from: node.from,
  251. to: node.to,
  252. }
  253. }
  254. if (node.type.is('Label')) {
  255. if (label) {
  256. // Multiple labels
  257. error = true
  258. return false
  259. }
  260. label = {
  261. from: node.from,
  262. to: node.to,
  263. }
  264. }
  265. if (node.type.is('IncludeGraphics')) {
  266. if (file) {
  267. // Multiple figure
  268. error = true
  269. return false
  270. }
  271. graphicsCommand = {
  272. from: node.from,
  273. to: node.to,
  274. }
  275. const content = node.node
  276. .getChild('IncludeGraphicsArgument')
  277. ?.getChild('FilePathArgument')
  278. ?.getChild('LiteralArgContent')
  279. if (!content) {
  280. error = true
  281. return false
  282. }
  283. file = {
  284. from: content.from,
  285. to: content.to,
  286. path: state.sliceDoc(content.from, content.to),
  287. }
  288. const optionalArgs = node.node
  289. .getChild('OptionalArgument')
  290. ?.getChild('ShortOptionalArg')
  291. if (!optionalArgs) {
  292. width = undefined
  293. return false
  294. }
  295. graphicsCommandArguments = {
  296. from: optionalArgs.from,
  297. to: optionalArgs.to,
  298. }
  299. const optionalArgContent = state.sliceDoc(
  300. optionalArgs.from,
  301. optionalArgs.to
  302. )
  303. const widthMatch = optionalArgContent.match(
  304. /^width=([0-9]|(?:[0-9]*\.[0-9]+)|(?:[0-9]+\.))\\(linewidth|pagewidth|textwidth|hsize|columnwidth)$/
  305. )
  306. if (widthMatch) {
  307. width = parseFloat(widthMatch[1])
  308. if (widthMatch[2] !== 'linewidth') {
  309. // We shouldn't edit any width other that linewidth
  310. unknownGraphicsArguments = optionalArgContent
  311. }
  312. } else {
  313. unknownGraphicsArguments = optionalArgContent
  314. }
  315. }
  316. })
  317. if (error) {
  318. return null
  319. }
  320. if (graphicsCommand === undefined || file === undefined) {
  321. return null
  322. }
  323. return new FigureData({
  324. caption,
  325. label,
  326. file,
  327. from,
  328. to,
  329. width,
  330. unknownGraphicsArguments,
  331. graphicsCommand,
  332. graphicsCommandArguments,
  333. })
  334. }
  335. export const getBeginEnvSuffix = (state: EditorState, node: SyntaxNode) => {
  336. const argumentNode = node
  337. .getChild('OptionalArgument')
  338. ?.getChild('ShortOptionalArg')
  339. return argumentNode && state.sliceDoc(argumentNode.from, argumentNode.to)
  340. }