From 48a5077035234c540bd368492e9395b34cc70a9d Mon Sep 17 00:00:00 2001 From: Amaan Qureshi Date: Fri, 19 Sep 2025 01:57:15 -0400 Subject: [PATCH] feat(web)!: add API for editing points and ranges --- lib/binding_web/lib/exports.txt | 2 + lib/binding_web/src/constants.ts | 23 ------ lib/binding_web/src/edit.ts | 125 ++++++++++++++++++++++++++++++ lib/binding_web/src/index.ts | 2 +- lib/binding_web/src/marshal.ts | 3 +- lib/binding_web/src/node.ts | 3 +- lib/binding_web/src/tree.ts | 3 +- lib/binding_web/test/edit.test.ts | 124 +++++++++++++++++++++++++++++ lib/binding_web/test/tree.test.ts | 12 +-- 9 files changed, 264 insertions(+), 33 deletions(-) create mode 100644 lib/binding_web/src/edit.ts create mode 100644 lib/binding_web/test/edit.test.ts diff --git a/lib/binding_web/lib/exports.txt b/lib/binding_web/lib/exports.txt index 43a42bd4..86104496 100644 --- a/lib/binding_web/lib/exports.txt +++ b/lib/binding_web/lib/exports.txt @@ -61,6 +61,7 @@ "ts_parser_set_language", "ts_parser_set_included_ranges", "ts_parser_included_ranges_wasm", +"ts_point_edit", "ts_query_capture_count", "ts_query_capture_name_for_id", "ts_query_captures_wasm", @@ -79,6 +80,7 @@ "ts_query_is_pattern_non_local", "ts_query_is_pattern_rooted", "ts_query_is_pattern_guaranteed_at_step", +"ts_range_edit", "ts_tree_copy", "ts_tree_cursor_current_field_id_wasm", "ts_tree_cursor_current_depth_wasm", diff --git a/lib/binding_web/src/constants.ts b/lib/binding_web/src/constants.ts index 287d8291..026e9f16 100644 --- a/lib/binding_web/src/constants.ts +++ b/lib/binding_web/src/constants.ts @@ -33,29 +33,6 @@ export interface Range { endIndex: number; } -/** - * A summary of a change to a text document. - */ -export interface Edit { - /** The start position of the change. */ - startPosition: Point; - - /** The end position of the change before the edit. */ - oldEndPosition: Point; - - /** The end position of the change after the edit. */ - newEndPosition: Point; - - /** The start index of the change. */ - startIndex: number; - - /** The end index of the change before the edit. */ - oldEndIndex: number; - - /** The end index of the change after the edit. */ - newEndIndex: number; -} - /** @internal */ export const SIZE_OF_SHORT = 2; diff --git a/lib/binding_web/src/edit.ts b/lib/binding_web/src/edit.ts new file mode 100644 index 00000000..ee3d2d46 --- /dev/null +++ b/lib/binding_web/src/edit.ts @@ -0,0 +1,125 @@ +import { Point, Range } from "./constants"; + +export class Edit { + /** The start position of the change. */ + startPosition: Point; + + /** The end position of the change before the edit. */ + oldEndPosition: Point; + + /** The end position of the change after the edit. */ + newEndPosition: Point; + + /** The start index of the change. */ + startIndex: number; + + /** The end index of the change before the edit. */ + oldEndIndex: number; + + /** The end index of the change after the edit. */ + newEndIndex: number; + + constructor({ + startIndex, + oldEndIndex, + newEndIndex, + startPosition, + oldEndPosition, + newEndPosition, + }: { + startIndex: number; + oldEndIndex: number; + newEndIndex: number; + startPosition: Point; + oldEndPosition: Point; + newEndPosition: Point; + }) { + this.startIndex = startIndex >>> 0; + this.oldEndIndex = oldEndIndex >>> 0; + this.newEndIndex = newEndIndex >>> 0; + this.startPosition = startPosition; + this.oldEndPosition = oldEndPosition; + this.newEndPosition = newEndPosition; + } + + /** + * Edit a point and index to keep it in-sync with source code that has been edited. + * + * This function updates a single point's byte offset and row/column position + * based on an edit operation. This is useful for editing points without + * requiring a tree or node instance. + */ + editPoint(point: Point, index: number): { point: Point; index: number } { + let newIndex = index; + const newPoint = { ...point }; + + if (index >= this.oldEndIndex) { + newIndex = this.newEndIndex + (index - this.oldEndIndex); + const originalRow = point.row; + newPoint.row = this.newEndPosition.row + (point.row - this.oldEndPosition.row); + newPoint.column = originalRow === this.oldEndPosition.row + ? this.newEndPosition.column + (point.column - this.oldEndPosition.column) + : point.column; + } else if (index > this.startIndex) { + newIndex = this.newEndIndex; + newPoint.row = this.newEndPosition.row; + newPoint.column = this.newEndPosition.column; + } + + return { point: newPoint, index: newIndex }; + } + + /** + * Edit a range to keep it in-sync with source code that has been edited. + * + * This function updates a range's start and end positions based on an edit + * operation. This is useful for editing ranges without requiring a tree + * or node instance. + */ + editRange(range: Range): Range { + const newRange: Range = { + startIndex: range.startIndex, + startPosition: { ...range.startPosition }, + endIndex: range.endIndex, + endPosition: { ...range.endPosition } + }; + + if (range.endIndex >= this.oldEndIndex) { + if (range.endIndex !== Number.MAX_SAFE_INTEGER) { + newRange.endIndex = this.newEndIndex + (range.endIndex - this.oldEndIndex); + newRange.endPosition = { + row: this.newEndPosition.row + (range.endPosition.row - this.oldEndPosition.row), + column: range.endPosition.row === this.oldEndPosition.row + ? this.newEndPosition.column + (range.endPosition.column - this.oldEndPosition.column) + : range.endPosition.column, + }; + if (newRange.endIndex < this.newEndIndex) { + newRange.endIndex = Number.MAX_SAFE_INTEGER; + newRange.endPosition = { row: Number.MAX_SAFE_INTEGER, column: Number.MAX_SAFE_INTEGER }; + } + } + } else if (range.endIndex > this.startIndex) { + newRange.endIndex = this.startIndex; + newRange.endPosition = { ...this.startPosition }; + } + + if (range.startIndex >= this.oldEndIndex) { + newRange.startIndex = this.newEndIndex + (range.startIndex - this.oldEndIndex); + newRange.startPosition = { + row: this.newEndPosition.row + (range.startPosition.row - this.oldEndPosition.row), + column: range.startPosition.row === this.oldEndPosition.row + ? this.newEndPosition.column + (range.startPosition.column - this.oldEndPosition.column) + : range.startPosition.column, + }; + if (newRange.startIndex < this.newEndIndex) { + newRange.startIndex = Number.MAX_SAFE_INTEGER; + newRange.startPosition = { row: Number.MAX_SAFE_INTEGER, column: Number.MAX_SAFE_INTEGER }; + } + } else if (range.startIndex > this.startIndex) { + newRange.startIndex = this.startIndex; + newRange.startPosition = { ...this.startPosition }; + } + + return newRange; + } +} diff --git a/lib/binding_web/src/index.ts b/lib/binding_web/src/index.ts index 92791145..5876db92 100644 --- a/lib/binding_web/src/index.ts +++ b/lib/binding_web/src/index.ts @@ -1,11 +1,11 @@ export type { Point, Range, - Edit, ParseCallback, ProgressCallback, LogCallback, } from './constants'; +export { Edit } from './edit'; export { type ParseOptions, type ParseState, diff --git a/lib/binding_web/src/marshal.ts b/lib/binding_web/src/marshal.ts index c742afc6..f09f8e23 100644 --- a/lib/binding_web/src/marshal.ts +++ b/lib/binding_web/src/marshal.ts @@ -1,4 +1,4 @@ -import { Edit, INTERNAL, Point, Range, SIZE_OF_INT, SIZE_OF_NODE, SIZE_OF_POINT, C } from "./constants"; +import { INTERNAL, Point, Range, SIZE_OF_INT, SIZE_OF_NODE, SIZE_OF_POINT, C } from "./constants"; import { Node } from "./node"; import { Tree } from "./tree"; // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -6,6 +6,7 @@ import { Query, QueryCapture, type QueryMatch } from "./query"; import { TreeCursor } from "./tree_cursor"; import { TRANSFER_BUFFER } from "./parser"; import { LanguageMetadata } from "./language"; +import { Edit } from "./edit"; /** * @internal diff --git a/lib/binding_web/src/node.ts b/lib/binding_web/src/node.ts index aeecbd21..1026d680 100644 --- a/lib/binding_web/src/node.ts +++ b/lib/binding_web/src/node.ts @@ -1,10 +1,11 @@ -import { INTERNAL, Internal, assertInternal, Point, Edit, SIZE_OF_INT, SIZE_OF_NODE, SIZE_OF_POINT, ZERO_POINT, isPoint, C } from './constants'; +import { INTERNAL, Internal, assertInternal, SIZE_OF_INT, SIZE_OF_NODE, SIZE_OF_POINT, ZERO_POINT, isPoint, C, Point } from './constants'; import { getText, Tree } from './tree'; import { TreeCursor } from './tree_cursor'; // eslint-disable-next-line @typescript-eslint/no-unused-vars import { Language } from './language'; import { marshalNode, marshalPoint, unmarshalNode, unmarshalPoint } from './marshal'; import { TRANSFER_BUFFER } from './parser'; +import { Edit } from './edit'; /** A single node within a syntax {@link Tree}. */ export class Node { diff --git a/lib/binding_web/src/tree.ts b/lib/binding_web/src/tree.ts index e45ca304..7a251440 100644 --- a/lib/binding_web/src/tree.ts +++ b/lib/binding_web/src/tree.ts @@ -1,9 +1,10 @@ -import { INTERNAL, Internal, assertInternal, ParseCallback, Point, Range, Edit, SIZE_OF_NODE, SIZE_OF_INT, SIZE_OF_RANGE, C } from './constants'; +import { INTERNAL, Internal, assertInternal, ParseCallback, Point, Range, SIZE_OF_NODE, SIZE_OF_INT, SIZE_OF_RANGE, C } from './constants'; import { Language } from './language'; import { Node } from './node'; import { TreeCursor } from './tree_cursor'; import { marshalEdit, marshalPoint, unmarshalNode, unmarshalRange } from './marshal'; import { TRANSFER_BUFFER } from './parser'; +import { Edit } from './edit'; /** @internal */ export function getText(tree: Tree, startIndex: number, endIndex: number, startPosition: Point): string { diff --git a/lib/binding_web/test/edit.test.ts b/lib/binding_web/test/edit.test.ts new file mode 100644 index 00000000..bd4cb194 --- /dev/null +++ b/lib/binding_web/test/edit.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect } from 'vitest'; +import { Edit } from '../src'; + +describe('Edit', () => { + it('edits a point after the edit', () => { + const edit = new Edit({ + startIndex: 5, + oldEndIndex: 5, + newEndIndex: 10, + startPosition: { row: 0, column: 5 }, + oldEndPosition: { row: 0, column: 5 }, + newEndPosition: { row: 0, column: 10 }, + }); + + const point = { row: 0, column: 8 }; + const index = 8; + const result = edit.editPoint(point, index); + expect(result.point).toEqual({ row: 0, column: 13 }); + expect(result.index).toBe(13); + }); + + it('edits a point before the edit', () => { + const edit = new Edit({ + startIndex: 5, + oldEndIndex: 5, + newEndIndex: 10, + startPosition: { row: 0, column: 5 }, + oldEndPosition: { row: 0, column: 5 }, + newEndPosition: { row: 0, column: 10 }, + }); + + const point = { row: 0, column: 2 }; + const index = 2; + const result = edit.editPoint(point, index); + expect(result.point).toEqual({ row: 0, column: 2 }); + expect(result.index).toBe(2); + }); + + it('edits a point at the start of the edit', () => { + const edit = new Edit({ + startIndex: 5, + oldEndIndex: 5, + newEndIndex: 10, + startPosition: { row: 0, column: 5 }, + oldEndPosition: { row: 0, column: 5 }, + newEndPosition: { row: 0, column: 10 }, + }); + + const point = { row: 0, column: 5 }; + const index = 5; + const result = edit.editPoint(point, index); + expect(result.point).toEqual({ row: 0, column: 10 }); + expect(result.index).toBe(10); + }); + + it('edits a range after the edit', () => { + const edit = new Edit({ + startIndex: 10, + oldEndIndex: 15, + newEndIndex: 20, + startPosition: { row: 1, column: 0 }, + oldEndPosition: { row: 1, column: 5 }, + newEndPosition: { row: 2, column: 0 }, + }); + + const range = { + startPosition: { row: 2, column: 0 }, + endPosition: { row: 2, column: 5 }, + startIndex: 20, + endIndex: 25, + }; + const result = edit.editRange(range); + expect(result.startIndex).toBe(25); + expect(result.endIndex).toBe(30); + expect(result.startPosition).toEqual({ row: 3, column: 0 }); + expect(result.endPosition).toEqual({ row: 3, column: 5 }); + }); + + it('edits a range before the edit', () => { + const edit = new Edit({ + startIndex: 10, + oldEndIndex: 15, + newEndIndex: 20, + startPosition: { row: 1, column: 0 }, + oldEndPosition: { row: 1, column: 5 }, + newEndPosition: { row: 2, column: 0 }, + }); + + const range = { + startPosition: { row: 0, column: 5 }, + endPosition: { row: 0, column: 8 }, + startIndex: 5, + endIndex: 8, + }; + const result = edit.editRange(range); + expect(result.startIndex).toBe(5); + expect(result.endIndex).toBe(8); + expect(result.startPosition).toEqual({ row: 0, column: 5 }); + expect(result.endPosition).toEqual({ row: 0, column: 8 }); + }); + + it('edits a range overlapping the edit', () => { + const edit = new Edit({ + startIndex: 10, + oldEndIndex: 15, + newEndIndex: 20, + startPosition: { row: 1, column: 0 }, + oldEndPosition: { row: 1, column: 5 }, + newEndPosition: { row: 2, column: 0 } + }); + + const range = { + startPosition: { row: 0, column: 8 }, + endPosition: { row: 1, column: 2 }, + startIndex: 8, + endIndex: 12, + }; + const result = edit.editRange(range); + expect(result.startIndex).toBe(8); + expect(result.endIndex).toBe(10); + expect(result.startPosition).toEqual({ row: 0, column: 8 }); + expect(result.endPosition).toEqual({ row: 1, column: 0 }); + }); +}); diff --git a/lib/binding_web/test/tree.test.ts b/lib/binding_web/test/tree.test.ts index 3cf40eab..85f895a7 100644 --- a/lib/binding_web/test/tree.test.ts +++ b/lib/binding_web/test/tree.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; -import type { Point, Language, Tree, Edit, TreeCursor } from '../src'; -import { Parser } from '../src'; +import type { Point, Language, Tree, TreeCursor } from '../src'; +import { Parser, Edit } from '../src'; import helper from './helper'; let JavaScript: Language; @@ -103,14 +103,14 @@ describe('Tree', () => { ); sourceCode = 'abc + defg + hij'; - tree.edit({ + tree.edit(new Edit({ startIndex: 2, oldEndIndex: 2, newEndIndex: 5, startPosition: { row: 0, column: 2 }, oldEndPosition: { row: 0, column: 2 }, newEndPosition: { row: 0, column: 5 }, - }); + })); const tree2 = parser.parse(sourceCode, tree)!; expect(tree2.rootNode.toString()).toBe( @@ -402,14 +402,14 @@ function spliceInput(input: string, startIndex: number, lengthRemoved: number, n const newEndPosition = getExtent(input.slice(0, newEndIndex)); return [ input, - { + new Edit({ startIndex, startPosition, oldEndIndex, oldEndPosition, newEndIndex, newEndPosition, - }, + }), ]; }