diff --git a/demos/tree-sitter/matches.md b/demos/tree-sitter/matches.md new file mode 100644 index 000000000..ca53ac28d --- /dev/null +++ b/demos/tree-sitter/matches.md @@ -0,0 +1,38 @@ +# Matches + + \ No newline at end of file diff --git a/src/client/Falleri2014FGA_alorighm1.png b/src/client/Falleri2014FGA_alorighm1.png new file mode 100644 index 000000000..e1fd35abc Binary files /dev/null and b/src/client/Falleri2014FGA_alorighm1.png differ diff --git a/src/client/domain-code.js b/src/client/domain-code.js index 1df18333c..38ffcd20f 100644 --- a/src/client/domain-code.js +++ b/src/client/domain-code.js @@ -12,18 +12,25 @@ MD*/ import tinycolor from 'src/external/tinycolor.js'; -import {Parser, JavaScript} from "src/client/tree-sitter.js" +import {Parser, JavaScript, visit as treeSitterVisit} from "src/client/tree-sitter.js" import {loc} from "utils" -export function treesitterVisit(node, func) { - func(node) - for(let i=0; i< node.childCount; i++) { - let ea = node.child(i) - treesitterVisit(ea, func) - } -} - +// from: https://stackoverflow.com/questions/7616461/generate-a-hash-from-string-in-javascript +const cyrb53 = (str, seed = 0) => { + let h1 = 0xdeadbeef ^ seed, h2 = 0x41c6ce57 ^ seed; + for(let i = 0, ch; i < str.length; i++) { + ch = str.charCodeAt(i); + h1 = Math.imul(h1 ^ ch, 2654435761); + h2 = Math.imul(h2 ^ ch, 1597334677); + } + h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507); + h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909); + h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507); + h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909); + + return 4294967296 * (2097151 & h2) + (h1 >>> 0); +}; export class DomainObject { @@ -40,6 +47,10 @@ export class DomainObject { return true } + get isTreeSitter() { + return false + } + get isReplacement() { return false } @@ -87,8 +98,14 @@ export class DomainObject { **Strategy**: write tests and then go either way and see if we arrive there... MD*/ - static updateFromTreeSitter(rootNode, treeSitterNode) { - debugger + static updateFromTreeSitter(rootNode, treeSitterNode, edit) { + + // #TODO since TreeSitter does not reuse ids or update the old tree lets do it ourself + // we can find the corresbonding new AST nodes (for simple edits) by using the poisition + // (and modify with delta in edit accordingly) + + + let usedDomainObjects = new Set() let removedDomainObjects = new Set() let addedDomainObjects = new Set() @@ -106,7 +123,7 @@ export class DomainObject { }) - var newRootNode = TreeSitterDomainObject.fromTreeSitterAST(treeSitterNode, domainObjectsById, usedDomainObjects) + var newRootNode = TreeSitterDomainObject.fromTreeSitterAST(treeSitterNode) for(let replacement of replacementsForDomainObject.values()) { @@ -129,6 +146,116 @@ export class DomainObject { rootNode.treeSitter = newRootNode.treeSitter rootNode.children = newRootNode.children } + + + static adjustIndex(index, edit) { + + const delta = edit.newEndIndex - edit.oldEndIndex + if (index > edit.startIndex) { + return index + delta + } else { + return index + } + } + + static edit(rootDomainObject, sourceNew, edit ) { + + let { startIndex, oldEndIndex, newEndIndex } = edit + + // 1. detect editit history (diff oldTree -> newTree) + // a) deleted nodes from oldTree + // b) added nodes in new tree + + // 2. apply diff to domain tree + + + function assert(b) { if (!b) throw new Error() } + + // #TODO use incremental re-parse via edit() + const newTree = TreeSitterDomainObject.astFromSource(sourceNew) + + var tsQueue = [newTree.rootNode] + var doQueue = [rootDomainObject] + + while (tsQueue.length > 0) { + const tsNode = tsQueue.pop(); + const doNode = doQueue.pop(); + assert(tsNode.type === doNode.type); + + const lostChildren = [] + const missingOldChildren = [...doNode.children] + + // go over all new children, if we find a new child without an old child, create a new one + // if we do find a match, update the treeSitter reference + for (let i = 0; i < tsNode.childCount; i++) { + const tsChild = tsNode.child(i) + const doChild = doNode.children.find(child => tsChild.text === child.treeSitter.text) + if (!doChild) { + lostChildren.push([i, tsChild]) + } else { + doChild.treeSitter = tsChild + missingOldChildren.splice(missingOldChildren.indexOf(tsChild), 1) + } + } + + for (const [i, tsChild] of lostChildren) { + // we didn't find the exact child but maybe we can keep some of its children, if the + // type of the node is still the same (e.g., it's still a function) + const candidate = missingOldChildren.find(old => old.treeSitter.type === tsChild.type) + if (candidate) { + tsQueue.push(tsChild) + doQueue.push(candidate) + missingOldChildren.splice(missingOldChildren.indexOf(candidate), 1) + } else { + doNode.children.splice(i, 0, TreeSitterDomainObject.fromTreeSitterAST(tsChild)) + } + } + + for (const missing of missingOldChildren) { + doNode.children.splice(doNode.children.indexOf(missing), 1) + } + } + + + /* + const treeSitterNodesByLocationAndHash = new Map(); + const remainingNewTreeSitterNodes = new Set() + treeSitterVisit(newTree.rootNode, node => { + let hash = cyrb53(node.text) + let locAndHash = node.startIndex + "-" + node.endIndex + "-" + hash + treeSitterNodesByLocationAndHash.set(locAndHash, node) + remainingNewTreeSitterNodes.add(node) + }) + + function getTreeSitterNodesByLocationAndHash(from, to, hash) { + return treeSitterNodesByLocationAndHash.get(from + "-" + to + "-" + hash) + } + + let addedTreeSitterNodes = [] + let removedDomainObjects = [] + + rootDomainObject.visit(domainObject => { + let adjustedStartIndex = this.adjustIndex(domainObject.treeSitter.startIndex, edit) + let adjustedEndIndex = this.adjustIndex(domainObject.treeSitter.endIndex, edit) + let hash = cyrb53(domainObject.treeSitter.text) + + var correspondingNewTreeSitterNode = getTreeSitterNodesByLocationAndHash(adjustedStartIndex, adjustedEndIndex, hash) + + if (correspondingNewTreeSitterNode) { + // take new correspondingNewTreeSitterNode as your own + remainingNewTreeSitterNodes.delete(correspondingNewTreeSitterNode) + } else { + removedDomainObjects.push(domainObject) + } + }) + + for(let node of remainingNewTreeSitterNodes) { + // create new domain object and put at right position + // var newDomainObject = TreeSitterDomainObject.fromTreeSitterAST() + }*/ + + + } printStructure() { return "(" + this.type + this.children @@ -204,13 +331,15 @@ export class TreeSitterDomainObject extends DomainObject { var newAST = TreeSitterDomainObject.parser.parse(livelyCodeMirror.value, this.treeSitter.tree); this.debugNewAST = newAST - DomainObject.updateFromTreeSitter(this.rootNode(), newAST.rootNode) + DomainObject.updateFromTreeSitter(this.rootNode(), newAST.rootNode, edit) livelyCodeMirror.dispatchEvent(new CustomEvent("domain-code-changed", {detail: {node: this, edit: edit}})) } - + get isTreeSitter() { + return true + } get startPosition() { return this.treeSitter.startPosition @@ -243,29 +372,16 @@ export class TreeSitterDomainObject extends DomainObject { return this.fromTreeSitterAST(ast.rootNode) } - static fromTreeSitterAST(ast, optionalDomainObjectsById, optionalUsedDomainObjects) { - let domainObject - - if (optionalDomainObjectsById) { - domainObject = optionalDomainObjectsById.get(ast.id) - - if (domainObject) { - domainObject.treeSitter = ast - if (optionalUsedDomainObjects) optionalUsedDomainObjects.add(domainObject) - } - } - if (!domainObject) { - domainObject = new TreeSitterDomainObject(ast) - } + static fromTreeSitterAST(ast) { + let domainObject = new TreeSitterDomainObject(ast) domainObject.children = [] for(var i=0; i < ast.childCount; i++) { var child = ast.child(i) - let domainChild = TreeSitterDomainObject.fromTreeSitterAST(child, optionalDomainObjectsById, optionalUsedDomainObjects) + let domainChild = TreeSitterDomainObject.fromTreeSitterAST(child) domainChild.parent = domainObject domainObject.children.push(domainChild) } - return domainObject } } diff --git a/src/client/media/Falleri2014FGA_algorithm2.png b/src/client/media/Falleri2014FGA_algorithm2.png new file mode 100644 index 000000000..86befe3c4 Binary files /dev/null and b/src/client/media/Falleri2014FGA_algorithm2.png differ diff --git a/src/client/media/Falleri2014FGA_alorighm1.png b/src/client/media/Falleri2014FGA_alorighm1.png new file mode 100644 index 000000000..e1fd35abc Binary files /dev/null and b/src/client/media/Falleri2014FGA_alorighm1.png differ diff --git a/src/client/tree-sitter.js b/src/client/tree-sitter.js index b67e8c13e..0f7572d87 100644 --- a/src/client/tree-sitter.js +++ b/src/client/tree-sitter.js @@ -1,13 +1,260 @@ +/*MD +[test](edit://test/tree-sitter-test.js) + +MD*/ +import PriorityQueue from "src/external/priority-queue.js" +import _ from 'src/external/lodash/lodash.js' + -// #Copy from /src/components/tools/lively-ast-treesitter-inspector.js -// #TODO extract... ? await lively.loadJavaScriptThroughDOM("treeSitter", lively4url + "/src/external/tree-sitter/tree-sitter.js") export const Parser = window.TreeSitter; await Parser.init() -export const JavaScript = await Parser.Language.load(lively4url + "/src/external/tree-sitter/tree-sitter-javascript.wasm"); +export const JavaScript = await Parser.Language.load(lively4url + + "/src/external/tree-sitter/tree-sitter-javascript.wasm"); + + +export function visit(node, func) { + func(node) + for (let i = 0; i < node.childCount; i++) { + let ea = node.child(i) + visit(ea, func) + } +} + +export function visitPostorder(node, func) { + for (let i = 0; i < node.childCount; i++) { + let ea = node.child(i) + visitPostorder(ea, func) + } + func(node) +} + + +/*MD SOURCE: Falleri 2014. Fine-grained and Accurate Source Code Differencing MD*/ + +function root(node) { + return node.tree.rootNode +} + +function peekMax(priorityList) { + return priorityList.peek().node +} + +function pop(priorityList) { + return priorityList.pop().node +} + + +// Define a function to check if two subtrees are isomorphic +export function isomorphic(node1, node2) { + if (!node1 && !node2) { + // Both nodes are null, they are isomorphic + return true; + } + + if (!node1 || !node2) { + // One of the nodes is null, they are not isomorphic + return false; + } + + if (node1.type !== node2.type) { + // Node types are different, they are not isomorphic + return false; + } + + // Recursively check the child nodes + const children1 = node1.children || []; + const children2 = node2.children || []; + + if (children1.length !== children2.length) { + // Different number of child nodes, they are not isomorphic + return false; + } + + for (let i = 0; i < children1.length; i++) { + if (!isomorphic(children1[i], children2[i])) { + // Child subtrees are not isomorphic, so the subtrees are not isomorphic + return false; + } + } + + if (children1.length === 0) { + return label(node1) === label(node2) + } + + return true; +} + +function dice(t1, t2, M) { + // Extract elements of s(t1) that are mapped with t2 in M + const mappedElements = Array.from(s(t1)).filter(t => M.has([t, t2])); + + // Return the Dice coefficient + return 2 * mappedElements.length / (s(t1).length + s(t2).length); +} + +function s(node) { + // set of decendents of node + var result = new Set() + visit(node, ea => result.add(ea)) + result.delete(node) // not myself + return result +} + +function open(node, priorityList) { + for(let ea of node.children) { + priorityList.push({height: height(ea), node: ea}) + } +} + + +/*MD ![](media/Falleri2014FGA_alorighm1.png){width=400px} MD*/ +function height(node) { + /* "The height of a node t ∈ T is defined as: + 1) for a leaf node t, height(t) = 1 and + 2) for an internal node t, height(t) = max({height(c)|c ∈ children(t)}) + 1." + */ + + if (node.childCount === 0) return 1 + + return _.max(node.children.map(ea => height(ea))) +} + +export function mapTrees(T1, T2, minHeight) { + let L1 = new PriorityQueue((a, b) => a.height > b.height), + L2 = new PriorityQueue((a, b) => a.height > b.height), + A = [], + M = new Set(); + + L1.push({ height: height(T1), node: T1 }); + L2.push({ height: height(T2), node: T2 }); + + while (Math.min(peekMax(L1), peekMax(L2)) > minHeight) { + if (peekMax(L1) !== peekMax(L2)) { + if (peekMax(L1) > peekMax(L2)) { + for (let t of pop(L1)) { + open(t, L1); + } + } else { + for (let t of pop(L2)) { + open(t, L2); + } + } + } else { + const H1 = pop(L1); + const H2 = pop(L2); + + for (let t1 of H1) { + for (let t2 of H2) { + if (isomorphic(t1, t2)) { + const existTxT2 = T2.some(tx => isomorphic(t1, tx) && tx !== t2); + const existTxT1 = T1.some(tx => isomorphic(tx, t2) && tx !== t1); + + if (existTxT2 || existTxT1) { + A.push([t1, t2]); + } else { + // Add pairs of isomorphic nodes of s(t1) and s(t2) to M + // You'll need to define what "s(t1)" refers to in your actual code + } + } + } + } + + for (let t1 of H1) { + if (!A.some(pair => pair[0] === t1) && !M.has(t1)) { + open(t1, L1); + } + } + + for (let t2 of H2) { + if (!A.some(pair => pair[1] === t2) && !M.has(t2)) { + open(t2, L2); + } + } + } + } + + A.sort((a, b) => dice(parent(a[0]), parent(b[0]), M)); + + while (A.length > 0) { + const [t1, t2] = A.shift(); + + // Add pairs of isomorphic nodes of s(t1) and s(t2) to M + // Again, you'll need to define what "s(t1)" refers to + + A = A.filter(pair => pair[0] !== t1); + A = A.filter(pair => pair[1] !== t2); + } + + return M; +} +// Helper functions (peekMax, open, root, isomorphic, etc.) will need to be defined. + + +function candidate(t, M) { + /* "For each unmatched non-leaf node of T1, we extract a list of candidate nodes from T2. A node c ∈ T2 is a candidate for t1 if label(t1) = label(c), c is unmatched, and t1 and c have some matching descendants. We then select the candidate t2 ∈ T2 with the greatest dice(t1, t2,M) value. If dice(t1, t2,M) > minDice, t1 and t2 are matched together." [Falleri2014FGA] */ +} + +/*MD ![](media/Falleri2014FGA_algorithm2.png){width=400px} MD*/ +function isMatched(node, M) { + // TODO +} + +function hasMatchedChildren(t1, M) { + // TODO +} + +function opt(node1, node2) { + "finds a shortest edit script without move actions. In our implementation we use the RTED algorithm [27]. The mappings induced from this edit script are added in M if they involve nodes with identical labels." +} + +function label(node) { + if (node.childCount === 0) { + return node.text + } + return node.type +} + +function bottomUpPhase(T1, T2, M, minDice, maxSize) { + + visitPostorder(T1, t1 => { + if (!isMatched(t1, M) && hasMatchedChildren(t1, M)) { + let t2 = candidate(t1, M); + if (t2 !== null && dice(t1, t2, M) > minDice) { + M.add([t1, t2]); + if (Math.max(s(t1).size, s(t2)).size < maxSize) { + let R = opt(t1, t2); + for (let [ta, tb] of R) { + if (!isMatched(ta, M) && !isMatched(tb, M) && label(ta) === label(tb)) { + M.add([ta, tb]); + } + } + } + } + } + }) + return M; +} + +// Helper functions like postOrder, isMatched, hasMatchedChildren, candidate, dice, +// s, size, opt, and label will need to be defined based on your specific requirements. + + +export function match(tree1, tree2) { + let matches = new Set(); + // "We recommend minHeight = 2 to avoid single identifiers to match everywhere." [Falleri2014FGA] + let minHeight = 2 + mapTrees(tree1, tree2, minHeight) + // "maxSize is used in the recovery part of Algorithm 2 that can trigger a cubic algorithm. To avoid long computation times we recommend to use maxSize = 100."[Falleri2014FGA] + let maxSize = 100 + // "Finally under 50% of common nodes, two container nodes are probably different. Therefore we recommend using minDice = 0.5" + let minDice = 0.5 + bottomUpPhase(tree1, tree2, matches, minDice, maxSize) + return Array.from(matches); +} diff --git a/src/components/tools/lively-ast-treesitter-inspector.js b/src/components/tools/lively-ast-treesitter-inspector.js index a6540a96f..11749c875 100644 --- a/src/components/tools/lively-ast-treesitter-inspector.js +++ b/src/components/tools/lively-ast-treesitter-inspector.js @@ -88,7 +88,8 @@ export default class AstTreesitterInspector extends AstInspector { element.append(this.keyTemplate(element)); element.append(this.labelTemplate(target.type)); element.append({target.id}); - element.append( {target.startIndex}-{target.endIndex} ); + element.append( {target.id - target.tree.rootNode.id}); + element.append( {target.startIndex}-{target.endIndex} ); element.append(); const summary = this.treeSitterNodeSummary(element.target, element.isExpanded); diff --git a/src/components/tools/lively-domain-code-explorer-example-source.js b/src/components/tools/lively-domain-code-explorer-example-source.js index 7de3ed6fc..52d00b079 100644 --- a/src/components/tools/lively-domain-code-explorer-example-source.js +++ b/src/components/tools/lively-domain-code-explorer-example-source.js @@ -1,3 +1,4 @@ let a = 3 + 4 const b = a + var c = b diff --git a/src/components/tools/treesitter-matches.html b/src/components/tools/treesitter-matches.html new file mode 100644 index 000000000..9e6ed6992 --- /dev/null +++ b/src/components/tools/treesitter-matches.html @@ -0,0 +1,15 @@ + + diff --git a/src/components/tools/treesitter-matches.js b/src/components/tools/treesitter-matches.js new file mode 100644 index 000000000..bca7271be --- /dev/null +++ b/src/components/tools/treesitter-matches.js @@ -0,0 +1,76 @@ +"enable aexpr"; + + +/*MD [Demo](browse://demos/tree-sitter/matches.md) MD*/ + +import Morph from 'src/components/widgets/lively-morph.js'; +import {visit, Parser, JavaScript, match} from 'src/client/tree-sitter.js'; + +export default class TreesitterMatches extends Morph { + + async update() { + let graphviz = await () + + function renderTree(rootNode, clusterName) { + let dotEdges = [] + let dotNodes = [] + + visit(rootNode, node => { + dotNodes.push(`${node.id}[label="${node.type}"]`) + if (node.parent) dotEdges.push(`${node.parent.id} -> ${node.id}`) + }) + + return `subgraph ${clusterName} { + ${dotNodes.join(";\n")} + ${dotEdges.join(";\n")} + }` + + } + + function renderMatches(matches) { + let dotEdges = [] + for(let match of matches) { + dotEdges.push(`${match.node1.id} -> ${match.node2.id} [color=green]`) + } + return dotEdges.join(";\n") + } + + var source = `digraph { + rankdir=TB; + graph [ + splines="true" + overlap="false" ]; + node [ style="solid" shape="plain" fontname="Arial" fontsize="14" fontcolor="black" ]; + edge [ fontname="Arial" fontsize="8" ]; + ${renderTree(this.tree1.rootNode, "cluster_0")} + ${renderTree(this.tree2.rootNode, "cluster_1")} + ${renderMatches(this.matches)} + }` + graphviz.innerHTML = `<` +`script type="graphviz">${source}<` + `/script>}` + + graphviz.updateViz() + + this.get("#pane").innerHTML = "" + this.get("#pane").appendChild(graphviz) + } + + + async livelyExample() { + + + var parser = new Parser(); + parser.setLanguage(JavaScript); + + let sourceCode1 = `let a = 3 + 4` + this.tree1 = parser.parse(sourceCode1); + + let sourceCode2 = `let a = 3 + 4\na++` + this.tree2 = parser.parse(sourceCode2); + + this.matches = match(this.tree1.rootNode, this.tree2.rootNode) + + this.update() + } + + +} \ No newline at end of file diff --git a/src/external/priority-queue.js b/src/external/priority-queue.js new file mode 100644 index 000000000..ce1ec7b2d --- /dev/null +++ b/src/external/priority-queue.js @@ -0,0 +1,95 @@ +// source: https://stackoverflow.com/questions/42919469/efficient-way-to-implement-priority-queue-in-javascript + +/*MD +## Example +```javascript {style="background:lightgray"} +import PriorityQueue from "src/external/priority-queue.js" + + +// Default comparison semantics +const queue = new PriorityQueue(); +queue.push(10, 20, 30, 40, 50); +console.log('Top:', queue.peek()); //=> 50 +console.log('Size:', queue.size()); //=> 5 +console.log('Contents:'); +while (!queue.isEmpty()) { + console.log(queue.pop()); //=> 40, 30, 20, 10 +} + +// Pairwise comparison semantics +const pairwiseQueue = new PriorityQueue((a, b) => a[1] > b[1]); +pairwiseQueue.push(['low', 0], ['medium', 5], ['high', 10]); +console.log('\nContents:'); +while (!pairwiseQueue.isEmpty()) { + console.log(pairwiseQueue.pop()[0]); //=> 'high', 'medium', 'low' +} +``` +MD*/ + +const top = 0; +const parent = i => ((i + 1) >>> 1) - 1; +const left = i => (i << 1) + 1; +const right = i => (i + 1) << 1; + +export default class PriorityQueue { + constructor(comparator = (a, b) => a > b) { + this._heap = []; + this._comparator = comparator; + } + size() { + return this._heap.length; + } + isEmpty() { + return this.size() == 0; + } + peek() { + return this._heap[top]; + } + push(...values) { + values.forEach(value => { + this._heap.push(value); + this._siftUp(); + }); + return this.size(); + } + pop() { + const poppedValue = this.peek(); + const bottom = this.size() - 1; + if (bottom > top) { + this._swap(top, bottom); + } + this._heap.pop(); + this._siftDown(); + return poppedValue; + } + replace(value) { + const replacedValue = this.peek(); + this._heap[top] = value; + this._siftDown(); + return replacedValue; + } + _greater(i, j) { + return this._comparator(this._heap[i], this._heap[j]); + } + _swap(i, j) { + [this._heap[i], this._heap[j]] = [this._heap[j], this._heap[i]]; + } + _siftUp() { + let node = this.size() - 1; + while (node > top && this._greater(node, parent(node))) { + this._swap(node, parent(node)); + node = parent(node); + } + } + _siftDown() { + let node = top; + while ( + (left(node) < this.size() && this._greater(left(node), node)) || + (right(node) < this.size() && this._greater(right(node), node)) + ) { + let maxChild = (right(node) < this.size() && this._greater(right(node), left(node))) ? right(node) : left(node); + this._swap(node, maxChild); + node = maxChild; + } + } +} \ No newline at end of file diff --git a/test/domain-code-test.js b/test/domain-code-test.js index 1b646f201..a7f7a1421 100644 --- a/test/domain-code-test.js +++ b/test/domain-code-test.js @@ -50,13 +50,15 @@ describe('TreeSitter', () => { startPosition: {row: 1, column: 0}, oldEndPosition: {row: 1, column: 5}, newEndPosition: {row: 1, column: 3}, + } - originalAST.edit(edit); - treesitterVisit(originalAST.rootNode, node => node.edit(edit)) // to update index + window.xoriginalAST = originalAST - var newAST = TreeSitterDomainObject.parser.parse(newSourceCode, originalAST); + var result = originalAST.edit(edit); + debugger + // treesitterVisit(originalAST.rootNode, node => node.edit(edit)) // to update index - window.xoriginalAST = originalAST + var newAST = TreeSitterDomainObject.parser.parse(newSourceCode, originalAST); window.xnewAST = newAST @@ -80,6 +82,92 @@ describe('Domain Code', () => { }); describe('DomainObject', () => { + it('reconciles change when adding new statement at start', () => { + let sourceOriginal = ` +a = 3` + let sourceNew = `l +a = 3` + let root = TreeSitterDomainObject.fromSource(sourceOriginal) + DomainObject.edit(root, sourceNew, { startIndex: 0, oldEndIndex: 0, newEndIndex: 1 }) + + expect(root.children.length).equals(2); + expect(root.children[1].children[0].type).equals("assignment_expression") + }) + + it('reconciles change when adding new statement at end', () => { + let sourceOriginal = `a = 3` + let sourceNew = `a = 3 +l` + let root = TreeSitterDomainObject.fromSource(sourceOriginal) + DomainObject.edit(root, sourceNew, { startIndex: 0, oldEndIndex: 0, newEndIndex: 1 }) + + expect(root.children.length).equals(2); + expect(root.children[0].children[0].type).equals("assignment_expression") + }) + + it('reconciles change when removing statement at end', () => { + let sourceOriginal = `a = 3 +l` + let sourceNew = `a = 3` + let root = TreeSitterDomainObject.fromSource(sourceOriginal) + DomainObject.edit(root, sourceNew, { startIndex: 0, oldEndIndex: 0, newEndIndex: 1 }) + + expect(root.children.length).equals(1); + expect(root.children[0].children[0].type).equals("assignment_expression") + }) + + it('reconciles change when removing statement at start', () => { + let sourceOriginal = `l +a = 3` + let sourceNew = `a = 3` + let root = TreeSitterDomainObject.fromSource(sourceOriginal) + DomainObject.edit(root, sourceNew, { startIndex: 0, oldEndIndex: 0, newEndIndex: 1 }) + + expect(root.children.length).equals(1); + expect(root.children[0].children[0].type).equals("assignment_expression") + }) + + it('reconciles change when adding new statement at start of a function', () => { + let sourceOriginal = `function() { + + let a = 3 +}` + let sourceNew = `function() { + l + let a = 3 +}` + let root = TreeSitterDomainObject.fromSource(sourceOriginal) + DomainObject.edit(root, sourceNew, { startIndex: 15, oldEndIndex: 15, newEndIndex: 16 }) + + const block = root.children[0].children[0].children[2] + expect(block.type).equals("statement_block") + + expect(block.children.length).equals(4); + expect(block.children[2].type).equals("lexical_declaration") + }) + + describe('adjustIndex', () => { + it('do nothing to index before edits', async () => { + var index = 3 + var newIndex = DomainObject.adjustIndex(index, {startIndex: 5, oldEndIndex: 5, newEndIndex: 6}) + expect(newIndex).equals(index) + }) + + it('increases for index after adding edits', async () => { + var index = 10 + var newIndex = DomainObject.adjustIndex(index, {startIndex: 5, oldEndIndex: 5, newEndIndex: 6}) + expect(newIndex).equals(11) + }) + + it('decreases for index after deleting edits', async () => { + var index = 10 + var newIndex = DomainObject.adjustIndex(index, {startIndex: 5, oldEndIndex: 5, newEndIndex: 4}) + expect(newIndex).equals(9) + }) + + + }) + describe('updateFromTreeSitter', () => { it('should update let to const', async () => { let sourceCode = `let a = 3 + 4\nconsole.log("x")` diff --git a/test/tree-sitter-test.js b/test/tree-sitter-test.js new file mode 100644 index 000000000..049caafa3 --- /dev/null +++ b/test/tree-sitter-test.js @@ -0,0 +1,63 @@ +import {expect} from 'src/external/chai.js'; + + + +import {Parser, JavaScript, match, isomorphic} from 'src/client/tree-sitter.js'; + + +var parser = new Parser(); +parser.setLanguage(JavaScript); + +function parseAll(sources) { + return sources.map(ea => parser.parse(ea)) +} + +describe('tree-sitter', () => { + describe('isomorphic', () => { + it('find identical trees', async () => { + let [tree1, tree2] = parseAll([`let a = 3 + 4`, `let a = 3 + 4`]) + expect(isomorphic(tree1.rootNode, tree2.rootNode)).to.be.true + }) + + it('find identical trees with added whitespace', async () => { + let [tree1, tree2] = parseAll([`let a = 3 + 4`, ` let +a = 3 + 4`]) + expect(isomorphic(tree1.rootNode, tree2.rootNode)).to.be.true + }) + + it('find different trees', async () => { + let [tree1, tree2] = parseAll([`let a = 3 + 4`, `let a = 3 + 5`]) + expect(isomorphic(tree1.rootNode, tree2.rootNode)).to.be.false + }) + + it('find different structured trees', async () => { + let [tree1, tree2] = parseAll([`let a = 3 + 4`, `let a = 3 + 4; let b`]) + expect(isomorphic(tree1.rootNode, tree2.rootNode)).to.be.false + }) + }) + + describe('match', () => { + + it('match identical trees', async () => { + + let [tree1, tree2] = parseAll([`let a = 3 + 4`,`let a = 3 + 4`]) + var matches = match(tree1.rootNode, tree2.rootNode) + + expect(matches.length).gt(5) + for(let match of matches) { + expect(match.node1.text).to.equal(match.node2.text) + } + }) + + it('should match unganged trees', async () => { + let [tree1, tree2] = parseAll([`let a = 3 + 4`, `let a = 3 + 4\na++`]) + var matches = match(tree1.rootNode, tree2.rootNode) + expect(matches.length).gt(5) + for(let match of matches) { + if (match.node1.text !== tree1.rootNode.text) { + expect(match.node1.text).to.equal(match.node2.text) + } + } + }) + }) +}) \ No newline at end of file