diff --git a/package.json b/package.json index 51a83a31..7a57914c 100644 --- a/package.json +++ b/package.json @@ -203,7 +203,7 @@ "scripts": { "test": "node --test tests/unit/*.test.cjs", "test:extension-host": "vscode-test", - "test:runtime": "node --test tests/runtime.test.cjs", + "test:runtime": "node --test tests/runtime.test.cjs tests/raw-string-r.test.cjs", "test:package": "node --test tests/package.test.cjs", "vscode:prepublish": "npm run package:extension && npm run build:sidecar && npm run stage:sidecar", "build:sidecar": "node scripts/build-sidecar.js", diff --git a/src/Language/parser.ts b/src/Language/parser.ts index a5b9dd94..999b7aea 100644 --- a/src/Language/parser.ts +++ b/src/Language/parser.ts @@ -96,6 +96,7 @@ interface Token { type: TokenType; value: string; position: number; + closed?: boolean; } export type HighlightTokenKind = @@ -460,55 +461,29 @@ function parseRawString( pos: number ): { token: Token; newPos: number } | null { const start = pos; - pos += 1; - - const quote = code[pos]; - pos += 1; - - let delimiter = ""; - if (pos < code.length) { - const delim = code[pos]; - if (delim === "(" || delim === "[" || delim === "{") { - delimiter = delim; - pos += 1; - } else if (delim === "-") { - let dashes = ""; - while (pos < code.length && code[pos] === "-") { - dashes += "-"; - pos += 1; - } - delimiter = dashes; - } - } - - const closeDelim = - delimiter === "(" ? ")" : delimiter === "[" ? "]" : delimiter === "{" ? "}" : delimiter; - - while (pos < code.length) { - if (delimiter) { - if (code[pos] === closeDelim) { - const remaining = code.slice(pos); - if (remaining.startsWith(closeDelim + quote)) { - pos += closeDelim.length + 1; - return { - token: { type: TokenType.RawString, value: code.slice(start, pos), position: start }, - newPos: pos, - }; - } - } - } else if (code[pos] === quote) { - pos += 1; - return { - token: { type: TokenType.RawString, value: code.slice(start, pos), position: start }, - newPos: pos, - }; - } + const quote = code[pos + 1]; + pos += 2; + const dashStart = pos; + while (code[pos] === "-") { pos += 1; } + const closingBracket = + code[pos] === "(" ? ")" : code[pos] === "[" ? "]" : code[pos] === "{" ? "}" : undefined; + if (!closingBracket) { + return null; + } + const terminator = closingBracket + code.slice(dashStart, pos) + quote; + const end = code.indexOf(terminator, pos + 1); + const newPos = end < 0 ? code.length : end + terminator.length; return { - token: { type: TokenType.RawString, value: code.slice(start, pos), position: start }, - newPos: pos, + token: { + type: TokenType.RawString, + value: code.slice(start, newPos), + position: start, + closed: end >= 0, + }, + newPos, }; } @@ -661,7 +636,7 @@ function classifyExpressionHeuristic(code: string): LocalParseClassification { break; } case TokenType.RawString: - if (!isRawStringClosed(token.value)) { + if (!token.closed) { return "incomplete"; } break; @@ -794,35 +769,6 @@ function isDefinitelyCompleteToken(token: Token): boolean { } } -function isRawStringClosed(value: string): boolean { - if (value.length < 3) { - return false; - } - - const quote = value[1]; - if (value.length >= 4) { - const delim = value[2]; - if (delim === "(") { - return value.endsWith(`)${quote}`); - } - if (delim === "[") { - return value.endsWith(`]${quote}`); - } - if (delim === "{") { - return value.endsWith(`}${quote}`); - } - if (delim === "-") { - let dashes = 0; - for (let i = 2; i < value.length && value[i] === "-"; i += 1) { - dashes += 1; - } - return value.endsWith(`${"-".repeat(dashes)}${quote}`); - } - } - - return value.endsWith(quote); -} - function isAwaitingBody(tokens: Token[]): boolean { let depth = 0; let parenStart = -1; diff --git a/src/Terminal/rTerminal.ts b/src/Terminal/rTerminal.ts index 4a770011..1099dde4 100644 --- a/src/Terminal/rTerminal.ts +++ b/src/Terminal/rTerminal.ts @@ -221,8 +221,10 @@ export class RTerminal implements vscode.Pseudoterminal { private runtimeHostAdapter!: RuntimeHost; private programmaticSubmissionQueue: Promise = Promise.resolve(); + private inputCommitPending = false; private suppressNextEnterAfterPasteEnd = false; + private submissionGeneration = 0; private submissionQueue: Submission[] = []; private activeSubmission: Submission | null = null; private extensionResourcesDisposePromise: Promise | undefined; @@ -525,6 +527,10 @@ export class RTerminal implements vscode.Pseudoterminal { set activeSubmission(value) { self.activeSubmission = value; }, + get submissionGeneration() { + return self.submissionGeneration; + }, + cancelPendingSubmissions: () => self.cancelPendingSubmissions(), get submissionQueue() { return self.submissionQueue; }, @@ -869,8 +875,7 @@ export class RTerminal implements vscode.Pseudoterminal { !this.inBracketPaste && this.mode !== "closed" && this.mode !== "reply" && - this.inputState.text.length === 0 && - this.inputState.isAtEnd + (this.inputCommitPending || (this.inputState.text.length === 0 && this.inputState.isAtEnd)) ); } @@ -924,7 +929,7 @@ export class RTerminal implements vscode.Pseudoterminal { return; } - if (this.mode !== "ready" || !this.promptReady) { + if (this.mode !== "ready" || !this.promptReady || this.inputCommitPending) { this.pendingProgrammaticInput = data; this.clearPendingInputFlushTimer(); this.pendingInputFlushTimer = setTimeout(() => { @@ -950,26 +955,23 @@ export class RTerminal implements vscode.Pseudoterminal { } private queueProgrammaticSubmission(data: string): void { + const generation = this.submissionGeneration; this.programmaticSubmissionQueue = this.programmaticSubmissionQueue.then( - async () => this.handleQueuedProgrammaticSubmission(data), - async () => this.handleQueuedProgrammaticSubmission(data) + async () => this.handleQueuedProgrammaticSubmission(data, generation), + async () => this.handleQueuedProgrammaticSubmission(data, generation) ); } - private async handleQueuedProgrammaticSubmission(data: string): Promise { - if (this.mode === "closed") { - return; - } - - if (!this.canRunProgrammaticSubmission()) { + private async handleQueuedProgrammaticSubmission(data: string, generation: number): Promise { + while (this.mode !== "closed" && generation === this.submissionGeneration) { + if (this.canRunProgrammaticSubmission()) { + await this.handleProgrammaticSubmission(data); + return; + } await new Promise((resolve) => { setTimeout(resolve, this.getProgrammaticSubmissionFlushDelay()); }); - await this.handleQueuedProgrammaticSubmission(data); - return; } - - await this.handleProgrammaticSubmission(data); } private canRunProgrammaticSubmission(): boolean { @@ -985,9 +987,8 @@ export class RTerminal implements vscode.Pseudoterminal { const normalized = stripBracketedPasteMarkers(data) .replace(/\r\n/g, "\n") .replace(/\r/g, "\n"); - const trimmed = normalized.replace(/\n+$/, ""); - const submission = trimmed.trimEnd(); - if (!submission) { + const submission = this.stripFinalInputNewline(normalized); + if (!submission.trim()) { return; } @@ -998,22 +999,7 @@ export class RTerminal implements vscode.Pseudoterminal { this.rHistory.resetIndex(); const blocks = await this.enqueueRSubmission(submission, false); - if (blocks.length > 0) { - this.recordSubmissionHistory(blocks); - return; - } - - const isComplete = await this.inputState.isExpressionCompleteAsync(submission); - if ( - !isComplete && - this.mode === "ready" && - this.promptReady && - this.inputState.text.length === 0 && - this.inputState.isAtEnd - ) { - this.inputState.insertText(trimmed); - this.renderInput(); - } + this.recordSubmissionHistory(blocks); } private handleNestedReplyPaste(content: string): boolean { @@ -1111,6 +1097,11 @@ export class RTerminal implements vscode.Pseudoterminal { if (this.mode === "closed") { return; } + // Committed Enter/paste input is immutable during the existing parse. + // Ctrl+C still cancels the pending submission. + if (this.inputCommitPending && action.type !== "ctrl_c") { + return; + } if (this.mode === "executing" || this.mode === "starting") { if (action.type === "ctrl_c") { @@ -1396,6 +1387,7 @@ export class RTerminal implements vscode.Pseudoterminal { } return; case "ctrl_c": + this.cancelPendingSubmissions(); if (this.inputState.text.length > 0) { this.clearInputRender(); this.inputState.reset(); @@ -1578,13 +1570,31 @@ export class RTerminal implements vscode.Pseudoterminal { return; } - const cursorAtEnd = this.inputState.text.length === 0 || this.inputState.isAtEnd; - const trimmed = normalized.replace(/\n+$/, ""); - const fullText = this.inputState.text + trimmed; + // Apply the paste before awaiting the parser, preserving input order. + // Avoid rendering before a complete paste is submitted to R. + const cursorAtEnd = this.inputState.isAtEnd; + this.inputState.insertText(normalized.replace(/\n+$/, "")); + if (!cursorAtEnd) { + this.renderInput(); + return; + } - const isComplete = await this.inputState.isExpressionCompleteAsync(fullText); + const fullText = this.inputState.text; + const generation = this.submissionGeneration; + this.inputCommitPending = true; + let isComplete: boolean; + try { + isComplete = await this.inputState.isExpressionCompleteAsync(fullText); + } finally { + if (generation === this.submissionGeneration) { + this.inputCommitPending = false; + } + } + if (generation !== this.submissionGeneration || this.mode !== "ready") { + return; + } - if ((endsWithNewline || containsNewline) && cursorAtEnd && isComplete) { + if (isComplete) { const submission = fullText.trimEnd(); if (submission) { this.inputState.reset(); @@ -1592,13 +1602,20 @@ export class RTerminal implements vscode.Pseudoterminal { this.savedHistoryInput = undefined; this.historyCollapsed = true; this.rHistory.resetIndex(); - const blocks = await this.enqueueRSubmission(submission, !/[\r\n]/.test(submission)); - this.recordSubmissionHistory(blocks); + // The committed input stays owned until async block splitting has queued it. + this.inputCommitPending = true; + try { + const blocks = await this.enqueueRSubmission(submission, !/[\r\n]/.test(submission)); + this.recordSubmissionHistory(blocks); + } finally { + if (generation === this.submissionGeneration) { + this.inputCommitPending = false; + } + } } return; } - this.inputState.insertText(trimmed); this.renderInput(); } @@ -1688,7 +1705,20 @@ export class RTerminal implements vscode.Pseudoterminal { return; } - const isComplete = await this.inputState.isExpressionCompleteAsync(); + const generation = this.submissionGeneration; + const checkedText = this.inputState.text; + this.inputCommitPending = true; + let isComplete: boolean; + try { + isComplete = await this.inputState.isExpressionCompleteAsync(checkedText); + } finally { + if (generation === this.submissionGeneration) { + this.inputCommitPending = false; + } + } + if (generation !== this.submissionGeneration || this.mode !== "ready") { + return; + } if (!isComplete) { if (this.inputState.cursorCol === 0) { this.inputState.insertText("\n"); @@ -1747,11 +1777,19 @@ export class RTerminal implements vscode.Pseudoterminal { return; } - const blocks = await this.enqueueRSubmission( - sanitized, - !/[\r\n]/.test(sanitized) - ); - this.recordSubmissionHistory(blocks); + // Keep ownership through block splitting, not only the first parse. + this.inputCommitPending = true; + try { + const blocks = await this.enqueueRSubmission( + sanitized, + !/[\r\n]/.test(sanitized) + ); + this.recordSubmissionHistory(blocks); + } finally { + if (generation === this.submissionGeneration) { + this.inputCommitPending = false; + } + } } private reverseSearch(): void { @@ -2222,6 +2260,21 @@ export class RTerminal implements vscode.Pseudoterminal { sendRuntimeReply(this.runtimeHost(), text); } + private cancelPendingSubmissions(): void { + this.submissionGeneration += 1; + this.submissionQueue = []; + this.programmaticSubmissionQueue = Promise.resolve(); + this.inputCommitPending = false; + this.pendingProgrammaticInput = ""; + this.clearPendingInputFlushTimer(); + this.clearPendingConsoleInput(); + this.pasteBuffer = ""; + this.inBracketPaste = false; + this.suppressNextEnterAfterPasteEnd = false; + this.submissionPending = false; + this.awaitingExecutionStart = false; + } + private interruptR(): void { interruptRuntime(this.runtimeHost()); } diff --git a/src/Terminal/rTerminal/runtime.ts b/src/Terminal/rTerminal/runtime.ts index 911aec3d..b1fbf2f1 100644 --- a/src/Terminal/rTerminal/runtime.ts +++ b/src/Terminal/rTerminal/runtime.ts @@ -58,6 +58,7 @@ type Dimensions = { export type TerminalMode = "starting" | "ready" | "executing" | "reply" | "closed"; export type Submission = { + kind: "execute" | "edit"; code: string; initialPromptKind: "main" | "cont"; }; @@ -85,6 +86,8 @@ export type RuntimeHost = { sessionHostConnected: boolean; activeSubmission: Submission | null; submissionQueue: Submission[]; + readonly submissionGeneration: number; + cancelPendingSubmissions(): void; historyBrowsing: boolean; historyCollapsed: boolean; inputState: InputState; @@ -211,7 +214,7 @@ export async function startRuntime(host: RuntimeHost): Promise { host.clearPendingInputFlushTimer(); host.clearPromptRenderTimer(); host.lang.clearSessionState(); - host.clearPendingConsoleInput(); + host.cancelPendingSubmissions(); host.pendingPromptToken = true; host.mode = "starting"; host.promptReady = false; @@ -1057,16 +1060,34 @@ export function startRuntimeSubmission(host: RuntimeHost, task: Submission): voi } return; } + const sent = host.runtimeBackend?.sendSessionCommand(host.rProcess, { + type: "submit", + code: task.code, + }) ?? false; + if (!sent) { + const unsent = [task, ...host.submissionQueue].map((entry) => entry.code).join("\n"); + host.submissionQueue = []; + host.activeSubmission = null; + host.submissionPending = false; + host.awaitingExecutionStart = false; + host.mode = "ready"; + if (host.promptVisible) { + host.clearInputRender(); + host.promptVisible = false; + } + host.inputState.insertText(unsent); + host.pendingPromptToken = true; + handleRuntimeError(host, "R submission was not sent. Unsent code restored for editing.\n"); + host.schedulePrompt(); + return; + } + host.submissionPending = false; host.pendingPromptToken = false; host.awaitingExecutionStart = true; host.mode = "executing"; writeRuntimeSubmissionEcho(host, task); host.clearPromptRenderTimer(); - host.runtimeBackend?.sendSessionCommand(host.rProcess, { - type: "submit", - code: task.code, - }); } export function finishRuntimeSubmission(host: RuntimeHost): void { @@ -1080,13 +1101,19 @@ export function startNextRuntimeSubmission(host: RuntimeHost): void { if (host.mode !== "ready" && !(host.mode === "executing" && host.activeSubmission === null)) { return; } - if (host.activeSubmission) { + if (host.activeSubmission || host.inputState.text.length > 0) { return; } const task = host.submissionQueue.shift(); if (!task) { return; } + if (task.kind === "edit") { + host.mode = "ready"; + host.inputState.insertText(task.code); + host.renderInput(); + return; + } host.activeSubmission = task; startRuntimeSubmission(host, task); } @@ -1096,22 +1123,29 @@ export async function enqueueRuntimeSubmission( code: string, skipSplit: boolean = false ) : Promise { - const blocks = skipSplit - ? [normalizeSubmissionBlock(code)] - : await splitSubmissionBlocks(host, code); + const generation = host.submissionGeneration; + const { blocks, remainder } = skipSplit + ? { blocks: [normalizeSubmissionBlock(code)], remainder: "" } + : await splitSubmissionBlocks(host, code, generation); - if (blocks.length === 0) { - host.submissionPending = false; - host.awaitingExecutionStart = false; + if (generation !== host.submissionGeneration || host.mode === "closed") { return []; } for (const block of blocks) { host.submissionQueue.push({ + kind: "execute", code: block, initialPromptKind: host.promptKind, }); } + if (remainder) { + host.submissionQueue.push({ + kind: "edit", + code: remainder, + initialPromptKind: host.promptKind, + }); + } void host.lang.refreshCompletionContextDocument(host.inputState.text); startNextRuntimeSubmission(host); @@ -1124,7 +1158,11 @@ function normalizeSubmissionBlock(code: string): string { .trimEnd(); } -async function splitSubmissionBlocks(host: RuntimeHost, code: string): Promise { +async function splitSubmissionBlocks( + host: RuntimeHost, + code: string, + generation: number +): Promise<{ blocks: string[]; remainder: string }> { const normalized = code.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); const lines = normalized.split("\n"); const blocks: string[] = []; @@ -1139,6 +1177,9 @@ async function splitSubmissionBlocks(host: RuntimeHost, code: string): Promise { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "r-console-raw-")); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const script = path.join(dir, "raw.R"); + const checks = require("./helpers/raw-string-cases.cjs").map(({ literal, content }) => { + const source = stripCommentLines(`value <- ${literal}\n# outside`); + return `${source}\nstopifnot(identical(value, ${JSON.stringify(content)}))`; + }); + fs.writeFileSync(script, `${checks.join("\n")}\ncat("RAW_STRING_VALUES_OK\\n")\n`); + const output = execFileSync(process.env.R_TEST_EXECUTABLE || "R", ["--vanilla", "--slave", "-f", script], { + encoding: "utf8", timeout: 30000, + }); + assert.match(output, /RAW_STRING_VALUES_OK/); +}); diff --git a/tests/unit/parser.test.cjs b/tests/unit/parser.test.cjs index 8678be1b..4e0c31e2 100644 --- a/tests/unit/parser.test.cjs +++ b/tests/unit/parser.test.cjs @@ -31,3 +31,22 @@ test("native parsing distinguishes incomplete input from errors and falls back o assert.equal(await parser.isExpressionCompleteAsync("x[1]"), true); assert.equal(await parser.isExpressionCompleteAsync("x["), false); }); + +test("raw strings use the exact bracket, dash count, and quote across all supported delimiters", async () => { + for (const { literal, incomplete } of require("../helpers/raw-string-cases.cjs")) { + assert.equal(parser.stripCommentLines(`# outside\ns <- ${literal}\n# outside\ns`), `s <- ${literal}\ns`); + assert.equal(await parser.isExpressionCompleteAsync(literal), true, literal); + assert.equal(await parser.isExpressionCompleteAsync(incomplete), false, incomplete); + assert.equal(await parser.isExpressionCompleteAsync(`${literal}\nx <- (`), false); + assert.deepEqual(parser.tokenizeForHighlighting(`${literal}\nx`), [ + { kind: "string", value: literal, position: 0 }, + { kind: "identifier", value: "x", position: literal.length + 1 }, + ]); + } +}); + +test("unterminated raw strings cannot be made complete by a later ordinary quote", async () => { + for (const input of ['r"-(abc-"', 'r"--[abc]--\' tail "', 'R\'{abc)\'']) { + assert.equal(await parser.isExpressionCompleteAsync(input), false, input); + } +}); diff --git a/tests/unit/submission.test.cjs b/tests/unit/submission.test.cjs new file mode 100644 index 00000000..e028e51e --- /dev/null +++ b/tests/unit/submission.test.cjs @@ -0,0 +1,431 @@ +const assert = require("node:assert/strict"); +const { test } = require("node:test"); +const loadSource = require("../helpers/load-source.cjs"); +const { RTerminal } = loadSource("src/Terminal/rTerminal.ts", { + "./rTerminal/lang": {}, + vscode: { EventEmitter: class {} }, +}); +const runtime = loadSource("src/Terminal/rTerminal/runtime.ts", { vscode: {} }); +const { InputState } = loadSource("src/Terminal/inputState.ts"); +const { Renderer } = loadSource("src/Terminal/renderer.ts"); +const { KeyProcessor } = loadSource("src/Terminal/keyProcessor.ts"); + +function createTerminal() { + const commands = []; + const history = []; + const output = []; + const terminal = Object.assign(Object.create(RTerminal.prototype), { + mode: "ready", promptReady: true, promptKind: "main", promptVisible: false, + submissionGeneration: 0, submissionQueue: [], activeSubmission: null, + programmaticSubmissionQueue: Promise.resolve(), pendingProgrammaticInput: "", + pendingConsoleInputLines: [], pasteBuffer: "", inBracketPaste: false, + submissionPending: false, awaitingExecutionStart: false, + rProcess: {}, sessionHostConnected: true, + inputState: new InputState(), keyProcessor: new KeyProcessor(), + dimensions: { columns: 80, rows: 24 }, lastWriteEndedWithNewline: true, + lang: { refreshCompletionContextDocument: async () => {}, updateInput() {} }, + rHistory: { resetIndex() {}, push(code) { history.push(code); } }, + renderer: new Renderer(() => {}), + syntax: { setSource() {}, highlightLines(lines) { return lines; } }, + runtimeBackend: { + isAlive: () => true, + canUseSessionCommands: () => true, + sendSessionCommand(session, command) { commands.push(command); return true; }, + }, + writeEmitter: { fire(text) { output.push(text); } }, + ensureReadyPromptVisibleForInput() {}, + renderInput() { this.promptVisible = true; }, + clearInputRender() { this.promptVisible = false; }, + clearPromptRenderTimer() {}, clearReplyPromptRenderTimer() {}, + schedulePrompt() {}, scheduleReplyPrompt() {}, + getProgrammaticSubmissionFlushDelay() { return 1; }, + }); + terminal.runtimeHostAdapter = terminal.createRuntimeHost(); + return { terminal, commands, history, output }; +} + +test("incomplete editor tail survives completed expressions and is editable at the next prompt", async () => { + const { terminal, commands, history } = createTerminal(); + await terminal.handleProgrammaticSubmission("x <- 1\ny <- (\n"); + assert.deepEqual(commands, [{ type: "submit", code: "x <- 1" }]); + assert.deepEqual(history, ["x <- 1"]); + assert.equal(terminal.inputState.text, ""); + runtime.handleRuntimeControl(terminal.runtimeHostAdapter, { type: "busy", value: true }); + runtime.handleBackendPrompt(terminal.runtimeHostAdapter, "main"); + assert.equal(terminal.inputState.text, "y <- ("); + assert.equal(terminal.inputState.isAtEnd, true); + assert.equal(terminal.mode, "ready"); + terminal.inputState.insertText("2)"); + await terminal.handleEnterKey(); + assert.deepEqual(commands.at(-1), { type: "submit", code: "y <- (2)" }); + assert.deepEqual(history, ["x <- 1", "y <- (2)"]); +}); + +test("an entirely incomplete submission is restored without execution or history", async () => { + const { terminal, commands, history } = createTerminal(); + await terminal.handleProgrammaticSubmission("f <- function(x) {\n"); + assert.equal(terminal.inputState.text, "f <- function(x) {"); + assert.deepEqual(commands, []); + assert.deepEqual(history, []); +}); + +test("a pending tail survives nested input and is not used as a reply", async () => { + const { terminal, commands } = createTerminal(); + await terminal.handleProgrammaticSubmission('readline("name: ")\ny <- (\n'); + runtime.handleBackendInputRequest(terminal.runtimeHostAdapter, "name: "); + terminal.inputState.insertText("Ada"); + runtime.sendRuntimeReply(terminal.runtimeHostAdapter, "Ada"); + runtime.handleBackendPrompt(terminal.runtimeHostAdapter, "main"); + assert.equal(terminal.inputState.text, "y <- ("); + assert.deepEqual(commands.map(command => command.type), ["submit", "reply-input"]); +}); + +for (const mode of ["executing", "reply"]) { + test(`Ctrl+C cancels the remaining batch while ${mode}`, async () => { + const { terminal, commands } = createTerminal(); + await terminal.handleProgrammaticSubmission('Sys.sleep(30)\nmessage("must not run")\ny <- (\n'); + terminal.mode = mode; + // Also covers interrupt before the first busy notification. + terminal.interruptR(); + runtime.handleBackendPrompt(terminal.runtimeHostAdapter, "main"); + assert.deepEqual(commands.map(command => command.type), ["submit", "interrupt"]); + assert.deepEqual(terminal.submissionQueue, []); + assert.equal(terminal.inputState.text, ""); + assert.equal(terminal.mode, "ready"); + await terminal.handleProgrammaticSubmission("42\n"); + assert.deepEqual(commands.at(-1), { type: "submit", code: "42" }); + }); +} + +test("rejected interrupt retains queued submissions", async () => { + const { terminal } = createTerminal(); + await terminal.handleProgrammaticSubmission("1\n2\n"); + terminal.runtimeBackend.sendSessionCommand = () => false; + terminal.interruptR(); + assert.equal(terminal.submissionQueue.length, 1); + assert.equal(terminal.submissionGeneration, 0); +}); + +test("ready-mode Ctrl+C invalidates an in-flight editor parse with empty input", async () => { + const { terminal, commands, history } = createTerminal(); + let resolveParse; + terminal.inputState.isExpressionCompleteAsync = () => new Promise(resolve => { resolveParse = resolve; }); + const pending = terminal.handleProgrammaticSubmission("1\n2\n"); + terminal.applyKeyAction({ type: "ctrl_c" }); + resolveParse(true); + await pending; + assert.deepEqual(commands, []); + assert.deepEqual(terminal.submissionQueue, []); + assert.deepEqual(history, []); + assert.equal(terminal.inputState.text, ""); +}); + +for (const action of ["enter", "paste"]) { + test(`Ctrl+C invalidates an in-flight ${action} completeness check`, async () => { + const { terminal, commands } = createTerminal(); + let resolveParse; + terminal.inputState.isExpressionCompleteAsync = () => new Promise(resolve => { resolveParse = resolve; }); + terminal.inputState.insertText("1"); + terminal.pasteBuffer = "\n2\n"; + const pending = action === "enter" ? terminal.handleEnterKey() : terminal.handlePasteEnd(); + terminal.applyKeyAction({ type: "ctrl_c" }); + resolveParse(true); + await pending; + assert.deepEqual(commands, []); + assert.equal(terminal.inputState.text, ""); + assert.deepEqual(terminal.submissionQueue, []); + }); +} + +test("Ctrl+C cancels waiting editor requests and buffered input without blocking new requests", async () => { + const { terminal, commands } = createTerminal(); + terminal.mode = "executing"; + terminal.queueProgrammaticSubmission("old1\n"); + terminal.queueProgrammaticSubmission("old2\n"); + const oldQueue = terminal.programmaticSubmissionQueue; + await Promise.resolve(); // Let the first request start waiting for a prompt. + terminal.bufferProgrammaticInput("old3"); + terminal.pendingConsoleInputLines.push("old4"); + terminal.interruptR(); + terminal.mode = "ready"; + terminal.queueProgrammaticSubmission("new\n"); + await terminal.programmaticSubmissionQueue; + await oldQueue; + assert.deepEqual(commands, [{ type: "interrupt" }, { type: "submit", code: "new" }]); + assert.equal(terminal.pendingProgrammaticInput, ""); + assert.equal(terminal.pendingInputFlushTimer, null); + assert.deepEqual(terminal.pendingConsoleInputLines, []); +}); + +test("raw-string contents and following expressions survive submission splitting", async () => { + const { terminal, commands, history } = createTerminal(); + const code = 's <- r"-(a-"\n#keep\nb)-"'; + await terminal.handleProgrammaticSubmission(`${code}\nprint(s)\n`); + assert.deepEqual(commands, [{ type: "submit", code }]); + assert.deepEqual(history, [code, "print(s)"]); + runtime.handleRuntimeControl(terminal.runtimeHostAdapter, { type: "busy", value: true }); + runtime.handleBackendPrompt(terminal.runtimeHostAdapter, "main"); + assert.deepEqual(commands.at(-1), { type: "submit", code: "print(s)" }); +}); + +test("unfinished string contents retain trailing spaces and newlines for editing", async () => { + for (const tail of ['s <- "unfinished ', 's <- r"--(unfinished\n# content \n']) { + const { terminal } = createTerminal(); + await terminal.handleProgrammaticSubmission(`1\n${tail}\n`); + runtime.handleRuntimeControl(terminal.runtimeHostAdapter, { type: "busy", value: true }); + runtime.handleBackendPrompt(terminal.runtimeHostAdapter, "main"); + assert.equal(terminal.inputState.text, tail); + } +}); + +test("ready-mode Ctrl+C cancels queued submissions even with editable input", async () => { + const { terminal, commands } = createTerminal(); + terminal.inputState.insertText("not submitted"); + terminal.submissionQueue.push({ kind: "execute", code: "old", initialPromptKind: "main" }); + terminal.applyKeyAction({ type: "ctrl_c" }); + assert.equal(terminal.inputState.text, ""); + assert.deepEqual(terminal.submissionQueue, []); + assert.equal(terminal.submissionGeneration, 1); + assert.deepEqual(commands, []); +}); + +test("rejected delivery restores the unsent batch without leaving R executing", async () => { + const { terminal, commands, output } = createTerminal(); + terminal.runtimeBackend.sendSessionCommand = () => false; + await terminal.handleProgrammaticSubmission("1\n2\ny <- (\n"); + assert.deepEqual(commands, []); + assert.equal(terminal.mode, "ready"); + assert.equal(terminal.activeSubmission, null); + assert.equal(terminal.awaitingExecutionStart, false); + assert.deepEqual(terminal.submissionQueue, []); + assert.equal(terminal.inputState.text, "1\n2\ny <- ("); + assert.match(output.join(""), /submission was not sent.*Unsent code restored/); +}); + +test("rejected later delivery restores only the code not already submitted", async () => { + const { terminal, commands } = createTerminal(); + let sends = 0; + terminal.runtimeBackend.sendSessionCommand = (_session, command) => { + if (++sends > 1) return false; + commands.push(command); + return true; + }; + await terminal.handleProgrammaticSubmission("1\n2\ny <- (\n"); + runtime.handleRuntimeControl(terminal.runtimeHostAdapter, { type: "busy", value: true }); + runtime.handleBackendPrompt(terminal.runtimeHostAdapter, "main"); + assert.deepEqual(commands, [{ type: "submit", code: "1" }]); + assert.equal(terminal.inputState.text, "2\ny <- ("); + assert.deepEqual(terminal.submissionQueue, []); + assert.equal(terminal.activeSubmission, null); + assert.equal(terminal.mode, "ready"); +}); + +test("async editor parsing cannot overwrite console text entered during the parse", async () => { + const { terminal, commands } = createTerminal(); + let resolveTail; + terminal.inputState.isExpressionCompleteAsync = (code) => + code === "1" ? Promise.resolve(true) : new Promise((resolve) => { resolveTail = resolve; }); + const pending = terminal.handleProgrammaticSubmission("1\ny <- (\n"); + await Promise.resolve(); + assert.equal(typeof resolveTail, "function"); + terminal.inputState.insertText("user input"); + resolveTail(false); + await pending; + assert.deepEqual(commands, []); + assert.equal(terminal.inputState.text, "user input"); + assert.deepEqual(terminal.submissionQueue.map((task) => task.kind), ["execute", "edit"]); + terminal.inputState.reset(); + terminal.startNextSubmission(); + assert.deepEqual(commands, [{ type: "submit", code: "1" }]); + runtime.handleRuntimeControl(terminal.runtimeHostAdapter, { type: "busy", value: true }); + runtime.handleBackendPrompt(terminal.runtimeHostAdapter, "main"); + assert.equal(terminal.inputState.text, "y <- ("); +}); + +test("an edit tail cannot append to console input entered during earlier execution", async () => { + const { terminal, commands } = createTerminal(); + await terminal.handleProgrammaticSubmission("1\ny <- (\n"); + terminal.inputState.insertText("user input"); + runtime.handleRuntimeControl(terminal.runtimeHostAdapter, { type: "busy", value: true }); + runtime.handleBackendPrompt(terminal.runtimeHostAdapter, "main"); + assert.equal(terminal.inputState.text, "user input"); + assert.equal(terminal.submissionQueue.length, 1); + terminal.inputState.reset(); + terminal.startNextSubmission(); + assert.equal(terminal.inputState.text, "y <- ("); + assert.deepEqual(commands, [{ type: "submit", code: "1" }]); +}); + + +test("Enter commits input before the existing parser can finish", async () => { + for (const complete of [false, true]) { + const { terminal, commands } = createTerminal(); + let finishParse; + terminal.inputState.isExpressionCompleteAsync = () => + new Promise(resolve => { finishParse = resolve; }); + terminal.inputState.insertText("x <- 1"); + const pending = terminal.handleEnterKey(); + terminal.handleInput("2"); + terminal.handleInput("\x7f"); + terminal.handleInput("\x1b[D"); + terminal.handleInput("\r"); + assert.equal(terminal.inputState.text, "x <- 1"); + assert.equal(terminal.inputState.cursorPosition, 6); + finishParse(complete); + await pending; + assert.equal(terminal.inputCommitPending, false); + if (complete) { + assert.deepEqual(commands, [{ type: "submit", code: "x <- 1" }]); + assert.equal(terminal.inputState.text, ""); + } else { + assert.deepEqual(commands, []); + assert.equal(terminal.inputState.text, "x <- 1\n"); + } + } +}); + +test("incomplete Enter allows new input after the parse, not during it", async () => { + const { terminal, commands } = createTerminal(); + let finishParse; + terminal.inputState.isExpressionCompleteAsync = () => + new Promise(resolve => { finishParse = resolve; }); + terminal.inputState.insertText("x <- ("); + const pending = terminal.handleEnterKey(); + terminal.handleInput("4"); + assert.equal(terminal.inputState.text, "x <- ("); + finishParse(false); + await pending; + terminal.handleInput("42)"); + assert.equal(terminal.inputState.text, "x <- (\n 42)"); + assert.deepEqual(commands, []); +}); + +test("Ctrl+C releases a committed Enter even when the old parser finishes later", async () => { + const { terminal, commands } = createTerminal(); + let finishParse; + terminal.inputState.isExpressionCompleteAsync = () => + new Promise(resolve => { finishParse = resolve; }); + terminal.inputState.insertText("x <- 1"); + const pending = terminal.handleEnterKey(); + terminal.handleInput("\x03"); + finishParse(true); + await pending; + assert.equal(terminal.inputCommitPending, false); + assert.equal(terminal.inputState.text, ""); + assert.deepEqual(commands, []); +}); + +test("multiline paste commits its input before the existing parser returns", async () => { + for (const complete of [false, true]) { + const { terminal, commands } = createTerminal(); + let finishParse; + terminal.inputState.isExpressionCompleteAsync = () => + new Promise(resolve => { finishParse = resolve; }); + terminal.pasteBuffer = "x <- 1\n"; + const pending = terminal.handlePasteEnd(); + terminal.handleInput("2"); + terminal.handleInput("\x1b[D"); + assert.equal(terminal.inputState.text, "x <- 1"); + assert.equal(terminal.inputState.isAtEnd, true); + finishParse(complete); + await pending; + assert.equal(terminal.inputCommitPending, false); + if (complete) { + assert.deepEqual(commands, [{ type: "submit", code: "x <- 1" }]); + assert.equal(terminal.inputState.text, ""); + } else { + assert.deepEqual(commands, []); + assert.equal(terminal.inputState.text, "x <- 1"); + terminal.handleInput(" + 2"); + assert.equal(terminal.inputState.text, "x <- 1 + 2"); + } + } +}); + +test("Ctrl+C releases a committed paste without submitting it", async () => { + const { terminal, commands } = createTerminal(); + let finishParse; + terminal.inputState.isExpressionCompleteAsync = () => + new Promise(resolve => { finishParse = resolve; }); + terminal.pasteBuffer = "x <- 1\n"; + const pending = terminal.handlePasteEnd(); + terminal.handleInput("\x03"); + finishParse(true); + await pending; + assert.equal(terminal.inputCommitPending, false); + assert.equal(terminal.inputState.text, ""); + assert.deepEqual(commands, []); +}); + +test("Enter and paste preserve one completeness request apiece", async () => { + const { terminal, commands } = createTerminal(); + const requests = []; + terminal.inputState.isExpressionCompleteAsync = (code) => { + requests.push(code); + return Promise.resolve(true); + }; + terminal.inputState.insertText("1"); + await terminal.handleEnterKey(); + assert.deepEqual(requests, ["1"]); + assert.deepEqual(commands, [{ type: "submit", code: "1" }]); + runtime.handleRuntimeControl(terminal.runtimeHostAdapter, { type: "busy", value: true }); + runtime.handleBackendPrompt(terminal.runtimeHostAdapter, "main"); + terminal.pasteBuffer = "2\n"; + await terminal.handlePasteEnd(); + assert.deepEqual(requests, ["1", "2"]); + assert.deepEqual(commands.at(-1), { type: "submit", code: "2" }); +}); + +test("paste at an interior cursor remains an edit, not a submission", async () => { + const { terminal, commands } = createTerminal(); + terminal.inputState.insertText("x <- 3"); + terminal.inputState.cursorPosition = 5; + terminal.pasteBuffer = "2\n"; + await terminal.handlePasteEnd(); + assert.equal(terminal.inputState.text, "x <- 23"); + assert.deepEqual(commands, []); +}); + +test("submitted multiline paste stays committed through block splitting", async () => { + const { terminal, commands } = createTerminal(); + let finishSplit; + terminal.inputState.isExpressionCompleteAsync = code => + code === "a <- 1\nb <- 2" + ? Promise.resolve(true) + : code === "a <- 1" + ? new Promise(resolve => { finishSplit = resolve; }) + : Promise.resolve(true); + terminal.pasteBuffer = "a <- 1\nb <- 2\n"; + const pending = terminal.handlePasteEnd(); + await Promise.resolve(); + assert.equal(typeof finishSplit, "function"); + assert.equal(terminal.inputCommitPending, true); + terminal.handleInput("o"); + assert.equal(terminal.inputState.text, ""); + finishSplit(true); + await pending; + assert.equal(terminal.inputCommitPending, false); + assert.deepEqual(commands, [{ type: "submit", code: "a <- 1" }]); +}); + +test("source-editor commands received during committed Enter wait in the existing queue", async () => { + const { terminal, commands } = createTerminal(); + let finishParse; + terminal.inputState.insertText("x <- 1"); + terminal.inputState.isExpressionCompleteAsync = code => + code === "x <- 1" + ? new Promise(resolve => { finishParse = resolve; }) + : Promise.resolve(true); + const enter = terminal.handleEnterKey(); + terminal.handleInput("y <- 2\n"); + assert.equal(terminal.inputState.text, "x <- 1"); + finishParse(true); + await enter; + runtime.handleRuntimeControl(terminal.runtimeHostAdapter, { type: "busy", value: true }); + runtime.handleBackendPrompt(terminal.runtimeHostAdapter, "main"); + await terminal.programmaticSubmissionQueue; + assert.deepEqual(commands, [ + { type: "submit", code: "x <- 1" }, + { type: "submit", code: "y <- 2" }, + ]); +});