diff --git a/packages/runtime/src/io/input-parser.test.ts b/packages/runtime/src/io/input-parser.test.ts new file mode 100644 index 0000000..6b0201e --- /dev/null +++ b/packages/runtime/src/io/input-parser.test.ts @@ -0,0 +1,76 @@ +import { describe, test, expect } from "vite-plus/test"; +import { createInputParser } from "./input-parser.ts"; + +describe("input-parser", () => { + test("splits plain text into single event", () => { + const parser = createInputParser(); + expect(parser.push("hello")).toEqual(["hello"]); + }); + + test("parses CSI sequence (arrow up)", () => { + const parser = createInputParser(); + expect(parser.push("\x1b[A")).toEqual(["\x1b[A"]); + }); + + test("handles incomplete CSI as pending", () => { + const parser = createInputParser(); + expect(parser.push("\x1b[")).toEqual([]); + expect(parser.hasPendingEscape()).toBe(true); + expect(parser.push("A")).toEqual(["\x1b[A"]); + }); + + test("parses bracketed paste", () => { + const parser = createInputParser(); + const events = parser.push("\x1b[200~pasted text\x1b[201~"); + expect(events).toEqual([{ paste: "pasted text" }]); + }); + + test("splits backspace bytes", () => { + const parser = createInputParser(); + const events = parser.push("ab\x7F\x7Fc"); + expect(events).toEqual(["ab", "\x7F", "\x7F", "c"]); + }); + + test("hasPendingEscape is false during paste assembly", () => { + const parser = createInputParser(); + parser.push("\x1b[200"); + expect(parser.hasPendingEscape()).toBe(false); + }); + + test("flushPendingEscape returns pending sequence", () => { + const parser = createInputParser(); + parser.push("\x1b"); + expect(parser.hasPendingEscape()).toBe(true); + expect(parser.flushPendingEscape()).toBe("\x1b"); + }); + + test("parses SS3 sequence", () => { + const parser = createInputParser(); + expect(parser.push("\x1bOP")).toEqual(["\x1bOP"]); + }); + + test("parses double-ESC prefix (meta+arrow)", () => { + const parser = createInputParser(); + expect(parser.push("\x1b\x1b[A")).toEqual(["\x1b\x1b[A"]); + }); + + test("reset clears pending state", () => { + const parser = createInputParser(); + parser.push("\x1b["); + expect(parser.hasPendingEscape()).toBe(true); + parser.reset(); + expect(parser.hasPendingEscape()).toBe(false); + }); + + test("incomplete paste start does not trigger flush", () => { + const parser = createInputParser(); + parser.push("\x1b[200"); + expect(parser.hasPendingEscape()).toBe(false); + }); + + test("text mixed with escape sequences", () => { + const parser = createInputParser(); + const events = parser.push("a\x1b[Ab\x1b[B"); + expect(events).toEqual(["a", "\x1b[A", "b", "\x1b[B"]); + }); +}); diff --git a/packages/runtime/src/io/input-parser.ts b/packages/runtime/src/io/input-parser.ts new file mode 100644 index 0000000..896240f --- /dev/null +++ b/packages/runtime/src/io/input-parser.ts @@ -0,0 +1,284 @@ +const escape = ""; +const pasteStart = "[200~"; +const pasteEnd = "[201~"; + +export type InputEvent = string | { readonly paste: string }; + +type ParsedInput = { + readonly events: InputEvent[]; + readonly pending: string; +}; + +type ParsedSequence = + | { + readonly sequence: string; + readonly nextIndex: number; + } + | "pending" + | undefined; + +const isCsiParameterByte = (byte: number): boolean => { + return byte >= 0x30 && byte <= 0x3f; +}; + +const isCsiIntermediateByte = (byte: number): boolean => { + return byte >= 0x20 && byte <= 0x2f; +}; + +const isCsiFinalByte = (byte: number): boolean => { + return byte >= 0x40 && byte <= 0x7e; +}; + +const parseCsiSequence = ( + input: string, + startIndex: number, + prefixLength: number, +): ParsedSequence => { + const csiPayloadStart = startIndex + prefixLength + 1; + let index = csiPayloadStart; + for (; index < input.length; index++) { + const byte = input.codePointAt(index); + if (byte === undefined) { + return "pending"; + } + + if (isCsiParameterByte(byte) || isCsiIntermediateByte(byte)) { + continue; + } + + // Preserve legacy terminal function-key sequences like ESC[[A and ESC[[5~. + if (byte === 0x5b && index === csiPayloadStart) { + continue; + } + + if (isCsiFinalByte(byte)) { + return { + sequence: input.slice(startIndex, index + 1), + nextIndex: index + 1, + }; + } + + return undefined; + } + + return "pending"; +}; + +const parseSs3Sequence = ( + input: string, + startIndex: number, + prefixLength: number, +): ParsedSequence => { + const nextIndex = startIndex + prefixLength + 2; + if (nextIndex > input.length) { + return "pending"; + } + + const finalByte = input.codePointAt(nextIndex - 1); + if (finalByte === undefined || !isCsiFinalByte(finalByte)) { + return undefined; + } + + return { + sequence: input.slice(startIndex, nextIndex), + nextIndex, + }; +}; + +const parseControlSequence = ( + input: string, + startIndex: number, + prefixLength: number, +): ParsedSequence => { + const sequenceType = input[startIndex + prefixLength]; + if (sequenceType === undefined) { + return "pending"; + } + + if (sequenceType === "[") { + return parseCsiSequence(input, startIndex, prefixLength); + } + + if (sequenceType === "O") { + return parseSs3Sequence(input, startIndex, prefixLength); + } + + return undefined; +}; + +const parseEscapedCodePoint = ( + input: string, + escapeIndex: number, +): { + readonly sequence: string; + readonly nextIndex: number; +} => { + const nextCodePoint = input.codePointAt(escapeIndex + 1); + const nextCodePointLength = nextCodePoint !== undefined && nextCodePoint > 0xff_ff ? 2 : 1; + const nextIndex = escapeIndex + 1 + nextCodePointLength; + + return { + sequence: input.slice(escapeIndex, nextIndex), + nextIndex, + }; +}; + +type ParsedEscapeSequence = + | { + readonly sequence: string; + readonly nextIndex: number; + } + | "pending"; + +const parseEscapeSequence = (input: string, escapeIndex: number): ParsedEscapeSequence => { + if (escapeIndex === input.length - 1) { + return "pending"; + } + + const next = input[escapeIndex + 1]!; + if (next === escape) { + if (escapeIndex + 2 >= input.length) { + return "pending"; + } + + const doubleEscapeSequence = parseControlSequence(input, escapeIndex, 2); + if (doubleEscapeSequence === "pending") { + return "pending"; + } + + if (doubleEscapeSequence) { + return doubleEscapeSequence; + } + + return { + sequence: input.slice(escapeIndex, escapeIndex + 2), + nextIndex: escapeIndex + 2, + }; + } + + const controlSequence = parseControlSequence(input, escapeIndex, 1); + if (controlSequence === "pending") { + return "pending"; + } + + if (controlSequence) { + return controlSequence; + } + + return parseEscapedCodePoint(input, escapeIndex); +}; + +/** + * Split a chunk of non-escape text so that backspace bytes (`0x7F` and `0x08`) + * become individual events. When a user holds the backspace key, the terminal + * sends repeated bytes in a single stdin chunk. Without splitting, + * `parseKeypress` receives the multi-byte string and fails to recognize it as a + * key event, corrupting the input state. + * + * Other control characters like `\r` and `\t` are NOT split because they can + * legitimately appear inside pasted text. + */ +const splitBackspaceBytes = (text: string, events: InputEvent[]): void => { + let textSegmentStart = 0; + + for (let index = 0; index < text.length; index++) { + const character = text[index]!; + if (character === "" || character === "") { + if (index > textSegmentStart) { + events.push(text.slice(textSegmentStart, index)); + } + + events.push(character); + textSegmentStart = index + 1; + } + } + + if (textSegmentStart < text.length) { + events.push(text.slice(textSegmentStart)); + } +}; + +const parseKeypresses = (input: string): ParsedInput => { + const events: InputEvent[] = []; + let index = 0; + const pendingFrom = (pendingStartIndex: number): ParsedInput => ({ + events, + pending: input.slice(pendingStartIndex), + }); + + while (index < input.length) { + const escapeIndex = input.indexOf(escape, index); + if (escapeIndex === -1) { + splitBackspaceBytes(input.slice(index), events); + return { + events, + pending: "", + }; + } + + if (escapeIndex > index) { + splitBackspaceBytes(input.slice(index, escapeIndex), events); + } + + const parsedEscapeSequence = parseEscapeSequence(input, escapeIndex); + if (parsedEscapeSequence === "pending") { + return pendingFrom(escapeIndex); + } + + if (parsedEscapeSequence.sequence === pasteStart) { + const afterStart = parsedEscapeSequence.nextIndex; + const endIndex = input.indexOf(pasteEnd, afterStart); + if (endIndex === -1) { + return pendingFrom(escapeIndex); + } + + events.push({ paste: input.slice(afterStart, endIndex) }); + index = endIndex + pasteEnd.length; + continue; + } + + events.push(parsedEscapeSequence.sequence); + index = parsedEscapeSequence.nextIndex; + } + + return { + events, + pending: "", + }; +}; + +export type InputParser = { + push: (chunk: string) => InputEvent[]; + hasPendingEscape: () => boolean; + flushPendingEscape: () => string | undefined; + reset: () => void; +}; + +export const createInputParser = (): InputParser => { + let pending = ""; + + return { + push(chunk) { + const parsedInput = parseKeypresses(pending + chunk); + pending = parsedInput.pending; + return parsedInput.events; + }, + hasPendingEscape() { + // Don't trigger the escape flush timer while assembling a paste start + // marker (`[200` and then `~`) or while waiting for paste end. + return pending.startsWith(escape) && !pending.startsWith(pasteStart) && pending !== "[200"; + }, + flushPendingEscape() { + if (!pending.startsWith(escape)) { + return undefined; + } + + const pendingEscape = pending; + pending = ""; + return pendingEscape; + }, + reset() { + pending = ""; + }, + }; +};