Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol diffdown.com
at 87b736ef368a36cd6d4ebbcb18d2b4b30dc67f6d 265 lines 8.7 kB view raw
1import {Tree, TreeFragment} from "@lezer/common" 2import ist from "ist" 3import {parser} from "../dist/index.js" 4import {compareTree} from "./compare-tree.js" 5 6let doc1 = ` 7Header 8--- 9One **two** 10three *four* 11five. 12 13> Start of quote 14> 15> 1. Nested list 16> 17> 2. More content 18> inside the [list][link] 19> 20> Continued item 21> 22> ~~~ 23> Block of code 24> ~~~ 25> 26> 3. And so on 27 28[link]: /ref 29[another]: /one 30And a final paragraph. 31 *** 32The end. 33` 34 35type ChangeSpec = {from: number, to?: number, insert?: string}[] 36 37class State { 38 constructor(readonly doc: string, 39 readonly tree: Tree, 40 readonly fragments: readonly TreeFragment[]) {} 41 42 static start(doc: string) { 43 let tree = parser.parse(doc) 44 return new State(doc, tree, TreeFragment.addTree(tree)) 45 } 46 47 update(changes: ChangeSpec, reparse = true) { 48 let changed = [], doc = this.doc, off = 0 49 for (let {from, to = from, insert = ""} of changes) { 50 doc = doc.slice(0, from) + insert + doc.slice(to) 51 changed.push({fromA: from - off, toA: to - off, fromB: from, toB: from + insert.length}) 52 off += insert.length - (to - from) 53 } 54 let fragments = TreeFragment.applyChanges(this.fragments, changed, 2) 55 if (!reparse) return new State(doc, Tree.empty, fragments) 56 let tree = parser.parse(doc, fragments) 57 return new State(doc, tree, TreeFragment.addTree(tree, fragments)) 58 } 59} 60 61let _state1: State | null = null, state1 = () => _state1 || (_state1 = State.start(doc1)) 62 63function overlap(a: Tree, b: Tree) { 64 let inA = new Set<Tree>(), shared = 0, sharingTo = 0 65 for (let cur = a.cursor(); cur.next();) if (cur.tree) inA.add(cur.tree) 66 for (let cur = b.cursor(); cur.next();) if (cur.tree && inA.has(cur.tree) && cur.type.is("Block") && cur.from >= sharingTo) { 67 shared += cur.to - cur.from 68 sharingTo = cur.to 69 } 70 return Math.round(shared * 100 / b.length) 71} 72 73function testChange(change: ChangeSpec, reuse = 10) { 74 let state = state1().update(change) 75 compareTree(state.tree, parser.parse(state.doc)) 76 if (reuse) ist(overlap(state.tree, state1().tree), reuse, ">") 77} 78 79describe("Markdown incremental parsing", () => { 80 it("can produce the proper tree", () => { 81 // Replace 'three' with 'bears' 82 let state = state1().update([{from: 24, to: 29, insert: "bears"}]) 83 compareTree(state.tree, state1().tree) 84 }) 85 86 it("reuses nodes from the previous parse", () => { 87 // Replace 'three' with 'bears' 88 let state = state1().update([{from: 24, to: 29, insert: "bears"}]) 89 ist(overlap(state1().tree, state.tree), 80, ">") 90 }) 91 92 it("can reuse content for a change in a block context", () => { 93 // Replace 'content' with 'monkeys' 94 let state = state1().update([{from: 92, to: 99, insert: "monkeys"}]) 95 compareTree(state.tree, state1().tree) 96 ist(overlap(state1().tree, state.tree), 20, ">") 97 }) 98 99 it("can handle deleting a quote mark", () => testChange([{from: 82, to: 83}])) 100 101 it("can handle adding to a quoted block", () => testChange([{from: 37, insert: "> "}, {from: 45, insert: "> "}])) 102 103 it("can handle a change in a post-linkref paragraph", () => testChange([{from: 249, to: 251}])) 104 105 it("can handle a change in a paragraph-adjacent linkrefs", () => testChange([{from: 230, to: 231}])) 106 107 it("can deal with multiple changes applied separately", () => { 108 let state = state1().update([{from: 190, to: 191}], false).update([{from: 30, insert: "hi\n\nyou"}]) 109 compareTree(state.tree, parser.parse(state.doc)) 110 ist(overlap(state.tree, state1().tree), 20, ">") 111 }) 112 113 it("works when a change happens directly after a block", () => testChange([{from: 150, to: 167}])) 114 115 it("works when a change deletes a blank line after a paragraph", () => testChange([{from: 207, to: 213}])) 116 117 it("doesn't get confused by removing paragraph-breaking markup", () => testChange([{from: 264, to: 265}])) 118 119 function r(n: number) { return Math.floor(Math.random() * n) } 120 function rStr(len: number) { 121 let result = "", chars = "\n>x-" 122 while (result.length < len) result += chars[r(chars.length)] 123 return result 124 } 125 126 it("survives random changes", () => { 127 for (let i = 0, l = doc1.length; i < 20; i++) { 128 let c = 1 + r(4), changes = [] 129 for (let i = 0, rFrom = 0; i < c; i++) { 130 let rTo = rFrom + Math.floor((l - rFrom) / (c - i)) 131 let from = rFrom + r(rTo - rFrom - 1), to = r(2) == 1 ? from : from + r(Math.min(rTo - from, 20)) 132 let iR = r(3), insert = iR == 0 && from != to ? "" : iR == 1 ? "\n\n" : rStr(r(5) + 1) 133 changes.push({from, to, insert}) 134 l += insert.length - (to - from) 135 rFrom = to + insert.length 136 } 137 testChange(changes, 0) 138 } 139 }) 140 141 it("can handle large documents", () => { 142 let doc = doc1.repeat(50) 143 let state = State.start(doc) 144 let newState = state.update([{from: doc.length >> 1, insert: "a\n\nb"}]) 145 ist(overlap(state.tree, newState.tree), 90, ">") 146 }) 147 148 it("properly re-parses a continued indented code block", () => { 149 let state = State.start(` 150One paragraph to create a bit of string length here 151 152 Code 153 Block 154 155 156 157Another paragraph that is long enough to create a fragment 158`).update([{from: 76, insert: " "}]) 159 compareTree(state.tree, parser.parse(state.doc)) 160 }) 161 162 it("properly re-parses a continued list", () => { 163 let state = State.start(` 164One paragraph to create a bit of string length here 165 166 * List 167 168 169 170More content 171 172Another paragraph that is long enough to create a fragment 173`).update([{from: 65, insert: " * "}]) 174 compareTree(state.tree, parser.parse(state.doc)) 175 }) 176 177 it("can recover from incremental parses that stop in the middle of a list", () => { 178 let doc = ` 1791. I am a list item with ***some* emphasized 180 content inside** and the parser hopefully stops 181 parsing after me. 182 1832. Oh no the list continues. 184` 185 let parse = parser.startParse(doc), tree 186 parse.advance() 187 ist(parse.parsedPos, doc.length, "<") 188 parse.stopAt(parse.parsedPos) 189 while (!(tree = parse.advance())) {} 190 let state = new State(doc, tree, TreeFragment.addTree(tree)).update([]) 191 ist(state.tree.topNode.lastChild!.from, 1) 192 }) 193 194 it("can reuse list items", () => { 195 let start = State.start(" - List item\n".repeat(100)) 196 let state = start.update([{from: 18, to: 19}]) 197 ist(overlap(start.tree, state.tree), 80, ">") 198 }) 199 200 it("can reuse regular blocks before a continuable block", () => { 201 let start = State.start("A reusable paragraph\n\n".repeat(50) + "- etc\n\n") 202 let state = start.update([{from: start.doc.length, insert: "x"}]) 203 ist(overlap(start.tree, state.tree), 85, ">") 204 }) 205 206 it("returns a tree starting at the first range", () => { 207 let result = parser.parse("foo\n\nbar", [], [{from: 5, to: 8}]) 208 ist(result.toString(), "Document(Paragraph)") 209 ist(result.length, 3) 210 ist(result.positions[0], 0) 211 }) 212 213 it("Allows gaps in the input", () => { 214 let doc = ` 215The first X *y* X< 216 217>X paragraph. 218 219 - And *a X<*>X list* 220` 221 let tree = parser.parse(doc, [], [{from: 0, to: 11}, {from: 12, to: 17}, {from: 23, to: 46}, {from: 51, to: 58}]) 222 ist(tree.toString(), 223 "Document(Paragraph(Emphasis(EmphasisMark,EmphasisMark)),BulletList(ListItem(ListMark,Paragraph(Emphasis(EmphasisMark,EmphasisMark)))))") 224 ist(tree.length, doc.length) 225 let top = tree.topNode, first = top.firstChild! 226 ist(first.name, "Paragraph") 227 ist(first.from, 1) 228 ist(first.to, 34) 229 let last = top.lastChild!.lastChild!.lastChild!, em = last.lastChild! 230 ist(last.name, "Paragraph") 231 ist(last.from, 39) 232 ist(last.to, 57) 233 ist(em.name, "Emphasis") 234 ist(em.from, 43) 235 ist(em.to, 57) 236 }) 237 238 it("can reuse nodes at the end of the document", () => { 239 let doc = `* List item 240 241~~~js 242function foo() { 243 return false 244} 245~~~ 246` 247 let tree = parser.parse(doc) 248 let ins = 11 249 let doc2 = doc.slice(0, ins) + "\n* " + doc.slice(ins) 250 let fragments = TreeFragment.applyChanges(TreeFragment.addTree(tree), [{fromA: ins, toA: ins, fromB: ins, toB: ins + 3}]) 251 let tree2 = parser.parse(doc2, fragments) 252 ist(tree2.topNode.lastChild!.tree, tree.topNode.lastChild!.tree) 253 }) 254 255 it("places reused nodes at the right position when there are gaps before them", () => { 256 let doc = " {{}}\nb\n{{}}" 257 let ast1 = parser.parse(doc, undefined, [{from: 0, to: 1}, {from: 5, to: 8}]) 258 let frag = TreeFragment.applyChanges(TreeFragment.addTree(ast1), [{fromA: 0, toA: 0, fromB: 0, toB: 1}]) 259 let ast2 = parser.parse(" " + doc, frag, [{from: 0, to: 2}, {from: 6, to: 9}]) 260 ist(ast2.toString(), "Document(Paragraph)") 261 let p = ast2.topNode.firstChild! 262 ist(p.from, 7) 263 ist(p.to, 8) 264 }) 265})