Skip to content

Commit 727e52e

Browse files
committed
dirty range incremental parser instead of full document reparse on every typing event
1 parent 4fbb3db commit 727e52e

11 files changed

Lines changed: 884 additions & 214 deletions

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22

33
All notable changes to the "pointblank" extension will be documented in this file.
44

5-
## [0.6.4] - 2025-06-27
5+
## [0.7.0] - 2025-06-29
66
### Added
77
- You can now select a single-line bullet point for copying, making it easier to duplicate or move bullet items.
88
- When typing or pasting from the start of a list item line, the bullet point style now matches the next or previous list item line at the same level of indent for consistency.
9+
### Changed
10+
- Replaced the document parser with a high-performance "Dirty Range" incremental parser, eliminating typing lag in large documents.
911
### Fixed
1012
- Pasting a bullet at the beginning of a line no longer erases the line; pasted content is merged or inserted intelligently.
1113
- Numbered list items will remove their default bullet point.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ Point Blank's architecture is designed to be modular, performant, and maintainab
1717
* `DocumentModel`: Manages the document's state and uses `DocumentParser` to create an immutable `DocumentTree`.
1818
* `DecorationManager`: Applies visual decorations to the editor based on the `DocumentTree` and `DecorationCalculator`.
1919
* `CommandManager`: Registers and handles all user commands, interacting with the `DocumentModel` and other components.
20-
* `DocumentParser`: A stateless component solely responsible for converting text into an immutable `DocumentTree`.
20+
* `DocumentParser`: A stateless component responsible for converting text into an immutable `DocumentTree`. It uses a highly efficient "dirty range" incremental parsing strategy to ensure excellent performance even in very large documents.
2121

2222
For a detailed history of changes, see the [CHANGELOG](https://github.com/ryanncode/point-blank/blob/main/CHANGELOG.md).
2323

docs/architecture.html

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ <h2>Architecture Overview</h2>
1010
<h3>Data Flow:</h3>
1111
<ol>
1212
<li>When a document is opened or modified, VS Code fires an event that is picked up by the <code>DocumentModel</code>.</li>
13-
<li>The <code>DocumentModel</code> uses the <code>DocumentParser</code> to create or update the <code>DocumentTree</code>, an immutable, hierarchical representation of the document's text.</li>
13+
<li>The <code>DocumentModel</code> uses the <code>DocumentParser</code> to create or update the <code>DocumentTree</code>, an immutable, hierarchical representation of the document's text. The parser uses a highly efficient "dirty range" incremental strategy to avoid performance bottlenecks in large files.</li>
1414
<li>The <code>DocumentModel</code> notifies the <code>DecorationManager</code> of the change.</li>
1515
<li>The <code>DecorationManager</code>, using debouncing and viewport-aware logic, retrieves the visible <code>BlockNode</code>s from the <code>DocumentTree</code>.</li>
1616
<li>It passes these nodes to the <code>DecorationCalculator</code>, which determines the appropriate decorations for each node based on its properties (e.g., <code>bulletType</code>).</li>
@@ -22,6 +22,9 @@ <h2>Key Design Decisions</h2>
2222
<h3>Immutable Document Model</h3>
2323
<p>The use of an immutable <code>DocumentTree</code> and <code>BlockNode</code>s is a cornerstone of the architecture. When the document changes, a new tree is created rather than modifying the old one. This provides predictability, simplifies state management, and makes the flow of data easy to trace.</p>
2424

25+
<h3>Performant Incremental Parsing</h3>
26+
<p>To handle large documents without typing lag, Point Blank employs a "Dirty Range" incremental parser. Instead of performing a full re-parse on every keystroke, the parser identifies the minimal block of top-level nodes affected by a change, re-parses only that block, and splices the result back into the document tree. This ensures the UI remains responsive even in documents with thousands of lines.</p>
27+
2528
<h3>Design Philosophy: Insertion vs. Styling</h3>
2629
<p>A critical distinction in Point Blank's architecture is the separation between one-time character <strong>insertion</strong> and continuous visual <strong>styling</strong>.</p>
2730
<ul>

package.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"type": "git",
88
"url": "https://github.com/ryanncode/point-blank"
99
},
10-
"version": "0.6.4",
10+
"version": "0.7.0",
1111
"engines": {
1212
"vscode": "^1.100.0"
1313
},
@@ -179,7 +179,7 @@
179179
{
180180
"command": "pointblank.enterKey",
181181
"when": "false"
182-
}
182+
}
183183
]
184184
}
185185
},
@@ -193,7 +193,8 @@
193193
"pretest": "pnpm run compile-tests && pnpm run compile && pnpm run lint",
194194
"lint": "eslint src --ext ts",
195195
"test": "vscode-test",
196-
"test:paste": "jest test/pasteUtils.test.ts"
196+
"test:paste": "jest test/pasteUtils.test.ts",
197+
"test:parse": "jest test/documentParser.test.ts"
197198
},
198199
"devDependencies": {
199200
"@types/glob": "^8.1.0",
@@ -207,6 +208,7 @@
207208
"@vscode/test-electron": "^2.5.2",
208209
"eslint": "^9.25.1",
209210
"glob": "^8.1.0",
211+
"identity-obj-proxy": "^3.0.0",
210212
"jest": "^30.0.3",
211213
"ts-jest": "^29.4.0",
212214
"ts-loader": "^9.5.2",

pnpm-lock.yaml

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/document/blockNode.ts

Lines changed: 27 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ export class BlockNode {
2828
public readonly type?: string;
2929
public readonly typedNodeRange?: vscode.Range;
3030
public readonly isCodeBlockDelimiter: boolean;
31+
public parent: BlockNode | undefined;
32+
public children: BlockNode[];
3133
public readonly isExcluded: boolean;
3234
public readonly bulletType: 'star' | 'plus' | 'minus' | 'numbered' | 'blockquote' | 'default' | 'none' | 'atSign';
3335
public readonly bulletRange?: vscode.Range;
@@ -37,9 +39,9 @@ export class BlockNode {
3739
public readonly headerLevel?: number;
3840
public readonly headerText?: string;
3941

40-
// Hierarchy properties
41-
public readonly parent?: BlockNode;
42-
public readonly children: readonly BlockNode[];
42+
get lineCount(): number {
43+
return 1 + this.children.reduce((acc, child) => acc + child.lineCount, 0);
44+
}
4345

4446
constructor(
4547
line: vscode.TextLine,
@@ -54,7 +56,7 @@ export class BlockNode {
5456
this.indent = line.firstNonWhitespaceCharacterIndex;
5557
this.isExcluded = isExcluded;
5658
this.parent = parent;
57-
this.children = children;
59+
this.children = children || [];
5860

5961
// Perform all parsing upon construction to ensure immutability.
6062
const { isKeyValue, keyValue, isTypedNode, type, typedNodeRange, isCodeBlockDelimiter, isHeader, headerLevel, headerText } = this.parseLineContent();
@@ -145,48 +147,37 @@ export class BlockNode {
145147
}
146148

147149
/**
148-
* Creates a new `BlockNode` instance with updated children.
149-
* This method is essential for maintaining the immutability of the document tree.
150-
* @param newChildren The new array of child nodes.
151-
* @returns A new `BlockNode` instance.
150+
* Creates a new `BlockNode` with the specified parent.
151+
* @param parent The new parent for the node.
152+
* @returns A new `BlockNode` with the specified parent.
152153
*/
153-
public withChildren(newChildren: BlockNode[]): BlockNode {
154-
return new BlockNode(this.line, this.lineNumber, this.isExcluded, this.parent, newChildren);
154+
public withParent(parent: BlockNode): BlockNode {
155+
return new BlockNode(this.line, this.lineNumber, this.isExcluded, parent, this.children);
155156
}
156157

157158
/**
158-
* Creates a new `BlockNode` instance with an updated parent.
159-
* @param newParent The new parent node.
160-
* @returns A new `BlockNode` instance.
159+
* Creates a new `BlockNode` with the specified children.
160+
* @param children The new children for the node.
161+
* @returns A new `BlockNode` with the specified children.
161162
*/
162-
public withParent(newParent?: BlockNode): BlockNode {
163-
return new BlockNode(this.line, this.lineNumber, this.isExcluded, newParent, [...this.children]);
163+
public withChildren(children: BlockNode[]): BlockNode {
164+
return new BlockNode(this.line, this.lineNumber, this.isExcluded, this.parent, children);
164165
}
165166

166167
/**
167-
* Performs a shallow comparison to check if two `BlockNode`s have different content
168-
* relevant to decoration. This is used to optimize re-rendering.
169-
* @param otherNode The other `BlockNode` to compare against.
170-
* @returns `true` if the content is different, `false` otherwise.
168+
* Adds a child to this node. This method mutates the node.
169+
* @param child The `BlockNode` to add as a child.
171170
*/
172-
public isContentDifferent(otherNode: BlockNode): boolean {
173-
if (this.text !== otherNode.text ||
174-
this.bulletType !== otherNode.bulletType ||
175-
this.isKeyValue !== otherNode.isKeyValue ||
176-
this.isTypedNode !== otherNode.isTypedNode ||
177-
this.isHeader !== otherNode.isHeader ||
178-
this.headerLevel !== otherNode.headerLevel ||
179-
this.headerText !== otherNode.headerText) {
180-
return true;
181-
}
171+
public addChild(child: BlockNode) {
172+
this.children.push(child);
173+
child.parent = this;
174+
}
182175

183-
// Deep compare bulletRange only if necessary.
184-
const range1 = this.bulletRange;
185-
const range2 = otherNode.bulletRange;
186-
if ((range1 && !range2) || (!range1 && range2) || (range1 && range2 && !range1.isEqual(range2))) {
187-
return true;
176+
public getSelfAndDescendants(): BlockNode[] {
177+
const result: BlockNode[] = [this];
178+
for (const child of this.children) {
179+
result.push(...child.getSelfAndDescendants());
188180
}
189-
190-
return false;
181+
return result;
191182
}
192183
}

src/document/documentModel.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -80,23 +80,18 @@ export class DocumentModel {
8080
return;
8181
}
8282

83-
// If a bulk update is in progress, defer incremental parsing until the bulk update completes.
8483
if (this._isBulkUpdating) {
8584
return;
8685
}
8786

88-
// Update the internal document reference to the latest version
8987
this._document = event.document;
9088

91-
this._isParsing = true; // Set parsing flag
92-
// Create a new, updated tree by applying the changes to the previous tree.
93-
const newDocumentTree = this._parser.parse(this._documentTree, event.contentChanges);
89+
this._isParsing = true;
90+
const newDocumentTree = this._parser.parse(this._documentTree, event.contentChanges, this._document);
9491
this._documentTree = newDocumentTree;
95-
this._isParsing = false; // Clear parsing flag
96-
this._onDidParseEventEmitter.fire(); // Fire event
92+
this._isParsing = false;
93+
this._onDidParseEventEmitter.fire();
9794

98-
// Notify the DecorationManager with the new tree. The manager will then
99-
// handle the debounced update of the actual decorations in the editor.
10095
this._decorationManager.updateDecorations(this._documentTree);
10196
}
10297

0 commit comments

Comments
 (0)