blob: 581ddba94bf9d11a7609b614425ebf0212d7e516 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export interface BoundedChunkBufferOptions<T> {
maxChars: number;
maxChunks: number;
textOf: (chunk: T) => string;
withText: (chunk: T, text: string) => T;
sequence?: (chunk: T) => number;
}
/** A tail buffer bounded by both retained text and retained chunk objects. */
export class BoundedChunkBuffer<T> {
private chunks: Array<T | undefined> = [];
private head = 0;
private retainedChars = 0;
private cachedValues: readonly T[] | undefined;
private discardedThroughSequence: number | undefined;
private dropped = 0;
private revision = 0;
constructor(private readonly options: BoundedChunkBufferOptions<T>) {}
get length(): number {
return this.chunks.length - this.head;
}
get droppedChars(): number {
return this.dropped;
}
get version(): number {
return this.revision;
}
values(): readonly T[] {
this.cachedValues ??= this.chunks.slice(this.head) as T[];
return this.cachedValues;
}
append(chunk: T): boolean {
const sequenceOf = this.options.sequence;
if (sequenceOf) {
const sequence = sequenceOf(chunk);
if (
this.discardedThroughSequence !== undefined &&
sequence <= this.discardedThroughSequence
) {
return false;
}
for (let index = this.head; index < this.chunks.length; index += 1) {
const candidate = this.chunks[index];
if (candidate !== undefined && sequenceOf(candidate) === sequence) return false;
}
}
this.insert(chunk);
this.retainedChars += this.options.textOf(chunk).length;
this.trim();
this.revision += 1;
this.cachedValues = undefined;
return true;
}
private insert(chunk: T): void {
const sequenceOf = this.options.sequence;
const last = this.chunks[this.chunks.length - 1];
if (!sequenceOf || last === undefined || sequenceOf(last) <= sequenceOf(chunk)) {
this.chunks.push(chunk);
return;
}
this.compactStorage(true);
const index = this.chunks.findIndex(
(candidate) => candidate !== undefined && sequenceOf(candidate) > sequenceOf(chunk),
);
this.chunks.splice(index < 0 ? this.chunks.length : index, 0, chunk);
}
private trim(): void {
let excess = this.retainedChars - this.options.maxChars;
while (excess > 0 && this.length > 0) {
const first = this.chunks[this.head];
if (first === undefined) break;
const text = this.options.textOf(first);
if (text.length <= excess) {
this.dropFirst(first, text.length);
excess -= text.length;
continue;
}
const cut = unicodeSafePrefixLength(text, excess);
this.chunks[this.head] = this.options.withText(first, text.slice(cut));
this.retainedChars -= cut;
this.dropped += cut;
excess = 0;
}
while (this.length > this.options.maxChunks) {
const first = this.chunks[this.head];
if (first === undefined) break;
this.dropFirst(first, this.options.textOf(first).length);
}
this.compactStorage(false);
}
private dropFirst(chunk: T, chars: number): void {
const sequence = this.options.sequence?.(chunk);
if (
sequence !== undefined &&
(this.discardedThroughSequence === undefined || this.discardedThroughSequence < sequence)
) {
this.discardedThroughSequence = sequence;
}
this.chunks[this.head] = undefined;
this.head += 1;
this.retainedChars -= chars;
this.dropped += chars;
}
private compactStorage(force: boolean): void {
if (this.head === 0) return;
if (!force && (this.head < 64 || this.head * 2 < this.chunks.length)) return;
this.chunks.splice(0, this.head);
this.head = 0;
}
}
function unicodeSafePrefixLength(text: string, minimum: number): number {
const before = text.charCodeAt(minimum - 1);
const after = text.charCodeAt(minimum);
const splitsSurrogatePair =
before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff;
return splitsSurrogatePair ? minimum + 1 : minimum;
}