Files
rea/src/process/ProcessOutputTokenScanner.ts
T

150 lines
5.7 KiB
TypeScript
Raw Normal View History

import { isUtf8 } from "node:buffer";
/** One confirmed token with bounded context from its raw output stream. */
export interface ProviderProcessOutputToken {
/** Byte offset since construction or reset of the owning supervisor. */
readonly offset: number;
readonly line: string;
readonly truncated: boolean;
readonly invalidUtf8?: true;
/** True when the line's right edge is cut or its end has not been observed. */
readonly trailingContextIncomplete?: true;
}
export const PROCESS_OUTPUT_TOKEN_CONTEXT_BYTES = 1024;
const NEWLINE = 0x0a;
const isWordByte = (byte: number): boolean =>
(byte >= 0x30 && byte <= 0x39) ||
(byte >= 0x41 && byte <= 0x5a) ||
(byte >= 0x61 && byte <= 0x7a) ||
byte === 0x5f;
interface Match {
readonly offset: number;
readonly prefix: Buffer;
readonly prefixCut: boolean;
suffix: Buffer;
open: boolean;
suffixCut: boolean;
}
/** Bounded literal token capture, delimited on the right by a non-ASCII-word byte or actual EOF. */
export class ProcessOutputTokenScanner {
readonly #marker: Buffer;
#lineTail: Buffer = Buffer.alloc(0);
#lineTailCut = false;
#latest: Match | undefined;
#pending: Match | undefined;
constructor(marker: string) {
if (marker.length === 0 || marker.includes("\n"))
throw new RangeError("Output token must be non-empty single-line text");
this.#marker = Buffer.from(marker, "utf8");
if (!isWordByte(this.#marker[this.#marker.length - 1]!))
throw new RangeError("Output token must end in an ASCII word byte");
}
scan(chunk: Buffer, start: number): void {
if (chunk.length === 0) return;
// The next byte can invalidate a pending prefix without erasing the last
// confirmed token from an earlier line.
if (this.#pending !== undefined) {
if (!isWordByte(chunk[0]!)) this.#latest = this.#pending;
this.#pending = undefined;
}
this.#extendOpenLine(chunk);
const carried = this.#lineTail.byteLength;
const searched =
carried === 0 ? chunk : Buffer.concat([this.#lineTail, chunk]);
// A later identifier may share the literal prefix. Search past rejected
// candidates to retain the latest confirmed token in this same chunk.
let index = searched.lastIndexOf(this.#marker);
while (index >= 0 && index + this.#marker.byteLength > carried) {
const end = index + this.#marker.byteLength;
if (end === searched.length)
this.#pending = this.#record(searched, index, start - carried);
else if (!isWordByte(searched[end]!)) {
this.#latest = this.#record(searched, index, start - carried);
break;
}
index = index === 0 ? -1 : searched.lastIndexOf(this.#marker, index - 1);
}
this.#retainLineTail(searched);
}
/** Only a true stream end proves the boundary of a trailing bare token. */
finish(): void {
if (this.#pending !== undefined) this.#latest = this.#pending;
this.#pending = undefined;
if (this.#latest !== undefined) this.#latest.open = false;
}
latestWithin(
start: number,
end: number,
): ProviderProcessOutputToken | undefined {
const latest = this.#latest;
if (latest === undefined || latest.offset < start || latest.offset >= end)
return undefined;
// Omit a prefix crossing the checkpoint in full: neither its old cause
// text nor a credential fragment cut on the left belongs to this scope.
const prefixInRange = latest.offset - latest.prefix.length >= start;
const bytes = Buffer.concat([
...(prefixInRange ? [latest.prefix] : []),
latest.suffix,
]);
return {
offset: latest.offset,
line: bytes.toString("utf8"),
...(!isUtf8(bytes) ? { invalidUtf8: true as const } : {}),
truncated: latest.prefixCut || latest.suffixCut || !prefixInRange,
...(latest.suffixCut || latest.open
? { trailingContextIncomplete: true as const }
: {}),
};
}
#record(searched: Buffer, index: number, searchedStart: number): Match {
const lineStart =
index === 0 ? 0 : searched.lastIndexOf(NEWLINE, index - 1) + 1;
const prefixFits =
index - lineStart <= PROCESS_OUTPUT_TOKEN_CONTEXT_BYTES &&
(lineStart > 0 || !this.#lineTailCut);
const lineEnd = searched.indexOf(NEWLINE, index);
const available = lineEnd < 0 ? searched.byteLength : lineEnd;
const suffixEnd = Math.min(
available,
index + this.#marker.byteLength + PROCESS_OUTPUT_TOKEN_CONTEXT_BYTES,
);
return {
offset: searchedStart + index,
prefix: prefixFits
? Buffer.from(searched.subarray(lineStart, index))
: Buffer.alloc(0),
prefixCut: !prefixFits,
suffix: Buffer.from(searched.subarray(index, suffixEnd)),
open: lineEnd < 0 && suffixEnd === available,
suffixCut: suffixEnd < available,
};
}
#extendOpenLine(chunk: Buffer): void {
const latest = this.#latest;
if (latest?.open !== true) return;
const lineEnd = chunk.indexOf(NEWLINE);
const available = lineEnd < 0 ? chunk.byteLength : lineEnd;
const room =
this.#marker.byteLength +
PROCESS_OUTPUT_TOKEN_CONTEXT_BYTES -
latest.suffix.byteLength;
const taken = Math.min(available, Math.max(0, room));
if (taken > 0)
latest.suffix = Buffer.concat([latest.suffix, chunk.subarray(0, taken)]);
latest.suffixCut = taken < available;
latest.open = lineEnd < 0 && taken === available;
}
#retainLineTail(searched: Buffer): void {
const lineStart = searched.lastIndexOf(NEWLINE) + 1;
const limit =
PROCESS_OUTPUT_TOKEN_CONTEXT_BYTES + this.#marker.byteLength - 1;
this.#lineTailCut =
searched.byteLength - lineStart > limit ||
(lineStart === 0 && this.#lineTailCut);
this.#lineTail = Buffer.from(
searched.subarray(Math.max(lineStart, searched.byteLength - limit)),
);
}
}