diff --git a/package.json b/package.json index a1def11ac..689b336b6 100644 --- a/package.json +++ b/package.json @@ -253,6 +253,11 @@ "command": "swift.runAllTestsParallel", "title": "Run All Tests in Parallel", "category": "Test" + }, + { + "command": "swift.evaluate", + "title": "Evaluate in REPL", + "category": "Swift" } ], "configuration": [ @@ -394,6 +399,16 @@ "type": "boolean", "default": true, "markdownDescription": "Controls whether or not the extension will contribute environment variables defined in `Swift: Environment Variables` to the integrated terminal. If this is set to `true` and a custom `Swift: Path` is also set then the swift path is appended to the terminal's `PATH`." + "order": 15 + }, + "swift.repl": { + "type": "boolean", + "default": false, + "markdownDescription": "Use the native Swift REPL.", + "order": 16, + "tags": [ + "experimental" + ] } } }, @@ -831,6 +846,11 @@ "command": "swift.cleanBuild", "group": "2_pkg@1", "when": "swift.hasPackage" + }, + { + "command": "swift.evaluate", + "group": "1_file@6", + "when": "editorFocus && editorLangId == swift && config.swift.repl" } ], "view/title": [ diff --git a/src/WorkspaceContext.ts b/src/WorkspaceContext.ts index 0216845ed..8a32ff934 100644 --- a/src/WorkspaceContext.ts +++ b/src/WorkspaceContext.ts @@ -34,6 +34,7 @@ import { SwiftBuildStatus } from "./ui/SwiftBuildStatus"; import { SwiftToolchain } from "./toolchain/toolchain"; import { DiagnosticsManager } from "./DiagnosticsManager"; import { DocumentationManager } from "./documentation/DocumentationManager"; +import { REPL } from "./repl/REPL"; /** * Context for whole workspace. Holds array of contexts for each workspace folder @@ -53,6 +54,7 @@ export class WorkspaceContext implements vscode.Disposable { public documentation: DocumentationManager; private lastFocusUri: vscode.Uri | undefined; private initialisationFinished = false; + private repl: REPL | undefined; private constructor( extensionContext: vscode.ExtensionContext, @@ -663,6 +665,13 @@ export class WorkspaceContext implements vscode.Disposable { private observers = new Set<(listener: FolderEvent) => unknown>(); private swiftFileObservers = new Set<(listener: SwiftFileEvent) => unknown>(); + + public getRepl(): REPL { + if (!this.repl) { + this.repl = new REPL(this); + } + return this.repl; + } } /** Workspace Folder Operation types */ diff --git a/src/commands.ts b/src/commands.ts index 5581ccc1e..7a8632500 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -39,6 +39,7 @@ import { runPluginTask } from "./commands/runPluginTask"; import { runTestMultipleTimes } from "./commands/testMultipleTimes"; import { newSwiftFile } from "./commands/newFile"; import { runAllTestsParallel } from "./commands/runParallelTests"; +import { evaluateExpression } from "./repl/command"; /** * References: @@ -160,5 +161,6 @@ export function register(ctx: WorkspaceContext): vscode.Disposable[] { Commands.PREVIEW_DOCUMENTATION, async () => await ctx.documentation.launchDocumentationPreview() ), + vscode.commands.registerCommand("swift.evaluate", () => evaluateExpression(ctx)), ]; } diff --git a/src/configuration.ts b/src/configuration.ts index 5741e4a74..b9e6b02e4 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -67,6 +67,12 @@ export interface FolderConfiguration { readonly disableAutoResolve: boolean; } +/** REPL configuration */ +export interface REPLConfiguration { + /** Enable the native REPL */ + readonly enable: boolean; +} + /** * Type-safe wrapper around configuration settings. */ @@ -170,6 +176,17 @@ const configuration = { }, }; }, + + get repl(): REPLConfiguration { + return { + get enable(): boolean { + return vscode.workspace + .getConfiguration("swift.repl") + .get("enable", false); + }, + }; + }, + /** Files and directories to exclude from the code coverage. */ get excludeFromCodeCoverage(): string[] { return vscode.workspace diff --git a/src/repl/REPL.ts b/src/repl/REPL.ts new file mode 100644 index 000000000..605eb7729 --- /dev/null +++ b/src/repl/REPL.ts @@ -0,0 +1,212 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the VS Code Swift open source project +// +// Copyright (c) 2022 the VS Code Swift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of VS Code Swift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import { + Disposable, + NotebookController, + NotebookDocument, + NotebookCellOutput, + NotebookCellOutputItem, + notebooks, + workspace, + NotebookEditor, + ViewColumn, + TabInputNotebook, + commands, + window, + NotebookControllerAffinity, + NotebookCellData, + NotebookEdit, + WorkspaceEdit, +} from "vscode"; +import { ChildProcess, spawn } from "child_process"; +import { createInterface, Interface } from "readline"; +import { WorkspaceContext } from "../WorkspaceContext"; +import { NotebookCellKind } from "vscode-languageclient"; + +export interface ExecutionResult { + status: boolean; + output: string | undefined; +} + +export interface IREPL { + execute(code: string): Promise; + interrupt(): void; +} + +class REPLConnection implements IREPL { + private stdout: Interface; + private stderr: Interface; + + constructor(private repl: ChildProcess) { + this.stdout = createInterface({ input: repl.stdout as NodeJS.ReadableStream }); + this.stderr = createInterface({ input: repl.stderr as NodeJS.ReadableStream }); + this.stdout.on("line", line => { + console.log(`=> ${line}`); + }); + this.stderr.on("line", line => { + console.log(`=> ${line}`); + }); + } + + public async execute(code: string): Promise { + if (!code.endsWith("\n")) { + code += "\n"; + } + if (!this.repl.stdin?.write(code)) { + return Promise.resolve({ status: false, output: undefined }); + } + return new Promise((resolve, _reject) => { + this.stdout.on("line", line => { + return resolve({ status: true, output: line }); + }); + + const lines: string[] = []; + this.stderr.on("line", line => { + lines.push(line); + if (!line) { + return resolve({ status: false, output: lines.join("\n") }); + } + }); + }); + } + + public interrupt(): void { + this.repl.stdin?.write(":q"); + } +} + +export class REPL implements Disposable { + private repl: REPLConnection; + private controller: NotebookController; + private document: NotebookDocument | undefined; + private listener: Disposable | undefined; + + constructor(workspace: WorkspaceContext) { + const repl = spawn(workspace.toolchain.getToolchainExecutable("swift"), ["repl"]); + repl.on("exit", (code, _signal) => { + console.error(`repl exited with code ${code}`); + }); + repl.on("error", error => { + console.error(`repl error: ${error}`); + }); + + this.repl = new REPLConnection(repl); + + this.controller = notebooks.createNotebookController( + "SwiftREPL", + "interactive", + "Swift REPL" + ); + this.controller.supportedLanguages = ["swift"]; + this.controller.supportsExecutionOrder = true; + this.controller.description = "Swift REPL"; + this.controller.interruptHandler = async () => { + this.repl.interrupt(); + }; + this.controller.executeHandler = async (cells, _notebook, controller) => { + for (const cell of cells) { + const execution = controller.createNotebookCellExecution(cell); + execution.start(Date.now()); + + const result = await this.repl.execute(cell.document.getText()); + if (result?.output) { + execution.replaceOutput([ + new NotebookCellOutput([ + NotebookCellOutputItem.text(result.output, "text/plain"), + ]), + ]); + } + + execution.end(result?.status); + } + }; + + this.watchNotebookClose(); + } + + dispose(): void { + this.controller.dispose(); + this.listener?.dispose(); + } + + private watchNotebookClose() { + this.listener = workspace.onDidCloseNotebookDocument(notebook => { + if (notebook.uri.toString() === this.document?.uri.toString()) { + this.document = undefined; + } + }); + } + + private getNotebookColumn(): ViewColumn | undefined { + const uri = this.document?.uri.toString(); + return window.tabGroups.all.flatMap(group => { + return group.tabs.flatMap(tab => { + if (tab.label === "Swift REPL") { + if ((tab.input as TabInputNotebook)?.uri.toString() === uri) { + return tab.group.viewColumn; + } + } + return undefined; + }); + })?.[0]; + } + + public async evaluate(code: string): Promise { + let editor: NotebookEditor | undefined; + if (this.document) { + const column = this.getNotebookColumn() ?? ViewColumn.Beside; + editor = await window.showNotebookDocument(this.document!, { viewColumn: column }); + } else { + const notebook = (await commands.executeCommand( + "interactive.open", + { + preserveFocus: true, + viewColumn: ViewColumn.Beside, + }, + undefined, + this.controller.id, + "Swift REPL" + )) as { notebookEditor: NotebookEditor }; + editor = notebook.notebookEditor; + this.document = editor.notebook; + } + + if (this.document) { + this.controller.updateNotebookAffinity( + this.document, + NotebookControllerAffinity.Default + ); + + await commands.executeCommand("notebook.selectKernel", { + notebookEdtior: this.document, + id: this.controller.id, + extension: "sswg.swift-lang", + }); + + const edit = new WorkspaceEdit(); + edit.set(this.document.uri, [ + NotebookEdit.insertCells(this.document.cellCount, [ + new NotebookCellData(NotebookCellKind.Code, code, "swift"), + ]), + ]); + workspace.applyEdit(edit); + + commands.executeCommand("notebook.cell.execute", { + ranges: [{ start: this.document.cellCount, end: this.document.cellCount + 1 }], + document: this.document.uri, + }); + } + } +} diff --git a/src/repl/command.ts b/src/repl/command.ts new file mode 100644 index 000000000..9221980db --- /dev/null +++ b/src/repl/command.ts @@ -0,0 +1,30 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the VS Code Swift open source project +// +// Copyright (c) 2022 the VS Code Swift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of VS Code Swift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import { window } from "vscode"; +import { WorkspaceContext } from "../WorkspaceContext"; + +export async function evaluateExpression(context: WorkspaceContext): Promise { + const editor = window.activeTextEditor; + + // const multiline = !editor?.selection.isSingleLine ?? false; + // const complete = true; // TODO(compnerd) determine if the input is complete + const code = editor?.document.lineAt(editor?.selection.start.line).text; + if (!code) { + return; + } + + const repl = context.getRepl(); + await repl.evaluate(code); +}