tricu

An interpreted language for exploring Tree Calculus
Log | Files | Refs | README | LICENSE

io-v2-output.ts (3327B)


      1 // Presentation only. Byte acceptance and strict source UTF-8 Results are separate.
      2 // No evaluator handles, host callbacks, IO acknowledgements or terminal escapes.
      3 import { decodeUTF8, UTF8Error } from './io-v2-utf8.js';
      4 export type OutputStream = 'output' | 'error';
      5 export type OutputToken = { kind: 'text'; offset: number; text: string } |
      6   { kind: 'bytes'; offset: number; bytes: number[]; reason: 'invalid' | 'incomplete' | 'pending' };
      7 export interface OutputView { bytes: number[]; hex: string; tokens: OutputToken[] }
      8 
      9 function view(bytes: number[], final: boolean): OutputView {
     10   const tokens: OutputToken[] = [];
     11   for (let offset = 0; offset < bytes.length;) {
     12     const lead = bytes[offset]!;
     13     const width = lead <= 0x7f ? 1 : lead >= 0xc2 && lead <= 0xdf ? 2 :
     14       lead >= 0xe0 && lead <= 0xef ? 3 : lead >= 0xf0 && lead <= 0xf4 ? 4 : 1;
     15     const candidate = bytes.slice(offset, offset + width);
     16     try {
     17       const decoded = decodeUTF8(candidate, false);
     18       if (decoded.carry.length) {
     19         tokens.push({ kind: 'bytes', offset, bytes: candidate, reason: final ? 'incomplete' : 'pending' });
     20         break;
     21       }
     22       const previous = tokens.at(-1);
     23       if (previous?.kind === 'text') previous.text += decoded.text;
     24       else tokens.push({ kind: 'text', offset, text: decoded.text });
     25       offset += width;
     26     } catch (error) {
     27       if (!(error instanceof UTF8Error)) throw error;
     28       // Escape one offending byte; reconsider following bytes independently.
     29       // Thus invalid prefixes cannot swallow subsequent valid text.
     30       tokens.push({ kind: 'bytes', offset, bytes: [lead], reason: 'invalid' });
     31       offset++;
     32     }
     33   }
     34   return { bytes, hex: bytes.map(b => b.toString(16).padStart(2, '0')).join(' '), tokens };
     35 }
     36 
     37 // Owned by one UI run. The session separately enforces attempted-output budgets;
     38 // this buffer bounds retained accepted bytes, without refunds or history.
     39 export class OutputBuffer {
     40   private readonly streams: Record<OutputStream, number[]> = { output: [], error: [] };
     41   private size = 0;
     42   private ended = false;
     43   constructor(readonly limit = 16384) {
     44     if (!Number.isSafeInteger(limit) || limit < 0 || limit > 16384) throw new Error('invalid output limit');
     45   }
     46   append(stream: OutputStream, bytes: readonly number[]): void {
     47     if (this.ended) throw new Error('output presentation ended');
     48     if (stream !== 'output' && stream !== 'error') throw new Error('invalid output stream');
     49     if (!Array.isArray(bytes) || bytes.length === 0 || bytes.length > 4096) throw new Error('invalid output bytes');
     50     for (const byte of bytes) if (!Number.isInteger(byte) || byte < 0 || byte > 255) throw new Error('invalid output bytes');
     51     if (bytes.length > this.limit - this.size) throw new Error('output presentation limit');
     52     this.streams[stream].push(...bytes); this.size += bytes.length;
     53   }
     54   // End-of-presentation, including after structured cancellation, not closing a
     55   // borrowed stream. Incomplete suffixes become visible bytes, never IO failures.
     56   finish(): void { this.ended = true; }
     57   snapshot(): { done: boolean; size: number; output: OutputView; error: OutputView } {
     58     return { done: this.ended, size: this.size,
     59       output: view([...this.streams.output], this.ended), error: view([...this.streams.error], this.ended) };
     60   }
     61 }