latex-outline.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568
  1. import { LanguageSupport } from '@codemirror/language'
  2. import { EditorState, Text } from '@codemirror/state'
  3. import { EditorView } from '@codemirror/view'
  4. import { expect } from 'chai'
  5. import { documentOutline } from '../../../../../../frontend/js/features/source-editor/languages/latex/document-outline'
  6. import {
  7. FlatOutline,
  8. getNestingLevel,
  9. } from '../../../../../../frontend/js/features/source-editor/utils/tree-query'
  10. import { LaTeXLanguage } from '../../../../../../frontend/js/features/source-editor/languages/latex/latex-language'
  11. import {
  12. Book,
  13. Chapter,
  14. Paragraph,
  15. Part,
  16. Section,
  17. SubParagraph,
  18. SubSection,
  19. SubSubSection,
  20. } from '../../../../../../frontend/js/features/source-editor/lezer-latex/latex.terms.mjs'
  21. const latex = new LanguageSupport(LaTeXLanguage, documentOutline.extension)
  22. const makeView = (lines: string[]): EditorView => {
  23. const text = Text.of(lines)
  24. const view = new EditorView({
  25. state: EditorState.create({
  26. doc: text,
  27. extensions: [latex],
  28. }),
  29. })
  30. return view
  31. }
  32. const BOOK_LEVEL = getNestingLevel(Book)
  33. const PART_LEVEL = getNestingLevel(Part)
  34. const CHAPTER_LEVEL = getNestingLevel(Chapter)
  35. const SECTION_LEVEL = getNestingLevel(Section)
  36. const SUB_SECTION_LEVEL = getNestingLevel(SubSection)
  37. const SUB_SUB_SECTION_LEVEL = getNestingLevel(SubSubSection)
  38. const PARAGRAPH_LEVEL = getNestingLevel(Paragraph)
  39. const SUB_PARAGRAPH_LEVEL = getNestingLevel(SubParagraph)
  40. const FRAME_LEVEL = getNestingLevel('frame')
  41. const insertText = (view: EditorView, position: number, text: string) => {
  42. view.dispatch({
  43. changes: [{ from: position, insert: text }],
  44. })
  45. }
  46. const deleteText = (view: EditorView, position: number, length: number) => {
  47. view.dispatch({
  48. changes: [{ from: position - length, to: position }],
  49. })
  50. }
  51. const getOutline = (view: EditorView): FlatOutline | null => {
  52. return view.state.field(documentOutline)?.items || null
  53. }
  54. describe('CodeMirror LaTeX-FileOutline', function () {
  55. describe('with no update', function () {
  56. describe('an empty document', function () {
  57. let view: EditorView, content: string[]
  58. beforeEach(function () {
  59. content = ['']
  60. view = makeView(content)
  61. })
  62. it('should have empty outline', function () {
  63. const outline = getOutline(view)
  64. expect(outline).to.be.empty
  65. })
  66. })
  67. describe('a document with nested sections', function () {
  68. let view: EditorView, content: string[]
  69. beforeEach(function () {
  70. content = [
  71. 'line 1',
  72. '\\section{sec title}',
  73. 'content',
  74. '\\subsection{subsec title}',
  75. ]
  76. view = makeView(content)
  77. })
  78. it('should have outline with different levels', function () {
  79. const outline = getOutline(view)
  80. expect(outline).to.be.deep.equal([
  81. {
  82. from: 7,
  83. to: 26,
  84. level: SECTION_LEVEL,
  85. title: 'sec title',
  86. line: 2,
  87. toLine: 2,
  88. },
  89. {
  90. from: 35,
  91. to: 60,
  92. level: SUB_SECTION_LEVEL,
  93. title: 'subsec title',
  94. line: 4,
  95. toLine: 4,
  96. },
  97. ])
  98. })
  99. })
  100. describe('a document with sibling sections', function () {
  101. let view: EditorView, content: string[]
  102. beforeEach(function () {
  103. content = [
  104. 'line 1',
  105. '\\section{sec title 1}',
  106. 'content',
  107. '\\section{sec title 2}',
  108. ]
  109. view = makeView(content)
  110. })
  111. it('should have outline with same levels for siblings', function () {
  112. const outline = getOutline(view)
  113. expect(outline).to.be.deep.equal([
  114. {
  115. from: 7,
  116. to: 28,
  117. level: SECTION_LEVEL,
  118. title: 'sec title 1',
  119. line: 2,
  120. toLine: 2,
  121. },
  122. {
  123. from: 37,
  124. to: 58,
  125. level: SECTION_LEVEL,
  126. title: 'sec title 2',
  127. line: 4,
  128. toLine: 4,
  129. },
  130. ])
  131. })
  132. })
  133. })
  134. describe('with change to title', function () {
  135. let view: EditorView, content: string[]
  136. beforeEach(function () {
  137. content = ['\\section{title }']
  138. view = makeView(content)
  139. const initialOutline = getOutline(view)
  140. expect(initialOutline).to.deep.equal([
  141. {
  142. from: 0,
  143. to: 16,
  144. title: 'title ',
  145. line: 1,
  146. toLine: 1,
  147. level: SECTION_LEVEL,
  148. },
  149. ])
  150. })
  151. describe('for appending to title', function () {
  152. beforeEach(function () {
  153. insertText(view, 15, '1')
  154. })
  155. it('should update title in outline', function () {
  156. const updatedOutline = getOutline(view)
  157. expect(updatedOutline).to.deep.equal([
  158. {
  159. from: 0,
  160. to: 17,
  161. title: 'title 1',
  162. line: 1,
  163. toLine: 1,
  164. level: SECTION_LEVEL,
  165. },
  166. ])
  167. })
  168. })
  169. describe('for removing from title', function () {
  170. beforeEach(function () {
  171. deleteText(view, 15, 1)
  172. })
  173. it('should update title in outline', function () {
  174. const updatedOutline = getOutline(view)
  175. expect(updatedOutline).to.deep.equal([
  176. {
  177. from: 0,
  178. to: 15,
  179. title: 'title',
  180. line: 1,
  181. toLine: 1,
  182. level: SECTION_LEVEL,
  183. },
  184. ])
  185. })
  186. })
  187. })
  188. describe('for moving section', function () {
  189. let view: EditorView, content: string[]
  190. beforeEach(function () {
  191. content = ['\\section{title}', '\\subsection{subtitle}']
  192. view = makeView(content)
  193. const initialOutline = getOutline(view)
  194. expect(initialOutline).to.deep.equal([
  195. {
  196. from: 0,
  197. to: 15,
  198. title: 'title',
  199. line: 1,
  200. toLine: 1,
  201. level: SECTION_LEVEL,
  202. },
  203. {
  204. from: 16,
  205. to: 37,
  206. title: 'subtitle',
  207. line: 2,
  208. toLine: 2,
  209. level: SUB_SECTION_LEVEL,
  210. },
  211. ])
  212. insertText(view, 15, '\n')
  213. })
  214. it('should update position for moved section', function () {
  215. const updatedOutline = getOutline(view)
  216. expect(updatedOutline).to.deep.equal([
  217. {
  218. from: 0,
  219. to: 15,
  220. title: 'title',
  221. line: 1,
  222. toLine: 1,
  223. level: SECTION_LEVEL,
  224. },
  225. {
  226. from: 17,
  227. to: 38,
  228. title: 'subtitle',
  229. line: 3,
  230. toLine: 3,
  231. level: SUB_SECTION_LEVEL,
  232. },
  233. ])
  234. })
  235. })
  236. describe('for removing a section', function () {
  237. let view: EditorView, content: string[]
  238. beforeEach(function () {
  239. content = ['\\section{title}']
  240. view = makeView(content)
  241. const initialOutline = getOutline(view)
  242. expect(initialOutline).to.deep.equal([
  243. {
  244. from: 0,
  245. to: 15,
  246. title: 'title',
  247. line: 1,
  248. toLine: 1,
  249. level: SECTION_LEVEL,
  250. },
  251. ])
  252. deleteText(view, 4, 1)
  253. })
  254. it('should remove the section from the outline', function () {
  255. const updatedOutline = getOutline(view)
  256. expect(updatedOutline).to.be.empty
  257. })
  258. })
  259. describe('for changing parent section', function () {
  260. let view: EditorView, content: string[]
  261. beforeEach(function () {
  262. content = [
  263. '\\section{section}',
  264. '%\\subsection{subsection}', // initially commented out
  265. '\\subsubsection{subsubsection}',
  266. ]
  267. view = makeView(content)
  268. const initialOutline = getOutline(view)
  269. expect(initialOutline).to.deep.equal([
  270. {
  271. from: 0,
  272. to: 17,
  273. title: 'section',
  274. line: 1,
  275. toLine: 1,
  276. level: SECTION_LEVEL,
  277. },
  278. {
  279. from: 43,
  280. to: 72,
  281. title: 'subsubsection',
  282. line: 3,
  283. toLine: 3,
  284. level: SUB_SUB_SECTION_LEVEL,
  285. },
  286. ])
  287. // Remove the %
  288. deleteText(view, 19, 1)
  289. })
  290. it('should be nested properly', function () {
  291. const updatedOutline = getOutline(view)
  292. expect(updatedOutline).to.deep.equal([
  293. {
  294. from: 0,
  295. to: 17,
  296. title: 'section',
  297. line: 1,
  298. toLine: 1,
  299. level: SECTION_LEVEL,
  300. },
  301. {
  302. from: 18,
  303. to: 41,
  304. title: 'subsection',
  305. line: 2,
  306. toLine: 2,
  307. level: SUB_SECTION_LEVEL,
  308. },
  309. {
  310. from: 42,
  311. to: 71,
  312. title: 'subsubsection',
  313. line: 3,
  314. toLine: 3,
  315. level: SUB_SUB_SECTION_LEVEL,
  316. },
  317. ])
  318. })
  319. })
  320. describe('for a sectioning command inside a newcommand or renewcommand', function () {
  321. let view: EditorView, content: string[]
  322. beforeEach(function () {
  323. content = [
  324. '\\section{section}',
  325. '\\newcommand{\\test}{\\section{should not display}}',
  326. '\\renewcommand{\\test}{\\section{should still not display}}',
  327. ]
  328. view = makeView(content)
  329. })
  330. it('should not include them in the outline', function () {
  331. const outline = getOutline(view)
  332. expect(outline?.length).to.equal(1)
  333. expect(outline).to.deep.equal([
  334. {
  335. from: 0,
  336. to: 17,
  337. title: 'section',
  338. line: 1,
  339. toLine: 1,
  340. level: SECTION_LEVEL,
  341. },
  342. ])
  343. })
  344. })
  345. describe('for all section types', function () {
  346. let view: EditorView, content: string[]
  347. beforeEach(function () {
  348. content = [
  349. '\\book{book}',
  350. '\\part{part}',
  351. '\\chapter{chapter}',
  352. '\\section{section}',
  353. '\\subsection{subsection}',
  354. '\\subsubsection{subsubsection}',
  355. '\\paragraph{paragraph}',
  356. '\\subparagraph{subparagraph}',
  357. ]
  358. view = makeView(content)
  359. })
  360. it('should include them in the file outline', function () {
  361. const outline = getOutline(view)
  362. expect(outline).to.deep.equal([
  363. {
  364. from: 0,
  365. to: 11,
  366. title: 'book',
  367. line: 1,
  368. toLine: 1,
  369. level: BOOK_LEVEL,
  370. },
  371. {
  372. from: 12,
  373. to: 23,
  374. title: 'part',
  375. line: 2,
  376. toLine: 2,
  377. level: PART_LEVEL,
  378. },
  379. {
  380. from: 24,
  381. to: 41,
  382. title: 'chapter',
  383. line: 3,
  384. toLine: 3,
  385. level: CHAPTER_LEVEL,
  386. },
  387. {
  388. from: 42,
  389. to: 59,
  390. title: 'section',
  391. line: 4,
  392. toLine: 4,
  393. level: SECTION_LEVEL,
  394. },
  395. {
  396. from: 60,
  397. to: 83,
  398. title: 'subsection',
  399. line: 5,
  400. toLine: 5,
  401. level: SUB_SECTION_LEVEL,
  402. },
  403. {
  404. from: 84,
  405. to: 113,
  406. title: 'subsubsection',
  407. line: 6,
  408. toLine: 6,
  409. level: SUB_SUB_SECTION_LEVEL,
  410. },
  411. {
  412. from: 114,
  413. to: 135,
  414. title: 'paragraph',
  415. line: 7,
  416. toLine: 7,
  417. level: PARAGRAPH_LEVEL,
  418. },
  419. {
  420. from: 136,
  421. to: 163,
  422. title: 'subparagraph',
  423. line: 8,
  424. toLine: 8,
  425. level: SUB_PARAGRAPH_LEVEL,
  426. },
  427. ])
  428. })
  429. })
  430. describe('sectioning commands with optional arguments', function () {
  431. let view: EditorView, content: string[]
  432. beforeEach(function () {
  433. content = ['\\section[short title]{section}']
  434. view = makeView(content)
  435. })
  436. it('should use the long argument as title', function () {
  437. const outline = getOutline(view)
  438. expect(outline).to.deep.equal([
  439. {
  440. from: 0,
  441. to: 30,
  442. title: 'section',
  443. line: 1,
  444. toLine: 1,
  445. level: SECTION_LEVEL,
  446. },
  447. ])
  448. })
  449. })
  450. describe('for labels using texorpdfstring', function () {
  451. let view: EditorView, content: string[]
  452. beforeEach(function () {
  453. content = [
  454. '\\section{The \\texorpdfstring{function $f(x) = x^2$}{function f(x) = x^2}: Properties of \\texorpdfstring{$x$}{x}.}',
  455. ]
  456. view = makeView(content)
  457. })
  458. it('should use the text argument as title', function () {
  459. const outline = getOutline(view)
  460. expect(outline).to.deep.equal([
  461. {
  462. from: 0,
  463. to: 113,
  464. title: 'The function f(x) = x^2: Properties of x.',
  465. line: 1,
  466. toLine: 1,
  467. level: SECTION_LEVEL,
  468. },
  469. ])
  470. })
  471. })
  472. describe('for ill-formed \\def command', function () {
  473. let view: EditorView, content: string[]
  474. beforeEach(function () {
  475. content = ['\\def\\x{', '\\section{test}', '\\subsection{test2}']
  476. view = makeView(content)
  477. })
  478. it('still shows an outline', function () {
  479. const outline = getOutline(view)
  480. expect(outline).to.deep.equal([
  481. {
  482. from: 8,
  483. to: 22,
  484. title: 'test',
  485. line: 2,
  486. toLine: 2,
  487. level: SECTION_LEVEL,
  488. },
  489. {
  490. from: 23,
  491. to: 41,
  492. title: 'test2',
  493. line: 3,
  494. toLine: 3,
  495. level: SUB_SECTION_LEVEL,
  496. },
  497. ])
  498. })
  499. })
  500. describe('for beamer frames', function () {
  501. describe('with titles', function () {
  502. let view: EditorView, content: string[]
  503. beforeEach(function () {
  504. content = ['\\begin{frame}{frame title}{}', '\\end{frame}']
  505. view = makeView(content)
  506. })
  507. it('should show up in the file outline', function () {
  508. const outline = getOutline(view)
  509. expect(outline).to.deep.equal([
  510. {
  511. from: 0,
  512. to: 28,
  513. title: 'frame title',
  514. line: 1,
  515. toLine: 1,
  516. level: FRAME_LEVEL,
  517. },
  518. ])
  519. })
  520. })
  521. describe('without titles', function () {
  522. let view: EditorView, content: string[]
  523. beforeEach(function () {
  524. content = ['\\begin{frame}', '\\end{frame}']
  525. view = makeView(content)
  526. })
  527. it('should not show up in the file outline', function () {
  528. const outline = getOutline(view)
  529. expect(outline).to.be.empty
  530. })
  531. })
  532. })
  533. })