latex-folding.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459
  1. import { expect } from 'chai'
  2. import { EditorState, Text } from '@codemirror/state'
  3. import { LaTeXLanguage } from '../../../../../../frontend/js/features/source-editor/languages/latex/latex-language'
  4. import {
  5. ensureSyntaxTree,
  6. foldNodeProp,
  7. LanguageSupport,
  8. } from '@codemirror/language'
  9. import { EditorView } from '@codemirror/view'
  10. const latex = new LanguageSupport(LaTeXLanguage)
  11. const makeView = (lines: string[]): EditorView => {
  12. const text = Text.of(lines)
  13. const view = new EditorView({
  14. state: EditorState.create({
  15. doc: text,
  16. extensions: [latex],
  17. }),
  18. })
  19. return view
  20. }
  21. type Fold = { from: number; to: number }
  22. const _getFolds = (view: EditorView) => {
  23. const ranges: Fold[] = []
  24. const tree = ensureSyntaxTree(view.state, view.state.doc.length)
  25. if (!tree) {
  26. throw new Error("Couldn't get Syntax Tree")
  27. }
  28. tree.iterate({
  29. enter: nodeRef => {
  30. const prop = nodeRef.type.prop(foldNodeProp)
  31. if (prop) {
  32. const hasFold = prop(nodeRef.node, view.state)
  33. if (hasFold) {
  34. ranges.push({ from: hasFold.from, to: hasFold.to })
  35. }
  36. }
  37. },
  38. })
  39. return ranges
  40. }
  41. describe('CodeMirror LaTeX-folding', function () {
  42. describe('With empty document', function () {
  43. let view: EditorView, content: string[]
  44. beforeEach(function () {
  45. content = ['']
  46. view = makeView(content)
  47. })
  48. it('should not produce any folds', function () {
  49. const folds = _getFolds(view)
  50. expect(folds).to.be.empty
  51. })
  52. })
  53. describe('Sectioning command folding', function () {
  54. describe('with no foldable sections', function () {
  55. let view: EditorView, content: string[]
  56. beforeEach(function () {
  57. content = ['hello', 'test']
  58. view = makeView(content)
  59. })
  60. it('should not produce any folds', function () {
  61. const folds = _getFolds(view)
  62. expect(folds).to.be.empty
  63. })
  64. })
  65. describe('with one foldable section', function () {
  66. let view: EditorView, content: string[]
  67. beforeEach(function () {
  68. content = ['hello', '\\section{one}', 'a', 'b', 'c']
  69. view = makeView(content)
  70. })
  71. it('should produce one fold', function () {
  72. const folds = _getFolds(view)
  73. expect(folds.length).to.equal(1)
  74. })
  75. it('should fold from the section line to last line', function () {
  76. const folds = _getFolds(view)
  77. const fold = folds[0]
  78. expect(view.state.doc.lineAt(fold.from).number).to.equal(2)
  79. expect(view.state.doc.lineAt(fold.to).number).to.equal(
  80. view.state.doc.lines
  81. )
  82. })
  83. })
  84. describe('with two foldable sections', function () {
  85. let view: EditorView, content: string[]
  86. beforeEach(function () {
  87. content = [
  88. 'hello',
  89. '\\section{one}',
  90. 'a',
  91. 'b',
  92. '\\section{two}',
  93. 'c',
  94. 'd',
  95. ]
  96. view = makeView(content)
  97. })
  98. it('should produce two folds', function () {
  99. const folds = _getFolds(view)
  100. expect(folds.length).to.equal(2)
  101. expect(view.state.doc.lineAt(folds[0].from).number).to.equal(2)
  102. expect(view.state.doc.lineAt(folds[0].to).number).to.equal(4)
  103. expect(view.state.doc.lineAt(folds[1].from).number).to.equal(5)
  104. expect(view.state.doc.lineAt(folds[1].to).number).to.equal(
  105. view.state.doc.lines
  106. )
  107. })
  108. })
  109. describe('with a comment line before an indented sectioning command', function () {
  110. let view: EditorView, content: string[]
  111. beforeEach(function () {
  112. // Reproduces a bug where a comment line followed by an indented
  113. // `\subsection` should not pull the next sectioning command's line
  114. // into the previous fold.
  115. content = [
  116. '\\section{1}',
  117. ' Content',
  118. ' \\subsection{1a}',
  119. ' Content',
  120. ' %',
  121. ' \\subsection{1b}',
  122. ' Content',
  123. ]
  124. view = makeView(content)
  125. })
  126. it('should fold subsection 1a up to the comment line, leaving subsection 1b on its own line', function () {
  127. const folds = _getFolds(view)
  128. const subsection1aFold = folds.find(
  129. fold => view.state.doc.lineAt(fold.from).number === 3
  130. )
  131. expect(subsection1aFold).not.to.be.undefined
  132. // The fold for subsection 1a should end on (or before) line 5 so that
  133. // line 6 (subsection 1b) can be folded independently.
  134. expect(
  135. view.state.doc.lineAt(subsection1aFold!.to).number
  136. ).to.be.at.most(5)
  137. })
  138. it('should still produce a separate fold for subsection 1b', function () {
  139. const folds = _getFolds(view)
  140. const subsection1bFold = folds.find(
  141. fold => view.state.doc.lineAt(fold.from).number === 6
  142. )
  143. expect(subsection1bFold).not.to.be.undefined
  144. })
  145. })
  146. describe('with an indented sectioning command after a blank line', function () {
  147. let view: EditorView, content: string[]
  148. beforeEach(function () {
  149. // The folding algorithm should not consider whitespace on the line
  150. // before an indented sectioning command as part of the previous
  151. // section's fold.
  152. content = ['\\section{1}', 'Content', '', ' \\section{2}', 'Content']
  153. view = makeView(content)
  154. })
  155. it('should not include the indented sectioning command line in the previous fold', function () {
  156. const folds = _getFolds(view)
  157. const firstFold = folds.find(
  158. fold => view.state.doc.lineAt(fold.from).number === 1
  159. )
  160. expect(firstFold).not.to.be.undefined
  161. expect(view.state.doc.lineAt(firstFold!.to).number).to.be.at.most(3)
  162. })
  163. })
  164. describe('with a complex mix of comments, blank lines and indented sectioning commands', function () {
  165. let view: EditorView, content: string[]
  166. beforeEach(function () {
  167. // Combines all the tricky cases:
  168. // - trailing `%` comment lines before indented and non-indented
  169. // sectioning commands
  170. // - blank lines (with and without trailing whitespace) before indented
  171. // sectioning commands
  172. // - multiple levels of (sub)sectioning at varying indentation
  173. content = [
  174. '\\section{1}',
  175. ' Content',
  176. ' \\subsection{1a}',
  177. ' Content a',
  178. ' %',
  179. ' \\subsubsection{1a1a}',
  180. ' Hello',
  181. ' %',
  182. '\\subsection{1b}',
  183. 'Content b',
  184. '',
  185. ' \\subsection{1c}',
  186. ' Content c',
  187. '',
  188. ' \\subsubsection{1c1a}',
  189. ' World',
  190. ' ',
  191. ' \\subsubsection{1c1b}',
  192. ' New ',
  193. ' ',
  194. ]
  195. view = makeView(content)
  196. })
  197. it('should produce a separate fold for each sectioning command', function () {
  198. const folds = _getFolds(view)
  199. const fromLines = folds
  200. .map(fold => view.state.doc.lineAt(fold.from).number)
  201. .sort((a, b) => a - b)
  202. // One fold per sectioning command line
  203. expect(fromLines).to.deep.equal([1, 3, 6, 9, 12, 15, 18])
  204. })
  205. it('should not pull a sibling sectioning command line into the previous fold', function () {
  206. const folds = _getFolds(view)
  207. const foldByFromLine = new Map<number, Fold>()
  208. for (const fold of folds) {
  209. foldByFromLine.set(view.state.doc.lineAt(fold.from).number, fold)
  210. }
  211. // Each subsection 1a / 1b / 1c sibling fold must end before the next
  212. // subsection's line so the next subsection can be folded independently.
  213. const siblingPairs: Array<[number, number]> = [
  214. [3, 9], // \subsection{1a} fold must end before line 9 (\subsection{1b})
  215. [9, 12], // \subsection{1b} fold must end before line 12 (\subsection{1c})
  216. [15, 18], // \subsubsection{1c1a} fold must end before line 18 (\subsubsection{1c1b})
  217. ]
  218. for (const [fromLine, nextFromLine] of siblingPairs) {
  219. const fold = foldByFromLine.get(fromLine)
  220. expect(fold, `fold starting on line ${fromLine}`).not.to.be.undefined
  221. expect(
  222. view.state.doc.lineAt(fold!.to).number,
  223. `fold starting on line ${fromLine} should end before line ${nextFromLine}`
  224. ).to.be.lessThan(nextFromLine)
  225. }
  226. })
  227. })
  228. describe('with realistic nesting', function () {
  229. let view: EditorView, content: string[]
  230. beforeEach(function () {
  231. content = [
  232. 'hello',
  233. '\\chapter{1}',
  234. ' a',
  235. ' \\section{1.1}',
  236. ' a',
  237. ' \\subsection{1.1.1}',
  238. ' a',
  239. ' \\section{1.2}',
  240. ' a',
  241. ' \\subsection{1.2.1}',
  242. ' a',
  243. '\\chapter{2}',
  244. ' a',
  245. ' \\section{2.1}',
  246. ' a',
  247. ' \\section{2.2}',
  248. ' a',
  249. ]
  250. view = makeView(content)
  251. })
  252. it('should produce many folds', function () {
  253. const folds = _getFolds(view)
  254. expect(folds.length).to.equal(8)
  255. const foldDescriptions = folds.map(fold => {
  256. const fromLine = view.state.doc.lineAt(fold.from).number
  257. const toLine = view.state.doc.lineAt(fold.to).number
  258. return { fromLine, toLine }
  259. })
  260. expect(foldDescriptions).to.deep.equal([
  261. { fromLine: 2, toLine: 11 },
  262. { fromLine: 4, toLine: 7 },
  263. { fromLine: 6, toLine: 7 },
  264. { fromLine: 8, toLine: 11 },
  265. { fromLine: 10, toLine: 11 },
  266. { fromLine: 12, toLine: 17 },
  267. { fromLine: 14, toLine: 15 },
  268. { fromLine: 16, toLine: 17 },
  269. ])
  270. })
  271. })
  272. })
  273. describe('Environment folding', function () {
  274. describe('with single environment', function () {
  275. let view: EditorView, content: string[]
  276. beforeEach(function () {
  277. content = ['\\begin{foo}', 'content', '\\end{foo}']
  278. view = makeView(content)
  279. })
  280. it('should fold the environment', function () {
  281. const folds = _getFolds(view)
  282. expect(folds.length).to.equal(1)
  283. expect(folds).to.deep.equal([{ from: 11, to: 20 }])
  284. })
  285. })
  286. describe('with nested environment', function () {
  287. let view: EditorView, content: string[]
  288. beforeEach(function () {
  289. content = [
  290. '\\begin{foo}',
  291. '\\begin{bar}',
  292. 'content',
  293. '\\end{bar}',
  294. '\\end{foo}',
  295. ]
  296. view = makeView(content)
  297. })
  298. it('should fold the environment', function () {
  299. const folds = _getFolds(view)
  300. expect(folds.length).to.equal(2)
  301. expect(folds).to.deep.equal([
  302. { from: 11, to: 42 },
  303. { from: 23, to: 32 },
  304. ])
  305. })
  306. })
  307. })
  308. describe('Comment folding', function () {
  309. describe('with a single set of comments', function () {
  310. let view: EditorView, content: string[]
  311. beforeEach(function () {
  312. content = ['Hello', '% {', 'this is folded', '% }', 'End']
  313. view = makeView(content)
  314. })
  315. it('should fold the region marked by comments', function () {
  316. const folds = _getFolds(view)
  317. expect(folds.length).to.equal(1)
  318. expect(folds).to.deep.equal([{ from: 9, to: 27 }])
  319. })
  320. })
  321. describe('with several sets of comments', function () {
  322. let view: EditorView, content: string[]
  323. beforeEach(function () {
  324. content = [
  325. 'Hello',
  326. '% {',
  327. 'this is folded',
  328. '% }',
  329. '',
  330. '% {',
  331. 'and this also',
  332. '% }',
  333. 'End',
  334. ]
  335. view = makeView(content)
  336. })
  337. it('should fold both regions marked by comments', function () {
  338. const folds = _getFolds(view)
  339. expect(folds.length).to.equal(2)
  340. expect(folds).to.deep.equal([
  341. { from: 9, to: 27 },
  342. { from: 33, to: 50 },
  343. ])
  344. })
  345. })
  346. describe('with nested sets of comments', function () {
  347. let view: EditorView, content: string[]
  348. beforeEach(function () {
  349. content = [
  350. 'Hello',
  351. '% {',
  352. 'one',
  353. '% {',
  354. 'two',
  355. '% {',
  356. 'three',
  357. '% }',
  358. 'two',
  359. '% }',
  360. 'one',
  361. '% }',
  362. 'End',
  363. ]
  364. view = makeView(content)
  365. })
  366. it('should fold all the regions marked by comments, with nesting', function () {
  367. const folds = _getFolds(view)
  368. expect(folds.length).to.equal(3)
  369. expect(folds).to.deep.equal([
  370. { from: 9, to: 50 },
  371. { from: 17, to: 42 },
  372. { from: 25, to: 34 },
  373. ])
  374. })
  375. })
  376. describe('with fold comment spanning entire document', function () {
  377. let view: EditorView, content: string[]
  378. beforeEach(function () {
  379. content = ['% {', 'Hello', '% }']
  380. view = makeView(content)
  381. })
  382. it('should fold', function () {
  383. const folds = _getFolds(view)
  384. expect(folds.length).to.equal(1)
  385. expect(folds).to.deep.equal([{ from: 3, to: 12 }])
  386. })
  387. })
  388. describe('with fold comment at start of document', function () {
  389. let view: EditorView, content: string[]
  390. beforeEach(function () {
  391. content = ['% {', 'Hello', '% }', 'Test']
  392. view = makeView(content)
  393. })
  394. it('should fold', function () {
  395. const folds = _getFolds(view)
  396. expect(folds.length).to.equal(1)
  397. expect(folds).to.deep.equal([{ from: 3, to: 12 }])
  398. })
  399. })
  400. describe('with fold comment at end of document', function () {
  401. let view: EditorView, content: string[]
  402. beforeEach(function () {
  403. content = ['Test', '% {', 'Hello', '% }']
  404. view = makeView(content)
  405. })
  406. it('should fold', function () {
  407. const folds = _getFolds(view)
  408. expect(folds.length).to.equal(1)
  409. expect(folds).to.deep.equal([{ from: 8, to: 17 }])
  410. })
  411. })
  412. })
  413. })