diff --git a/packages/safe-ds-lang/src/language/lsp/safe-ds-messaging-provider.ts b/packages/safe-ds-lang/src/language/lsp/safe-ds-messaging-provider.ts index 358fecafa..3d5447caa 100644 --- a/packages/safe-ds-lang/src/language/lsp/safe-ds-messaging-provider.ts +++ b/packages/safe-ds-lang/src/language/lsp/safe-ds-messaging-provider.ts @@ -9,6 +9,8 @@ import { Disposable } from 'vscode-languageserver-protocol'; */ export class SafeDsMessagingProvider { private readonly connection: Connection | undefined; + private logger: Logger | undefined = undefined; + private userMessageProvider: UserMessageProvider | undefined = undefined; constructor(services: SafeDsServices) { this.connection = services.shared.lsp.Connection; @@ -18,8 +20,10 @@ export class SafeDsMessagingProvider { * Log the given data to the trace log. */ trace(tag: string, message: string, verbose?: string): void { - if (this.connection) { - const text = this.formatLogMessage(tag, message); + const text = this.formatLogMessage(tag, message); + if (this.logger?.trace) { + this.logger.trace(text, verbose); + } else if (this.connection) { this.connection.tracer.log(text, verbose); } } @@ -28,8 +32,10 @@ export class SafeDsMessagingProvider { * Log a debug message. */ debug(tag: string, message: string): void { - if (this.connection) { - const text = this.formatLogMessage(tag, message); + const text = this.formatLogMessage(tag, message); + if (this.logger?.debug) { + this.logger.debug(text); + } else if (this.connection) { this.connection.console.debug(text); } } @@ -38,8 +44,10 @@ export class SafeDsMessagingProvider { * Log an information message. */ info(tag: string, message: string): void { - if (this.connection) { - const text = this.formatLogMessage(tag, message); + const text = this.formatLogMessage(tag, message); + if (this.logger?.info) { + this.logger.info(text); + } else if (this.connection) { this.connection.console.info(text); } } @@ -48,8 +56,10 @@ export class SafeDsMessagingProvider { * Log a warning message. */ warn(tag: string, message: string): void { - if (this.connection) { - const text = this.formatLogMessage(tag, message); + const text = this.formatLogMessage(tag, message); + if (this.logger?.warn) { + this.logger.warn(text); + } else if (this.connection) { this.connection.console.warn(text); } } @@ -58,8 +68,10 @@ export class SafeDsMessagingProvider { * Log an error message. */ error(tag: string, message: string): void { - if (this.connection) { - const text = this.formatLogMessage(tag, message); + const text = this.formatLogMessage(tag, message); + if (this.logger?.error) { + this.logger.error(text); + } else if (this.connection) { this.connection.console.error(text); } } @@ -75,7 +87,9 @@ export class SafeDsMessagingProvider { * notification center. */ showInformationMessage(message: string): void { - if (this.connection) { + if (this.userMessageProvider?.showInformationMessage) { + this.userMessageProvider.showInformationMessage(message); + } else if (this.connection) { this.connection.window.showInformationMessage(message); } } @@ -87,7 +101,9 @@ export class SafeDsMessagingProvider { * notification center. */ showWarningMessage(message: string): void { - if (this.connection) { + if (this.userMessageProvider?.showWarningMessage) { + this.userMessageProvider.showWarningMessage(message); + } else if (this.connection) { this.connection.window.showWarningMessage(message); } } @@ -99,7 +115,9 @@ export class SafeDsMessagingProvider { * notification center. */ showErrorMessage(message: string): void { - if (this.connection) { + if (this.userMessageProvider?.showErrorMessage) { + this.userMessageProvider.showErrorMessage(message); + } else if (this.connection) { this.connection.window.showErrorMessage(message); } } @@ -131,6 +149,70 @@ export class SafeDsMessagingProvider { await this.connection.sendNotification(method, params); } } + + /** + * Set the logger to use for logging messages. + */ + setLogger(logger: Logger) { + this.logger = logger; + } + + /** + * Set the user message provider to use for showing messages to the user. + */ + setUserMessageProvider(userMessageProvider: UserMessageProvider) { + this.userMessageProvider = userMessageProvider; + } } /* c8 ignore stop */ + +/** + * A logging provider. + */ +export interface Logger { + /** + * Log the given data to the trace log. + */ + trace?: (message: string, verbose?: string) => void; + + /** + * Log a debug message. + */ + debug?: (message: string) => void; + + /** + * Log an information message. + */ + info?: (message: string) => void; + + /** + * Log a warning message. + */ + warn?: (message: string) => void; + + /** + * Log an error message. + */ + error?: (message: string) => void; +} + +/** + * A service for showing messages to the user. + */ +export interface UserMessageProvider { + /** + * Prominently show an information message. The message should be short and human-readable. + */ + showInformationMessage?: (message: string) => void; + + /** + * Prominently show a warning message. The message should be short and human-readable. + */ + showWarningMessage?: (message: string) => void; + + /** + * Prominently show an error message. The message should be short and human-readable. + */ + showErrorMessage?: (message: string) => void; +} diff --git a/packages/safe-ds-lang/src/language/runner/safe-ds-runner.ts b/packages/safe-ds-lang/src/language/runner/safe-ds-runner.ts index 5a941a143..299604342 100644 --- a/packages/safe-ds-lang/src/language/runner/safe-ds-runner.ts +++ b/packages/safe-ds-lang/src/language/runner/safe-ds-runner.ts @@ -18,6 +18,7 @@ import { SafeDsAnnotations } from '../builtins/safe-ds-annotations.js'; import { SafeDsPythonGenerator } from '../generation/safe-ds-python-generator.js'; import { isSdsModule } from '../generated/ast.js'; import semver from 'semver'; +import { SafeDsMessagingProvider } from '../lsp/safe-ds-messaging-provider.js'; // Most of the functionality cannot be tested automatically as a functioning runner setup would always be required @@ -26,11 +27,13 @@ const LOWEST_UNSUPPORTED_VERSION = '0.9.0'; const npmVersionRange = `>=${LOWEST_SUPPORTED_VERSION} <${LOWEST_UNSUPPORTED_VERSION}`; const pipVersionRange = `>=${LOWEST_SUPPORTED_VERSION},<${LOWEST_UNSUPPORTED_VERSION}`; +const RUNNER_TAG = 'Runner'; + export class SafeDsRunner { private readonly annotations: SafeDsAnnotations; private readonly generator: SafeDsPythonGenerator; + private readonly messaging: SafeDsMessagingProvider; - private logging: RunnerLoggingOutput; private runnerCommand: string = 'safe-ds-runner'; private runnerProcess: child_process.ChildProcessWithoutNullStreams | undefined = undefined; private port: number | undefined = undefined; @@ -53,17 +56,7 @@ export class SafeDsRunner { constructor(services: SafeDsServices) { this.annotations = services.builtins.Annotations; this.generator = services.generation.PythonGenerator; - this.logging = { - outputError(value: string) { - services.lsp.MessagingProvider.error('Runner', value); - }, - outputInfo(value: string) { - services.lsp.MessagingProvider.info('Runner', value); - }, - displayError(value: string) { - services.lsp.MessagingProvider.showErrorMessage(value); - }, - }; + this.messaging = services.lsp.MessagingProvider; // Register listeners this.registerMessageLoggingCallbacks(); @@ -80,14 +73,12 @@ export class SafeDsRunner { private registerMessageLoggingCallbacks() { this.addMessageCallback((message) => { - this.logging.outputInfo( + this.info( `Placeholder value is (${message.id}): ${message.data.name} of type ${message.data.type} = ${message.data.value}`, ); }, 'placeholder_value'); this.addMessageCallback((message) => { - this.logging.outputInfo( - `Placeholder was calculated (${message.id}): ${message.data.name} of type ${message.data.type}`, - ); + this.info(`Placeholder was calculated (${message.id}): ${message.data.name} of type ${message.data.type}`); const execInfo = this.getExecutionContext(message.id); execInfo?.calculatedPlaceholders.set(message.data.name, message.data.type); // this.sendMessageToPythonServer( @@ -95,7 +86,7 @@ export class SafeDsRunner { //); }, 'placeholder_type'); this.addMessageCallback((message) => { - this.logging.outputInfo(`Runner-Progress (${message.id}): ${message.data}`); + this.info(`Runner-Progress (${message.id}): ${message.data}`); }, 'runtime_progress'); this.addMessageCallback(async (message) => { let readableStacktraceSafeDs: string[] = []; @@ -114,12 +105,12 @@ export class SafeDsRunner { return `\tat ${frame.file} line ${frame.line}`; }), ); - this.logging.outputError( + this.error( `Runner-RuntimeError (${message.id}): ${ (message).data.message } \n${readableStacktracePython.join('\n')}`, ); - this.logging.outputError( + this.error( `Safe-DS Error (${message.id}): ${(message).data.message} \n${readableStacktraceSafeDs .reverse() .join('\n')}`, @@ -140,15 +131,6 @@ export class SafeDsRunner { } } - /** - * Change the output functions for runner logging and error information to those provided. - * - * @param logging New Runner output functions. - */ - public updateRunnerLogging(logging: RunnerLoggingOutput): void { - this.logging = logging; - } - /** * Start the python server on the next usable port, starting at 5000. * Uses the 'safe-ds.runner.command' setting to execute the process. @@ -162,30 +144,28 @@ export class SafeDsRunner { const pythonServerTest = child_process.spawn(runnerCommand, [...runnerCommandParts, '-V']); const versionString = await this.getPythonServerVersion(pythonServerTest); if (!semver.satisfies(versionString, npmVersionRange)) { - this.logging.outputError( - `Installed runner version ${versionString} does not meet requirements: ${pipVersionRange}`, - ); - this.logging.displayError( + this.error(`Installed runner version ${versionString} does not meet requirements: ${pipVersionRange}`); + this.messaging.showErrorMessage( `The installed runner version ${versionString} is not compatible with this version of the extension. The installed version should match these requirements: ${pipVersionRange}. Please update to a matching version.`, ); return; } else { - this.logging.outputInfo(`Using safe-ds-runner version: ${versionString}`); + this.info(`Using safe-ds-runner version: ${versionString}`); } } catch (error) { - this.logging.outputError(`Could not start runner: ${error instanceof Error ? error.message : error}`); - this.logging.displayError( + this.error(`Could not start runner: ${error instanceof Error ? error.message : error}`); + this.messaging.showErrorMessage( `The runner process could not be started: ${error instanceof Error ? error.message : error}`, ); return; } // Start the runner at the specified port this.port = await this.findFirstFreePort(5000); - this.logging.outputInfo(`Trying to use port ${this.port} to start python server...`); - this.logging.outputInfo(`Using command '${this.runnerCommand}' to start python server...`); + this.info(`Trying to use port ${this.port} to start python server...`); + this.info(`Using command '${this.runnerCommand}' to start python server...`); const runnerArgs = [...runnerCommandParts, 'start', '--port', String(this.port)]; - this.logging.outputInfo(`Running ${runnerCommand}; Args: ${runnerArgs.join(' ')}`); + this.info(`Running ${runnerCommand}; Args: ${runnerArgs.join(' ')}`); this.runnerProcess = child_process.spawn(runnerCommand, runnerArgs); this.manageRunnerSubprocessOutputIO(); try { @@ -194,7 +174,7 @@ export class SafeDsRunner { await this.stopPythonServer(); return; } - this.logging.outputInfo('Started python server successfully'); + this.info('Started python server successfully'); } /** @@ -202,17 +182,17 @@ export class SafeDsRunner { * If that fails, the whole process tree (starting at the child process spawned by startPythonServer) will get killed. */ async stopPythonServer(): Promise { - this.logging.outputInfo('Stopping python server...'); + this.info('Stopping python server...'); if (this.runnerProcess !== undefined) { if ((this.acceptsConnections && !(await this.requestGracefulShutdown(2500))) || !this.acceptsConnections) { - this.logging.outputInfo(`Tree-killing python server process ${this.runnerProcess.pid}...`); + this.info(`Tree-killing python server process ${this.runnerProcess.pid}...`); const pid = this.runnerProcess.pid!; // Wait for tree-kill to finish killing the tree await new Promise((resolve, _reject) => { treeKill(pid, (error) => { resolve(); if (error) { - this.logging.outputError(`Error while killing runner process tree: ${error}`); + this.error(`Error while killing runner process tree: ${error}`); } }); }); @@ -224,7 +204,7 @@ export class SafeDsRunner { } private async requestGracefulShutdown(maxTimeoutMs: number): Promise { - this.logging.outputInfo('Trying graceful shutdown...'); + this.info('Trying graceful shutdown...'); this.sendMessageToPythonServer(createShutdownMessage()); return new Promise((resolve, _reject) => { this.runnerProcess?.on('close', () => resolve(true)); @@ -462,13 +442,13 @@ export class SafeDsRunner { /* c8 ignore start */ public sendMessageToPythonServer(message: PythonServerMessage): void { const messageString = JSON.stringify(message); - this.logging.outputInfo(`Sending message to python server: ${messageString}`); + this.info(`Sending message to python server: ${messageString}`); this.serverConnection!.send(messageString); } private async getPythonServerVersion(process: child_process.ChildProcessWithoutNullStreams) { process.stderr.on('data', (data: Buffer) => { - this.logging.outputInfo(`[Runner-Err] ${data.toString().trim()}`); + this.info(`[Runner-Err] ${data.toString().trim()}`); }); return new Promise((resolve, reject) => { process.stdout.on('data', (data: Buffer) => { @@ -491,13 +471,13 @@ export class SafeDsRunner { return; } this.runnerProcess.stdout.on('data', (data: Buffer) => { - this.logging.outputInfo(`[Runner-Out] ${data.toString().trim()}`); + this.info(`[Runner-Out] ${data.toString().trim()}`); }); this.runnerProcess.stderr.on('data', (data: Buffer) => { - this.logging.outputInfo(`[Runner-Err] ${data.toString().trim()}`); + this.info(`[Runner-Err] ${data.toString().trim()}`); }); this.runnerProcess.on('close', (code) => { - this.logging.outputInfo(`[Runner] Exited: ${code}`); + this.info(`Exited: ${code}`); // when the server shuts down, no connections will be accepted this.acceptsConnections = false; this.runnerProcess = undefined; @@ -516,25 +496,21 @@ export class SafeDsRunner { }); this.serverConnection.onopen = (event) => { this.acceptsConnections = true; - this.logging.outputInfo(`[Runner] Now accepting connections: ${event.type}`); + this.info(`Now accepting connections: ${event.type}`); resolve(); }; this.serverConnection.onerror = (event) => { currentTry += 1; if (event.message.includes('ECONNREFUSED')) { if (currentTry > maxConnectionTries) { - this.logging.outputInfo( - '[Runner] Max retries reached. No further attempt at connecting is made.', - ); + this.info('Max retries reached. No further attempt at connecting is made.'); } else { - this.logging.outputInfo(`[Runner] Server is not yet up. Retrying...`); + this.info(`Server is not yet up. Retrying...`); setTimeout(tryConnect, timeoutMs * (2 ** currentTry - 1)); // use exponential backoff return; } } - this.logging.outputError( - `[Runner] An error occurred: ${event.message} (${event.type}) {${event.error}}`, - ); + this.error(`An error occurred: ${event.message} (${event.type}) {${event.error}}`); if (this.isPythonServerAvailable()) { return; } @@ -542,19 +518,17 @@ export class SafeDsRunner { }; this.serverConnection.onmessage = (event) => { if (typeof event.data !== 'string') { - this.logging.outputInfo( - `[Runner] Message received: (${event.type}, ${typeof event.data}) ${event.data}`, - ); + this.info(`Message received: (${event.type}, ${typeof event.data}) ${event.data}`); return; } - this.logging.outputInfo( - `[Runner] Message received: '${ + this.info( + `Message received: '${ event.data.length > 128 ? event.data.substring(0, 128) + '' : event.data }'`, ); const pythonServerMessage: PythonServerMessage = JSON.parse(event.data); if (!this.messageCallbacks.has(pythonServerMessage.type)) { - this.logging.outputInfo(`[Runner] Message type '${pythonServerMessage.type}' is not handled`); + this.info(`Message type '${pythonServerMessage.type}' is not handled`); return; } for (const callback of this.messageCallbacks.get(pythonServerMessage.type)!) { @@ -565,7 +539,7 @@ export class SafeDsRunner { if (this.isPythonServerAvailable()) { // The connection was interrupted this.acceptsConnections = false; - this.logging.outputError('[Runner] Connection was unexpectedly closed'); + this.error('Connection was unexpectedly closed'); } }; }; @@ -599,15 +573,16 @@ export class SafeDsRunner { tryNextPort(); }); } -} -/** - * Runner Logging interface - */ -export interface RunnerLoggingOutput { - outputInfo: (value: string) => void; - outputError: (value: string) => void; - displayError: (value: string) => void; + /* c8 ignore start */ + private info(message: string) { + this.messaging.info(RUNNER_TAG, message); + } + + private error(message: string) { + this.messaging.error(RUNNER_TAG, message); + } + /* c8 ignore stop */ } /** diff --git a/packages/safe-ds-lang/src/language/safe-ds-module.ts b/packages/safe-ds-lang/src/language/safe-ds-module.ts index 6957f48eb..2a537d1b8 100644 --- a/packages/safe-ds-lang/src/language/safe-ds-module.ts +++ b/packages/safe-ds-lang/src/language/safe-ds-module.ts @@ -45,7 +45,7 @@ import { SafeDsTypeFactory } from './typing/safe-ds-type-factory.js'; import { SafeDsMarkdownGenerator } from './generation/safe-ds-markdown-generator.js'; import { SafeDsCompletionProvider } from './lsp/safe-ds-completion-provider.js'; import { SafeDsFuzzyMatcher } from './lsp/safe-ds-fuzzy-matcher.js'; -import { SafeDsMessagingProvider } from './lsp/safe-ds-messaging-provider.js'; +import { type Logger, SafeDsMessagingProvider, type UserMessageProvider } from './lsp/safe-ds-messaging-provider.js'; import { SafeDsConfigurationProvider } from './workspace/safe-ds-configuration-provider.js'; /** @@ -233,12 +233,20 @@ export const createSafeDsServices = async function ( } // Apply options + if (options?.logger) { + /* c8 ignore next 2 */ + SafeDs.lsp.MessagingProvider.setLogger(options.logger); + } if (!options?.omitBuiltins) { await shared.workspace.WorkspaceManager.initializeWorkspace([]); } if (options?.runnerCommand) { /* c8 ignore next 2 */ - SafeDs.runtime.Runner.updateRunnerCommand(options?.runnerCommand); + SafeDs.runtime.Runner.updateRunnerCommand(options.runnerCommand); + } + if (options?.userMessageProvider) { + /* c8 ignore next 2 */ + SafeDs.lsp.MessagingProvider.setUserMessageProvider(options.userMessageProvider); } return { shared, SafeDs }; @@ -248,6 +256,12 @@ export const createSafeDsServices = async function ( * Options to pass to the creation of Safe-DS services. */ export interface ModuleOptions { + /** + * A logging provider. If the logger lacks a capability, we fall back to the logger provided by the language server + * connection, if available. + */ + logger?: Logger; + /** * By default, builtins are loaded into the workspace. If this option is set to true, builtins are omitted. */ @@ -257,4 +271,10 @@ export interface ModuleOptions { * Command to start the runner. */ runnerCommand?: string; + + /** + * A service for showing messages to the user. If the provider lacks a capability, we fall back to the language + * server connection, if available. + */ + userMessageProvider?: UserMessageProvider; } diff --git a/packages/safe-ds-lang/src/language/workspace/safe-ds-settings-provider.ts b/packages/safe-ds-lang/src/language/workspace/safe-ds-settings-provider.ts index a313fcd50..7c0d22148 100644 --- a/packages/safe-ds-lang/src/language/workspace/safe-ds-settings-provider.ts +++ b/packages/safe-ds-lang/src/language/workspace/safe-ds-settings-provider.ts @@ -84,12 +84,7 @@ export class SafeDsSettingsProvider { } } -interface SettingsWatcher { - accessor: (settings: DeepPartial) => T; - callback: (newValue: T) => void; -} - -interface Settings { +export interface Settings { inlayHints: InlayHintsSettings; runner: RunnerSettings; validation: ValidationSettings; @@ -125,3 +120,8 @@ interface ValidationSettings { enabled: boolean; }; } + +interface SettingsWatcher { + accessor: (settings: DeepPartial) => T; + callback: (newValue: T) => void; +} diff --git a/packages/safe-ds-vscode/src/extension/mainClient.ts b/packages/safe-ds-vscode/src/extension/mainClient.ts index 4bd296952..559eecf47 100644 --- a/packages/safe-ds-vscode/src/extension/mainClient.ts +++ b/packages/safe-ds-vscode/src/extension/mainClient.ts @@ -25,18 +25,18 @@ export const activate = async function (context: vscode.ExtensionContext) { initializeLog(); client = startLanguageClient(context); const runnerCommandSetting = vscode.workspace.getConfiguration('safe-ds.runner').get('command')!; // Default is set - services = (await createSafeDsServices(NodeFileSystem, { runnerCommand: runnerCommandSetting })).SafeDs; - services.runtime.Runner.updateRunnerLogging({ - displayError(value: string): void { - vscode.window.showErrorMessage(value); - }, - outputError(value: string): void { - logError(value); - }, - outputInfo(value: string): void { - logOutput(value); - }, - }); + services = ( + await createSafeDsServices(NodeFileSystem, { + logger: { + info: logOutput, + error: logError, + }, + runnerCommand: runnerCommandSetting, + userMessageProvider: { + showErrorMessage: vscode.window.showErrorMessage, + }, + }) + ).SafeDs; await services.runtime.Runner.startPythonServer(); acceptRunRequests(context); };