codemirror.ts 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  1. /* eslint-disable no-dupe-class-members */
  2. import { LanguageSupport } from '@codemirror/language'
  3. import { EditorSelection, Line, SelectionRange } from '@codemirror/state'
  4. import { EditorView } from '@codemirror/view'
  5. import { Assertion } from 'chai'
  6. import { LaTeXLanguage } from '../../../../../frontend/js/features/source-editor/languages/latex/latex-language'
  7. export class CodemirrorTestSession {
  8. public view: EditorView
  9. constructor(content: string[] | string) {
  10. this.view = createView(content)
  11. }
  12. insert(content: string): void {
  13. this.view.dispatch(
  14. this.view.state.changeByRange(range => {
  15. const changeDescription = [
  16. {
  17. from: range.from,
  18. to: range.to,
  19. insert: content,
  20. },
  21. ]
  22. const changes = this.view.state.changes(changeDescription)
  23. return {
  24. range: EditorSelection.cursor(range.head).map(changes),
  25. changes,
  26. }
  27. })
  28. )
  29. }
  30. insertAt(position: number, content: string) {
  31. const changes = [{ from: position, insert: content }]
  32. this.view.dispatch({
  33. changes,
  34. selection: this.view.state.selection.map(
  35. this.view.state.changes(changes),
  36. 1
  37. ),
  38. })
  39. }
  40. insertAtLine(line: number, offset: number, content: string): void
  41. insertAtLine(line: number, content: string): void
  42. insertAtLine(
  43. lineNumber: number,
  44. offsetOrContent: string | number,
  45. content?: string
  46. ) {
  47. const line = this.view.state.doc.line(lineNumber)
  48. if (typeof offsetOrContent === 'string' && typeof content === 'string') {
  49. throw new Error(
  50. 'If a third argument is provided, the second must be an integer'
  51. )
  52. }
  53. // Insert at end of line
  54. if (typeof offsetOrContent === 'string') {
  55. content = offsetOrContent
  56. offsetOrContent = line.to
  57. }
  58. if (typeof content !== 'string') {
  59. throw new Error('content must be provided to insertAtLine')
  60. }
  61. if (offsetOrContent < line.from || offsetOrContent > line.to) {
  62. throw new Error('Offset is outside the range of the line')
  63. }
  64. this.insertAt(line.from + offsetOrContent, content)
  65. }
  66. delete(position: number, length: number) {
  67. this.view.dispatch({
  68. changes: [{ from: position - length, to: position }],
  69. })
  70. }
  71. applyCommand(command: (view: EditorView) => any) {
  72. return command(this.view)
  73. }
  74. setCursor(position: number): void
  75. setCursor(line: number, offset: number): void
  76. setCursor(positionOrLine: number, offset?: number) {
  77. if (offset !== undefined) {
  78. const line = this.view.state.doc.line(positionOrLine)
  79. positionOrLine = line.from + offset
  80. }
  81. this.view.dispatch({
  82. selection: EditorSelection.cursor(positionOrLine),
  83. })
  84. }
  85. setSelection(selection: EditorSelection) {
  86. this.view.dispatch({
  87. selection,
  88. })
  89. }
  90. }
  91. const latex = new LanguageSupport(LaTeXLanguage)
  92. function createView(content: string[] | string): EditorView {
  93. if (Array.isArray(content)) {
  94. content = content.join('\n')
  95. }
  96. return new EditorView({
  97. doc: stripSelectionMarkers(content),
  98. selection: createSelections(content) ?? EditorSelection.cursor(0),
  99. extensions: [latex],
  100. })
  101. }
  102. function stripSelectionMarkers(content: string) {
  103. return content.replaceAll(/[<|>]/g, '')
  104. }
  105. function hasSelectionMarkers(content: string) {
  106. return !!content.match(/[<|>]/g)
  107. }
  108. function createSelections(content: string, offset = 0) {
  109. const selections = []
  110. let index = 0
  111. for (let i = 0; i < content.length; i++) {
  112. if (content[i] === '|') {
  113. selections.push(EditorSelection.cursor(index + offset))
  114. }
  115. if (content[i] === '<') {
  116. // find end
  117. const startOfRange = index
  118. let foundEnd = false
  119. for (++i; i < content.length; ++i) {
  120. if (content[i] === '|') {
  121. throw new Error(
  122. "Invalid cursor indicator '|' within a range started with '<'"
  123. )
  124. }
  125. if (content[i] === '<') {
  126. throw new Error(
  127. "Invalid start range indicator '<' inside another range"
  128. )
  129. }
  130. if (content[i] === '>') {
  131. foundEnd = true
  132. selections.push(
  133. EditorSelection.range(startOfRange + offset, index + offset)
  134. )
  135. break
  136. }
  137. index++
  138. }
  139. if (!foundEnd) {
  140. throw new Error("Missing end range indicator '>'")
  141. }
  142. }
  143. index++
  144. }
  145. if (selections.length) {
  146. return EditorSelection.create(selections)
  147. }
  148. return null
  149. }
  150. declare global {
  151. // eslint-disable-next-line @typescript-eslint/no-namespace
  152. export namespace Chai {
  153. interface Assertion {
  154. line(lineNumber: number): Assertion
  155. }
  156. }
  157. }
  158. export function viewHelpers(chai: Chai.ChaiStatic, utils: Chai.ChaiUtils) {
  159. utils.addMethod(
  160. chai.Assertion.prototype,
  161. 'line',
  162. function getLine(this: Chai.Assertion, line: number) {
  163. const object = utils.flag(this, 'object')
  164. new Assertion(object).to.be.instanceOf(CodemirrorTestSession)
  165. const testSession = object as CodemirrorTestSession
  166. const lineInEditor = testSession.view.state.doc.line(line)
  167. utils.flag(this, 'object', lineInEditor.text)
  168. utils.flag(this, 'cmSession', testSession)
  169. utils.flag(this, 'line', lineInEditor)
  170. }
  171. )
  172. utils.overwriteMethod(chai.Assertion.prototype, 'equal', (_super: any) => {
  173. return function newEqual(
  174. this: Chai.Assertion,
  175. value: string,
  176. requireSelections?: boolean
  177. ) {
  178. const session = utils.flag(this, 'cmSession') as
  179. | CodemirrorTestSession
  180. | undefined
  181. utils.flag(this, 'cmSession', null)
  182. const line = utils.flag(this, 'line') as Line | undefined
  183. utils.flag(this, 'line', null)
  184. if (!session || !line) {
  185. // eslint-disable-next-line prefer-rest-params
  186. return _super.apply(this, arguments)
  187. }
  188. const lineContent = stripSelectionMarkers(value)
  189. if (requireSelections === undefined) {
  190. requireSelections = hasSelectionMarkers(value)
  191. }
  192. // We can now check selections as well
  193. const selections = createSelections(value, line.from)
  194. const contentAssertion = new Assertion(line.text)
  195. utils.transferFlags(this, contentAssertion)
  196. contentAssertion.to.equal(lineContent)
  197. if (selections) {
  198. const selectionAssertion = new Assertion(
  199. session.view.state.selection.ranges
  200. )
  201. utils.transferFlags(this, selectionAssertion, false)
  202. for (const rangeToMatch of selections.ranges) {
  203. selectionAssertion.satisfies(
  204. (ranges: SelectionRange[]) =>
  205. ranges.some(
  206. possibleMatch =>
  207. possibleMatch.eq(rangeToMatch) ||
  208. // Allow reverse selections as well, as we don't syntactically
  209. // distinguish them
  210. EditorSelection.range(
  211. possibleMatch.to,
  212. possibleMatch.from
  213. ).eq(rangeToMatch)
  214. ),
  215. `Selections [${session.view.state.selection.ranges
  216. .map(range => `{ from: ${range.from}, to: ${range.to}}`)
  217. .join(', ')}] did not include selection {from: ${
  218. rangeToMatch.from
  219. }, to: ${rangeToMatch.to}}`
  220. )
  221. }
  222. }
  223. }
  224. })
  225. }