From 30b42838fd0e0674c86970df72f57b314c30bc0b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 27 May 2026 16:27:26 +0000 Subject: [PATCH] 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 --- src/common/StringBuilder.test.ts | 143 +++++++++++++++++++++++ src/common/StringBuilder.ts | 67 +++++++++++ src/common/parser/ApcParser.ts | 6 +- src/common/parser/DcsParser.ts | 6 +- src/common/parser/OscParser.ts | 6 +- src/common/parser/PayloadStringBuffer.ts | 34 ------ 6 files changed, 219 insertions(+), 43 deletions(-) create mode 100644 src/common/StringBuilder.test.ts create mode 100644 src/common/StringBuilder.ts delete mode 100644 src/common/parser/PayloadStringBuffer.ts diff --git a/src/common/StringBuilder.test.ts b/src/common/StringBuilder.test.ts new file mode 100644 index 000000000..1794de0f7 --- /dev/null +++ b/src/common/StringBuilder.test.ts @@ -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(), ''); + }); +}); diff --git a/src/common/StringBuilder.ts b/src/common/StringBuilder.ts new file mode 100644 index 000000000..d9184ce31 --- /dev/null +++ b/src/common/StringBuilder.ts @@ -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(); + } +} diff --git a/src/common/parser/ApcParser.ts b/src/common/parser/ApcParser.ts index d3f5b8e2e..4e647d037 100644 --- a/src/common/parser/ApcParser.ts +++ b/src/common/parser/ApcParser.ts @@ -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) { } @@ -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; } } diff --git a/src/common/parser/DcsParser.ts b/src/common/parser/DcsParser.ts index be2fa39d5..25c22b8dd 100644 --- a/src/common/parser/DcsParser.ts +++ b/src/common/parser/DcsParser.ts @@ -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; } } diff --git a/src/common/parser/OscParser.ts b/src/common/parser/OscParser.ts index 6a17af817..3e05f2298 100644 --- a/src/common/parser/OscParser.ts +++ b/src/common/parser/OscParser.ts @@ -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) { } @@ -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; } } diff --git a/src/common/parser/PayloadStringBuffer.ts b/src/common/parser/PayloadStringBuffer.ts deleted file mode 100644 index 9caef25ea..000000000 --- a/src/common/parser/PayloadStringBuffer.ts +++ /dev/null @@ -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(''); - } -}