From 392154dbe051dd172e81904e2b2cedb39ffd47a1 Mon Sep 17 00:00:00 2001 From: Lars Reimann Date: Fri, 19 Apr 2024 22:58:13 +0200 Subject: [PATCH] feat: run pipeline via code lens (#1068) ### Summary of Changes Pipeline runs are now triggered via a code lens instead of a button. Unlike the button, these don't show up when the runner is not available. They also make it more obvious *which* pipeline is being run. --- .../lsp/safe-ds-code-lens-provider.ts | 36 +- .../lsp/safe-ds-code-lens-provider.test.ts | 8 +- packages/safe-ds-vscode/package.json | 15 - .../src/extension/mainClient.ts | 367 +++++++++--------- 4 files changed, 216 insertions(+), 210 deletions(-) diff --git a/packages/safe-ds-lang/src/language/lsp/safe-ds-code-lens-provider.ts b/packages/safe-ds-lang/src/language/lsp/safe-ds-code-lens-provider.ts index f40d62bf3..e7d86e3bc 100644 --- a/packages/safe-ds-lang/src/language/lsp/safe-ds-code-lens-provider.ts +++ b/packages/safe-ds-lang/src/language/lsp/safe-ds-code-lens-provider.ts @@ -2,8 +2,8 @@ import { CodeLensProvider } from 'langium/lsp'; import { CancellationToken, CodeLens, type CodeLensParams } from 'vscode-languageserver'; import { SafeDsServices } from '../safe-ds-module.js'; import { SafeDsTypeComputer } from '../typing/safe-ds-type-computer.js'; -import { AstNodeLocator, AstUtils, interruptAndCheck, LangiumDocument } from 'langium'; -import { isSdsModule, isSdsPipeline, SdsModuleMember, SdsPlaceholder } from '../generated/ast.js'; +import { AstNode, AstNodeLocator, AstUtils, interruptAndCheck, LangiumDocument } from 'langium'; +import { isSdsModule, isSdsPipeline, SdsModuleMember, SdsPipeline, SdsPlaceholder } from '../generated/ast.js'; import { SafeDsRunner } from '../runner/safe-ds-runner.js'; import { getModuleMembers, streamPlaceholders } from '../helpers/nodeProperties.js'; import { SafeDsTypeChecker } from '../typing/safe-ds-type-checker.js'; @@ -53,6 +53,8 @@ export class SafeDsCodeLensProvider implements CodeLensProvider { cancelToken: CancellationToken = CancellationToken.None, ): Promise { if (isSdsPipeline(node)) { + await this.computeCodeLensForPipeline(node, accept); + for (const placeholder of streamPlaceholders(node.body)) { await interruptAndCheck(cancelToken); await this.computeCodeLensForPlaceholder(placeholder, accept); @@ -60,6 +62,23 @@ export class SafeDsCodeLensProvider implements CodeLensProvider { } } + private async computeCodeLensForPipeline(node: SdsPipeline, accept: CodeLensAcceptor): Promise { + const cstNode = node.$cstNode; + if (!cstNode) { + /* c8 ignore next 2 */ + return; + } + + accept({ + range: cstNode.range, + command: { + title: `Run ${node.name}`, + command: 'safe-ds.runPipeline', + arguments: this.computeNodeId(node), + }, + }); + } + private async computeCodeLensForPlaceholder(node: SdsPlaceholder, accept: CodeLensAcceptor): Promise { const cstNode = node.$cstNode; if (!cstNode) { @@ -68,19 +87,22 @@ export class SafeDsCodeLensProvider implements CodeLensProvider { } if (this.typeChecker.isTabular(this.typeComputer.computeType(node))) { - const documentUri = AstUtils.getDocument(node).uri.toString(); - const nodePath = this.astNodeLocator.getAstNodePath(node); - accept({ range: cstNode.range, command: { title: `Explore ${node.name}`, - command: 'safe-ds.runEda', - arguments: [documentUri, nodePath], + command: 'safe-ds.exploreTable', + arguments: this.computeNodeId(node), }, }); } } + + private computeNodeId(node: AstNode): [string, string] { + const documentUri = AstUtils.getDocument(node).uri; + const nodePath = this.astNodeLocator.getAstNodePath(node); + return [documentUri.toString(), nodePath]; + } } type CodeLensAcceptor = (codeLens: CodeLens) => void; diff --git a/packages/safe-ds-lang/tests/language/lsp/safe-ds-code-lens-provider.test.ts b/packages/safe-ds-lang/tests/language/lsp/safe-ds-code-lens-provider.test.ts index 63b861516..7489c8732 100644 --- a/packages/safe-ds-lang/tests/language/lsp/safe-ds-code-lens-provider.test.ts +++ b/packages/safe-ds-lang/tests/language/lsp/safe-ds-code-lens-provider.test.ts @@ -19,21 +19,21 @@ describe('SafeDsCodeLensProvider', () => { { testName: 'empty pipeline', code: 'pipeline myPipeline {}', - expectedCodeLensTitles: [], + expectedCodeLensTitles: ['Run myPipeline'], }, { testName: 'pipeline with Int placeholder', code: `pipeline myPipeline { val a = 1; }`, - expectedCodeLensTitles: [], + expectedCodeLensTitles: ['Run myPipeline'], }, { testName: 'pipeline with Table placeholder', code: `pipeline myPipeline { val a = Table(); }`, - expectedCodeLensTitles: ['Explore a'], + expectedCodeLensTitles: ['Run myPipeline', 'Explore a'], }, { testName: 'block lambda with Table placeholder', @@ -42,7 +42,7 @@ describe('SafeDsCodeLensProvider', () => { val a = Table(); }; }`, - expectedCodeLensTitles: [], + expectedCodeLensTitles: ['Run myPipeline'], }, { testName: 'segment with Table placeholder', diff --git a/packages/safe-ds-vscode/package.json b/packages/safe-ds-vscode/package.json index 06e18294e..b46187a9a 100644 --- a/packages/safe-ds-vscode/package.json +++ b/packages/safe-ds-vscode/package.json @@ -239,15 +239,6 @@ "files.trimTrailingWhitespace": true } }, - "menus": { - "editor/title/run": [ - { - "command": "safe-ds.runPipelineFile", - "when": "resourceLangId == safe-ds", - "group": "navigation@1" - } - ] - }, "commands": [ { "command": "safe-ds.dumpDiagnostics", @@ -269,12 +260,6 @@ "title": "Refresh Webview", "category": "Safe-DS" }, - { - "command": "safe-ds.runPipelineFile", - "title": "Run Pipeline", - "category": "Safe-DS", - "icon": "$(play)" - }, { "command": "safe-ds.updateRunner", "title": "Update the Safe-DS Runner", diff --git a/packages/safe-ds-vscode/src/extension/mainClient.ts b/packages/safe-ds-vscode/src/extension/mainClient.ts index c38314cc4..2a09c4c15 100644 --- a/packages/safe-ds-vscode/src/extension/mainClient.ts +++ b/packages/safe-ds-vscode/src/extension/mainClient.ts @@ -1,6 +1,6 @@ import * as path from 'node:path'; import * as vscode from 'vscode'; -import { TextEditor, Uri } from 'vscode'; +import { Uri } from 'vscode'; import type { LanguageClientOptions, ServerOptions } from 'vscode-languageclient/node.js'; import { LanguageClient, TransportKind } from 'vscode-languageclient/node.js'; import { ast, createSafeDsServices, getModuleMembers, messages, rpc, SafeDsServices } from '@safe-ds/lang'; @@ -11,7 +11,7 @@ import { AstUtils, LangiumDocument } from 'langium'; import { EDAPanel } from './eda/edaPanel.ts'; import { dumpDiagnostics } from './commands/dumpDiagnostics.js'; import { openDiagnosticsDumps } from './commands/openDiagnosticsDumps.js'; -import { isSdsPlaceholder, SdsPipeline } from '../../../safe-ds-lang/src/language/generated/ast.js'; +import { isSdsPipeline, isSdsPlaceholder } from '../../../safe-ds-lang/src/language/generated/ast.js'; import { installRunner } from './commands/installRunner.js'; import { updateRunner } from './commands/updateRunner.js'; @@ -23,7 +23,9 @@ let lastSuccessfulTableName: string | undefined; let lastSuccessfulPipelinePath: vscode.Uri | undefined; let lastSuccessfulPipelineNode: ast.SdsPipeline | undefined; -// This function is called when the extension is activated. +/** + * This function is called when the extension is activated. + */ export const activate = async function (context: vscode.ExtensionContext) { initializeLog(); services = ( @@ -39,25 +41,16 @@ export const activate = async function (context: vscode.ExtensionContext) { ).SafeDs; client = createLanguageClient(context); - registerNotificationListeners(context); - await client.start(); - registerVSCodeCommands(context); -}; + registerNotificationListeners(context); + registerCommands(context); -const registerNotificationListeners = function (context: vscode.ExtensionContext) { - client.onNotification(rpc.runnerInstall, async () => { - await installRunner(context, client, services)(); - }); - client.onNotification(rpc.runnerStarted, async (port: number) => { - await services.runtime.Runner.connectToPort(port); - }); - client.onNotification(rpc.runnerUpdate, async () => { - await updateRunner(context, client, services)(); - }); + await client.start(); }; -// This function is called when the extension is deactivated. +/** + * This function is called when the extension is deactivated. + */ export const deactivate = async function (): Promise { await services.runtime.Runner.stopPythonServer(); if (client) { @@ -106,85 +99,64 @@ const createLanguageClient = function (context: vscode.ExtensionContext): Langua return new LanguageClient('safe-ds', 'Safe-DS', serverOptions, clientOptions); }; -const registerVSCodeCommands = function (context: vscode.ExtensionContext) { - context.subscriptions.push(vscode.commands.registerCommand('safe-ds.dumpDiagnostics', dumpDiagnostics(context))); +const registerNotificationListeners = function (context: vscode.ExtensionContext) { context.subscriptions.push( - vscode.commands.registerCommand('safe-ds.installRunner', installRunner(context, client, services)), + client.onNotification(rpc.runnerInstall, async () => { + await installRunner(context, client, services)(); + }), + client.onNotification(rpc.runnerStarted, async (port: number) => { + await services.runtime.Runner.connectToPort(port); + }), + client.onNotification(rpc.runnerUpdate, async () => { + await updateRunner(context, client, services)(); + }), ); +}; + +const registerCommands = function (context: vscode.ExtensionContext) { context.subscriptions.push( + vscode.commands.registerCommand('safe-ds.dumpDiagnostics', dumpDiagnostics(context)), + vscode.commands.registerCommand('safe-ds.exploreTable', exploreTable(context)), + vscode.commands.registerCommand('safe-ds.installRunner', installRunner(context, client, services)), vscode.commands.registerCommand('safe-ds.openDiagnosticsDumps', openDiagnosticsDumps(context)), - ); - context.subscriptions.push( + vscode.commands.registerCommand('safe-ds.refreshWebview', refreshWebview(context)), + vscode.commands.registerCommand('safe-ds.runPipeline', runPipeline), vscode.commands.registerCommand('safe-ds.updateRunner', updateRunner(context, client, services)), ); +}; - context.subscriptions.push(vscode.commands.registerCommand('safe-ds.runPipelineFile', commandRunPipelineFile)); - context.subscriptions.push( - vscode.commands.registerCommand('safe-ds.runEda', async (documentUri: string, nodePath: string) => { - await vscode.workspace.saveAll(); - - const editor = vscode.window.activeTextEditor; - if (!editor) { - vscode.window.showErrorMessage('No active text editor.'); - return; - } - - const document = await getPipelineDocument(Uri.parse(documentUri)); - if (!document) { - vscode.window.showErrorMessage('Internal error.'); - return; - } - - const root = document.parseResult.value; - const node = services.workspace.AstNodeLocator.getAstNode(root, nodePath); - if (!isSdsPlaceholder(node)) { - vscode.window.showErrorMessage('Selected node is not a placeholder.'); - return; - } - - const pipelineNode = AstUtils.getContainerOfType(node, ast.isSdsPipeline); - if (!pipelineNode) { - vscode.window.showErrorMessage('Selected placeholder is not in a pipeline.'); - return; - } - - runEda(editor, context, pipelineNode, pipelineNode.name, node.name); - }), - ); - - context.subscriptions.push( - vscode.commands.registerCommand('safe-ds.refreshWebview', () => { - if ( - !lastSuccessfulPipelinePath || - !lastFinishedPipelineExecutionId || - !lastSuccessfulPipelineName || - !lastSuccessfulTableName || - !lastSuccessfulPipelineNode - ) { - vscode.window.showErrorMessage('No EDA Panel to refresh!'); - return; - } - EDAPanel.kill(lastSuccessfulPipelineName! + '.' + lastSuccessfulTableName!); - setTimeout(() => { - EDAPanel.createOrShow( - context.extensionUri, - context, - lastFinishedPipelineExecutionId!, - services, - lastSuccessfulPipelinePath!, - lastSuccessfulPipelineName!, - lastSuccessfulPipelineNode!, - lastSuccessfulTableName!, - ); - }, 100); - setTimeout(() => { - vscode.commands.executeCommand('workbench.action.webview.openDeveloperTools'); - }, 100); - }), - ); +const refreshWebview = function (context: vscode.ExtensionContext) { + return async () => { + if ( + !lastSuccessfulPipelinePath || + !lastFinishedPipelineExecutionId || + !lastSuccessfulPipelineName || + !lastSuccessfulTableName || + !lastSuccessfulPipelineNode + ) { + vscode.window.showErrorMessage('No EDA Panel to refresh!'); + return; + } + EDAPanel.kill(lastSuccessfulPipelineName! + '.' + lastSuccessfulTableName!); + setTimeout(() => { + EDAPanel.createOrShow( + context.extensionUri, + context, + lastFinishedPipelineExecutionId!, + services, + lastSuccessfulPipelinePath!, + lastSuccessfulPipelineName!, + lastSuccessfulPipelineNode!, + lastSuccessfulTableName!, + ); + }, 100); + setTimeout(() => { + vscode.commands.executeCommand('workbench.action.webview.openDeveloperTools'); + }, 100); + }; }; -const runPipelineFile = async function ( +const doRunPipelineFile = async function ( filePath: vscode.Uri | undefined, pipelineExecutionId: string, knownPipelineName?: string, @@ -202,7 +174,7 @@ const runPipelineFile = async function ( vscode.window.showErrorMessage('The current file cannot be executed, as no pipeline could be found.'); return; } - pipelineName = services.builtins.Annotations.getPythonName(firstPipeline) || firstPipeline.name; + pipelineName = services.builtins.Annotations.getPythonName(firstPipeline) ?? firstPipeline.name; } else { pipelineName = knownPipelineName; } @@ -213,111 +185,121 @@ const runPipelineFile = async function ( } }; -const runEda = function ( - editor: TextEditor, - context: vscode.ExtensionContext, - pipelineNode: SdsPipeline, - pipelineName: string, - requestedPlaceholderName: string, -) { - // gen custom id for pipeline - const pipelineExecutionId = crypto.randomUUID(); +const exploreTable = (context: vscode.ExtensionContext) => { + return async (documentUri: string, nodePath: string) => { + await vscode.workspace.saveAll(); - let loadingInProgress = true; // Flag to track loading status - // Show progress indicator - vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: 'Loading Table ...', - }, - (progress, _) => { - progress.report({ increment: 0 }); - return new Promise((resolve) => { - // Resolve the promise when loading is no longer in progress - const checkInterval = setInterval(() => { - if (!loadingInProgress) { - clearInterval(checkInterval); - resolve(); - } - }, 1000); // Check every second - }); - }, - ); - const cleanupLoadingIndication = () => { - loadingInProgress = false; - }; + const uri = Uri.parse(documentUri); - const placeholderTypeCallback = function (message: messages.PlaceholderTypeMessage) { - printOutputMessage( - `Placeholder was calculated (${message.id}): ${message.data.name} of type ${message.data.type}`, - ); - if ( - message.id === pipelineExecutionId && - // Can be removed altogether if the EDA tool is only triggered via code lenses - (message.data.type === 'Table' || - message.data.type === 'TaggedTable' || - message.data.type === 'TimeSeries') && - message.data.name === requestedPlaceholderName - ) { - lastFinishedPipelineExecutionId = pipelineExecutionId; - lastSuccessfulPipelinePath = editor.document.uri; - lastSuccessfulTableName = requestedPlaceholderName; - lastSuccessfulPipelineName = pipelineName; - lastSuccessfulPipelineNode = pipelineNode; - EDAPanel.createOrShow( - context.extensionUri, - context, - pipelineExecutionId, - services, - editor.document.uri, - pipelineName, - pipelineNode, - message.data.name, - ); - services.runtime.Runner.removeMessageCallback(placeholderTypeCallback, 'placeholder_type'); - cleanupLoadingIndication(); - } else if (message.id === pipelineExecutionId && message.data.name !== requestedPlaceholderName) { + const document = await getPipelineDocument(Uri.parse(documentUri)); + if (!document) { + vscode.window.showErrorMessage('Could not find document.'); return; - } else if (message.id === pipelineExecutionId) { - lastFinishedPipelineExecutionId = pipelineExecutionId; - vscode.window.showErrorMessage(`Selected placeholder is not of type 'Table'.`); - services.runtime.Runner.removeMessageCallback(placeholderTypeCallback, 'placeholder_type'); - cleanupLoadingIndication(); } - }; - services.runtime.Runner.addMessageCallback(placeholderTypeCallback, 'placeholder_type'); - const runtimeProgressCallback = function (message: messages.RuntimeProgressMessage) { - printOutputMessage(`Runner-Progress (${message.id}): ${message.data}`); - if ( - message.id === pipelineExecutionId && - message.data === 'done' && - lastFinishedPipelineExecutionId !== pipelineExecutionId - ) { - lastFinishedPipelineExecutionId = pipelineExecutionId; - vscode.window.showErrorMessage(`Selected text is not a placeholder!`); - services.runtime.Runner.removeMessageCallback(runtimeProgressCallback, 'runtime_progress'); - cleanupLoadingIndication(); + const root = document.parseResult.value; + const placeholderNode = services.workspace.AstNodeLocator.getAstNode(root, nodePath); + if (!isSdsPlaceholder(placeholderNode)) { + vscode.window.showErrorMessage('Selected node is not a placeholder.'); + return; } - }; - services.runtime.Runner.addMessageCallback(runtimeProgressCallback, 'runtime_progress'); - - const runtimeErrorCallback = function (message: messages.RuntimeErrorMessage) { - if (message.id === pipelineExecutionId && lastFinishedPipelineExecutionId !== pipelineExecutionId) { - lastFinishedPipelineExecutionId = pipelineExecutionId; - vscode.window.showErrorMessage(`Pipeline ran into an Error!`); - services.runtime.Runner.removeMessageCallback(runtimeErrorCallback, 'runtime_error'); - cleanupLoadingIndication(); + + const pipelineNode = AstUtils.getContainerOfType(placeholderNode, ast.isSdsPipeline); + if (!pipelineNode) { + vscode.window.showErrorMessage('Selected placeholder is not in a pipeline.'); + return; } - }; - services.runtime.Runner.addMessageCallback(runtimeErrorCallback, 'runtime_error'); - runPipelineFile(editor.document.uri, pipelineExecutionId, pipelineName, requestedPlaceholderName); + const pipelineName = pipelineNode.name; + const requestedPlaceholderName = placeholderNode.name; + + // gen custom id for pipeline + const pipelineExecutionId = crypto.randomUUID(); + + let loadingInProgress = true; // Flag to track loading status + // Show progress indicator + vscode.window.withProgress( + { + location: vscode.ProgressLocation.Window, + title: 'Loading Table...', + }, + (progress, _) => { + progress.report({ increment: 0 }); + return new Promise((resolve) => { + // Resolve the promise when loading is no longer in progress + const checkInterval = setInterval(() => { + if (!loadingInProgress) { + clearInterval(checkInterval); + resolve(); + } + }, 1000); // Check every second + }); + }, + ); + const cleanupLoadingIndication = () => { + loadingInProgress = false; + }; + + const placeholderTypeCallback = function (message: messages.PlaceholderTypeMessage) { + printOutputMessage( + `Placeholder was calculated (${message.id}): ${message.data.name} of type ${message.data.type}`, + ); + if (message.id === pipelineExecutionId && message.data.name === requestedPlaceholderName) { + lastFinishedPipelineExecutionId = pipelineExecutionId; + lastSuccessfulPipelinePath = uri; + lastSuccessfulTableName = requestedPlaceholderName; + lastSuccessfulPipelineName = pipelineName; + lastSuccessfulPipelineNode = pipelineNode; + EDAPanel.createOrShow( + context.extensionUri, + context, + pipelineExecutionId, + services, + uri, + pipelineName, + pipelineNode, + message.data.name, + ); + services.runtime.Runner.removeMessageCallback(placeholderTypeCallback, 'placeholder_type'); + cleanupLoadingIndication(); + } + }; + services.runtime.Runner.addMessageCallback(placeholderTypeCallback, 'placeholder_type'); + + const runtimeProgressCallback = function (message: messages.RuntimeProgressMessage) { + printOutputMessage(`Runner-Progress (${message.id}): ${message.data}`); + if ( + message.id === pipelineExecutionId && + message.data === 'done' && + lastFinishedPipelineExecutionId !== pipelineExecutionId + ) { + lastFinishedPipelineExecutionId = pipelineExecutionId; + vscode.window.showErrorMessage(`Selected text is not a placeholder!`); + services.runtime.Runner.removeMessageCallback(runtimeProgressCallback, 'runtime_progress'); + cleanupLoadingIndication(); + } + }; + services.runtime.Runner.addMessageCallback(runtimeProgressCallback, 'runtime_progress'); + + const runtimeErrorCallback = function (message: messages.RuntimeErrorMessage) { + if (message.id === pipelineExecutionId && lastFinishedPipelineExecutionId !== pipelineExecutionId) { + lastFinishedPipelineExecutionId = pipelineExecutionId; + vscode.window.showErrorMessage(`Pipeline ran into an Error!`); + services.runtime.Runner.removeMessageCallback(runtimeErrorCallback, 'runtime_error'); + cleanupLoadingIndication(); + } + }; + services.runtime.Runner.addMessageCallback(runtimeErrorCallback, 'runtime_error'); + + await doRunPipelineFile(uri, pipelineExecutionId, pipelineName, requestedPlaceholderName); + }; }; export const getPipelineDocument = async function ( filePath: vscode.Uri | undefined, ): Promise { + await vscode.workspace.saveAll(); + let pipelinePath = filePath; // Allow execution via command menu if (!pipelinePath && vscode.window.activeTextEditor) { @@ -376,9 +358,26 @@ export const getPipelineDocument = async function ( return mainDocument; }; -const commandRunPipelineFile = async function (filePath: vscode.Uri | undefined) { - await vscode.workspace.saveAll(); - await runPipelineFile(filePath, crypto.randomUUID()); +const runPipeline = async (documentUri: string, nodePath: string) => { + const uri = Uri.parse(documentUri); + const document = await getPipelineDocument(uri); + if (!document) { + vscode.window.showErrorMessage('Could not find document.'); + return; + } + + const root = document.parseResult.value; + const pipeline = services.workspace.AstNodeLocator.getAstNode(root, nodePath); + if (!isSdsPipeline(pipeline)) { + vscode.window.showErrorMessage('Selected node is not a pipeline.'); + return; + } + + const pipelineExecutionId = crypto.randomUUID(); + + printOutputMessage(`Launching Pipeline (${pipelineExecutionId}): ${documentUri} - ${pipeline.name}`); + + await services.runtime.Runner.executePipeline(pipelineExecutionId, document, pipeline.name); }; const validateDocuments = async function (