tricu

A lazy functional language based on Tree Calculus
Log | Files | Refs | README | LICENSE

io-terminal.ts (3272B)


      1 // Terminal provider; cloneable identities only, independent of evaluators.
      2 import type { Event, Completion } from './io-events.js';
      3 import { OutputBuffer } from './io-output.js';
      4 export type Reply = Completion & { run: string; id: number };
      5 export class Terminal {
      6   readonly output = new OutputBuffer();
      7   private nextAfter = 0;
      8   private reader: Extract<Event, { kind: 'read' }> | undefined;
      9   private buffered: number[] = [];
     10   private eof = false;
     11   private ended = false;
     12   constructor(readonly run: string) {
     13     if (!/^[1-9][0-9]*$/.test(run) || run.length > 20 || BigInt(run) > 0xffffffffffffffffn) throw new Error('invalid terminal run');
     14   }
     15   get waiting(): { run: string; id: number } | undefined {
     16     return this.reader ? { run: this.run, id: this.reader.id } : undefined;
     17   }
     18   private read(): Reply | undefined {
     19     const reader = this.reader;
     20     if (!reader || !this.buffered.length && !this.eof) return;
     21     const bytes = this.buffered.splice(0, reader.limit); this.reader = undefined;
     22     return { run: this.run, id: reader.id, kind: 'read', bytes, eof: bytes.length === 0 };
     23   }
     24   handle(event: Event): Reply | undefined {
     25     if (this.ended || event.run !== this.run || !Number.isSafeInteger(event.id) || event.id < 1) throw new Error('invalid terminal event');
     26     if (event.kind === 'cancel') {
     27       if (event.id > this.nextAfter || !['read', 'write'].includes(event.operation)) throw new Error('invalid terminal cancellation');
     28       // Completed synchronous writes/reads already have a normal acknowledgement
     29       // queued. A racing abort does not erase it or manufacture a second reply.
     30       if (this.reader?.id !== event.id) return;
     31       if (event.operation !== 'read') throw new Error('invalid terminal cancellation');
     32       this.reader = undefined;
     33       return { run: this.run, id: event.id, kind: 'cancelled' };
     34     }
     35     if (event.id <= this.nextAfter) throw new Error('stale terminal event');
     36     if (event.kind === 'read') {
     37       if (this.reader || !Number.isInteger(event.limit) || event.limit < 1 || event.limit > 4096) throw new Error('invalid terminal read');
     38       this.reader = { ...event };
     39     } else if (event.kind === 'write') this.output.append(event.stream, event.bytes);
     40     else throw new Error('invalid terminal operation');
     41     this.nextAfter = event.id;
     42     return event.kind === 'read' ? this.read() : { run: this.run, id: event.id, kind: 'write', count: event.bytes.length };
     43   }
     44   // Explicit one-line UI: no CR/LF in the submitted text, append ONE LF; an
     45   // empty line is data, never EOF. Scalars, not UTF-16 code units, are bounded.
     46   line(run: string, id: number, value: unknown): Reply | undefined {
     47     if (run !== this.run || this.reader?.id !== id || typeof value !== 'string' || value.length > 512 ||
     48         /[\r\n\uD800-\uDFFF]/u.test(value) || Array.from(value).length > 256) return;
     49     this.buffered = [...new TextEncoder().encode(value), 10];
     50     return this.read();
     51   }
     52   endInput(run: string, id: number): Reply | undefined {
     53     if (run !== this.run || this.reader?.id !== id) return;
     54     this.eof = true; return this.read();
     55   }
     56   finish(): void {
     57     if (this.reader) throw new Error('terminal input still pending');
     58     this.ended = true; this.buffered = []; this.output.finish();
     59   }
     60 }