Extract StringBuilder and LimitedStringBuilder with unit tests

Replace PayloadStringBuffer with LimitedStringBuilder from common.
Add thorough StringBuilder and LimitedStringBuilder test coverage.

Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-05-27 16:27:26 +00:00
co-authored by Daniel Imms
parent fad4821fd9
commit 30b42838fd
6 changed files with 219 additions and 43 deletions
+143
View File
@@ -0,0 +1,143 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { assert } from 'chai';
import { LimitedStringBuilder, StringBuilder } from 'common/StringBuilder';
describe('StringBuilder', () => {
it('should start empty', () => {
const builder = new StringBuilder();
assert.strictEqual(builder.length, 0);
assert.strictEqual(builder.toString(), '');
});
it('should append a single chunk', () => {
const builder = new StringBuilder();
builder.append('hello');
assert.strictEqual(builder.length, 5);
assert.strictEqual(builder.toString(), 'hello');
});
it('should join multiple chunks in order', () => {
const builder = new StringBuilder();
builder.append('foo');
builder.append('bar');
builder.append('baz');
assert.strictEqual(builder.length, 9);
assert.strictEqual(builder.toString(), 'foobarbaz');
});
it('should handle empty chunks', () => {
const builder = new StringBuilder();
builder.append('');
builder.append('a');
builder.append('');
assert.strictEqual(builder.length, 1);
assert.strictEqual(builder.toString(), 'a');
});
it('should reset accumulated data', () => {
const builder = new StringBuilder();
builder.append('hello');
builder.reset();
assert.strictEqual(builder.length, 0);
assert.strictEqual(builder.toString(), '');
});
it('should allow appending after reset', () => {
const builder = new StringBuilder();
builder.append('old');
builder.reset();
builder.append('new');
assert.strictEqual(builder.toString(), 'new');
});
it('should accumulate many small chunks without quadratic concatenation', () => {
const builder = new StringBuilder();
const chunk = 'x';
const count = 10000;
for (let i = 0; i < count; i++) {
builder.append(chunk);
}
assert.strictEqual(builder.length, count);
assert.strictEqual(builder.toString(), 'x'.repeat(count));
});
});
describe('LimitedStringBuilder', () => {
it('should expose the configured limit', () => {
const builder = new LimitedStringBuilder(42);
assert.strictEqual(builder.limit, 42);
});
it('should start empty', () => {
const builder = new LimitedStringBuilder(10);
assert.strictEqual(builder.length, 0);
assert.strictEqual(builder.toString(), '');
});
it('should accept data up to the limit', () => {
const builder = new LimitedStringBuilder(10);
assert.strictEqual(builder.append('12345'), false);
assert.strictEqual(builder.append('67890'), false);
assert.strictEqual(builder.length, 10);
assert.strictEqual(builder.toString(), '1234567890');
});
it('should accept a single chunk exactly at the limit', () => {
const builder = new LimitedStringBuilder(5);
assert.strictEqual(builder.append('abcde'), false);
assert.strictEqual(builder.length, 5);
assert.strictEqual(builder.toString(), 'abcde');
});
it('should reject data exceeding the limit and clear the buffer', () => {
const builder = new LimitedStringBuilder(5);
builder.append('abc');
assert.strictEqual(builder.append('def'), true);
assert.strictEqual(builder.length, 0);
assert.strictEqual(builder.toString(), '');
});
it('should reject a single chunk larger than the limit', () => {
const builder = new LimitedStringBuilder(3);
assert.strictEqual(builder.append('toolong'), true);
assert.strictEqual(builder.length, 0);
assert.strictEqual(builder.toString(), '');
});
it('should allow appending again after reset following a limit breach', () => {
const builder = new LimitedStringBuilder(3);
assert.strictEqual(builder.append('abcd'), true);
builder.reset();
assert.strictEqual(builder.append('ab'), false);
assert.strictEqual(builder.toString(), 'ab');
});
it('should accumulate many chunks before hitting the limit', () => {
const limit = 100;
const builder = new LimitedStringBuilder(limit);
const chunk = 'A';
for (let i = 0; i < limit; i++) {
assert.strictEqual(builder.append(chunk), false);
}
assert.strictEqual(builder.toString(), 'A'.repeat(limit));
assert.strictEqual(builder.append('B'), true);
assert.strictEqual(builder.toString(), '');
});
it('should reject when limit is zero and any data is appended', () => {
const builder = new LimitedStringBuilder(0);
assert.strictEqual(builder.append('a'), true);
assert.strictEqual(builder.length, 0);
});
it('should allow zero-length appends at the limit', () => {
const builder = new LimitedStringBuilder(0);
assert.strictEqual(builder.append(''), false);
assert.strictEqual(builder.length, 0);
assert.strictEqual(builder.toString(), '');
});
});
+67
View File
@@ -0,0 +1,67 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
/**
* Accumulates string data from multiple chunks without O(n²) string concatenation.
*/
export class StringBuilder {
private _chunks: string[] = [];
private _length = 0;
public get length(): number {
return this._length;
}
public reset(): void {
this._chunks.length = 0;
this._length = 0;
}
public append(chunk: string): void {
this._chunks.push(chunk);
this._length += chunk.length;
}
public toString(): string {
return this._chunks.join('');
}
}
/**
* String builder that rejects payloads larger than a fixed limit.
*/
export class LimitedStringBuilder {
private readonly _builder = new StringBuilder();
constructor(private readonly _limit: number) { }
public get length(): number {
return this._builder.length;
}
public get limit(): number {
return this._limit;
}
public reset(): void {
this._builder.reset();
}
/**
* @returns true if the limit was exceeded (buffer is cleared in that case)
*/
public append(chunk: string): boolean {
this._builder.append(chunk);
if (this._builder.length > this._limit) {
this._builder.reset();
return true;
}
return false;
}
public toString(): string {
return this._builder.toString();
}
}
+3 -3
View File
@@ -7,7 +7,7 @@ import { IApcHandler, IHandlerCollection, ApcFallbackHandlerType, IApcParser, IS
import { ParserConstants } from 'common/parser/Constants';
import { utf32ToString } from 'common/input/TextDecoder';
import { IDisposable } from 'common/Types';
import { PayloadStringBuffer } from 'common/parser/PayloadStringBuffer';
import { LimitedStringBuilder } from 'common/StringBuilder';
const EMPTY_HANDLERS: IApcHandler[] = [];
@@ -154,7 +154,7 @@ export class ApcParser implements IApcParser {
export class ApcHandler implements IApcHandler {
private static _payloadLimit = ParserConstants.PAYLOAD_LIMIT;
private _data = new PayloadStringBuffer();
private _data = new LimitedStringBuilder(ApcHandler._payloadLimit);
private _hitLimit: boolean = false;
constructor(private _handler: (data: string) => boolean | Promise<boolean>) { }
@@ -168,7 +168,7 @@ export class ApcHandler implements IApcHandler {
if (this._hitLimit) {
return;
}
if (this._data.add(utf32ToString(data, start, end), ApcHandler._payloadLimit)) {
if (this._data.append(utf32ToString(data, start, end))) {
this._hitLimit = true;
}
}
+3 -3
View File
@@ -8,7 +8,7 @@ import { IDcsHandler, IParams, IHandlerCollection, IDcsParser, DcsFallbackHandle
import { utf32ToString } from 'common/input/TextDecoder';
import { Params } from 'common/parser/Params';
import { ParserConstants } from 'common/parser/Constants';
import { PayloadStringBuffer } from 'common/parser/PayloadStringBuffer';
import { LimitedStringBuilder } from 'common/StringBuilder';
const EMPTY_HANDLERS: IDcsHandler[] = [];
@@ -141,7 +141,7 @@ EMPTY_PARAMS.addParam(0);
export class DcsHandler implements IDcsHandler {
private static _payloadLimit = ParserConstants.PAYLOAD_LIMIT;
private _data = new PayloadStringBuffer();
private _data = new LimitedStringBuilder(DcsHandler._payloadLimit);
private _params: IParams = EMPTY_PARAMS;
private _hitLimit: boolean = false;
@@ -161,7 +161,7 @@ export class DcsHandler implements IDcsHandler {
if (this._hitLimit) {
return;
}
if (this._data.add(utf32ToString(data, start, end), DcsHandler._payloadLimit)) {
if (this._data.append(utf32ToString(data, start, end))) {
this._hitLimit = true;
}
}
+3 -3
View File
@@ -7,7 +7,7 @@ import { IOscHandler, IHandlerCollection, OscFallbackHandlerType, IOscParser, IS
import { OscState, ParserConstants } from 'common/parser/Constants';
import { utf32ToString } from 'common/input/TextDecoder';
import { IDisposable } from 'common/Types';
import { PayloadStringBuffer } from 'common/parser/PayloadStringBuffer';
import { LimitedStringBuilder } from 'common/StringBuilder';
const EMPTY_HANDLERS: IOscHandler[] = [];
@@ -195,7 +195,7 @@ export class OscParser implements IOscParser {
export class OscHandler implements IOscHandler {
private static _payloadLimit = ParserConstants.PAYLOAD_LIMIT;
private _data = new PayloadStringBuffer();
private _data = new LimitedStringBuilder(OscHandler._payloadLimit);
private _hitLimit: boolean = false;
constructor(private _handler: (data: string) => boolean | Promise<boolean>) { }
@@ -209,7 +209,7 @@ export class OscHandler implements IOscHandler {
if (this._hitLimit) {
return;
}
if (this._data.add(utf32ToString(data, start, end), OscHandler._payloadLimit)) {
if (this._data.append(utf32ToString(data, start, end))) {
this._hitLimit = true;
}
}
-34
View File
@@ -1,34 +0,0 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
/**
* Accumulates string payloads from multiple chunks without O(n²) string concatenation.
*/
export class PayloadStringBuffer {
private _chunks: string[] = [];
private _length = 0;
public reset(): void {
this._chunks.length = 0;
this._length = 0;
}
/**
* @returns true if the payload limit was exceeded (buffer is cleared in that case)
*/
public add(chunk: string, limit: number): boolean {
this._chunks.push(chunk);
this._length += chunk.length;
if (this._length > limit) {
this.reset();
return true;
}
return false;
}
public toString(): string {
return this._chunks.join('');
}
}